mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 10:40:58 +00:00
Merge branch 'dev' into fixes
This commit is contained in:
@@ -73,6 +73,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bookingGrossWeightTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
sizePartialOfferWagons,
|
||||
@@ -152,6 +153,14 @@ export interface ExportTrainOption {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** A train a paid-unallocated booking can board (route + capacity verified). */
|
||||
export interface AllocationCandidate {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
direction: string | null;
|
||||
scheduledDepartureDate: Date;
|
||||
}
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
originYardId: string;
|
||||
@@ -1448,6 +1457,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
*/
|
||||
async getBatchBoard(
|
||||
query: BatchBoardQueryDto = {},
|
||||
allowedDirections?: string[],
|
||||
): Promise<BatchBoardListResponse> {
|
||||
// Board cards are heavy (per-schedule booking summaries), so the default
|
||||
// page is smaller than the toolkit-wide 20.
|
||||
@@ -1455,6 +1465,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
defaultPageSize: 12,
|
||||
});
|
||||
|
||||
// The board is IMPORT-only — a user scoped away from IMPORT sees nothing.
|
||||
if (allowedDirections && !allowedDirections.includes("IMPORT")) {
|
||||
return { items: [], meta: buildPaginationMeta(0, page, pageSize) };
|
||||
}
|
||||
|
||||
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
|
||||
// arrived / cancelled / dispatched schedules stay visible as history.
|
||||
const allowedStatuses = new Set<string>(BATCH_BOARD_STATUSES);
|
||||
@@ -3027,6 +3042,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
: booking.status;
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
trainScheduleId: newScheduleId,
|
||||
scheduledDate: schedule.scheduledDepartureDate,
|
||||
status: restoredStatus,
|
||||
// A paid booking still hunting for a wagon keeps its flag through the
|
||||
// move — it only clears when wagons are actually assigned.
|
||||
@@ -3045,6 +3061,104 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.notifyBoardChanged(newScheduleId, "booking_moved");
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains a paid-but-unallocated booking can board right now: OPEN window,
|
||||
* future departure, route covers the booking's leg, and remaining corridor
|
||||
* capacity fits it. Split by the booking's own scheduled day so the UI can
|
||||
* offer one-click same-day allocation vs an explicit "another date" choice.
|
||||
*/
|
||||
async allocationCandidates(bookingId: string): Promise<{
|
||||
sameDay: AllocationCandidate[];
|
||||
otherDays: AllocationCandidate[];
|
||||
}> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
relations: {
|
||||
bookingContainers: { containerType: true },
|
||||
// wagonTypes drives the break-bulk items-per-wagon fit — size the
|
||||
// booking exactly as the intercity accept check does.
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const today = eatDay(new Date());
|
||||
const bookingDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null;
|
||||
const sameDay: AllocationCandidate[] = [];
|
||||
const otherDays: AllocationCandidate[] = [];
|
||||
for (const s of schedules) {
|
||||
if (!s.scheduledDepartureDate || eatDay(s.scheduledDepartureDate) < today) continue;
|
||||
if (s.bookingWindowStatus !== "OPEN") continue;
|
||||
if (s.id === booking.trainScheduleId) continue;
|
||||
const stops = await this.stopsForSchedule(s);
|
||||
const fromIdx = stops.indexOf(booking.originYardId);
|
||||
const toIdx = stops.indexOf(booking.destinationYardId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) continue;
|
||||
// ponytail: full capacity build per candidate is heavy; the set is small
|
||||
// (future OPEN trains on the booking's route) — precompute if it grows.
|
||||
const cap = await this.intercityCapacity(s.id);
|
||||
if (!cap) continue;
|
||||
const leg = cap.budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
if (!cap.budget.fits(cap.needFor(booking), leg)) continue;
|
||||
const candidate: AllocationCandidate = {
|
||||
id: s.id,
|
||||
reference: s.reference ?? s.trainNumber ?? null,
|
||||
direction: s.direction ?? null,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate,
|
||||
};
|
||||
(eatDay(s.scheduledDepartureDate) === bookingDay ? sameDay : otherDays).push(candidate);
|
||||
}
|
||||
const byDate = (a: AllocationCandidate, b: AllocationCandidate) =>
|
||||
new Date(a.scheduledDepartureDate).getTime() - new Date(b.scheduledDepartureDate).getTime();
|
||||
sameDay.sort(byDate);
|
||||
otherDays.sort(byDate);
|
||||
return { sameDay, otherDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a PAID booking that lost (or never got) its train: re-point via
|
||||
* moveToSchedule (window/route validation + day sync), then allocate it
|
||||
* immediately — payment already landed, so no new pay window opens. The
|
||||
* customer gets an in-app notice when the new train departs on a different
|
||||
* day than their original choice.
|
||||
*/
|
||||
async allocatePaid(bookingId: string, scheduleId: string): Promise<void> {
|
||||
const before = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!before) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (before.paymentStatus !== "PAID" && before.status !== "PAID") {
|
||||
throw new BadRequestException(
|
||||
"Booking is not paid — use the regular scheduling flow",
|
||||
);
|
||||
}
|
||||
const previousDay = before.scheduledDate ? eatDay(before.scheduledDate) : null;
|
||||
await this.moveToSchedule(bookingId, scheduleId);
|
||||
const fresh = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||||
});
|
||||
if (!fresh) return;
|
||||
if (!(await this.holdIfWagonShort(scheduleId, fresh))) {
|
||||
await this.allocate(scheduleId, fresh, "paid");
|
||||
}
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: scheduleId } });
|
||||
if (
|
||||
previousDay &&
|
||||
schedule?.scheduledDepartureDate &&
|
||||
eatDay(schedule.scheduledDepartureDate) !== previousDay
|
||||
) {
|
||||
this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One reminder per hold, shortly before its pay deadline (the window tick
|
||||
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
|
||||
@@ -3531,6 +3645,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Paid but detached from any train (staff removed it from an allocation,
|
||||
// or a sweep caught it unpinned): money was taken, so it must board — it
|
||||
// stays paid-unallocated for staff to place via the allocate action.
|
||||
if (paid) {
|
||||
this.logger.log(
|
||||
`[BATCH] expire skipped for ${booking.reference} — payment landed ` +
|
||||
`but no train attached; left paid-unallocated for manual placement`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Reconcile-before-expire (only when a pay window was actually open):
|
||||
// no webhook arrived, so ask the gateway DIRECTLY whether the money
|
||||
// landed. A late capture found there is registered as SUCCEEDED and
|
||||
@@ -3859,12 +3983,26 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// booking can use — don't kill it for nothing.
|
||||
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
|
||||
if (!overlaps) continue;
|
||||
const victimPaid =
|
||||
victim.paymentStatus === "PAID" || victim.status === "PAID";
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||
scheduleId,
|
||||
victim.id,
|
||||
manager,
|
||||
);
|
||||
if (victimPaid) {
|
||||
// Paid bookings are never expired — money was taken, so it boards.
|
||||
// Detach it so it surfaces in the paid-unallocated queue for staff
|
||||
// to re-place; the settled invoice stays untouched.
|
||||
await manager.getRepository(Booking).update(victim.id, {
|
||||
trainScheduleId: null,
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
return;
|
||||
}
|
||||
await manager.getRepository(Booking).update(victim.id, {
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
@@ -4106,8 +4244,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
|
||||
const cargoTons = bookingCargoTons(booking);
|
||||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||||
// wagon), so divide by the cap where one is configured for this type.
|
||||
const tonsPerWagon = bulkTonsPerWagon(
|
||||
booking.cargoType,
|
||||
booking.cargoType?.wagonTypes?.[0]?.id,
|
||||
capacityTons,
|
||||
);
|
||||
const byWeight =
|
||||
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
||||
cargoTons > 0 && tonsPerWagon > 0 ? Math.ceil(cargoTons / tonsPerWagon) : 0;
|
||||
|
||||
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
|
||||
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
|
||||
@@ -4175,6 +4320,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
|
||||
.map((o) => {
|
||||
const wagonTypeId = o.wagonTypeId as string;
|
||||
// Each type sized on its OWN per-wagon tonnage cap, not just its rating
|
||||
// — a type capped lower swallows less per wagon.
|
||||
const tonsPerWagon = bulkTonsPerWagon(
|
||||
booking.cargoType,
|
||||
wagonTypeId,
|
||||
o.dims.capacityTons,
|
||||
);
|
||||
const wagonsIfAlone = Math.max(
|
||||
1,
|
||||
bulkItemWagonsRequired(
|
||||
@@ -4182,8 +4334,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
o.dims.capacityTons,
|
||||
bulkItemsFitFor(booking.cargoType, wagonTypeId),
|
||||
) ||
|
||||
(o.dims.capacityTons > 0
|
||||
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons)
|
||||
(tonsPerWagon > 0
|
||||
? Math.ceil(bookingCargoTons(booking) / tonsPerWagon)
|
||||
: total),
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -275,6 +275,19 @@ export class BookingNotifierService {
|
||||
this.inApp(b, 'Booking rescheduled', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff placed a paid booking onto a train departing on a DIFFERENT day than
|
||||
* the customer's original choice. In-app only — staff drove the change and
|
||||
* the allocation itself already notifies through the secured path.
|
||||
*/
|
||||
allocatedOtherDay(b: Booking, newDeparture: Date): void {
|
||||
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||
const msg =
|
||||
`Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` +
|
||||
`New departure date: ${when}.`;
|
||||
this.inApp(b, 'Booking allocated to another date', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking was removed from its train during a staff reschedule (not a government
|
||||
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
|
||||
|
||||
@@ -9,9 +9,107 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Per-schedule booking-window rule chosen AT CREATION, instead of inheriting the
|
||||
* live global rules. Mirrors {@link UpdateScheduleWindowRuleDto}, plus the
|
||||
* booking-close offset (which the post-creation override deliberately never
|
||||
* touches). Every field is optional — an omitted field falls back to the global
|
||||
* value, so staff can override just the one knob they care about.
|
||||
*/
|
||||
export class CreateScheduleWindowRuleDto {
|
||||
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(23)
|
||||
windowOpenHour?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 17,
|
||||
description:
|
||||
'Local EAT hour the booking desk shuts each day. Equal to windowOpenHour = 24-hour desk',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(23)
|
||||
windowCloseHour?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.0166)
|
||||
@Max(12)
|
||||
windowDurationHours?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
docReviewMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
paymentWindowMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 3,
|
||||
description: 'Days before departure the IMPORT/DOMESTIC booking window starts',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
importWindowLeadDays?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 24,
|
||||
description: 'Hours before departure the single FCFS EXPORT window opens',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
exportBookingLeadHours?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 180,
|
||||
nullable: true,
|
||||
description:
|
||||
'Minutes before departure the booking window closes; 0/null = close at departure. ' +
|
||||
'Only the offset matching the schedule direction is used (import offset for ' +
|
||||
'IMPORT/DOMESTIC, export offset for EXPORT).',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
importCloseOffsetMinutes?: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 1440,
|
||||
nullable: true,
|
||||
description: 'Minutes before departure an EXPORT booking window closes; 0/null = at departure',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
exportCloseOffsetMinutes?: number | null;
|
||||
}
|
||||
|
||||
export class CreateContainerTrainScheduleDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
@@ -73,4 +171,19 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
reverseWagonOrder?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: CreateScheduleWindowRuleDto,
|
||||
description:
|
||||
'Configure the booking window for THIS schedule instead of inheriting the live ' +
|
||||
'global rules. Omit to use the global rules (the default). The values sent are ' +
|
||||
'frozen onto the schedule as its rule snapshot, exactly as a post-creation ' +
|
||||
'override would. Rejected for an IMPORT/DOMESTIC train that joins an existing ' +
|
||||
'route+day group — those siblings share one window timeline, so edit the group ' +
|
||||
"window instead of giving one member its own.",
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CreateScheduleWindowRuleDto)
|
||||
windowRule?: CreateScheduleWindowRuleDto;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util';
|
||||
import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
@@ -56,8 +56,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
|
||||
// holds the item count there, not tons. No wagon type is fixed yet, so use
|
||||
// the best count across the cargo's allowed types (per-type items-fit
|
||||
// respected); falls back to `capacity` when the relation isn't loaded.
|
||||
const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity);
|
||||
if (byItems > 0) return byItems;
|
||||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||||
// wagon), so tonnage divides by that cap, not by raw capacity.
|
||||
const byWagons = bulkWagonsForAllowedTypes(booking, booking.cargoType, capacity);
|
||||
if (byWagons > 0) return byWagons;
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
return Math.max(1, Math.ceil(weight / capacity));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
bookingTrainLengthMeters,
|
||||
bulkItemWagonsForAllowedTypes,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonWagonsForAllowedTypes,
|
||||
bulkTonWagonsRequired,
|
||||
bulkWagonsForAllowedTypes,
|
||||
consistUsage,
|
||||
consistViolations,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
@@ -135,6 +139,61 @@ describe('train-capacity.util', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkTonsPerWagon / bulkTonWagonsRequired (PER_TON loading cap)', () => {
|
||||
// Sugar is loaded 50T per wagon even on a 70T wagon.
|
||||
const sugar = { wagonTypes: [{ id: 'nw5', capacityTons: 70 }], tonsPerWagonMap: { nw5: 50 } };
|
||||
const bulk = (tons: number) => ({ freightType: 'BULK', cargoTotalWeightVgm: tons });
|
||||
|
||||
it('uses the configured cap instead of the rated capacity', () => {
|
||||
expect(bulkTonsPerWagon(sugar, 'nw5', 70)).toBe(50);
|
||||
});
|
||||
|
||||
it('falls back to rated capacity when the cargo type caps nothing', () => {
|
||||
expect(bulkTonsPerWagon(null, 'nw5', 70)).toBe(70);
|
||||
expect(bulkTonsPerWagon({ wagonTypes: [] }, 'nw5', 70)).toBe(70);
|
||||
expect(bulkTonsPerWagon({ tonsPerWagonMap: { other: 50 } }, 'nw5', 70)).toBe(70);
|
||||
});
|
||||
|
||||
it('clamps a stale cap that now exceeds the rating (wagon type edited down)', () => {
|
||||
// Saved when NW5 was rated 70T; the type was later re-rated to 45T.
|
||||
expect(bulkTonsPerWagon(sugar, 'nw5', 45)).toBe(45);
|
||||
});
|
||||
|
||||
it('sizes 200T of capped sugar at 4 wagons, not the 3 raw capacity implies', () => {
|
||||
expect(bulkTonWagonsRequired(bulk(200), sugar, 'nw5', 70)).toBe(4);
|
||||
// Same booking, no cap → the old 3-wagon answer.
|
||||
expect(bulkTonWagonsRequired(bulk(200), null, 'nw5', 70)).toBe(3);
|
||||
});
|
||||
|
||||
it('picks the fewest-wagon allowed type, each on its own cap', () => {
|
||||
const cargoType = {
|
||||
wagonTypes: [
|
||||
{ id: 'nw5', capacityTons: 70 },
|
||||
{ id: 'nw7', capacityTons: 80 },
|
||||
],
|
||||
tonsPerWagonMap: { nw5: 50 },
|
||||
};
|
||||
// NW5 capped 50 → 4 wagons; NW7 uncapped 80 → 3 wagons. Best = 3.
|
||||
expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3);
|
||||
});
|
||||
|
||||
it('routes PER_ITEM and PER_TON through one call', () => {
|
||||
expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4);
|
||||
// PER_ITEM still wins where an item count is present.
|
||||
const cars = {
|
||||
wagonTypes: [{ id: 'nw5', capacityTons: 70 }],
|
||||
itemsPerWagonMap: { nw5: 4 },
|
||||
};
|
||||
expect(
|
||||
bulkWagonsForAllowedTypes(
|
||||
{ freightType: 'BULK', cargoTotalWeightVgm: 50, bulkTotalWeightTons: 1000 },
|
||||
cars,
|
||||
70,
|
||||
),
|
||||
).toBe(17);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingCargoTons (break-bulk weight preference)', () => {
|
||||
it('prefers bulkTotalWeightTons over the item-count VGM column', () => {
|
||||
expect(
|
||||
|
||||
@@ -152,8 +152,98 @@ export function bulkItemWagonsRequired(
|
||||
type ItemFitCargoType = {
|
||||
wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null;
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
tonsPerWagonMap?: Record<string, number> | null;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* Tons of THIS cargo one wagon of this type may carry: the cargo type's
|
||||
* configured loading limit when set, else the wagon's full rated capacity.
|
||||
* Sugar capped at 50T rides 50T on a 70T wagon, so 200T needs 4 wagons and each
|
||||
* is loaded to 50 — both the count and the fill follow from this one number.
|
||||
*
|
||||
* The configured cap is CLAMPED to the rated capacity rather than trusted: the
|
||||
* cargo-types service rejects a cap above capacity at save time, but a wagon
|
||||
* type edited DOWN afterwards would leave a stale cap that overloads the wagon.
|
||||
* Clamping here means no call site can ever load past the physical rating.
|
||||
*/
|
||||
export function bulkTonsPerWagon(
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
wagonTypeId: string | null | undefined,
|
||||
capacityTons: number | string | null | undefined,
|
||||
): number {
|
||||
const capacity = num(capacityTons);
|
||||
const cap = wagonTypeId ? num(cargoType?.tonsPerWagonMap?.[wagonTypeId]) : 0;
|
||||
if (!(cap > 0)) return capacity;
|
||||
return capacity > 0 ? Math.min(cap, capacity) : cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons a PER_TON bulk booking needs on one wagon type, respecting the cargo
|
||||
* type's per-wagon loading limit: 200T of sugar capped at 50T → 4 wagons even
|
||||
* though the wagon is rated 70T. Returns 0 when there is no tonnage or no
|
||||
* usable per-wagon figure, so callers can fall back as before.
|
||||
*/
|
||||
export function bulkTonWagonsRequired(
|
||||
booking: Parameters<typeof bookingCargoTons>[0],
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
wagonTypeId: string | null | undefined,
|
||||
capacityTons: number | string | null | undefined,
|
||||
): number {
|
||||
const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons);
|
||||
const tons = bookingCargoTons(booking);
|
||||
if (!(perWagon > 0) || !(tons > 0)) return 0;
|
||||
return Math.max(1, Math.ceil(tons / perWagon));
|
||||
}
|
||||
|
||||
/**
|
||||
* Best (fewest-wagon) PER_TON count across the cargo type's allowed wagon
|
||||
* types, each sized on its OWN loading limit — the tonnage twin of
|
||||
* {@link bulkItemWagonsForAllowedTypes}, for the call sites that have no single
|
||||
* wagon type fixed yet. Falls back to `fallbackCapacityTons` when the cargo
|
||||
* type has no usable allowed types.
|
||||
*/
|
||||
export function bulkTonWagonsForAllowedTypes(
|
||||
booking: Parameters<typeof bookingCargoTons>[0],
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
fallbackCapacityTons: number,
|
||||
): number {
|
||||
const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0);
|
||||
if (!allowed.length) {
|
||||
return bulkTonWagonsRequired(booking, cargoType, null, fallbackCapacityTons);
|
||||
}
|
||||
let best = 0;
|
||||
for (const wagonType of allowed) {
|
||||
const wagons = bulkTonWagonsRequired(
|
||||
booking,
|
||||
cargoType,
|
||||
wagonType.id,
|
||||
wagonType.capacityTons,
|
||||
);
|
||||
if (wagons > 0 && (best === 0 || wagons < best)) best = wagons;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons a BULK booking needs, whichever way its cargo is measured: PER_ITEM
|
||||
* sizes by indivisible items, everything else by tonnage under the cargo type's
|
||||
* per-wagon loading limit. One call so no site has to remember both paths.
|
||||
*/
|
||||
export function bulkWagonsForAllowedTypes(
|
||||
booking: Parameters<typeof bookingCargoTons>[0] & {
|
||||
freightType?: string | null;
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bulkTotalWeightTons?: number | string | null;
|
||||
},
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
fallbackCapacityTons: number,
|
||||
): number {
|
||||
return (
|
||||
bulkItemWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) ||
|
||||
bulkTonWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons)
|
||||
);
|
||||
}
|
||||
|
||||
/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */
|
||||
export function bulkItemsFitFor(
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
||||
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||
|
||||
import {
|
||||
@@ -66,6 +67,7 @@ export class TrainSchedulingController {
|
||||
private readonly intercityService: IntercityService,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
) { }
|
||||
|
||||
@Get("my-booking-windows")
|
||||
@@ -130,8 +132,14 @@ export class TrainSchedulingController {
|
||||
summary:
|
||||
"Batch monitoring board: paginated import schedules (all statuses) with bookings grouped by state",
|
||||
})
|
||||
getBatchBoard(@Query() query: BatchBoardQueryDto) {
|
||||
return this.bookingBatchService.getBatchBoard(query);
|
||||
async getBatchBoard(
|
||||
@Query() query: BatchBoardQueryDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
// Batch board is IMPORT-only — a user without IMPORT access sees nothing.
|
||||
const allowed =
|
||||
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.bookingBatchService.getBatchBoard(query, allowed ?? undefined);
|
||||
}
|
||||
|
||||
@Get("batch-board/:scheduleId")
|
||||
@@ -703,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")
|
||||
@@ -834,6 +856,32 @@ export class TrainSchedulingController {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get("bookings/:bookingId/allocation-candidates")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Trains a paid-unallocated booking fits, split same-day vs other days",
|
||||
})
|
||||
getAllocationCandidates(
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
) {
|
||||
return this.bookingBatchService.allocationCandidates(bookingId);
|
||||
}
|
||||
|
||||
@Post("bookings/:bookingId/allocate")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Staff: place a paid booking onto a fitting train (notifies customer on date change)",
|
||||
})
|
||||
async allocatePaidBooking(
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
@Body("trainScheduleId", ParseUUIDPipe) trainScheduleId: string,
|
||||
) {
|
||||
await this.bookingBatchService.allocatePaid(bookingId, trainScheduleId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get("schedules/:id/checkpoints")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
@@ -868,15 +916,31 @@ export class TrainSchedulingController {
|
||||
@Get("container/schedules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List container train schedules (paginated)" })
|
||||
getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules(query);
|
||||
async getContainerTrainSchedules(
|
||||
@Query() query: ListTrainSchedulesQueryDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const allowed =
|
||||
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.trainSchedulingService.getContainerTrainSchedules(
|
||||
query,
|
||||
allowed ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("bulk/schedules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List bulk train schedules (paginated)" })
|
||||
getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules(query);
|
||||
async getBulkTrainSchedules(
|
||||
@Query() query: ListTrainSchedulesQueryDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const allowed =
|
||||
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.trainSchedulingService.getContainerTrainSchedules(
|
||||
query,
|
||||
allowed ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("container/schedules/:id")
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||
@@ -63,6 +64,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
]),
|
||||
forwardRef(() => BookingsModule),
|
||||
BillingModule,
|
||||
UserTradeAccessModule,
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
LocomotivesModule,
|
||||
|
||||
@@ -805,6 +805,51 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
describe('restampPendingWindows (hand-configured windows are exempt)', () => {
|
||||
const future = new Date(Date.now() + 30 * 24 * 3600_000);
|
||||
const update = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
update.mockClear();
|
||||
// Global rules read + the TrainSchedule repo the restamp writes through.
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
const name = (entity as { name?: string })?.name;
|
||||
if (name === 'TrainSchedulingGlobalRules') {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
return { update };
|
||||
});
|
||||
});
|
||||
|
||||
it('re-stamps a schedule that follows the global rules', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
id: 'sched-global',
|
||||
direction: 'IMPORT',
|
||||
scheduledDepartureDate: future,
|
||||
windowRuleCustom: false,
|
||||
},
|
||||
]);
|
||||
await expect(service.restampPendingWindows()).resolves.toBe(1);
|
||||
expect(update).toHaveBeenCalledWith('sched-global', expect.anything());
|
||||
});
|
||||
|
||||
it('leaves a hand-configured schedule alone', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
id: 'sched-custom',
|
||||
direction: 'IMPORT',
|
||||
scheduledDepartureDate: future,
|
||||
windowRuleCustom: true,
|
||||
},
|
||||
]);
|
||||
// Staff picked these times deliberately — a global-rules edit must not
|
||||
// overwrite them, or the per-schedule configuration would be pointless.
|
||||
await expect(service.restampPendingWindows()).resolves.toBe(0);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnassignedBookings', () => {
|
||||
const scheduleId = 'sched-unassigned-1';
|
||||
const trainSetId = 'train-set-unassigned';
|
||||
@@ -1121,6 +1166,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', () => {
|
||||
|
||||
@@ -138,6 +138,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
combinedLocomotiveLimits,
|
||||
trainSetLocomotiveLimits,
|
||||
@@ -180,6 +181,13 @@ import {
|
||||
|
||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||||
|
||||
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
|
||||
function pickDefined<T extends object>(source: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).filter(([, v]) => v !== undefined),
|
||||
) as Partial<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking-window rule fields frozen onto a train schedule at creation (and
|
||||
* refreshed by restampPendingWindows for not-yet-open schedules). The board draws
|
||||
@@ -905,6 +913,9 @@ export class TrainSchedulingService {
|
||||
windowClosesAt: cap(times.windowClosesAt, t.departure),
|
||||
...ruleFields,
|
||||
rulePaymentWindowMinutes,
|
||||
// Deliberately overridden — exempt from the global re-stamp, which would
|
||||
// otherwise revert this schedule the next time global rules are saved.
|
||||
windowRuleCustom: true,
|
||||
});
|
||||
}
|
||||
this.logger.log(
|
||||
@@ -1196,6 +1207,9 @@ export class TrainSchedulingService {
|
||||
let restamped = 0;
|
||||
for (const s of schedules) {
|
||||
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
|
||||
// Hand-configured windows are not "pending the global rule" — staff picked
|
||||
// these times deliberately, so a global-rules edit must leave them alone.
|
||||
if (s.windowRuleCustom) continue;
|
||||
const times =
|
||||
s.direction === 'EXPORT'
|
||||
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
|
||||
@@ -1459,29 +1473,8 @@ export class TrainSchedulingService {
|
||||
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
|
||||
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
|
||||
// 24h before departure (FCFS). No schedule is ever always-open now.
|
||||
const windowCfg = await this.getWindowConfig();
|
||||
const globalCfg = await this.getWindowConfig();
|
||||
|
||||
// Staff cannot schedule inside the lead window — there must be room for a
|
||||
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
|
||||
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
|
||||
// lead is in hours (24h = 1 day ahead).
|
||||
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
|
||||
if (departure.getTime() < earliest.getTime()) {
|
||||
const detail =
|
||||
direction === 'EXPORT'
|
||||
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
|
||||
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
|
||||
throw new BadRequestException(
|
||||
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
|
||||
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
|
||||
`(earliest ${earliest.toISOString()})`,
|
||||
);
|
||||
}
|
||||
// Freeze the rule this schedule is born with. A later global-rules edit
|
||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||
// windows from it rather than the live config.
|
||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||
// Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
|
||||
// on this origin + destination + EAT departure day, this new train JOINS
|
||||
// its group and adopts the group's shared window timeline (open/close +
|
||||
@@ -1505,6 +1498,77 @@ export class TrainSchedulingService {
|
||||
route.destinationYardId,
|
||||
departure,
|
||||
);
|
||||
|
||||
// Per-schedule window rule chosen at creation. Refused for a train that
|
||||
// JOINS an existing route+day group: the group shares ONE window timeline,
|
||||
// so a joining train adopts the anchor's times verbatim and its own
|
||||
// settings would be silently discarded. Staff edit the group's window
|
||||
// instead (Booking window settings, which fans out to every sibling).
|
||||
if (dto.windowRule && groupAnchor) {
|
||||
throw new BadRequestException(
|
||||
'This train joins an existing booking group (same route and departure day), ' +
|
||||
'which shares one booking window across all its trains. Create it with the ' +
|
||||
'group settings, then use Booking window settings to change the window for ' +
|
||||
'the whole group.',
|
||||
);
|
||||
}
|
||||
|
||||
// The rule this schedule is born under: staff overrides on top of the live
|
||||
// global config, so an omitted field still follows the global value.
|
||||
const windowCfg: BookingWindowConfig = dto.windowRule
|
||||
? {
|
||||
...globalCfg,
|
||||
...pickDefined({
|
||||
windowOpenHour: dto.windowRule.windowOpenHour,
|
||||
windowCloseHour: dto.windowRule.windowCloseHour,
|
||||
windowDurationHours: dto.windowRule.windowDurationHours,
|
||||
docReviewMinutes: dto.windowRule.docReviewMinutes,
|
||||
importWindowLeadDays: dto.windowRule.importWindowLeadDays,
|
||||
exportBookingLeadHours: dto.windowRule.exportBookingLeadHours,
|
||||
}),
|
||||
// One pay-window override drives both directions (only the one
|
||||
// matching this schedule's direction is ever read).
|
||||
...(dto.windowRule.paymentWindowMinutes !== undefined
|
||||
? {
|
||||
paymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
|
||||
exportPaymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
|
||||
}
|
||||
: {}),
|
||||
// Close offsets are nullable-by-intent: null/0 means "close at
|
||||
// departure", which must override a non-null global, so these are
|
||||
// merged on presence rather than on definedness.
|
||||
...(dto.windowRule.importCloseOffsetMinutes !== undefined
|
||||
? { importCloseOffsetMinutes: dto.windowRule.importCloseOffsetMinutes ?? null }
|
||||
: {}),
|
||||
...(dto.windowRule.exportCloseOffsetMinutes !== undefined
|
||||
? { exportCloseOffsetMinutes: dto.windowRule.exportCloseOffsetMinutes ?? null }
|
||||
: {}),
|
||||
}
|
||||
: globalCfg;
|
||||
|
||||
// Staff cannot schedule inside the lead window — there must be room for a
|
||||
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
|
||||
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
|
||||
// lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN
|
||||
// lead, so a custom lead is honoured rather than rejected by the global one.
|
||||
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
|
||||
if (departure.getTime() < earliest.getTime()) {
|
||||
const detail =
|
||||
direction === 'EXPORT'
|
||||
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
|
||||
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
|
||||
throw new BadRequestException(
|
||||
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
|
||||
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
|
||||
`(earliest ${earliest.toISOString()})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Freeze the rule this schedule is born with. A later global-rules edit
|
||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||
// windows from it rather than the live config.
|
||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||
const computedTimes =
|
||||
direction === 'EXPORT'
|
||||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||
@@ -1513,12 +1577,30 @@ export class TrainSchedulingService {
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
if (
|
||||
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'These booking-window settings leave no window before departure — with the ' +
|
||||
'desk hours and close offset applied, the window would only open once the ' +
|
||||
'train has left.',
|
||||
);
|
||||
}
|
||||
const windowFields = {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...(groupAnchor
|
||||
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
||||
: computedTimes),
|
||||
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
|
||||
// live global value for the direction), so an explicit staff override is
|
||||
// persisted here — the same field the post-creation override writes.
|
||||
...(dto.windowRule?.paymentWindowMinutes !== undefined
|
||||
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
|
||||
: {}),
|
||||
// Hand-configured windows opt OUT of the global re-stamp, or the next
|
||||
// global-rules edit would overwrite exactly what staff chose here.
|
||||
windowRuleCustom: dto.windowRule != null,
|
||||
};
|
||||
// A built train's own consist is the schedule's capacity: full when all
|
||||
// its wagons are allocated. Trains built without wagons yet fall back to
|
||||
@@ -2928,6 +3010,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 +3108,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 +3129,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 +3173,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 +3219,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 +3248,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 +3273,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 +3294,7 @@ export class TrainSchedulingService {
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
||||
${unassignedRows}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3899,13 +4086,25 @@ export class TrainSchedulingService {
|
||||
return Object.assign(detail, { warehouseAutomation });
|
||||
}
|
||||
|
||||
async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) {
|
||||
async getContainerTrainSchedules(
|
||||
query: ListTrainSchedulesQueryDto = {},
|
||||
allowedDirections?: string[],
|
||||
) {
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
|
||||
// Per-user trade-direction scope: schedules carry a `direction` column.
|
||||
if (allowedDirections && allowedDirections.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
meta: buildPaginationMeta(0, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
// Exact-match filters (enum/id semantics). Freight type is derived from
|
||||
// the bookings aboard — no column to match — so it rides on `id` as an
|
||||
// EXISTS fragment instead.
|
||||
const base: FindOptionsWhere<TrainSchedule> = {};
|
||||
if (allowedDirections) base.direction = In(allowedDirections) as never;
|
||||
if (query.status) base.status = query.status;
|
||||
if (query.originStationId) base.originStationId = query.originStationId;
|
||||
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
|
||||
@@ -7335,8 +7534,10 @@ export class TrainSchedulingService {
|
||||
? Math.ceil(booking.wagonsRequired)
|
||||
: 0;
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
const byWeight =
|
||||
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
|
||||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||||
// wagon) — more wagons for the same cargo, so more tare to pull.
|
||||
const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons);
|
||||
const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0;
|
||||
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
|
||||
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
|
||||
const byItems = bulkItemWagonsRequired(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsForAllowedTypes,
|
||||
bulkWagonsForAllowedTypes,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
sortBookingsForScheduling,
|
||||
@@ -122,10 +122,11 @@ const shortageFor = (
|
||||
? Math.max(
|
||||
1,
|
||||
// Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map
|
||||
// respected); PER_TON falls through to tonnage over the largest
|
||||
// candidate. bookingCargoTons, not raw VGM — for PER_ITEM that
|
||||
// column is the item count, not tons.
|
||||
bulkItemWagonsForAllowedTypes(
|
||||
// respected); PER_TON divides by its per-wagon tonnage cap where one
|
||||
// is configured, else the largest candidate's rating.
|
||||
// bookingCargoTons, not raw VGM — for PER_ITEM that column is the
|
||||
// item count, not tons.
|
||||
bulkWagonsForAllowedTypes(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonWagonsRequired,
|
||||
consistViolations,
|
||||
} from './train-capacity.util';
|
||||
|
||||
@@ -186,15 +188,29 @@ export function buildBulkWagonPlan(
|
||||
bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)),
|
||||
);
|
||||
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
|
||||
|
||||
// PER_TON cargo with a per-wagon tonnage cap (sugar 50T on a 70T wagon) can't
|
||||
// pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs
|
||||
// 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on
|
||||
// their own cap; only genuinely uncapped tonnage pools at rated capacity.
|
||||
const cappedTonSlotsByBooking = bookings.map((b, i) =>
|
||||
itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity
|
||||
? 0
|
||||
: bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity),
|
||||
);
|
||||
const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0);
|
||||
|
||||
const totalWeight = roundTons(
|
||||
bookings.reduce(
|
||||
(sum, b, i) =>
|
||||
itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0),
|
||||
itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0
|
||||
? sum
|
||||
: sum + Number(b.cargoTotalWeightVgm ?? 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0;
|
||||
const slots = Math.max(1, tonSlots + itemSlots);
|
||||
const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots);
|
||||
|
||||
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
@@ -315,6 +331,7 @@ function allocateBookingsToSlots(
|
||||
// bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM)
|
||||
// bookings that column is an item COUNT, not tons.
|
||||
remainingWeightTons: roundTons(bookingCargoTons(booking)),
|
||||
cargoType: booking.cargoType,
|
||||
}));
|
||||
|
||||
let bookingIndex = 0;
|
||||
@@ -326,8 +343,15 @@ function allocateBookingsToSlots(
|
||||
|
||||
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
|
||||
const booking = remaining[bookingIndex];
|
||||
// A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well
|
||||
// as the wagon count — the plan reserved a wagon per capped chunk, so
|
||||
// pouring rated capacity into it would leave the last wagon empty.
|
||||
const takeCap = Math.min(
|
||||
wagonRemaining,
|
||||
bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons),
|
||||
);
|
||||
const allocatedWeightTons = roundTons(
|
||||
Math.min(wagonRemaining, booking.remainingWeightTons),
|
||||
Math.min(takeCap, booking.remainingWeightTons),
|
||||
);
|
||||
|
||||
if (allocatedWeightTons <= 0) {
|
||||
@@ -350,6 +374,12 @@ function allocateBookingsToSlots(
|
||||
|
||||
if (booking.remainingWeightTons <= 0) {
|
||||
bookingIndex += 1;
|
||||
} else if (allocatedWeightTons >= takeCap) {
|
||||
// The cap stopped this wagon short of its rating and the booking has
|
||||
// more to load. The leftover room is NOT free: `buildBulkWagonPlan`
|
||||
// already reserved a wagon for the rest, so backfilling another booking
|
||||
// here would double-book the consist. Close the wagon.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user