import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; /** * READ-ONLY view into the train-scheduling / wagons domain for the warehouse module. * * IMPORTANT: this facade only ever runs SELECTs. The warehouse must never modify * wagon assignment, rescheduling, import_ready/export_ready, or locomotive flow. * It is intentionally decoupled (raw SQL) so it does not import the scheduling * services/entities and cannot accidentally write to them. */ export interface ImportTrainRow { scheduleId: string; trainNumber: string | null; route: string | null; origin: string | null; destination: string | null; arrivalTime: string | null; totalBookings: number; totalContainers: number; totalCargoes: number; status: string; } export interface ImportTrainItemRow { bookingId: string; bookingReference: string | null; customerId: string | null; customerName: string | null; containerNumber: string | null; cargoType: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null; lastMileRequested: boolean; pickupOption: string; } export interface WagonView { id: string; wagonNumber: string; status: string; trainId: string | null; } export interface BookingScheduleView { schedule: { id: string; status: string; scheduledDepartureDate: string | null; scheduledArrivalDate: string | null; originStationId: string | null; destinationStationId: string | null; } | null; wagon: { wagonId: string | null; wagonNumber: string | null; sequenceNo: number | null; allocatedWeightTons: number | null; } | null; /** Mirror of schedule.status — the headline "where is the train" indicator. */ departureStatus: string | null; } @Injectable() export class SchedulingReadFacade { constructor(private readonly dataSource: DataSource) {} /** Look up a single physical wagon. Returns null if it does not exist. */ async findWagon(wagonId: string): Promise { const rows = await this.dataSource.query( `SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId" FROM freight.wagons WHERE id = $1 AND deleted_at IS NULL LIMIT 1`, [wagonId], ); return rows?.[0] ?? null; } /** True when the wagon is already part of a train set (selected by an existing schedule). */ async isWagonScheduled(wagonId: string): Promise { const rows = await this.dataSource.query( `SELECT 1 FROM freight.train_set_wagons WHERE physical_wagon_id = $1 AND deleted_at IS NULL LIMIT 1`, [wagonId], ); return (rows?.length ?? 0) > 0; } /** List wagons usable for loading (available, or already assigned to a schedule). */ listLoadableWagons(): Promise { return this.dataSource.query( `SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId" FROM freight.wagons WHERE deleted_at IS NULL AND status NOT IN ('RETIRED', 'MAINTENANCE') ORDER BY wagon_number ASC`, ); } /** * Given a booking, return its related schedule, wagon assignment and departure status. * All fields are read straight from the scheduling tables — nothing is written. */ async getBookingSchedule(bookingId: string): Promise { const scheduleRows = await this.dataSource.query( `SELECT ts.id, ts.status, ts.scheduled_departure_date AS "scheduledDepartureDate", ts.scheduled_arrival_date AS "scheduledArrivalDate", ts.origin_station_id AS "originStationId", ts.destination_station_id AS "destinationStationId" FROM freight.train_schedule_bookings tsb INNER JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id WHERE tsb.booking_id = $1 AND ts.deleted_at IS NULL ORDER BY ts.scheduled_departure_date DESC NULLS LAST LIMIT 1`, [bookingId], ); const schedule = scheduleRows?.[0] ?? null; const wagonRows = await this.dataSource.query( `SELECT w.id AS "wagonId", w.wagon_number AS "wagonNumber", tsw.sequence_no AS "sequenceNo", wba.allocated_weight_tons AS "allocatedWeightTons" FROM freight.wagon_booking_allocations wba INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id WHERE wba.booking_id = $1 ORDER BY tsw.sequence_no ASC NULLS LAST LIMIT 1`, [bookingId], ); const wagon = wagonRows?.[0] ?? null; return { schedule, wagon, departureStatus: schedule?.status ?? null, }; } /** * ARRIVED train schedules whose route is IMPORT (origin country = Djibouti), with per-train * booking/container/cargo counts. Direction is derived from the origin/destination station * countries (route-based), so EXPORT/DOMESTIC trains never appear. Read-only. */ async importArriveQueue(): Promise { const rows: Array< ImportTrainRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( `SELECT ts.id AS "scheduleId", ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", oy.country AS "originCountry", dy.country AS "destinationCountry", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", ts.status, (SELECT count(*) FROM freight.train_schedule_bookings tsb WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL) AS "totalBookings", (SELECT count(*) FROM freight.containers c JOIN freight.train_schedule_bookings tsbc ON tsbc.booking_id = c.booking_id AND tsbc.deleted_at IS NULL WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers", (SELECT count(*) FROM freight.cargoes cg JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id WHERE ts.deleted_at IS NULL AND ts.status = 'ARRIVED' ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`, ); return rows .filter( (r) => deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT', ) .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({ ...rest, totalBookings: Number(rest.totalBookings) || 0, totalContainers: Number(rest.totalContainers) || 0, totalCargoes: Number(rest.totalCargoes) || 0, route: rest.origin || rest.destination ? `${rest.origin ?? '?'} → ${rest.destination ?? '?'}` : null, })); } /** Assigned bookings/items for an arrived import train (one row per booking). Read-only. */ async importTrainDetail(scheduleId: string): Promise { const rows: ImportTrainItemRow[] = await this.dataSource.query( `SELECT b.id AS "bookingId", b.reference AS "bookingReference", b.company_id AS "customerId", company.name AS "customerName", (SELECT c.container_number FROM freight.containers c WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", b.cargo_total_weight_vgm AS "weight", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", COALESCE(inv.status, b.status) AS "currentStatus", (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", CASE WHEN b.last_mile_delivery_address IS NOT NULL THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption" FROM freight.train_schedule_bookings tsb JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL ORDER BY b.reference ASC NULLS LAST`, [scheduleId], ); return rows; } }