mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
End-to-end testing of the direct truck-to-train flow surfaced two defects in the carriage acceptance sheet's no-wagon fallback. Container numbers were read from freight.containers, which only gains rows at allocation, so a direct booking's declared containers never appeared; they are read from booking_container_units now, seal numbers included. The weight used bulkTotalWeightTons alone, which is null for CONTAINER and PER_TON bulk bookings, printing 0.00 — cargoTotalWeightVgm is the real weight there and only holds an item count for PER_ITEM break-bulk. Separately, warehouse cargo can be loaded from the Load-to-Train queue or from the schedule, but loading from the schedule never advanced warehouse_inventory. The booking went IN_TRANSIT while its cargo still read as sitting in the warehouse — the same inconsistency that produced the spurious dispatch block. loadBooking now moves the inventory to LOADED in the same transaction, and no-ops for direct bookings.
697 lines
29 KiB
TypeScript
697 lines
29 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
Optional,
|
|
} from '@nestjs/common';
|
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
import { DataSource, EntityManager, In } from 'typeorm';
|
|
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';
|
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
|
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
|
|
|
/**
|
|
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
|
*
|
|
* A booking rides only its own origin→destination leg, so "dispatched" and
|
|
* "arrived" are per-booking facts confirmed by the yard operator, not train
|
|
* facts: load at the booking's origin yard (PAID → IN_TRANSIT, loadedAt) and
|
|
* unload at its destination yard (IN_TRANSIT → ARRIVED for import/export,
|
|
* → COMPLETED for intercity), possibly long before the train's final arrival.
|
|
* Both are gated on the train's latest recorded checkpoint being at that yard.
|
|
* Unload also fires automatically: recording a checkpoint at a yard auto-
|
|
* unloads every booking destined there (autoUnloadAtYard), so the manual
|
|
* unload endpoint remains only a fallback.
|
|
*
|
|
* Unloading also settles the physical wagons: each wagon that alights with the
|
|
* booking is released at that yard and the move is written to the
|
|
* wagon_movements ledger.
|
|
*/
|
|
@Injectable()
|
|
export class BookingJourneyService {
|
|
private readonly logger = new Logger(BookingJourneyService.name);
|
|
|
|
constructor(
|
|
@InjectDataSource() private readonly dataSource: DataSource,
|
|
private readonly yardFacilities: YardFacilitiesService,
|
|
private readonly facilityHandling: FacilityHandlingService,
|
|
private readonly events: EventEmitter2,
|
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
|
) {}
|
|
|
|
/** Statuses from which a booking may be loaded (gov bookings don't prepay). */
|
|
private canLoad(booking: Booking): boolean {
|
|
if (booking.status === 'PAID') return true;
|
|
return booking.isGovernment && booking.status === 'APPROVED';
|
|
}
|
|
|
|
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
|
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
|
if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
|
|
throw new BadRequestException('Booking is already loaded');
|
|
}
|
|
if (!this.canLoad(booking)) {
|
|
throw new BadRequestException(
|
|
`Booking must be paid before loading (currently ${booking.status})`,
|
|
);
|
|
}
|
|
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
|
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
|
|
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
|
// however it arrived and whatever it is allocated to.
|
|
await assertExportReceivedWithGrn(this.dataSource, booking);
|
|
|
|
const now = new Date();
|
|
await this.dataSource.transaction(async (manager) => {
|
|
await manager.getRepository(Booking).update(bookingId, {
|
|
status: 'IN_TRANSIT',
|
|
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.
|
|
await manager
|
|
.getRepository(TrainScheduleBooking)
|
|
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' });
|
|
// Warehouse cargo may be loaded either from the warehouse Load-to-Train
|
|
// queue or from the schedule itself. Loading here must move its inventory
|
|
// too, otherwise the goods read as still sitting in the shed while the
|
|
// train leaves with them. No-ops for direct truck-to-train (no inventory).
|
|
// ponytail: no WarehouseLoading record on this path — those are only read
|
|
// back as per-inventory loading history, never billed. Create them here if
|
|
// that history ever has to be complete.
|
|
await manager.query(
|
|
`UPDATE freight.warehouse_inventory
|
|
SET status = 'LOADED', loaded_at = COALESCE(loaded_at, $2), updated_at = NOW()
|
|
WHERE booking_id = $1 AND deleted_at IS NULL
|
|
AND status NOT IN ('LOADED', 'DISPATCHED')`,
|
|
[bookingId, now],
|
|
);
|
|
// The facility handed the cargo over — raise its GRN. No-ops for yards
|
|
// without a facility (import/export terminals), which keep their own flow.
|
|
await this.facilityHandling.recordHandling(manager, {
|
|
booking,
|
|
yardId: booking.originYardId,
|
|
trainScheduleId: scheduleId,
|
|
eventType: 'LOAD',
|
|
performedBy: userId ?? null,
|
|
occurredAt: now,
|
|
});
|
|
});
|
|
|
|
// Customer tracking: cargo is on the train — loading milestones plus the
|
|
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
|
// bookings (intercity) and already-completed codes.
|
|
void this.completeMilestones(booking, [
|
|
'CARGO_ARRIVED',
|
|
'READY_FOR_LOADING',
|
|
'LOADED',
|
|
...(booking.tradeDirection === 'IMPORT'
|
|
? ['DEPARTED_FROM_DJIBOUTI']
|
|
: booking.tradeDirection === 'EXPORT'
|
|
? ['DEPARTED_TO_DJIBOUTI']
|
|
: []),
|
|
]);
|
|
|
|
return { bookingId, status: 'IN_TRANSIT' as const, loadedAt: now.toISOString() };
|
|
}
|
|
|
|
async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
|
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
|
if (booking.status !== 'IN_TRANSIT') {
|
|
throw new BadRequestException(
|
|
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
|
);
|
|
}
|
|
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
|
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
|
|
|
|
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
|
// export continue into clearance, keyed on the booking's own arrival.
|
|
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
|
|
const now = new Date();
|
|
await this.dataSource.transaction(async (manager) => {
|
|
await manager.getRepository(Booking).update(bookingId, {
|
|
status: nextStatus,
|
|
arrivedAt: now,
|
|
arrivedByUserId: userId ?? null,
|
|
} as never);
|
|
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
|
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
|
|
// The facility took the cargo off the train — raise its GRN. Where the
|
|
// facility also stores cargo (Indode), the event links the storage record
|
|
// that storage/demurrage accrue against.
|
|
await this.facilityHandling.recordHandling(manager, {
|
|
booking,
|
|
yardId: booking.destinationYardId,
|
|
trainScheduleId: scheduleId,
|
|
eventType: 'UNLOAD',
|
|
performedBy: userId ?? null,
|
|
occurredAt: now,
|
|
});
|
|
});
|
|
|
|
// Intercity ends here — a ONE_TIME contract closes on its shipment being
|
|
// delivered (import/export emit this from booking-transition.complete).
|
|
if (nextStatus === 'COMPLETED') {
|
|
this.events.emit('booking.completed', { bookingId });
|
|
}
|
|
|
|
// The cargo is physically off the train at its own yard — mid-corridor or
|
|
// final. WarehouseInventoryService picks this up to create the warehouse
|
|
// record (import/intercity only; export already has one from receive).
|
|
this.events.emit('booking.unloadedAtYard', {
|
|
bookingId,
|
|
tradeDirection: booking.tradeDirection,
|
|
});
|
|
|
|
// Customer tracking: THIS booking arrived (train may still be rolling).
|
|
void this.completeMilestones(booking, [
|
|
...(booking.tradeDirection === 'IMPORT'
|
|
? ['ARRIVED_ETHIOPIA']
|
|
: booking.tradeDirection === 'EXPORT'
|
|
? ['ARRIVED_AT_DJIBOUTI']
|
|
: []),
|
|
]);
|
|
|
|
return { bookingId, status: nextStatus, arrivedAt: now.toISOString() };
|
|
}
|
|
|
|
/**
|
|
* Per-yard operator worklist for a schedule: which bookings board / alight at
|
|
* each stop, with their journey state, so the yard operator at Dire sees
|
|
* exactly what to load and unload when the train is there.
|
|
*/
|
|
async listYardWork(scheduleId: string) {
|
|
const schedule = await this.getSchedule(scheduleId);
|
|
const bookings = await this.dataSource
|
|
.getRepository(Booking)
|
|
.createQueryBuilder('booking')
|
|
.leftJoinAndSelect('booking.company', 'company')
|
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
|
.innerJoin(
|
|
TrainScheduleBooking,
|
|
'tsb',
|
|
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
|
{ scheduleId },
|
|
)
|
|
.getMany();
|
|
|
|
const latest = await this.latestCheckpoint(scheduleId);
|
|
const yardIds = [
|
|
...new Set(
|
|
bookings.flatMap((b) => [b.originYardId, b.destinationYardId]).filter(Boolean),
|
|
),
|
|
];
|
|
const yards = yardIds.length
|
|
? await this.dataSource.getRepository(Yard).find({ where: { id: In(yardIds) } })
|
|
: [];
|
|
const yardById = new Map(yards.map((y) => [y.id, y]));
|
|
const yardLabel = (id: string) =>
|
|
yardById.get(id)?.label ?? yardById.get(id)?.code ?? id;
|
|
|
|
const mapBooking = (b: Booking) => ({
|
|
id: b.id,
|
|
reference: b.reference,
|
|
status: b.status,
|
|
tradeDirection: b.tradeDirection,
|
|
isGovernment: b.isGovernment,
|
|
customer: b.company?.name ?? 'Unknown customer',
|
|
originYardId: b.originYardId,
|
|
destinationYardId: b.destinationYardId,
|
|
origin: yardLabel(b.originYardId),
|
|
destination: yardLabel(b.destinationYardId),
|
|
loadedAt: b.loadedAt?.toISOString() ?? null,
|
|
arrivedAt: b.arrivedAt?.toISOString() ?? null,
|
|
canLoad: !b.loadedAt && this.canLoad(b),
|
|
canUnload: b.status === 'IN_TRANSIT',
|
|
});
|
|
|
|
const byYard = new Map<
|
|
string,
|
|
{ yardId: string; yard: string; toLoad: ReturnType<typeof mapBooking>[]; toUnload: ReturnType<typeof mapBooking>[] }
|
|
>();
|
|
const bucket = (yardId: string) => {
|
|
let entry = byYard.get(yardId);
|
|
if (!entry) {
|
|
entry = { yardId, yard: yardLabel(yardId), toLoad: [], toUnload: [] };
|
|
byYard.set(yardId, entry);
|
|
}
|
|
return entry;
|
|
};
|
|
for (const b of bookings) {
|
|
bucket(b.originYardId).toLoad.push(mapBooking(b));
|
|
bucket(b.destinationYardId).toUnload.push(mapBooking(b));
|
|
}
|
|
|
|
return {
|
|
scheduleId,
|
|
scheduleStatus: schedule.status,
|
|
// No checkpoint yet ⇒ the train is still at its origin, even just after
|
|
// dispatch — assertTrainAtYard allows origin loading in that state, so
|
|
// the UI position must agree or origin Load buttons grey out wrongly.
|
|
trainAtYardId: latest?.yardId ?? schedule.originStationId,
|
|
yards: [...byYard.values()],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Auto-unload on checkpoint: every IN_TRANSIT booking on this schedule whose
|
|
* destination is the yard the train just reached alights automatically, so
|
|
* the customer's booking flips to ARRIVED (COMPLETED for intercity) the
|
|
* moment the train is recorded at their yard — no separate operator unload.
|
|
* Runs through the same per-booking unload path (wagon settle + ledger +
|
|
* milestones); one booking's failure is logged and never blocks the
|
|
* checkpoint or the other bookings. Returns the unloaded booking ids.
|
|
*/
|
|
async autoUnloadAtYard(
|
|
scheduleId: string,
|
|
yardId: string,
|
|
userId?: string | null,
|
|
): Promise<string[]> {
|
|
const bookings = await this.dataSource
|
|
.getRepository(Booking)
|
|
.createQueryBuilder('booking')
|
|
// Entity-class join: a raw 'freight.table' string is parsed by TypeORM as
|
|
// an alias.property path ("freight" alias was not found) — runtime 500.
|
|
.innerJoin(
|
|
TrainScheduleBooking,
|
|
'tsb',
|
|
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
|
{ scheduleId },
|
|
)
|
|
.where('booking.destination_yard_id = :yardId', { yardId })
|
|
.andWhere(`booking.status = 'IN_TRANSIT'`)
|
|
.getMany();
|
|
|
|
const unloaded: string[] = [];
|
|
for (const booking of bookings) {
|
|
try {
|
|
await this.unloadBooking(scheduleId, booking.id, userId);
|
|
unloaded.push(booking.id);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Auto-unload failed for booking ${booking.id} at yard ${yardId}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
return unloaded;
|
|
}
|
|
|
|
/**
|
|
* Bulk fallback at the train's FINAL arrival: any booking destined for the
|
|
* final yard that operators didn't unload individually gets its per-booking
|
|
* arrival stamped now, so nothing stays stuck. Mid-corridor bookings are NOT
|
|
* touched — their arrival is their own unload. Returns the affected ids.
|
|
*/
|
|
async autoArriveAtFinalYard(
|
|
manager: EntityManager,
|
|
schedule: TrainSchedule,
|
|
now: Date,
|
|
): Promise<string[]> {
|
|
const rows: Array<{ id: string; trade_direction: string }> = await manager.query(
|
|
`UPDATE freight.bookings b
|
|
SET status = CASE WHEN b.trade_direction = 'DOMESTIC' THEN 'COMPLETED' ELSE 'ARRIVED' END,
|
|
scheduling_status = 'DISPATCHED',
|
|
arrived_at = COALESCE(b.arrived_at, $3),
|
|
loaded_at = COALESCE(b.loaded_at, b.created_at)
|
|
FROM freight.train_schedule_bookings tsb
|
|
WHERE tsb.booking_id = b.id
|
|
AND tsb.train_schedule_id = $1
|
|
AND tsb.deleted_at IS NULL
|
|
AND b.deleted_at IS NULL
|
|
AND b.destination_yard_id = $2
|
|
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED', 'ARRIVED', 'DELIVERED')
|
|
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".
|
|
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);
|
|
}
|
|
|
|
// ---- helpers ---------------------------------------------------------------
|
|
|
|
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
|
|
const schedule = await this.dataSource
|
|
.getRepository(TrainSchedule)
|
|
.findOne({ where: { id: scheduleId } });
|
|
if (!schedule) {
|
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
|
}
|
|
return schedule;
|
|
}
|
|
|
|
private async getScheduleBooking(scheduleId: string, bookingId: string) {
|
|
const schedule = await this.getSchedule(scheduleId);
|
|
const booking = await this.dataSource
|
|
.getRepository(Booking)
|
|
.findOne({ where: { id: bookingId } });
|
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
|
if (booking.trainScheduleId !== scheduleId) {
|
|
throw new BadRequestException('Booking is not assigned to this schedule');
|
|
}
|
|
return { schedule, booking };
|
|
}
|
|
|
|
private async latestCheckpoint(scheduleId: string): Promise<TrainCheckpointEvent | null> {
|
|
return this.dataSource.getRepository(TrainCheckpointEvent).findOne({
|
|
where: { trainScheduleId: scheduleId },
|
|
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* INTERCITY ONLY. Intercity cargo rides a passing train and is handled at the
|
|
* booking's own yards, so those yards need the equipment to do it — a train
|
|
* stopping somewhere is not the same as somewhere being able to load it.
|
|
*
|
|
* Import/export are untouched: their cargo is handled at the route's terminal
|
|
* ports, not at an arbitrary mid-corridor yard, and gating them here would
|
|
* block existing traffic.
|
|
*
|
|
* Lives here rather than in the controller so the checkpoint-driven
|
|
* autoUnloadAtYard path cannot route around it.
|
|
*/
|
|
private async assertYardCanHandleCargo(
|
|
booking: Booking,
|
|
yardId: string,
|
|
side: 'origin' | 'destination',
|
|
): Promise<void> {
|
|
if (booking.tradeDirection !== 'DOMESTIC') return;
|
|
const facility = await this.yardFacilities.facilityForYard(yardId);
|
|
const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination';
|
|
|
|
if (!facility?.hasFacility) {
|
|
throw new BadRequestException(
|
|
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`,
|
|
);
|
|
}
|
|
// A facility only handles what its equipment can lift: containers need a
|
|
// reach stacker/gantry, bulk does not.
|
|
if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) {
|
|
throw new BadRequestException(
|
|
`${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` +
|
|
`an intercity booking cannot be ${where} here.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
|
* or — for a booking boarding at the train's own origin — when the train has
|
|
* not recorded any checkpoint yet (still sitting at its origin).
|
|
*/
|
|
private async assertTrainAtYard(
|
|
schedule: TrainSchedule,
|
|
yardId: string,
|
|
side: 'origin' | 'destination',
|
|
): Promise<void> {
|
|
const latest = await this.latestCheckpoint(schedule.id);
|
|
if (!latest) {
|
|
if (side === 'origin' && schedule.originStationId === yardId) return;
|
|
throw new BadRequestException(
|
|
'Train has not reached this yard yet — record its checkpoint first',
|
|
);
|
|
}
|
|
if (latest.yardId !== yardId) {
|
|
throw new BadRequestException(
|
|
`Train's last recorded position is not at the booking's ${side} yard`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
bookingId: string,
|
|
status: 'LOADED' | 'DEPARTED',
|
|
): Promise<void> {
|
|
const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId);
|
|
if (!allocations.length) return;
|
|
await manager
|
|
.getRepository(WagonBookingAllocation)
|
|
.update({ id: In(allocations.map((a) => a.id)) }, { status });
|
|
}
|
|
|
|
private async allocationsForBooking(
|
|
manager: EntityManager,
|
|
scheduleId: string,
|
|
bookingId: string,
|
|
): Promise<Array<WagonBookingAllocation & { trainSetWagon?: TrainSetWagon }>> {
|
|
return manager
|
|
.getRepository(WagonBookingAllocation)
|
|
.createQueryBuilder('alloc')
|
|
.innerJoinAndSelect('alloc.trainSetWagon', 'slot')
|
|
.innerJoin(
|
|
TrainSchedule,
|
|
'schedule',
|
|
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
|
{ scheduleId },
|
|
)
|
|
.where('alloc.booking_id = :bookingId', { bookingId })
|
|
.getMany();
|
|
}
|
|
|
|
/**
|
|
* On unload: write the wagon_movements ledger rows (board yard → unload yard,
|
|
* kind LOADED) for the booking's pinned wagons, and release each wagon whose
|
|
* slot alights here — it detaches, stays at this yard, and becomes Available
|
|
* (dynamic consist). Wagons shared with a still-loaded consolidated partner
|
|
* stay pinned until the last booking on the slot unloads.
|
|
*/
|
|
private async settleWagonsOnUnload(
|
|
manager: EntityManager,
|
|
schedule: TrainSchedule,
|
|
booking: Booking,
|
|
now: Date,
|
|
userId: string | null,
|
|
): Promise<void> {
|
|
const allocations = await this.allocationsForBooking(manager, schedule.id, booking.id);
|
|
for (const alloc of allocations) {
|
|
const slot = alloc.trainSetWagon;
|
|
if (!slot?.physicalWagonId) continue;
|
|
|
|
const boardYardId = slot.boardYardId ?? schedule.originStationId;
|
|
await manager.getRepository(WagonMovement).save(
|
|
manager.getRepository(WagonMovement).create({
|
|
wagonId: slot.physicalWagonId,
|
|
fromYardId: boardYardId,
|
|
toYardId: booking.destinationYardId,
|
|
trainScheduleId: schedule.id,
|
|
bookingId: booking.id,
|
|
kind: Freight.WagonMovementKind.Loaded,
|
|
movedByUserId: userId,
|
|
occurredAt: now,
|
|
}),
|
|
);
|
|
|
|
// Detach only when this yard is where the slot's leg ends and no other
|
|
// booking on the wagon is still in transit.
|
|
const slotAlightYardId = slot.alightYardId ?? schedule.destinationStationId;
|
|
if (slotAlightYardId !== booking.destinationYardId) continue;
|
|
const siblings = await manager
|
|
.getRepository(WagonBookingAllocation)
|
|
.createQueryBuilder('alloc')
|
|
.innerJoin('alloc.booking', 'b')
|
|
.where('alloc.train_set_wagon_id = :slotId', { slotId: slot.id })
|
|
.andWhere('alloc.booking_id != :bookingId', { bookingId: booking.id })
|
|
.andWhere(`b.status = 'IN_TRANSIT'`)
|
|
.getCount();
|
|
if (siblings > 0) continue;
|
|
|
|
await manager.getRepository(TrainSetWagon).update(slot.id, { status: 'DEPARTED' });
|
|
const wagon = await manager
|
|
.getRepository(Wagon)
|
|
.findOne({ where: { id: slot.physicalWagonId } });
|
|
// Only settle a wagon still bound to this schedule (it may have been
|
|
// re-pinned elsewhere already).
|
|
if (wagon && wagon.currentTrainScheduleId === schedule.id) {
|
|
await manager.getRepository(Wagon).update(wagon.id, {
|
|
currentYardId: booking.destinationYardId,
|
|
currentTrainScheduleId: null,
|
|
trainSetWagonId: null,
|
|
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
|
|
// only loose wagons return to the open AVAILABLE pool. Marking a
|
|
// coupled wagon AVAILABLE made it show up in the train-builder's
|
|
// "available wagons" picker, where attaching it always 409'd.
|
|
status: wagon.trainId
|
|
? Freight.WagonStatus.Assigned
|
|
: Freight.WagonStatus.Available,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private async completeMilestones(booking: Booking, codes: string[]): Promise<void> {
|
|
if (!this.milestoneService || !codes.length) return;
|
|
for (const code of codes) {
|
|
try {
|
|
await this.milestoneService.completeByDocTrigger({ bookingId: booking.id }, code);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Milestone ${code} completion failed for booking ${booking.id}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|