mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Intercity bookings ride the wagons freed by earlier unloads (e.g. import containers uncoupled at Dire Dawa). loadBooking now auto-allocates a DOMESTIC booking onto on-train slots whose cargo has all departed — greedy in consist order by capacity, container numbers copied for the marshalling tally. Falls back to unallocated load when nothing is free.
683 lines
28 KiB
TypeScript
683 lines
28 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' });
|
|
// 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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|