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; unloadedBookings: number; pendingUnloadBookings: number; fullyUnloaded: boolean; 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; inspectionStatus: string | null; lastMileRequested: boolean; pickupOption: string; } export interface ExportDjiboutiQueueFilter { scheduleId?: string; destination?: string; status?: string; dateFrom?: string; dateTo?: string; } export interface ExportTrainRow extends ImportTrainRow { departureTime: string | null; } export interface ExportTrainItemRow { bookingId: string; bookingReference: string | null; customerId: string | null; customerName: string | null; itemType: 'CONTAINER' | 'CARGO'; itemId: string | null; inventoryId: string | null; containerNumber: string | null; cargoType: string | null; weight: number | null; origin: string | null; destination: string | null; trainSchedule: string | null; arrivalTime: string | null; currentStatus: string | null; } 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) {} private isDjiboutiPortDestination(value: string | null | undefined): boolean { const normalized = (value ?? '').toUpperCase(); return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) => normalized.includes(token), ); } /** 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 UPPER(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", (SELECT count(*) FROM freight.train_schedule_bookings tsbp JOIN freight.bookings bp ON bp.id = tsbp.booking_id AND bp.deleted_at IS NULL WHERE tsbp.train_schedule_id = ts.id AND tsbp.deleted_at IS NULL AND bp.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED') AND ( NOT EXISTS ( SELECT 1 FROM freight.warehouse_inventory invp WHERE invp.booking_id = bp.id AND invp.deleted_at IS NULL ) OR EXISTS ( SELECT 1 FROM freight.warehouse_inventory invr WHERE invr.booking_id = bp.id AND invr.deleted_at IS NULL AND invr.status = 'RECEIVED' ) )) AS "pendingUnloadBookings" 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 }) => { const totalBookings = Number(rest.totalBookings) || 0; const pendingUnloadBookings = Number(rest.pendingUnloadBookings) || 0; const unloadedBookings = Math.max(totalBookings - pendingUnloadBookings, 0); return { ...rest, totalBookings, totalContainers: Number(rest.totalContainers) || 0, totalCargoes: Number(rest.totalCargoes) || 0, unloadedBookings, pendingUnloadBookings, fullyUnloaded: totalBookings > 0 && pendingUnloadBookings === 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", inv.inspection_status AS "inspectionStatus", (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; } /** * ARRIVED export train schedules at Djibouti-side destinations, with assigned item counts. * Read-only: this only selects from scheduling/booking/inventory tables. */ async exportDjiboutiArrivalQueue( filter: ExportDjiboutiQueueFilter = {}, ): Promise { const params: unknown[] = []; const where = [ 'ts.deleted_at IS NULL', "ts.status = ANY($1)", `EXISTS ( SELECT 1 FROM freight.train_schedule_bookings tsb_exists JOIN freight.bookings b_exists ON b_exists.id = tsb_exists.booking_id AND b_exists.deleted_at IS NULL LEFT JOIN freight.warehouse_inventory inv_exists ON inv_exists.booking_id = b_exists.id AND inv_exists.deleted_at IS NULL WHERE tsb_exists.train_schedule_id = ts.id AND tsb_exists.deleted_at IS NULL AND (inv_exists.status = ANY($2) OR b_exists.status = ANY($2)) )`, ]; params.push( filter.status ? [filter.status] : ['ARRIVED', 'ARRIVED_AT_DJIBOUTI'], [ 'LOADED', 'DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION', 'UNLOADED_AT_DJIBOUTI_PORT', ], ); if (filter.scheduleId) { params.push(filter.scheduleId); where.push(`ts.id = $${params.length}`); } if (filter.destination) { params.push(`%${filter.destination}%`); where.push(`(dy.code ILIKE $${params.length} OR dy.label ILIKE $${params.length})`); } if (filter.dateFrom) { params.push(filter.dateFrom); where.push(`COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) >= $${params.length}`); } if (filter.dateTo) { params.push(filter.dateTo); where.push(`COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) <= $${params.length}`); } const rows: Array< ExportTrainRow & { originCountry: string | null; destinationCountry: string | null; destinationName: string | null; } > = await this.dataSource.query( `SELECT ts.id AS "scheduleId", ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", dy.label AS "destinationName", oy.country AS "originCountry", dy.country AS "destinationCountry", ts.scheduled_departure_date AS "departureTime", 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 ${where.join(' AND ')} ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`, params, ); return rows .filter((r) => { const direction = deriveTradeDirection( { country: r.originCountry }, { country: r.destinationCountry }, ); return ( direction === 'EXPORT' && this.isDjiboutiPortDestination(`${r.destination ?? ''} ${r.destinationName ?? ''}`) ); }) .map(({ originCountry: _oc, destinationCountry: _dc, destinationName: _dn, ...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 export booking items for an arrived Djibouti-side export train. Read-only. */ async exportDjiboutiTrainDetail(scheduleId: string): Promise { const rows: ExportTrainItemRow[] = await this.dataSource.query( `WITH assigned AS ( SELECT b.id AS booking_id, b.reference, b.company_id, company.name AS customer_name, COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type, b.cargo_total_weight_vgm AS booking_weight, oy.code AS origin, dy.code AS destination, ts.train_number, COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS arrival_time, inv.id AS inventory_id, COALESCE(inv.status, b.status) AS current_status 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.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_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 ) SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", a.company_id AS "customerId", a.customer_name AS "customerName", 'CONTAINER' AS "itemType", c.id AS "itemId", a.inventory_id AS "inventoryId", c.container_number AS "containerNumber", a.cargo_type AS "cargoType", a.booking_weight AS "weight", a.origin, a.destination, a.train_number AS "trainSchedule", a.arrival_time AS "arrivalTime", a.current_status AS "currentStatus" FROM assigned a JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL UNION ALL SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", a.company_id AS "customerId", a.customer_name AS "customerName", 'CARGO' AS "itemType", cg.id AS "itemId", a.inventory_id AS "inventoryId", NULL AS "containerNumber", COALESCE(cgt.cargo_type_name, a.cargo_type) AS "cargoType", COALESCE(cg.weight, a.booking_weight) AS "weight", a.origin, a.destination, a.train_number AS "trainSchedule", a.arrival_time AS "arrivalTime", a.current_status AS "currentStatus" FROM assigned a JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id UNION ALL SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", a.company_id AS "customerId", a.customer_name AS "customerName", CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType", a.inventory_id AS "itemId", a.inventory_id AS "inventoryId", NULL AS "containerNumber", a.cargo_type AS "cargoType", a.booking_weight AS "weight", a.origin, a.destination, a.train_number AS "trainSchedule", a.arrival_time AS "arrivalTime", a.current_status AS "currentStatus" FROM assigned a WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL) AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL) ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`, [scheduleId], ); return rows; } }