import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import {
  Between,
  DataSource,
  EntityManager,
  FindManyOptions,
  FindOptionsWhere,
  ILike,
  In,
  IsNull,
  LessThanOrEqual,
  MoreThanOrEqual,
  Raw,
} from 'typeorm';

import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { generateGrnNumber } from '../../common/grn.util';
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Company } from '../companies/entities/company.entity';
import { Container } from '../container-management/entities/container.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import {
  companyNotifyPhoneExpr,
  primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
import { SignaturesService } from '../signatures/signatures.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseInspectionService } from './warehouse-inspection.service';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import {
  WAREHOUSE_INVENTORY_TRANSITIONS,
  WarehouseInventory,
  WarehouseInventoryStatus,
} from './entities/warehouse-inventory.entity';
import { WarehouseLoading } from './entities/warehouse-loading.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity';
import { WarehouseZone } from './entities/warehouse-zone.entity';
import { Warehouse } from './entities/warehouse.entity';
import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
import { HandoverService } from './handover.service';
import { NotificationAudience, NotificationType } from '@edr/types';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';

/** Wagon states that may receive a load (besides being part of an existing schedule). */
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];

const normalizeWagonStatus = (status: string | null | undefined) =>
  (status ?? '')
    .trim()
    .replace(/[\s-]+/g, '_')
    .toUpperCase();

const isLoadableWagonStatus = (status: string | null | undefined) =>
  LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));

const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';

export interface InventoryInquiryResult {
  id: string;
  inventoryId: string | null;
  bookingId: string | null;
  bookingReference: string | null;
  bookingNumber: string | null;
  bookingStatus: string | null;
  customerName: string | null;
  containerNumber: string | null;
  cargoType: string | null;
  cargoDescription: string | null;
  goodsId: string | null;
  warehouse: { id: string; name: string; code: string } | null;
  yard: { id: string; name: string; code: string } | null;
  zone: { id: string; name: string; code: string } | null;
  status: string | null;
  trainNumber: string | null;
  trainStatus: string | null;
  route: string | null;
  locationSummary: string | null;
  quantity: number;
  weight: number;
  arrivedAt: Date | null;
  readyForLoadingAt: Date | null;
}

interface BookingSummaryRow {
  id: string;
  reference: string | null;
  status: string | null;
  customer: string | null;
}

interface ArrivalQueueRow {
  bookingId: string;
  bookingReference: string | null;
  customer: string | null;
  cargo: string | null;
  container: string | null;
  arrivalDate: Date | null;
  bookingStatus: string | null;
  inventoryId: string | null;
  currentStatus: string | null;
  inspectionStatus: string | null;
  facility: string | null;
  warehouse: string | null;
  yard: string | null;
  zone: string | null;
}

export interface ArrivalQueueItem {
  bookingId: string;
  bookingReference: string | null;
  customer: string | null;
  cargo: string | null;
  container: string | null;
  facility: string | null;
  warehouse: string | null;
  yard: string | null;
  zone: string | null;
  inventoryId: string | null;
  currentStatus: string | null;
  arrivalDate: Date | null;
  inspectionStatus: string | null;
  unloaded: boolean;
}

interface DefaultLocation {
  warehouseId: string;
  facilityId?: string | null;
  yardId: string;
  zoneId: string;
}

interface StorageAllocationLocation extends DefaultLocation {
  path?: string | null;
  rule?: { id: string; name: string; storageType: string | null } | null;
}

interface InventoryAllocationCriteria {
  freightType?: string | null;
  tradeDirection?: string | null;
  cargoTypeCode?: string | null;
  containerStatus?: string | null;
  requiresInspection?: boolean | null;
}

export interface AutoUnloadResult {
  processedCount: number;
  skippedCount: number;
  failedCount: number;
  results: Array<{
    bookingId: string;
    inventoryId?: string;
    status: 'PROCESSED' | 'FAILED';
    reason?: string;
  }>;
}

export interface AutoLoadResult {
  loadedCount: number;
  skippedCount: number;
  results: Array<{
    inventoryId: string;
    status: 'LOADED' | 'SKIPPED';
    reason?: string;
  }>;
}

interface WarehouseDashboardSummary {
  totalWarehouses: number;
  totalInventory: number;
  receivedToday: number;
  stored: number;
  reserved: number;
  readyForLoading: number;
  loaded: number;
  dispatched: number;
}

interface LocationRef {
  warehouseId: string;
  yardId: string;
  zoneId: string;
}

interface BookingUnloadLocation extends LocationRef {
  bookingId: string;
}

interface LocationNode {
  capacityWeight?: number | null;
  capacityContainers?: number | null;
  currentWeight: number;
  maxWeight?: number | null;
  maxVolume?: number | null;
  currentVolume?: number | null;
  currentContainers: number;
}

// ── Receive (Import/Export bulk) shapes ──────────────────────────────────────
export interface EligibleBookingRow {
  id: string;
  reference: string;
  customerId: string | null;
  customer: string | null;
  customerTin: string | null;
  customerPhone: string | null;
  containerNumber: string | null;
  sealNumbers: string | null;
  containerQuantity: number | null;
  containerPackagingType: string | null;
  cargoDescription: string | null;
  lastMileRequested: boolean;
  direction: string;
  origin: string | null;
  destination: string | null;
  freightType: string | null;
  cargo: string | null;
  weight: string | null;
  paymentStatus: string;
  status: string;
  hasFirstMile: boolean;
  firstMileRequestId: string | null;
  firstMileStatus: string | null;
  firstMileVehicleId: string | null;
  firstMileTruckPlateNumber: string | null;
  firstMileTrailerPlateNumber: string | null;
  firstMileDriverName: string | null;
  firstMileDriverPhone: string | null;
  firstMileDriverLicenseNumber: string | null;
  firstMileTruckType: string | null;
  customerTruckPlateNumber: string | null;
  customerTruckDriverName: string | null;
  customerTruckType: string | null;
  customerTruckContainerNumber: string | null;
  customerTruckAssignedAt: string | null;
}

export interface BulkReceiveResult {
  receivedCount: number;
  skippedCount: number;
  results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
}


export interface BulkInspectResult {
  inspectedCount: number;
  skippedCount: number;
  results: { inventoryId: string; status: string; reason?: string }[];
}

export interface ReadyToLoadRow {
  id: string;
  bookingId: string | null;
  bookingReference: string | null;
  customerId: string | null;
  customerName: string | null;
  containerNumber: string | null;
  cargoType: string | null;
  weight: number | null;
  grnNumber: string | null;
  origin: string | null;
  destination: string | null;
  inspectionStatus: string | null;
  status: string;
}

export interface BulkDispatchResult {
  dispatchedCount: number;
  skippedCount: number;
  results: { inventoryId: string; status: string; reason?: string }[];
}

/** An EXPORT train (pre-dispatch schedule) that has inventory waiting to be loaded. */
export interface LoadableTrainRow {
  scheduleId: string;
  trainNumber: string | null;
  origin: string | null;
  destination: string | null;
  status: string;
  departureTime: string | Date | null;
  /** Received/ready inventory not yet loaded onto this train. */
  readyCount: number;
  /** Inventory already loaded onto this train. */
  loadedCount: number;
}

/** A warehouse-inventory item (container/cargo) assigned to a train, with its allocated wagon. */
export interface TrainLoadableItemRow {
  id: string;
  bookingId: string | null;
  bookingReference: string | null;
  customerName: string | null;
  containerNumber: string | null;
  cargoType: string | null;
  weight: number | null;
  grnNumber: string | null;
  inspectionStatus: string | null;
  status: string;
  wagonId: string | null;
  wagonNumber: string | null;
  sequenceNo: number | null;
  /** True only when the item is READY_FOR_LOADING and has an allocated wagon. */
  loadable: boolean;
}

export interface TrainLoadResult {
  loadedCount: number;
  skippedCount: number;
  results: { inventoryId: string; status: string; reason?: string }[];
}

export interface AutoUnloadArrivedResult {
  unloadedCount: number;
  skippedCount: number;
  failedCount: number;
  results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
}

export interface AutoUnloadExportDjiboutiResult {
  unloadedCount: number;
  skippedCount: number;
  failedCount: number;
  interchangeDocument?: Pick<InterchangeDocument, 'id' | 'documentNo' | 'status'>;
  results: Array<{
    bookingId: string;
    itemType: 'CONTAINER' | 'CARGO';
    itemId?: string | null;
    inventoryId?: string;
    containerNumber?: string | null;
    status: string;
    message?: string;
    reason?: string;
  }>;
}

export interface ImportUnloadedRow {
  id: string;
  bookingId: string | null;
  bookingReference: string | null;
  customerId: string | null;
  customerName: string | null;
  arrivalTime: string | null;
  containerNumber: string | null;
  cargoType: string | null;
  weight: number | null;
  grnNumber: string | null;
  trainSchedule: string | null;
  inspectionStatus: string | null;
  pickupOption: string;
  lastMileRequested: boolean;
  customerTruckPlateNumber: string | null;
  customerTruckDriverName: string | null;
  customerTruckType: string | null;
  customerTruckContainerNumber: string | null;
  customerTruckAssignedAt: string | null;
  hasAssignedTruck: boolean;
  /** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */
  doubleHandling: boolean | null;
  currentStatus: string;
  releaseDate: string | null;
  releaseOrderReference: string | null;
  handoverDocumentReference: string | null;
  handoverDocumentDate: string | null;
  deliveredAt: string | null;
  notes: string | null;
}

@Injectable()
export class WarehouseInventoryService {
  private readonly logger = new Logger(WarehouseInventoryService.name);

  constructor(
    private readonly dataSource: DataSource,
    private readonly inventoryRepository: WarehouseInventoryRepository,
    private readonly loadingRepository: WarehouseLoadingRepository,
    private readonly activityLog: WarehouseActivityLogService,
    private readonly scheduling: SchedulingReadFacade,
    private readonly allocation: WarehouseAllocationService,
    private readonly invoices: WarehouseInvoiceService,
    private readonly inspectionService: WarehouseInspectionService,
    private readonly releaseDocuments: WarehouseReleaseDocumentService,
    private readonly interchangeDocuments: InterchangeDocumentsService,
    private readonly lastMileService: LastMileService,
    private readonly notifications: NotificationsService,
    private readonly signatures: SignaturesService,
    private readonly handover: HandoverService,
    private readonly inbox: NotificationInboxService,
    private readonly events: EventEmitter2,
  ) {}

  /**
   * When a self-haul booking (no EDR first/last mile) is received to the warehouse
   * but has no customer truck assigned yet, nudge the customer to assign one — with
   * a deep-link to the booking's truck-assignment card. Fire-and-forget.
   */
  /**
   * At-a-glance warehouse ops counters for the KPI strip:
   *  - receivedToday: items received today
   *  - pendingInspection: RECEIVED items not yet inspected
   *  - trucksOnSite: customer trucks arrived but not departed
   *  - itemsAging: in-warehouse items older than 7 days (demurrage risk)
   */
  /**
   * Every truck currently inside the yard, across all bookings — the list behind
   * the `trucksOnSite` figure on the ops dashboard, which until now could only
   * be counted and never opened.
   *
   * Covers both haulage paths because the gate does: a customer's own truck and
   * an EDR last-mile truck arrive at the same barrier and need the same paper.
   * Includes trucks assigned but not yet arrived, flagged INBOUND, so staff see
   * what is coming as well as what is here — an assigned truck only stamps
   * `arrived_at` when it reaches the warehouse. A truck drops off the list once
   * it departs.
   */
  async trucksOnSite(): Promise<
    Array<{
      source: 'CUSTOMER' | 'EDR';
      assignmentId: string;
      status: 'INBOUND' | 'ON_SITE';
      plateNumber: string | null;
      driverName: string | null;
      truckType: string | null;
      arrivedAt: string | null;
      bookingId: string;
      bookingReference: string | null;
      customerName: string | null;
      containers: string | null;
    }>
  > {
    return this.dataSource.query(
      `SELECT 'CUSTOMER'      AS "source",
              a.id            AS "assignmentId",
              CASE WHEN a.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
              a.plate_number  AS "plateNumber",
              a.driver_name   AS "driverName",
              a.truck_type    AS "truckType",
              a.arrived_at    AS "arrivedAt",
              b.id            AS "bookingId",
              b.reference     AS "bookingReference",
              company.name    AS "customerName",
              (SELECT string_agg(c.container_number, ', ' ORDER BY c.container_number)
                 FROM freight.customer_truck_containers c
                WHERE c.assignment_id = a.id AND c.deleted_at IS NULL) AS "containers"
         FROM freight.customer_truck_assignments a
         JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
         LEFT JOIN freight.companies company ON company.id = b.company_id
        WHERE a.deleted_at IS NULL
          AND a.departed_at IS NULL

        UNION ALL

       SELECT 'EDR'           AS "source",
              va.id           AS "assignmentId",
              CASE WHEN va.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
              COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber",
              NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName",
              v.vehicle_type  AS "truckType",
              va.arrived_at   AS "arrivedAt",
              b.id            AS "bookingId",
              b.reference     AS "bookingReference",
              company.name    AS "customerName",
              (SELECT string_agg(lvc.container_number, ', ' ORDER BY lvc.container_number)
                 FROM freight.last_mile_vehicle_containers lvc
                WHERE lvc.assignment_id = va.id AND lvc.deleted_at IS NULL) AS "containers"
         FROM freight.last_mile_vehicle_assignments va
         JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
         JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
         LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
         LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id
         LEFT JOIN freight.companies company ON company.id = b.company_id
        WHERE va.deleted_at IS NULL
          AND va.departed_at IS NULL

        -- On-site trucks first, each group oldest-arrival first; inbound trucks
        -- (null arrival) sort to the end.
        ORDER BY "arrivedAt" ASC NULLS LAST`,
    );
  }

  async opsStats(): Promise<{
    receivedToday: number;
    receivedYesterday: number;
    pendingInspection: number;
    trucksOnSite: number;
    itemsAging: number;
  }> {
    const [row]: Array<{
      receivedToday: number;
      receivedYesterday: number;
      pendingInspection: number;
      trucksOnSite: number;
      itemsAging: number;
    }> = await this.dataSource.query(
      `SELECT
         (SELECT count(*)::int FROM freight.warehouse_inventory
           WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
         (SELECT count(*)::int FROM freight.warehouse_inventory
           WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
         (SELECT count(*)::int FROM freight.warehouse_inventory
           WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
         -- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite()
         ((SELECT count(*)::int FROM freight.customer_truck_assignments a
             JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
            WHERE a.deleted_at IS NULL AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL)
          +
          (SELECT count(*)::int FROM freight.last_mile_vehicle_assignments va
             JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
             JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
            WHERE va.deleted_at IS NULL AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL)) AS "trucksOnSite",
         (SELECT count(*)::int FROM freight.warehouse_inventory
           WHERE deleted_at IS NULL
             AND status = ANY($1)
             AND created_at < now() - interval '7 days') AS "itemsAging"`,
      [this.IN_WAREHOUSE_STATUSES],
    );
    return {
      receivedToday: row?.receivedToday ?? 0,
      receivedYesterday: row?.receivedYesterday ?? 0,
      pendingInspection: row?.pendingInspection ?? 0,
      trucksOnSite: row?.trucksOnSite ?? 0,
      itemsAging: row?.itemsAging ?? 0,
    };
  }

  /** In-warehouse statuses used by the dwell / aging metrics. */
  private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [
    'RECEIVED',
    'UNLOADED',
    'STORED',
    'RESERVED',
    'READY_FOR_LOADING',
    'READY_FOR_PICKUP',
  ];

  /**
   * Dwell time of items still in the warehouse: average days held plus a count
   * per aging bucket (0–3 / 4–7 / 8–14 / 15+). Clock starts at arrival (falling
   * back to created_at). Powers the dwell / aging histogram.
   */
  async dwellStats(): Promise<{
    avgDwellDays: number;
    inWarehouseCount: number;
    buckets: Array<{ key: string; label: string; count: number }>;
  }> {
    const [row]: Array<{
      avgDwellDays: number | null;
      inWarehouseCount: number;
      b0: number;
      b1: number;
      b2: number;
      b3: number;
    }> = await this.dataSource.query(
      `WITH held AS (
         SELECT EXTRACT(EPOCH FROM (now() - COALESCE(arrived_at, created_at))) / 86400.0 AS age_days
           FROM freight.warehouse_inventory
          WHERE deleted_at IS NULL
            AND status = ANY($1)
       )
       SELECT COALESCE(round(avg(age_days)::numeric, 1), 0)::float8 AS "avgDwellDays",
              count(*)::int AS "inWarehouseCount",
              count(*) FILTER (WHERE age_days <  4)::int                  AS b0,
              count(*) FILTER (WHERE age_days >= 4  AND age_days <  8)::int AS b1,
              count(*) FILTER (WHERE age_days >= 8  AND age_days < 15)::int AS b2,
              count(*) FILTER (WHERE age_days >= 15)::int                  AS b3
         FROM held`,
      [this.IN_WAREHOUSE_STATUSES],
    );
    return {
      avgDwellDays: row?.avgDwellDays ?? 0,
      inWarehouseCount: row?.inWarehouseCount ?? 0,
      buckets: [
        { key: '0-3', label: '0–3 days', count: row?.b0 ?? 0 },
        { key: '4-7', label: '4–7 days', count: row?.b1 ?? 0 },
        { key: '8-14', label: '8–14 days', count: row?.b2 ?? 0 },
        { key: '15+', label: '15+ days', count: row?.b3 ?? 0 },
      ],
    };
  }

  /**
   * Average stage cycle times over items dispatched in the last 90 days:
   * arrived→ready, ready→loaded, loaded→dispatched, and the total
   * arrived→dispatched (dock-to-dispatch). Days, to one decimal.
   */
  async cycleStats(): Promise<{
    sampleSize: number;
    avgDockToDispatchDays: number;
    stages: Array<{ key: string; label: string; avgDays: number }>;
  }> {
    const gapDays = (from: string, to: string) =>
      `round((avg(EXTRACT(EPOCH FROM (${to} - ${from})) / 86400.0) FILTER (WHERE ${from} IS NOT NULL AND ${to} IS NOT NULL))::numeric, 1)::float8`;
    const [row]: Array<{
      sampleSize: number;
      total: number | null;
      arrivedReady: number | null;
      readyLoaded: number | null;
      loadedDispatched: number | null;
    }> = await this.dataSource.query(
      `SELECT count(*)::int AS "sampleSize",
              ${gapDays('arrived_at', 'dispatched_at')} AS "total",
              ${gapDays('arrived_at', 'ready_for_loading_at')} AS "arrivedReady",
              ${gapDays('ready_for_loading_at', 'loaded_at')} AS "readyLoaded",
              ${gapDays('loaded_at', 'dispatched_at')} AS "loadedDispatched"
         FROM freight.warehouse_inventory
        WHERE deleted_at IS NULL
          AND arrived_at IS NOT NULL
          AND dispatched_at IS NOT NULL
          AND dispatched_at > now() - interval '90 days'`,
    );
    return {
      sampleSize: row?.sampleSize ?? 0,
      avgDockToDispatchDays: row?.total ?? 0,
      stages: [
        { key: 'arrived-ready', label: 'Arrived → Ready', avgDays: row?.arrivedReady ?? 0 },
        { key: 'ready-loaded', label: 'Ready → Loaded', avgDays: row?.readyLoaded ?? 0 },
        { key: 'loaded-dispatched', label: 'Loaded → Dispatched', avgDays: row?.loadedDispatched ?? 0 },
      ],
    };
  }

  /**
   * Gate / dock throughput: items cleared through the gate today, the average
   * arrival→gate-clearance turnaround (hours, last 30 days), and gate clearances
   * bucketed per hour over the last 24 hours. Powers the gate throughput card.
   */
  async gateStats(): Promise<{
    clearedToday: number;
    avgTurnaroundHours: number | null;
    byHour: Array<{ hour: string; count: number }>;
  }> {
    const [scalar]: Array<{ clearedToday: number; avgTurnaroundHours: number | null }> =
      await this.dataSource.query(
        `SELECT
           count(*) FILTER (WHERE gate_cleared_at::date = CURRENT_DATE)::int AS "clearedToday",
           round(
             avg(EXTRACT(EPOCH FROM (gate_cleared_at - arrived_at)) / 3600.0)
               FILTER (
                 WHERE gate_cleared_at IS NOT NULL AND arrived_at IS NOT NULL
                   AND gate_cleared_at > now() - interval '30 days'
               )::numeric,
             1
           )::float8 AS "avgTurnaroundHours"
           FROM freight.warehouse_inventory
          WHERE deleted_at IS NULL`,
      );
    const byHour: Array<{ hour: string; count: number }> = await this.dataSource.query(
      `WITH hours AS (
         SELECT gs AS h
           FROM generate_series(
             date_trunc('hour', now()) - interval '23 hours',
             date_trunc('hour', now()),
             interval '1 hour'
           ) gs
       )
       SELECT to_char(hours.h, 'HH24:00') AS hour,
              COALESCE(g.cnt, 0)::int      AS count
         FROM hours
         LEFT JOIN (
           SELECT date_trunc('hour', gate_cleared_at) AS ph, count(*) AS cnt
             FROM freight.warehouse_inventory
            WHERE deleted_at IS NULL AND gate_cleared_at IS NOT NULL
            GROUP BY 1
         ) g ON g.ph = hours.h
        ORDER BY hours.h`,
    );
    return {
      clearedToday: scalar?.clearedToday ?? 0,
      avgTurnaroundHours: scalar?.avgTurnaroundHours ?? null,
      byHour,
    };
  }

  /**
   * Received-vs-dispatched throughput as a server-side time series. Buckets by
   * date_trunc over the last N periods (8 weeks / 12 months / 5 years) with a
   * generate_series so empty periods still return a zero row — replaces the
   * client-side approach that downloaded the whole inventory to bucket it.
   */
  async throughput(
    granularity: 'week' | 'month' | 'year' = 'month',
  ): Promise<Array<{ periodStart: string; received: number; dispatched: number }>> {
    // Whitelist the unit — it is interpolated into date_trunc / interval literals.
    const unit: 'week' | 'month' | 'year' = ['week', 'month', 'year'].includes(granularity)
      ? granularity
      : 'month';
    const back = unit === 'week' ? 7 : unit === 'month' ? 11 : 4;

    const rows: Array<{ periodStart: string; received: number; dispatched: number }> =
      await this.dataSource.query(
        `WITH periods AS (
           SELECT gs AS period_start
             FROM generate_series(
               date_trunc('${unit}', now()) - ($1 || ' ${unit}')::interval,
               date_trunc('${unit}', now()),
               '1 ${unit}'::interval
             ) gs
         )
         SELECT p.period_start          AS "periodStart",
                COALESCE(r.cnt, 0)::int AS received,
                COALESCE(d.cnt, 0)::int AS dispatched
           FROM periods p
           LEFT JOIN (
             SELECT date_trunc('${unit}', arrived_at) AS ps, count(*) AS cnt
               FROM freight.warehouse_inventory
              WHERE deleted_at IS NULL AND arrived_at IS NOT NULL
              GROUP BY 1
           ) r ON r.ps = p.period_start
           LEFT JOIN (
             SELECT date_trunc('${unit}', dispatched_at) AS ps, count(*) AS cnt
               FROM freight.warehouse_inventory
              WHERE deleted_at IS NULL AND dispatched_at IS NOT NULL
              GROUP BY 1
           ) d ON d.ps = p.period_start
          ORDER BY p.period_start`,
        [back],
      );
    return rows;
  }

  /**
   * Live occupancy per zone: rated capacity vs the weight/items currently held
   * (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
   * occupancy heatmap. Optionally scoped to one yard.
   */
  async zoneOccupancy(yardId?: string): Promise<
    Array<{
      id: string;
      name: string;
      code: string;
      type: string;
      yardId: string;
      capacityWeight: number | null;
      capacityContainers: number | null;
      usedWeight: number;
      usedItems: number;
      occupancyPct: number | null;
    }>
  > {
    const rows: Array<{
      id: string;
      name: string;
      code: string;
      type: string;
      yardId: string;
      capacityWeight: string | null;
      capacityContainers: number | null;
      usedWeight: number;
      usedItems: number;
    }> = await this.dataSource.query(
      `SELECT z.id,
              z.name,
              z.code,
              z.type,
              z.yard_id AS "yardId",
              z.capacity_weight AS "capacityWeight",
              z.capacity_containers AS "capacityContainers",
              COALESCE(SUM(inv.weight) FILTER (
                WHERE inv.deleted_at IS NULL
                  AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
              ), 0)::float8 AS "usedWeight",
              COALESCE(COUNT(inv.id) FILTER (
                WHERE inv.deleted_at IS NULL
                  AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
              ), 0)::int AS "usedItems"
         FROM freight.warehouse_zones z
         LEFT JOIN freight.warehouse_inventory inv ON inv.zone_id = z.id
        WHERE z.is_active = true
          AND z.deleted_at IS NULL
          AND ($1::uuid IS NULL OR z.yard_id = $1)
        GROUP BY z.id
        ORDER BY z.name`,
      [yardId ?? null],
    );

    return rows.map((r) => {
      const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
      // Zone capacity_weight is in TONNES; inventory weight is in KG — normalise
      // used weight to tonnes before comparing so weight occupancy is correct.
      const usedWeightTons = r.usedWeight / 1000;
      const byWeight =
        capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null;
      const byItems =
        r.capacityContainers && r.capacityContainers > 0
          ? (r.usedItems / r.capacityContainers) * 100
          : null;
      // Container zones use item-count occupancy; bulk zones (no container cap)
      // fall back to the now unit-correct weight occupancy.
      const pct = byItems ?? byWeight;
      return {
        id: r.id,
        name: r.name,
        code: r.code,
        type: r.type,
        yardId: r.yardId,
        capacityWeight: capWeight,
        capacityContainers: r.capacityContainers,
        usedWeight: r.usedWeight,
        usedItems: r.usedItems,
        occupancyPct: pct == null ? null : Math.round(pct * 10) / 10,
      };
    });
  }

  /**
   * Recurring nudge: keep reminding self-haul IMPORT customers to assign a
   * collection truck while their goods are still in the warehouse
   * (READY_FOR_PICKUP) and no truck has been assigned yet. Stops once a truck is
   * assigned (customer_truck_assigned_at set) or the goods leave (DELIVERED).
   */
  @Cron(CronExpression.EVERY_30_MINUTES, { name: 'import-truck-assignment-reminder' })
  async remindImportTruckAssignment(): Promise<void> {
    try {
      const rows: Array<{
        bookingId: string;
        companyId: string | null;
        reference: string | null;
      }> = await this.dataSource.query(
        `SELECT DISTINCT b.id AS "bookingId",
                b.company_id AS "companyId",
                b.reference
           FROM freight.warehouse_inventory inv
           JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
          WHERE inv.deleted_at IS NULL
            AND inv.status = 'READY_FOR_PICKUP'
            AND b.trade_direction = 'IMPORT'
            AND b.customer_truck_assigned_at IS NULL
            AND COALESCE(NULLIF(TRIM(b.last_mile_delivery_address), ''), '') = ''`,
      );
      if (!rows.length) return;
      this.logger.log(
        `Import truck-assignment reminder: ${rows.length} booking(s) awaiting a collection truck`,
      );
      for (const row of rows) {
        await this.notifyTruckAssignmentNeeded(
          {
            companyId: row.companyId,
            reference: row.reference,
            hasFirstMile: false,
            hasLastMile: false,
            customerTruckAssignedAt: null,
          },
          row.bookingId,
        );
      }
    } catch (err) {
      this.logger.warn(
        `Import truck-assignment reminder tick failed: ${(err as Error).message}`,
      );
    }
  }

  private async notifyTruckAssignmentNeeded(booking: {
    companyId?: string | null;
    reference?: string | null;
    hasFirstMile?: boolean;
    hasLastMile?: boolean;
    customerTruckAssignedAt?: string | null;
  }, bookingId: string): Promise<void> {
    if (!booking.companyId) return;
    if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
    if (booking.customerTruckAssignedAt) return; // already assigned
    const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`;
    try {
      await this.inbox.notify({
        recipients: { companyId: booking.companyId },
        audience: NotificationAudience.PORTAL,
        type: NotificationType.BOOKING_STATUS,
        title: 'Assign a truck for pickup',
        body,
        link: `/bookings/${bookingId}`,
        data: { bookingId, action: 'ASSIGN_TRUCK' },
      });
      await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
    } catch (err) {
      this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
    }
  }

  /**
   * Batch 6 — final terminal release / gate clearance.
   * Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
   * inspection / storage / loading steps — only the final release.
   */
  async gateClearance(id: string, performedBy?: string): Promise<WarehouseInventory> {
    const [item]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
      `SELECT id, warehouse_id AS "warehouseId"
         FROM freight.warehouse_inventory
        WHERE id = $1 AND deleted_at IS NULL
        LIMIT 1`,
      [id],
    );
    if (!item) {
      throw new NotFoundException(`Inventory item ${id} not found`);
    }

    const blocking = await this.invoices.findBlockingInvoice(id);
    if (blocking) {
      throw new BadRequestException(
        'Warehouse demurrage/storage fee must be paid before terminal release.',
      );
    }

    const now = new Date();
    const [gateColumn]: Array<{ exists: boolean }> = await this.dataSource.query(
      `SELECT EXISTS (
         SELECT 1
           FROM information_schema.columns
          WHERE table_schema = 'freight'
            AND table_name = 'warehouse_inventory'
            AND column_name = 'gate_cleared_at'
       ) AS "exists"`,
    );
    if (gateColumn?.exists) {
      await this.dataSource.query(
        `UPDATE freight.warehouse_inventory
            SET gate_cleared_at = $2,
                release_date = COALESCE(release_date, $2),
                updated_at = now()
          WHERE id = $1 AND deleted_at IS NULL`,
        [id, now],
      );
    } else {
      await this.dataSource.query(
        `UPDATE freight.warehouse_inventory
            SET release_date = COALESCE(release_date, $2),
                updated_at = now()
          WHERE id = $1 AND deleted_at IS NULL`,
        [id, now],
      );
    }

    await this.activityLog.record({
      activityType: 'INVENTORY_DISPATCHED',
      inventoryId: id,
      warehouseId: item.warehouseId,
      description: 'Gate clearance / terminal release',
      performedBy,
    });
    return this.findById(id);
  }

  // ── Listing ────────────────────────────────────────────────────────────

  async findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
    const createdAt =
      filter.dateFrom && filter.dateTo
        ? Between(new Date(filter.dateFrom), new Date(filter.dateTo))
        : filter.dateFrom
          ? MoreThanOrEqual(new Date(filter.dateFrom))
          : filter.dateTo
            ? LessThanOrEqual(new Date(filter.dateTo))
            : undefined;

    const base: FindOptionsWhere<WarehouseInventory> = {
      ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
      ...(filter.yardId ? { yardId: filter.yardId } : {}),
      ...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
      ...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
      ...(filter.cargoId ? { cargoId: filter.cargoId } : {}),
      ...(filter.containerId ? { containerId: filter.containerId } : {}),
      ...(filter.goodsId ? { goodsId: filter.goodsId } : {}),
      ...(filter.status ? { status: filter.status } : {}),
      ...(createdAt ? { createdAt } : {}),
      ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}),
      ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}),
    };

    // KPI drill-down filters — predicates mirror opsStats() exactly so the
    // dashboard card's count equals the length of the list it opens.
    if (filter.receivedToday) {
      base.createdAt = Raw((alias) => `${alias}::date = CURRENT_DATE`);
    }
    if (filter.pendingInspection) {
      base.status = 'RECEIVED';
      base.inspectionStatus = IsNull();
    }
    if (filter.agingOverDays) {
      if (!filter.status && !filter.pendingInspection) {
        base.status = In(this.IN_WAREHOUSE_STATUSES);
      }
      base.createdAt = Raw((alias) => `${alias} < now() - make_interval(days => :days)`, {
        days: filter.agingOverDays,
      });
    }

    const search = filter.search?.trim();
    const where: FindManyOptions<WarehouseInventory>['where'] = search
      ? [
          { ...base, notes: ILike(`%${search}%`) },
          { ...base, grnNumber: ILike(`%${search}%`) },
        ]
      : base;

    const items = await this.inventoryRepository.findAll({
      where,
      relations: { warehouse: true, yard: true, zone: true },
      order: { createdAt: 'DESC' },
    });
    await this.attachBookingSummaries(items);
    return items;
  }

  findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
    return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
  }

  /**
   * Warehouse location rows for one booking, trimmed for the customer portal:
   * no staff guard on the route, so only location fields leave the API —
   * never notes, fees or inspection internals.
   */
  async bookingLocation(bookingId: string) {
    const items = await this.inventoryRepository.findAll({
      where: { bookingId },
      relations: { warehouse: true, yard: true, zone: true },
      order: { createdAt: 'DESC' },
    });
    return items.map((i) => ({
      id: i.id,
      bookingId: i.bookingId,
      status: i.status,
      arrivedAt: i.arrivedAt ?? null,
      warehouse: i.warehouse ? { id: i.warehouse.id, name: i.warehouse.name, code: i.warehouse.code } : null,
      yard: i.yard ? { id: i.yard.id, name: i.yard.name, code: i.yard.code } : null,
      zone: i.zone ? { id: i.zone.id, name: i.zone.name, code: i.zone.code } : null,
    }));
  }

  async findById(id: string): Promise<WarehouseInventory> {
    const item = await this.inventoryRepository.findById(id, {
      relations: { warehouse: { facility: true }, yard: true, zone: true },
    });

    if (!item) {
      throw new NotFoundException(`Inventory item ${id} not found`);
    }

    return item;
  }

  // ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────

  /** Bookings whose goods have arrived and may be unloaded into the warehouse. */
  private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED'];

  /** Arrived bookings + their current inventory/inspection state (queue view). */
  async arrivalQueue(): Promise<ArrivalQueueItem[]> {
    const rows: ArrivalQueueRow[] = await this.dataSource.query(
      `SELECT b.id            AS "bookingId",
              b.reference      AS "bookingReference",
              company.name     AS "customer",
              b.cargo_free_text AS "cargo",
              ct.container_number AS "container",
              b.scheduled_date AS "arrivalDate",
              b.status         AS "bookingStatus",
              inv.id           AS "inventoryId",
              inv.status       AS "currentStatus",
              inv.inspection_status AS "inspectionStatus",
              fac.name         AS "facility",
              wh.name          AS "warehouse",
              yard.name        AS "yard",
              zone.name        AS "zone"
         FROM freight.bookings b
         LEFT JOIN freight.companies company ON company.id = b.company_id
         LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
         LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
         LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
         LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
         LEFT JOIN freight.facilities fac ON fac.id = wh.facility_id
         LEFT JOIN freight.containers ct ON ct.id = inv.container_id
        WHERE b.status = ANY($1) AND b.deleted_at IS NULL
        ORDER BY b.scheduled_date DESC NULLS LAST`,
      [this.ARRIVED_BOOKING_STATUSES],
    );

    return rows.map((r) => ({
      bookingId: r.bookingId,
      bookingReference: r.bookingReference,
      customer: r.customer ?? null,
      cargo: r.cargo ?? null,
      container: r.container ?? null,
      facility: r.facility ?? null,
      warehouse: r.warehouse ?? null,
      yard: r.yard ?? null,
      zone: r.zone ?? null,
      inventoryId: r.inventoryId ?? null,
      currentStatus: r.currentStatus ?? null,
      arrivalDate: r.arrivalDate ?? null,
      inspectionStatus: r.inspectionStatus ?? null,
      unloaded: Boolean(r.inventoryId),
    }));
  }

  /** First matching warehouse that has at least one yard + zone (fallback location for auto-unload). */
  private async pickDefaultLocation(warehouseId?: string): Promise<DefaultLocation | null> {
    const params = warehouseId ? [warehouseId] : [];
    const [row]: DefaultLocation[] = await this.dataSource.query(
      `SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId",
              yard.id AS "yardId", zone.id AS "zoneId"
         FROM freight.warehouses wh
         JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL
         JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL
        WHERE wh.deleted_at IS NULL
          ${warehouseId ? 'AND wh.id = $1' : ''}
        ORDER BY wh.created_at ASC
       LIMIT 1`,
      params,
    );
    return row ?? null;
  }

  /** Bulk-create inventory (RECEIVED) for arrived bookings that are not yet unloaded. */
  async autoUnloadArrived(): Promise<AutoUnloadResult> {
    const arrived: {
      id: string;
      weight: string | null;
      freightType: string | null;
      tradeDirection: string | null;
      cargoTypeCode: string | null;
    }[] = await this.dataSource.query(
      `SELECT b.id,
              -- Received weight must land on the inventory row: a booking with no
              -- declared VGM still has per-container VGM to record.
              COALESCE(
                NULLIF(b.cargo_total_weight_vgm, 0),
                (SELECT SUM(bcu.vgm_tons)
                   FROM freight.booking_container_units bcu
                   JOIN freight.booking_container bc2
                     ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
                  WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
              ) AS weight,
              b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
              cgt.code AS "cargoTypeCode"
         FROM freight.bookings b
         LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
         LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
        WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`,
      [this.ARRIVED_BOOKING_STATUSES],
    );

    const result: AutoUnloadResult = { processedCount: 0, skippedCount: 0, failedCount: 0, results: [] };

    if (arrived.length === 0) return result;

    const fallback = await this.pickDefaultLocation();

    for (const booking of arrived) {
      try {
        // Deterministic allocation by rules; fall back to default location if no rule resolves.
        const allocated = await this.allocation.resolveLocation({
          freightType: booking.freightType,
          tradeDirection: booking.tradeDirection,
          cargoTypeCode: booking.cargoTypeCode,
        });
        const location = allocated ?? fallback;
        if (!location) {
          result.failedCount += 1;
          result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
          continue;
        }
        // EXPORT goods get their GRN on arrival at the warehouse — nothing loads
        // onto a train without one. Import GRN handling is left untouched.
        const saved = await this.inventoryRepository.create({
          warehouseId: location.warehouseId,
          yardId: location.yardId,
          zoneId: location.zoneId,
          bookingId: booking.id,
          quantity: 1,
          weight: Number(booking.weight) || 0,
          status: 'RECEIVED',
          arrivedAt: new Date(),
          ...(booking.tradeDirection === 'EXPORT'
            ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
            : {}),
          notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
        });
        result.processedCount += 1;
        result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'PROCESSED' });
      } catch (error) {
        result.failedCount += 1;
        result.results.push({
          bookingId: booking.id,
          status: 'FAILED',
          reason: error instanceof Error ? error.message : String(error),
        });
      }
    }

    return result;
  }

  /** Unload a single arrived booking into a chosen (or default) location. */
  async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
    const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
    // A GRN is the receipt for cargo entering the warehouse, so every booking
    // gets one on unload — import as well as export. The direction only decides
    // the GRN prefix, not whether one is issued.
    const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
      `SELECT trade_direction AS "tradeDirection"
         FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
      [bookingId],
    );
    const grnDirection = bookingRow?.tradeDirection ?? 'WH';

    let location: DefaultLocation | null =
      dto.warehouseId && dto.yardId && dto.zoneId
        ? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null }
        : null;
    if (!location && dto.warehouseId) location = await this.pickDefaultLocation(dto.warehouseId);
    if (!location) location = await this.pickDefaultLocation();
    if (!location) {
      throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading');
    }

    const arrivedAt = dto.unloadedAt ? new Date(dto.unloadedAt) : new Date();

    if (existing[0]) {
      await this.inventoryRepository.update(existing[0].id, {
        warehouseId: location.warehouseId,
        yardId: location.yardId,
        zoneId: location.zoneId,
        status: 'RECEIVED',
        arrivedAt,
        // Keep an already-issued GRN rather than reissuing; mint one otherwise.
        ...(existing[0].grnNumber
          ? {}
          : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
        notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
      });
      return this.findById(existing[0].id);
    }

    const saved = await this.inventoryRepository.create({
      warehouseId: location.warehouseId,
      yardId: location.yardId,
      zoneId: location.zoneId,
      bookingId,
      quantity: 1,
      weight: 0,
      status: 'RECEIVED',
      arrivedAt,
      grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
      notes: dto.notes ?? 'Unloaded',
    });
    return this.findById(saved.id);
  }

  /** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */
  async autoLoadReady(): Promise<AutoLoadResult> {
    const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
    const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };

    for (const item of ready) {
      const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
      if (bookingStatus !== 'PAID') {
        result.skippedCount += 1;
        result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' });
        continue;
      }
      await this.dataSource.transaction(async (manager) => {
        await manager.getRepository(WarehouseInventory).update(item.id, {
          status: 'LOADED',
          loadedAt: new Date(),
        });
        await this.activityLog.record(
          {
            activityType: 'INVENTORY_LOADED',
            inventoryId: item.id,
            warehouseId: item.warehouseId,
            description: 'Auto-loaded (PAID booking)',
          },
          manager,
        );
      });
      result.loadedCount += 1;
      result.results.push({ inventoryId: item.id, status: 'LOADED' });
    }

    return result;
  }

  // ── Receive (Import/Export bulk) ───────────────────────────────────────────

  /**
   * Eligible PAID bookings that have NOT been received yet, classified IMPORT/EXPORT by route
   * (origin/destination yard countries). Pass a direction to filter to one; omit it to return
   * all import + export bookings in a single call (DOMESTIC routes are excluded either way).
   */
  async eligibleBookings(direction?: 'IMPORT' | 'EXPORT'): Promise<EligibleBookingRow[]> {
    const rows: Array<
      EligibleBookingRow & { originCountry: string | null; destinationCountry: string | null }
    > = await this.dataSource.query(
      `SELECT b.id,
              b.reference              AS "reference",
              b.company_id             AS "customerId",
              company.name             AS "customer",
              company.tin              AS "customerTin",
              ${companyNotifyPhoneExpr('company')} AS "customerPhone",
              COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber",
              bcu.seal_numbers         AS "sealNumbers",
              bc.container_quantity    AS "containerQuantity",
              bc.container_packaging_type AS "containerPackagingType",
              (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
                OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
              oy.code                  AS "origin",
              dy.code                  AS "destination",
              oy.country               AS "originCountry",
              dy.country               AS "destinationCountry",
              b.freight_type           AS "freightType",
              COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo",
              COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
              b.cargo_total_weight_vgm AS "weight",
              b.payment_status         AS "paymentStatus",
              b.status                 AS "status",
              (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
                OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
              fm.id                    AS "firstMileRequestId",
              fm.status                AS "firstMileStatus",
              fm.vehicle_id            AS "firstMileVehicleId",
              v.plate_number           AS "firstMileTruckPlateNumber",
              v.trailer_plate_no       AS "firstMileTrailerPlateNumber",
              COALESCE(
                NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
                v.assigned_driver_name
              ) AS "firstMileDriverName",
              driver.phone_number      AS "firstMileDriverPhone",
              driver.license_number    AS "firstMileDriverLicenseNumber",
              v.vehicle_type           AS "firstMileTruckType",
              -- Multi-truck self-haul writes plates/drivers to
              -- customer_truck_assignments and leaves the booking columns null,
              -- so read the assignments first and keep the legacy column as the
              -- fallback for single-truck bookings written before that table.
              COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
                          FROM freight.customer_truck_assignments cta
                         WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
                       b.customer_truck_plate_number) AS "customerTruckPlateNumber",
              COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
                          FROM freight.customer_truck_assignments cta
                         WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
                       b.customer_truck_driver_name) AS "customerTruckDriverName",
              b.customer_truck_type AS "customerTruckType",
              b.customer_truck_container_number AS "customerTruckContainerNumber",
              b.customer_truck_assigned_at AS "customerTruckAssignedAt"
         FROM freight.bookings b
         LEFT JOIN freight.companies company ON company.id = b.company_id
         ${primaryContactUserJoin('company')}
         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.cargo_types ct ON ct.id = b.cargo_type_id
         LEFT JOIN freight.service_types st ON st.id = b.service_type_id
         LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
         LEFT JOIN LATERAL (
           SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
                  SUM(booking_container.quantity)::int AS container_quantity,
                  CASE
                    WHEN COUNT(booking_container.id) = 0 THEN NULL
                    WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
                    WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
                    WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
                    WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
                    WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
                    ELSE 'OTHER_CONTAINER'
                  END AS container_packaging_type
             FROM freight.booking_container booking_container
             LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
            WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
         ) bc ON true
         LEFT JOIN LATERAL (
           SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers,
                  string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers
             FROM freight.booking_container_units unit
             JOIN freight.booking_container line
               ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
            WHERE line.booking_id = b.id AND unit.deleted_at IS NULL
         ) bcu ON true
         LEFT JOIN LATERAL (
           SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
             FROM freight.first_mile first_mile
            WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
            ORDER BY first_mile.created_at DESC
            LIMIT 1
         ) fm ON true
         LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
         LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
        WHERE b.deleted_at IS NULL
          AND b.payment_status = 'PAID'
          AND inv.id IS NULL
        ORDER BY b.scheduled_date DESC NULLS LAST`,
    );

    // Direction is derived from the route (origin/destination yard countries), reusing deriveTradeDirection.
    return rows
      .map((r) => ({
        ...r,
        direction: deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }),
      }))
      .filter((r) =>
        direction ? r.direction === direction : r.direction === 'IMPORT' || r.direction === 'EXPORT',
      );
  }

  /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
  async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
    const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
    /** Sent after the transaction commits so the gateway never blocks the receive. */
    const pendingNotifications: Array<{
      owner: {
        phone?: string | null;
        ownerName?: string | null;
        bookingReference?: string | null;
        grnNumber: string;
        direction?: string | null;
        warehouseId?: string | null;
      };
      booking: {
        companyId?: string | null;
        reference?: string | null;
        hasFirstMile?: boolean;
        hasLastMile?: boolean;
        customerTruckAssignedAt?: string | null;
      };
      bookingId: string;
    }> = [];

    await this.dataSource.transaction(async (manager) => {
      const { warehouse, yard, zone } = await this.validateLocation(manager, {
        warehouseId: dto.warehouseId,
        yardId: dto.yardId,
        zoneId: dto.zoneId,
      });
      // The receive location is whatever the operator selected above — never a
      // hand-typed string. Stamp it on the truck entrance for the GRN/notes.
      if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) {
        dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code]
          .filter(Boolean)
          .join(' / ');
      }

      for (const bookingId of dto.bookingIds) {
        const skip = (reason: string) => {
          result.skippedCount += 1;
          result.results.push({ bookingId, status: 'SKIPPED', reason });
        };

        const [booking] = await manager.query(
          `SELECT b.reference AS "reference",
                  b.payment_status AS "paymentStatus",
                  b.freight_type AS "freightType",
                  b.cargo_total_weight_vgm AS "weight",
                  company.name AS "customer",
                  company.tin AS "customerTin",
                  ${companyNotifyPhoneExpr('company')} AS "customerPhone",
                  bc.container_numbers AS "containerNumber",
                  bc.container_quantity AS "containerQuantity",
                  bc.container_packaging_type AS "containerPackagingType",
                  COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
                  oy.country AS "originCountry", dy.country AS "destinationCountry",
                  (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
                    OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
                  fm.id AS "firstMileRequestId",
                  fm.status AS "firstMileStatus",
                  v.plate_number AS "firstMileTruckPlateNumber",
                  v.trailer_plate_no AS "firstMileTrailerPlateNumber",
                  COALESCE(
                    NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
                    v.assigned_driver_name
                  ) AS "firstMileDriverName",
                  driver.phone_number AS "firstMileDriverPhone",
                  driver.license_number AS "firstMileDriverLicenseNumber",
                  v.vehicle_type AS "firstMileTruckType",
                  COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
                              FROM freight.customer_truck_assignments cta
                             WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
                           b.customer_truck_plate_number) AS "customerTruckPlateNumber",
                  COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
                              FROM freight.customer_truck_assignments cta
                             WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
                           b.customer_truck_driver_name) AS "customerTruckDriverName",
                  b.customer_truck_type AS "customerTruckType",
                  b.customer_truck_container_number AS "customerTruckContainerNumber",
                  b.customer_truck_assigned_at AS "customerTruckAssignedAt",
                  b.company_id AS "companyId",
                  (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
                    OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
             FROM freight.bookings b
             LEFT JOIN freight.companies company ON company.id = b.company_id
             ${primaryContactUserJoin('company')}
             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.service_types st ON st.id = b.service_type_id
             LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
             LEFT JOIN LATERAL (
               SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
                      SUM(booking_container.quantity)::int AS container_quantity,
                      CASE
                        WHEN COUNT(booking_container.id) = 0 THEN NULL
                        WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
                        WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
                        WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
                        WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
                        WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
                        ELSE 'OTHER_CONTAINER'
                      END AS container_packaging_type
                 FROM freight.booking_container booking_container
                 LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
                WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
             ) bc ON true
             LEFT JOIN LATERAL (
               SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
                 FROM freight.first_mile first_mile
                WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
                ORDER BY first_mile.created_at DESC
                LIMIT 1
             ) fm ON true
             LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
             LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
            WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
          [bookingId],
        );
        if (!booking) { skip('Booking not found'); continue; }
        if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; }
        // Direction is derived from the route (yard countries), not the stored field.
        const bookingDirection = deriveTradeDirection(
          { country: booking.originCountry },
          { country: booking.destinationCountry },
        );
        if (bookingDirection !== dto.direction) {
          skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
          continue;
        }
        if (dto.direction === 'EXPORT' && booking.hasFirstMile) {
          if (!booking.firstMileRequestId) {
            skip('First-mile request not created');
            continue;
          }
          if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') {
            skip('First-mile truck has not arrived');
            continue;
          }
        }

        const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
        if (existing) { skip('Already received'); continue; }

        const containerQuantity = Number(booking.containerQuantity ?? 0);
        if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
          skip('Container booking has no container quantity');
          continue;
        }

        const now = new Date();
        const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
        const truckEntrance = dto.truckEntrance
          ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
          : undefined;
        if (dto.direction === 'EXPORT') {
          this.assertTruckEntrance(truckEntrance);
        }
        const receiveNote = this.buildReceiveNote({
          grnNumber,
          direction: dto.direction,
          notes: `Bulk received (${dto.direction})`,
          truckEntrance,
        });

        // Validate capacity before saving
        const weight = Number(booking.weight) || 0;
        const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0;
        this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
        this.assertCapacity('Yard', yard, weight, 0, containerCount);
        this.assertCapacity('Zone', zone, weight, 0, containerCount);

        const saved = await manager.getRepository(WarehouseInventory).save(
          manager.getRepository(WarehouseInventory).create({
            warehouseId: dto.warehouseId,
            yardId: dto.yardId,
            zoneId: dto.zoneId,
            bookingId,
            quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
            weight,
            grnNumber,
            status: 'RECEIVED',
            arrivedAt: now,
            notes: receiveNote,
          }),
        );

        // Update warehouse/yard/zone capacity counters
        await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);

        // Receiving the booking flags every container unit as received into the
        // port (self-haul export: the delivering truck's goods are now in) so
        // staff can raise the per-container GRN over what's received.
        await manager.query(
          `UPDATE freight.booking_container_units bcu
              SET received_to_port = true,
                  received_at = COALESCE(bcu.received_at, NOW()),
                  updated_at = NOW()
             FROM freight.booking_container bc
            WHERE bc.id = bcu.booking_container_id
              AND bc.booking_id = $1
              AND bc.deleted_at IS NULL
              AND bcu.deleted_at IS NULL
              AND bcu.received_to_port = false`,
          [bookingId],
        );

        await this.activityLog.record(
          {
            activityType: 'INVENTORY_RECEIVED',
            inventoryId: saved.id,
            warehouseId: dto.warehouseId,
            description: truckEntrance?.truckPlateNumber
              ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
              : `GRN ${grnNumber}: bulk received ${dto.direction} booking`,
            performedBy: dto.performedBy,
          },
          manager,
        );

        // Queued, not sent here: an SMS/email round-trip inside the transaction
        // holds capacity/location locks open for the whole gateway latency.
        pendingNotifications.push({
          owner: {
            phone: truckEntrance?.customerPhone ?? booking.customerPhone,
            ownerName: truckEntrance?.ownerName ?? booking.customer,
            bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
            grnNumber,
            direction: dto.direction,
            warehouseId: dto.warehouseId,
          },
          booking,
          bookingId,
        });

        result.receivedCount += 1;
        result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
      }
    });

    // Fan out after commit, un-awaited: the receive response must not wait on the
    // SMS gateway. Both notifiers swallow their own errors.
    for (const pending of pendingNotifications) {
      void this.notifyOwnerInventoryReceived(pending.owner);
      void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId);
    }

    return result;
  }


  /** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
  private async exportInventoryByStatus(
    status: WarehouseInventoryStatus,
    requireInspectionPassed = false,
  ): Promise<ReadyToLoadRow[]> {
    const rows: Array<
      ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null }
    > = await this.dataSource.query(
      `SELECT inv.id,
              inv.booking_id           AS "bookingId",
              b.reference              AS "bookingReference",
              b.company_id             AS "customerId",
              company.name             AS "customerName",
              ct.container_number      AS "containerNumber",
              COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
              inv.weight               AS "weight",
              COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
              oy.code                  AS "origin",
              dy.code                  AS "destination",
              oy.country               AS "originCountry",
              dy.country               AS "destinationCountry",
              inv.inspection_status    AS "inspectionStatus",
              inv.status
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
         LEFT JOIN freight.companies company ON company.id = b.company_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.cargo_types cgt ON cgt.id = b.cargo_type_id
         LEFT JOIN freight.containers ct ON ct.id = inv.container_id
        WHERE inv.deleted_at IS NULL
          AND inv.status = $1
          ${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''}
        ORDER BY inv.created_at DESC`,
      [status],
    );

    return rows
      .filter((r) => {
        const dir = deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry });
        return dir === 'EXPORT';
      })
      .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
  }

  /** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */
  async readyToLoadExport(): Promise<ReadyToLoadRow[]> {
    return this.exportInventoryByStatus('READY_FOR_LOADING', true);
  }

  /** EXPORT inventory received at the facility and awaiting inspection. */
  async receivedExport(): Promise<ReadyToLoadRow[]> {
    return this.exportInventoryByStatus('RECEIVED');
  }

  /** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */
  async loadedExport(): Promise<ReadyToLoadRow[]> {
    return this.exportInventoryByStatus('LOADED');
  }

  // ── Per-train loading (Load to Train tab) ─────────────────────────────────
  // Loading follows wagon allocation: staff pick an allocated EXPORT train, see
  // the arrived containers/cargoes assigned to it, and load the ready ones onto
  // their already-allocated wagons. Reuses the single-item load() machinery.

  /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
  /**
   * Export flow this queue serves: booked -> paid -> received at the warehouse
   * (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the
   * booking. Which bookings ride a train comes from the shared CTE.
   */
  private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE;

  async loadableTrains(): Promise<LoadableTrainRow[]> {
    const rows: Array<
      LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
    > = await this.dataSource.query(
      `WITH ${this.SCHEDULE_BOOKINGS_CTE}
       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",
              ts.status                   AS "status",
              ts.scheduled_departure_date AS "departureTime",
              (SELECT count(*) FROM sched_bookings sb
                 JOIN freight.warehouse_inventory inv
                   ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
                WHERE sb.schedule_id = ts.id
                  AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount",
              (SELECT count(*) FROM sched_bookings sb
                 JOIN freight.warehouse_inventory inv
                   ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
                WHERE sb.schedule_id = ts.id
                  AND inv.status = 'LOADED') AS "loadedCount"
         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 = ANY($1)
          AND EXISTS (
            SELECT 1 FROM sched_bookings sb2
              JOIN freight.warehouse_inventory inv2
                ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL
             WHERE sb2.schedule_id = ts.id
               AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
          )
        ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
      [['DRAFT', 'SCHEDULED']],
    );

    return rows
      .filter(
        (r) =>
          deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'EXPORT',
      )
      .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
        ...rest,
        readyCount: Number(rest.readyCount) || 0,
        loadedCount: Number(rest.loadedCount) || 0,
      }));
  }

  /**
   * Container/cargo inventory items assigned to a train, with the wagon each is
   * allocated to. Covers the arrived-but-not-loaded set (RECEIVED..READY_FOR_LOADING)
   * plus already-LOADED items, so the "Received" and "Loaded" stage tabs both fill.
   */
  async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
    const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
      `WITH ${this.SCHEDULE_BOOKINGS_CTE}
       SELECT inv.id                    AS "id",
              inv.booking_id            AS "bookingId",
              b.reference               AS "bookingReference",
              company.name              AS "customerName",
              ct.container_number       AS "containerNumber",
              COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
              inv.weight                AS "weight",
              -- receive() stamps the GRN onto the row and mirrors it into the
              -- note; prefer the column and fall back for legacy/seeded rows.
              COALESCE(
                inv.grn_number,
                substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
              )                         AS "grnNumber",
              inv.inspection_status     AS "inspectionStatus",
              inv.status                AS "status",
              wl.wagon_id               AS "wagonId",
              wl.wagon_number           AS "wagonNumber",
              wl.sequence_no            AS "sequenceNo"
         FROM sched_bookings sb
         JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
         JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
         JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.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.containers ct ON ct.id = inv.container_id
         LEFT JOIN LATERAL (
           SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no
             FROM freight.wagon_booking_allocations wba
             JOIN freight.train_set_wagons tsw
               ON tsw.id = wba.train_set_wagon_id
              AND tsw.train_set_id = ts.train_set_id
              AND tsw.deleted_at IS NULL
             JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
            WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
            ORDER BY tsw.sequence_no ASC NULLS LAST
            LIMIT 1
         ) wl ON true
        WHERE sb.schedule_id = $1
          AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
        ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
      [scheduleId],
    );

    return rows.map((r) => ({
      ...r,
      // Export flow: received at the warehouse -> GRN -> loaded onto its wagon.
      // The row only exists once the goods were received, so requiring a GRN and
      // an allocated wagon completes the chain.
      loadable:
        r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber),
    }));
  }

  /**
   * Load the selected inventory items onto their allocated wagons for the given
   * train. Each item must be assigned to this train, READY_FOR_LOADING, and have
   * an allocated wagon; others are skipped with a reason. When every inventory
   * item of a booking is loaded, its train_schedule_bookings.loading_status flips
   * to LOADED so the train's confirm-loading/dispatch step reflects reality.
   */
  async loadItemsOntoTrain(
    scheduleId: string,
    inventoryIds: string[],
    performedBy?: string,
  ): Promise<TrainLoadResult> {
    const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
    const [schedule]: Array<{
      trainNumber: string | null;
      origin: string | null;
      destination: string | null;
      departure: string | null;
    }> = await this.dataSource.query(
      `SELECT ts.train_number AS "trainNumber",
              COALESCE(oy.label, oy.code) AS "origin",
              COALESCE(dy.label, dy.code) AS "destination",
              ts.scheduled_departure_date AS "departure"
         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.id = $1 AND ts.deleted_at IS NULL`,
      [scheduleId],
    );
    const trainNote = schedule
      ? `Loaded onto train ${schedule.trainNumber ?? scheduleId.slice(0, 8)}` +
        (schedule.origin || schedule.destination
          ? ` (${schedule.origin ?? '?'} -> ${schedule.destination ?? '?'})`
          : '') +
        (schedule.departure ? `, departure ${new Date(schedule.departure).toISOString()}` : '')
      : undefined;
    const items = await this.trainLoadableItems(scheduleId);
    const byId = new Map(items.map((i) => [i.id, i]));
    const affectedBookingIds = new Set<string>();

    for (const inventoryId of inventoryIds) {
      const skip = (reason: string) => {
        result.skippedCount += 1;
        result.results.push({ inventoryId, status: 'SKIPPED', reason });
      };
      const item = byId.get(inventoryId);
      if (!item) { skip('Not assigned to this train'); continue; }
      if (item.status === 'LOADED') { skip('Already loaded'); continue; }
      if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
      // Export: the GRN is raised when the goods arrive at the warehouse, and
      // nothing rides a train without one.
      if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; }
      if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }

      try {
        await this.load(inventoryId, {
          wagonId: item.wagonId,
          loadedBy: performedBy,
          trainScheduleId: scheduleId,
          notes: trainNote,
        });
        result.loadedCount += 1;
        result.results.push({ inventoryId, status: 'LOADED' });
        if (item.bookingId) affectedBookingIds.add(item.bookingId);
      } catch (error) {
        skip(error instanceof Error ? error.message : 'Load failed');
      }
    }

    // Flip a booking's train loading_status to LOADED once no un-loaded inventory remains.
    for (const bookingId of affectedBookingIds) {
      await this.dataSource.query(
        `UPDATE freight.train_schedule_bookings tsb
            SET loading_status = 'LOADED', updated_at = NOW()
          WHERE tsb.train_schedule_id = $1 AND tsb.booking_id = $2 AND tsb.deleted_at IS NULL
            AND NOT EXISTS (
              SELECT 1 FROM freight.warehouse_inventory inv
               WHERE inv.booking_id = $2 AND inv.deleted_at IS NULL
                 AND inv.status NOT IN ('LOADED', 'DISPATCHED')
            )`,
        [scheduleId, bookingId],
      );
    }

    return result;
  }

  /** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
  private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
    const rows: Array<
      ImportUnloadedRow & { originCountry: string | null; destinationCountry: string | null }
    > = await this.dataSource.query(
      `SELECT inv.id,
              inv.booking_id           AS "bookingId",
              b.reference              AS "bookingReference",
              b.company_id             AS "customerId",
              company.name             AS "customerName",
              COALESCE(inv.unloaded_at, inv.arrived_at) AS "arrivalTime",
              (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",
              inv.weight               AS "weight",
              COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
              ts.train_number          AS "trainSchedule",
              inv.inspection_status    AS "inspectionStatus",
              CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
                         OR COALESCE(st.includes_last_mile, false)
                   THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
              (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
                OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
              -- Multi-truck self-haul writes plates/drivers to
              -- customer_truck_assignments and leaves the booking columns null,
              -- so read the assignments first and keep the legacy column as the
              -- fallback for single-truck bookings written before that table.
              COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
                          FROM freight.customer_truck_assignments cta
                         WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
                       b.customer_truck_plate_number) AS "customerTruckPlateNumber",
              COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
                          FROM freight.customer_truck_assignments cta
                         WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
                       b.customer_truck_driver_name) AS "customerTruckDriverName",
              b.customer_truck_type AS "customerTruckType",
              b.customer_truck_container_number AS "customerTruckContainerNumber",
              b.customer_truck_assigned_at AS "customerTruckAssignedAt",
              (b.customer_truck_assigned_at IS NOT NULL
                OR EXISTS (SELECT 1 FROM freight.last_mile lm
                            WHERE lm.booking_id = b.id
                              AND lm.vehicle_id IS NOT NULL
                              AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
              b.double_handling        AS "doubleHandling",
              inv.status               AS "currentStatus",
              inv.release_date         AS "releaseDate",
              inv.release_order_reference AS "releaseOrderReference",
              substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
              substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
              inv.delivered_at         AS "deliveredAt",
              inv.notes                AS "notes",
              oy.country               AS "originCountry",
              dy.country               AS "destinationCountry"
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.bookings b ON b.id = inv.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.service_types st ON st.id = b.service_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.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
         LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
        WHERE inv.deleted_at IS NULL
          AND inv.status = ANY($1)
        ORDER BY inv.created_at DESC`,
      [statuses],
    );

    return rows
      .filter(
        (r) =>
          deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
      )
      .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
  }

  /**
   * Batch 9 — IMPORT inventory sitting in the Unloaded Queue (UNLOADED / destination-inspection
   * states), with the columns the inspection screen needs. Read-only.
   */
  importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
    return this.importQueueByStatuses([
      'UNLOADED',
      'DESTINATION_INSPECTION',
      'UNDER_INSPECTION',
      'ARRIVED_AT_WAREHOUSE',
      'STORED',
      'READY_FOR_PICKUP',
      'DISPATCHED',
      'DELIVERED',
    ]);
  }

  /**
   * Batch 10 — IMPORT inventory that passed inspection and is PICKUP_READY (READY_FOR_PICKUP),
   * awaiting customer pickup / last mile / store / dispatch. Read-only.
   */
  importPickupReadyQueue(): Promise<ImportUnloadedRow[]> {
    return this.importQueueByStatuses(['READY_FOR_PICKUP']);
  }

  /**
   * Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition
   * (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not
   * LOADED or not EXPORT are skipped. The train/schedule flow later moves DISPATCHED → IN_TRANSIT.
   */
  async bulkDispatchExport(inventoryIds: string[], performedBy?: string): Promise<BulkDispatchResult> {
    const result: BulkDispatchResult = { dispatchedCount: 0, skippedCount: 0, results: [] };

    for (const inventoryId of inventoryIds) {
      const skip = (reason: string) => {
        result.skippedCount += 1;
        result.results.push({ inventoryId, status: 'SKIPPED', reason });
      };

      const item = await this.inventoryRepository.findById(inventoryId);
      if (!item) { skip('Inventory not found'); continue; }
      if (item.status !== 'LOADED') { skip(`Status is ${item.status}, not LOADED`); continue; }
      const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
      if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; }

      try {
        await this.dispatch(inventoryId, performedBy);
        result.dispatchedCount += 1;
        result.results.push({ inventoryId, status: 'DISPATCHED' });
      } catch (error) {
        skip(error instanceof Error ? error.message : String(error));
      }
    }

    return result;
  }

  /** Booking statuses that must never be unloaded into warehouse inventory. */
  private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED'];
  private readonly EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES = [
    'LOADED',
    'DISPATCHED',
    'IN_TRANSIT',
    'ARRIVED_AT_DJIBOUTI',
    'ARRIVED_AT_PORT',
    'ARRIVED_AT_DESTINATION',
  ];

  private isDjiboutiPortDestination(value: string | null | undefined): boolean {
    const normalized = (value ?? '').toUpperCase();
    return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
      normalized.includes(token),
    );
  }

  /**
   * Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state.
   * Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect —
   * items land in UNLOADED so the operator drives store/inspect/reserve/dispatch from the queue.
   */
  async autoUnloadArrivedBookings(
    scheduleId: string,
    performedBy?: string,
    warehouseId?: string,
    assignments: BookingUnloadLocation[] = [],
  ): Promise<AutoUnloadArrivedResult> {
    const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] };

    // 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries).
    const [schedule] = await this.dataSource.query(
      `SELECT ts.id, ts.status, ts.destination_station_id AS "destinationStationId",
              oy.country AS "originCountry", dy.country AS "destinationCountry"
         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.id = $1 AND ts.deleted_at IS NULL
        LIMIT 1`,
      [scheduleId],
    );
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (schedule.status !== 'ARRIVED') {
      throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
    }
    const direction = deriveTradeDirection(
      { country: schedule.originCountry },
      { country: schedule.destinationCountry },
    );
    if (direction !== 'IMPORT') {
      throw new BadRequestException(`Train schedule route is ${direction}, not IMPORT`);
    }

    // 2. Assigned bookings on this train.
    const bookings: {
      id: string;
      status: string;
      weight: string | null;
      freightType: string | null;
      tradeDirection: string | null;
      cargoTypeCode: string | null;
    }[] = await this.dataSource.query(
      // Only bookings whose destination IS this train's final yard unload into
      // this (final-destination) warehouse. A mid-corridor import that alighted
      // at an intermediate yard was already unloaded there by the checkpoint
      // auto-unload; without this filter it would be mis-located into the final
      // yard's inventory too.
      `SELECT b.id, b.status,
              -- Same fallback as autoUnloadArrived: never land a 0 t receipt when
              -- the booking's containers carry a VGM.
              COALESCE(
                NULLIF(b.cargo_total_weight_vgm, 0),
                (SELECT SUM(bcu.vgm_tons)
                   FROM freight.booking_container_units bcu
                   JOIN freight.booking_container bc2
                     ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
                  WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
              ) AS weight,
              b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
              cgt.code AS "cargoTypeCode"
         FROM freight.train_schedule_bookings tsb
         JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
         LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
        WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
          AND b.destination_yard_id = $2`,
      [scheduleId, schedule.destinationStationId],
    );

    const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;
    if (warehouseId && !requestedLocation) {
      throw new BadRequestException('Selected warehouse has no yard/zone configured for unloading');
    }
    const fallback = requestedLocation ?? (await this.pickDefaultLocation());
    const assignmentByBooking = new Map(
      assignments.map((assignment) => [
        assignment.bookingId,
        {
          warehouseId: assignment.warehouseId,
          yardId: assignment.yardId,
          zoneId: assignment.zoneId,
        } satisfies LocationRef,
      ]),
    );
    const now = new Date();

    for (const booking of bookings) {
      const skip = (reason: string) => {
        result.skippedCount += 1;
        result.results.push({ bookingId: booking.id, status: 'SKIPPED', reason });
      };
      const fail = (reason: string) => {
        result.failedCount += 1;
        result.results.push({ bookingId: booking.id, status: 'FAILED', reason });
      };

      if (this.IMPORT_UNLOAD_BLOCKED_STATUSES.includes(booking.status)) {
        skip(`Booking status ${booking.status} cannot be unloaded`);
        continue;
      }

      try {
        const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0];
        const assignedLocation = assignmentByBooking.get(booking.id) ?? null;
        const unloadLocation = assignedLocation ?? requestedLocation;

        // Already unloaded or further along — leave it (do not regress the lifecycle).
        if (existing && existing.status !== 'RECEIVED') {
          skip(`Inventory already ${existing.status}`);
          continue;
        }

        if (existing) {
          await this.inventoryRepository.update(existing.id, {
            ...(unloadLocation
              ? {
                  warehouseId: unloadLocation.warehouseId,
                  yardId: unloadLocation.yardId,
                  zoneId: unloadLocation.zoneId,
                }
              : {}),
            // Record the received weight on a row that never carried one — the
            // GRN prints this, and an existing non-zero weight is left alone.
            ...(Number(existing.weight) > 0 || !(Number(booking.weight) > 0)
              ? {}
              : { weight: Number(booking.weight) }),
            status: 'UNLOADED',
            unloadedAt: now,
            arrivedAt: existing.arrivedAt ?? now,
            // Import GRN is issued automatically at train unload.
            ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }),
          });
          await this.activityLog.record({
            activityType: 'INVENTORY_UNLOADED',
            inventoryId: existing.id,
            warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
            description: 'Unloaded from arrived import train',
            performedBy,
          });
          // Capacity follows the recorded weight: deliver() decrements by the
          // item's weight, so a weight written here must be counted here too.
          const addedWeight = Number(booking.weight) - Number(existing.weight ?? 0);
          if (addedWeight > 0) {
            await this.applyCapacityDelta(
              this.dataSource.manager,
              {
                warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
                yardId: unloadLocation?.yardId ?? existing.yardId,
                zoneId: unloadLocation?.zoneId ?? existing.zoneId,
              },
              addedWeight,
              0,
              0,
            );
          }
          result.unloadedCount += 1;
          result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' });
          continue;
        }

        // No inventory yet — create it at the allocated (or default) location, in UNLOADED state.
        const allocated = await this.allocation.resolveLocation({
          freightType: booking.freightType,
          tradeDirection: booking.tradeDirection,
          cargoTypeCode: booking.cargoTypeCode,
        });
        const location = assignedLocation ?? requestedLocation ?? allocated ?? fallback;
        if (!location) {
          fail('No warehouse/yard/zone configured');
          continue;
        }

        const saved = await this.inventoryRepository.create({
          warehouseId: location.warehouseId,
          yardId: location.yardId,
          zoneId: location.zoneId,
          bookingId: booking.id,
          quantity: 1,
          weight: Number(booking.weight) || 0,
          status: 'UNLOADED',
          grnNumber: this.generateGrnNumber('IMPORT', booking.id, now),
          arrivedAt: now,
          unloadedAt: now,
          notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
        });
        await this.activityLog.record({
          activityType: 'INVENTORY_UNLOADED',
          inventoryId: saved.id,
          warehouseId: saved.warehouseId,
          description: 'Unloaded from arrived import train',
          performedBy,
        });
        // New goods physically in the warehouse — count them, or deliver() would
        // later free capacity that was never taken.
        if (Number(saved.weight) > 0) {
          await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0);
        }
        result.unloadedCount += 1;
        result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' });
      } catch (error) {
        fail(error instanceof Error ? error.message : String(error));
      }
    }

    return result;
  }

  /**
   * Unload eligible EXPORT inventory from an arrived Djibouti-side train.
   * This only advances warehouse inventory items assigned to the train and does not write to
   * train schedules, wagon assignment, rescheduling, or booking payment state.
   */
  async autoUnloadExportAtDjibouti(
    scheduleId: string,
    performedBy?: string,
  ): Promise<AutoUnloadExportDjiboutiResult> {
    const result: AutoUnloadExportDjiboutiResult = {
      unloadedCount: 0,
      skippedCount: 0,
      failedCount: 0,
      results: [],
    };

    const [schedule] = await this.dataSource.query(
      `SELECT ts.id,
              ts.status,
              oy.country AS "originCountry",
              dy.country AS "destinationCountry",
              dy.code AS "destinationCode",
              dy.label AS "destinationName"
         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.id = $1 AND ts.deleted_at IS NULL
        LIMIT 1`,
      [scheduleId],
    );
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }

    const direction = deriveTradeDirection(
      { country: schedule.originCountry },
      { country: schedule.destinationCountry },
    );
    if (direction !== 'EXPORT') {
      throw new BadRequestException(`Train schedule route is ${direction}, not EXPORT`);
    }
    if (!this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
      throw new BadRequestException('Train schedule destination is not Djibouti / Doraleh / DMP / DCT / Nagad');
    }
    if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) {
      throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
    }
    const [gatepass] = await this.dataSource.query(
      `SELECT gatepass_granted_at AS "gatepassSecuredAt"
         FROM freight.import_djibouti_operations
        WHERE train_schedule_id = $1 AND deleted_at IS NULL
        LIMIT 1`,
      [scheduleId],
    );
    if (!gatepass?.gatepassSecuredAt) {
      throw new BadRequestException('Djibouti Port entry blocked: gate pass status is NOT_SECURED');
    }

    const items: Array<{
      bookingId: string;
      inventoryId: string | null;
      inventoryStatus: string | null;
      bookingStatus: string | null;
      warehouseId: string | null;
      yardId: string | null;
      zoneId: string | null;
      itemType: 'CONTAINER' | 'CARGO';
      itemId: string | null;
      containerNumber: string | null;
    }> = await this.dataSource.query(
      `WITH assigned AS (
          SELECT b.id AS booking_id,
                 b.status AS booking_status,
                 inv.id AS inventory_id,
                 inv.status AS inventory_status,
                 inv.warehouse_id,
                 inv.yard_id,
                 inv.zone_id
            FROM freight.train_schedule_bookings tsb
            JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
            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.inventory_id AS "inventoryId",
               a.inventory_status AS "inventoryStatus",
               a.booking_status AS "bookingStatus",
               a.warehouse_id AS "warehouseId",
               a.yard_id AS "yardId",
               a.zone_id AS "zoneId",
               'CONTAINER' AS "itemType",
               c.id AS "itemId",
               c.container_number AS "containerNumber"
          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.inventory_id AS "inventoryId",
               a.inventory_status AS "inventoryStatus",
               a.booking_status AS "bookingStatus",
               a.warehouse_id AS "warehouseId",
               a.yard_id AS "yardId",
               a.zone_id AS "zoneId",
               'CARGO' AS "itemType",
               cg.id AS "itemId",
               NULL AS "containerNumber"
          FROM assigned a
          JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL
        UNION ALL
        SELECT a.booking_id AS "bookingId",
               a.inventory_id AS "inventoryId",
               a.inventory_status AS "inventoryStatus",
               a.booking_status AS "bookingStatus",
               a.warehouse_id AS "warehouseId",
               a.yard_id AS "yardId",
               a.zone_id AS "zoneId",
               'CARGO' AS "itemType",
               a.inventory_id AS "itemId",
               NULL AS "containerNumber"
          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)`,
      [scheduleId],
    );

    const seenInventory = new Set<string>();
    const now = new Date();

    await this.dataSource.transaction(async (manager) => {
      for (const item of items) {
        const skip = (reason: string) => {
          result.skippedCount += 1;
          result.results.push({
            bookingId: item.bookingId,
            itemType: item.itemType,
            itemId: item.itemId,
            inventoryId: item.inventoryId ?? undefined,
            containerNumber: item.containerNumber,
            status: 'SKIPPED',
            reason,
          });
        };
        const fail = (reason: string) => {
          result.failedCount += 1;
          result.results.push({
            bookingId: item.bookingId,
            itemType: item.itemType,
            itemId: item.itemId,
            inventoryId: item.inventoryId ?? undefined,
            containerNumber: item.containerNumber,
            status: 'FAILED',
            reason,
          });
        };

        if (!item.inventoryId || !item.warehouseId || !item.yardId || !item.zoneId) {
          skip('No warehouse inventory found for assigned export item');
          continue;
        }
        if (seenInventory.has(item.inventoryId)) {
          result.results.push({
            bookingId: item.bookingId,
            itemType: item.itemType,
            itemId: item.itemId,
            inventoryId: item.inventoryId,
            containerNumber: item.containerNumber,
            status: 'UNLOADED_AT_DJIBOUTI_PORT',
            message: 'Unloaded at Djibouti Port',
          });
          continue;
        }

        const currentStatus = item.inventoryStatus ?? item.bookingStatus;
        if (currentStatus === 'UNLOADED_AT_DJIBOUTI_PORT') {
          seenInventory.add(item.inventoryId);
          result.unloadedCount += 1;
          result.results.push({
            bookingId: item.bookingId,
            itemType: item.itemType,
            itemId: item.itemId,
            inventoryId: item.inventoryId,
            containerNumber: item.containerNumber,
            status: 'UNLOADED_AT_DJIBOUTI_PORT',
            message: 'Already unloaded at Djibouti Port',
          });
          continue;
        }
        if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) {
          skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`);
          continue;
        }

        try {
          await manager.getRepository(WarehouseInventory).update(item.inventoryId, {
            status: 'UNLOADED_AT_DJIBOUTI_PORT',
            unloadedAt: now,
            arrivedAt: now,
            notes: 'Unloaded at Djibouti Port',
          });
          await manager.getRepository(WarehouseInventoryMovement).save(
            manager.getRepository(WarehouseInventoryMovement).create({
              inventoryId: item.inventoryId,
              fromWarehouseId: item.warehouseId,
              fromYardId: item.yardId,
              fromZoneId: item.zoneId,
              toWarehouseId: item.warehouseId,
              toYardId: item.yardId,
              toZoneId: item.zoneId,
              remarks: 'EXPORT_UNLOADED_AT_DJIBOUTI_PORT',
              movedBy: performedBy ?? null,
              movedAt: now,
            }),
          );
          await this.activityLog.record(
            {
              activityType: 'INVENTORY_UNLOADED',
              inventoryId: item.inventoryId,
              warehouseId: item.warehouseId,
              description: 'EXPORT_UNLOADED_AT_DJIBOUTI_PORT',
              performedBy,
            },
            manager,
          );
          seenInventory.add(item.inventoryId);
          result.unloadedCount += 1;
          result.results.push({
            bookingId: item.bookingId,
            itemType: item.itemType,
            itemId: item.itemId,
            inventoryId: item.inventoryId,
            containerNumber: item.containerNumber,
            status: 'UNLOADED_AT_DJIBOUTI_PORT',
            message: 'Unloaded at Djibouti Port',
          });
        } catch (error) {
          fail(error instanceof Error ? error.message : String(error));
        }
      }
    });

    if (result.unloadedCount > 0) {
      // Best-effort: the unload is already committed — a paperwork failure must
      // not fail the response (it did once: items unloaded, request 500'd, and
      // the document only appeared after a manual retry days later). The doc
      // backfills on any retry since already-unloaded items count as unloaded.
      try {
        let document = await this.interchangeDocuments.generateFromSchedule({
          scheduleId,
          direction: 'EXPORT',
          handoverLocation: schedule.destinationName ?? 'Djibouti Port',
          handoverFrom: 'EDR',
          handoverTo: 'Djibouti Port Operator',
          portOperatorName: 'Doraleh Multipurpose Port',
          generatedBy: performedBy ?? 'EDR Operations',
          remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
        });
        if (document.status !== 'ACKNOWLEDGED') {
          document = await this.interchangeDocuments.acknowledge(document.id, {
            acknowledgedBy: 'Djibouti Port Operator',
            remarks: 'Auto acknowledged after Djibouti export unloading.',
          });
        }
        result.interchangeDocument = {
          id: document.id,
          documentNo: document.documentNo,
          status: document.status,
        };
      } catch (err) {
        this.logger.warn(
          `Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`,
        );
      }
    }

    return result;
  }

  /**
   * Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal
   * report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING.
   * For damage / weight-loss / images, use the per-item Inspect / Report action instead.
   */
  async bulkMarkInspected(dto: BulkInspectDto): Promise<BulkInspectResult> {
    const result: BulkInspectResult = { inspectedCount: 0, skippedCount: 0, results: [] };
    // UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state).
    const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED'];

    for (const inventoryId of dto.inventoryIds) {
      const skip = (reason: string) => {
        result.skippedCount += 1;
        result.results.push({ inventoryId, status: 'SKIPPED', reason });
      };

      const item = await this.inventoryRepository.findById(inventoryId);
      if (!item) { skip('Inventory not found'); continue; }
      if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; }
      if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; }

      // Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt.
      await this.inspectionService.create(inventoryId, {
        reportType: 'INSPECTION',
        inspectionStatus: 'PASSED',
        remarks: dto.remarks ?? 'Bulk marked inspected (PASSED).',
        inspectedById: dto.inspectedBy,
      });

      // A passed item advances by trade direction:
      //   EXPORT → Ready To Load (READY_FOR_LOADING)
      //   IMPORT → Pickup Ready (READY_FOR_PICKUP) — NOT ready-for-loading.
      const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
      if (direction === 'EXPORT') {
        await this.dataSource.transaction(async (manager) => {
          await manager.getRepository(WarehouseInventory).update(inventoryId, {
            status: 'READY_FOR_LOADING',
            readyForLoadingAt: new Date(),
          });
          await this.activityLog.record(
            {
              activityType: 'READY_FOR_LOADING',
              inventoryId,
              warehouseId: item.warehouseId,
              description: 'Inspection passed → ready for loading',
              performedBy: dto.inspectedBy,
            },
            manager,
          );
        });
        result.results.push({ inventoryId, status: 'READY_FOR_LOADING' });
      } else if (direction === 'IMPORT') {
        await this.dataSource.transaction(async (manager) => {
          await manager.getRepository(WarehouseInventory).update(inventoryId, {
            status: 'READY_FOR_PICKUP',
            readyForPickupAt: new Date(),
          });
          await this.activityLog.record(
            {
              activityType: 'READY_FOR_PICKUP',
              inventoryId,
              warehouseId: item.warehouseId,
              description: 'Destination inspection passed → pickup ready',
              performedBy: dto.inspectedBy,
            },
            manager,
          );
        });
        await this.acceptLastMileIfRequested(item.bookingId);
        result.results.push({ inventoryId, status: 'READY_FOR_PICKUP' });
      } else {
        result.results.push({ inventoryId, status: 'INSPECTED' });
      }
      result.inspectedCount += 1;
    }

    return result;
  }

  // ── Receive ──────────────────────────────────────────────────────────────

  private async acceptLastMileIfRequested(bookingId?: string | null): Promise<void> {
    if (!bookingId) return;
    const [booking] = await this.dataSource.query(
      `SELECT reference,
              last_mile_delivery_address AS "lastMileDeliveryAddress",
              COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
         FROM freight.bookings b
         LEFT JOIN freight.service_types st ON st.id = b.service_type_id
        WHERE b.id = $1 AND b.deleted_at IS NULL
        LIMIT 1`,
      [bookingId],
    );
    const hasLastMile =
      Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile);
    if (!booking?.reference || !hasLastMile) return;
    await this.lastMileService.acceptBooking(booking.reference);
  }

  async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
    const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
    await this.assertExportBookingPaid(dto.bookingId, bookingDirection);

    const id = await this.dataSource.transaction(async (manager) => {
      const { warehouse, yard, zone } = await this.validateLocation(manager, dto);

      const bookingSource = dto.bookingId
        ? await this.getBookingTruckEntranceSource(manager, dto.bookingId)
        : null;
      if (dto.bookingId && !bookingSource?.reference) {
        throw new NotFoundException(`Booking ${dto.bookingId} not found`);
      }
      const quantity = bookingSource
        ? Number(bookingSource.containerQuantity) || 1
        : Number(dto.quantity) || 0;
      const weight = bookingSource
        ? Number(bookingSource.weight) || 0
        : Number(dto.weight) || 0;
      const volume = Number(dto.volume) || 0;
      const containerCount = dto.containerId ? Math.round(quantity) : 0;
      const truckEntrance = dto.bookingId
        ? this.mergeSystemTruckEntrance(dto.truckEntrance, bookingSource ?? {})
        : dto.truckEntrance;
      this.assertTruckEntrance(truckEntrance);

      this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
      this.assertCapacity('Yard', yard, weight, volume, containerCount);
      this.assertCapacity('Zone', zone, weight, volume, containerCount);

      const now = new Date();
      const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now);
      const receiveNote = this.buildReceiveNote({
        grnNumber,
        notes: dto.notes?.trim() || 'Single booking received',
        truckEntrance,
      });
      const saved = await manager.getRepository(WarehouseInventory).save(
        manager.getRepository(WarehouseInventory).create({
          warehouseId: dto.warehouseId,
          yardId: dto.yardId,
          zoneId: dto.zoneId,
          bookingId: dto.bookingId ?? null,
          cargoId: dto.cargoId ?? null,
          containerId: dto.containerId ?? null,
          goodsId: dto.goodsId ?? null,
          quantity,
          weight,
          volume: dto.volume ?? null,
          grnNumber,
          status: 'RECEIVED',
          arrivedAt: now,
          notes: receiveNote,
        }),
      );

      await this.applyCapacityDelta(manager, dto, weight, volume, containerCount);

      // Per-container receive: flag this container's unit as received into the
      // port so staff can raise the GRN over what's received.
      if (dto.bookingId && dto.containerId) {
        await manager.query(
          `UPDATE freight.booking_container_units bcu
              SET received_to_port = true,
                  received_at = COALESCE(bcu.received_at, NOW()),
                  updated_at = NOW()
             FROM freight.booking_container bc, freight.containers cont
            WHERE bc.id = bcu.booking_container_id
              AND bc.booking_id = $1
              AND bc.deleted_at IS NULL
              AND cont.id = $2
              AND cont.container_number = bcu.container_number
              AND bcu.deleted_at IS NULL
              AND bcu.received_to_port = false`,
          [dto.bookingId, dto.containerId],
        );
      }

      await this.activityLog.record(
        {
            activityType: 'INVENTORY_RECEIVED',
            inventoryId: saved.id,
            warehouseId: dto.warehouseId,
            description: `GRN ${grnNumber}: received ${weight}t via truck ${truckEntrance.truckPlateNumber}`,
            performedBy: dto.performedBy,
          },
          manager,
        );

      await this.notifyOwnerInventoryReceived({
        phone: truckEntrance.customerPhone,
        ownerName: truckEntrance.ownerName,
        bookingReference: truckEntrance.edrDigitalBookingId ?? dto.bookingId,
        grnNumber,
        direction: bookingDirection,
        warehouseId: dto.warehouseId,
      });

      return saved.id;
    });

    return this.findById(id);
  }

  async move(id: string, dto: MoveInventoryDto): Promise<WarehouseInventory> {
    const movedId = await this.dataSource.transaction(async (manager) => {
      const item = await manager.getRepository(WarehouseInventory).findOne({
        where: { id },
        lock: { mode: 'pessimistic_write' },
      });
      if (!item) {
        throw new NotFoundException(`Inventory item ${id} not found`);
      }

      if (
        item.warehouseId === dto.warehouseId &&
        item.yardId === dto.yardId &&
        item.zoneId === dto.zoneId
      ) {
        throw new BadRequestException('Destination location is the same as current location');
      }

      const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
      const weight = Number(item.weight) || 0;
      const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;

      if (item.warehouseId !== dto.warehouseId) {
        this.assertCapacity('Warehouse', warehouse, weight, Number(item.volume) || 0, containerCount);
      }
      if (item.yardId !== dto.yardId) {
        this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount);
      }
      this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount);

      await this.applyCapacityDelta(
        manager,
        {
          warehouseId: item.warehouseId,
          yardId: item.yardId,
          zoneId: item.zoneId,
        },
        -weight,
        -(Number(item.volume) || 0),
        -containerCount,
      );

      await this.applyCapacityDelta(manager, dto, weight, Number(item.volume) || 0, containerCount);

      item.warehouseId = dto.warehouseId;
      item.yardId = dto.yardId;
      item.zoneId = dto.zoneId;
      if (dto.remarks?.trim()) {
        const existingNotes = item.notes?.trim();
        item.notes = existingNotes
          ? `${existingNotes}\nMove: ${dto.remarks.trim()}`
          : `Move: ${dto.remarks.trim()}`;
      }

      const saved = await manager.getRepository(WarehouseInventory).save(item);
      return saved.id;
    });

    return this.findById(movedId);
  }

  // ── Lifecycle transitions ────────────────────────────────────────────────

  async store(
    id: string,
    performedBy?: string,
    chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
  ): Promise<WarehouseInventory> {
    const item = await this.findById(id);
    this.assertTransition(item.status, 'STORED');

    // Explicit location wins when the operator picked warehouse + yard + zone;
    // otherwise fall back to the allocation-rule / capacity-balanced auto pick.
    const manualLocation =
      chosen?.warehouseId && chosen?.yardId && chosen?.zoneId
        ? {
            warehouseId: chosen.warehouseId,
            yardId: chosen.yardId,
            zoneId: chosen.zoneId,
            path: undefined as string | undefined,
          }
        : null;

    const criteria = await this.getInventoryAllocationCriteria(item);
    const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria);
    const location =
      manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));

    if (!location) {
      throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
    }

    const weight = Number(item.weight) || 0;
    const volume = Number(item.volume) || 0;
    const containerCount = item.containerId ? Math.max(1, Math.round(Number(item.quantity) || 1)) : 0;

    await this.dataSource.transaction(async (manager) => {
      const locked = await manager.getRepository(WarehouseInventory).findOne({
        where: { id },
        lock: { mode: 'pessimistic_write' },
      });
      if (!locked) {
        throw new NotFoundException(`Inventory item ${id} not found`);
      }
      this.assertTransition(locked.status, 'STORED');

      if (
        locked.warehouseId !== location.warehouseId ||
        locked.yardId !== location.yardId ||
        locked.zoneId !== location.zoneId
      ) {
        const { warehouse, yard, zone } = await this.validateLocation(manager, location);
        this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
        this.assertCapacity('Yard', yard, weight, volume, containerCount);
        this.assertCapacity('Zone', zone, weight, volume, containerCount);

        await this.applyCapacityDelta(
          manager,
          {
            warehouseId: locked.warehouseId,
            yardId: locked.yardId,
            zoneId: locked.zoneId,
          },
          -weight,
          -volume,
          -containerCount,
        );
        await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
      }

      const storedReason = manualLocation
        ? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
        : ruleLocation?.rule
          ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
          : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;

      await manager.getRepository(WarehouseInventory).update(id, {
        status: 'STORED',
        storedAt: new Date(),
        warehouseId: location.warehouseId,
        yardId: location.yardId,
        zoneId: location.zoneId,
        notes: this.appendNote(locked.notes, storedReason),
      });

      await this.activityLog.record(
        {
          activityType: 'INVENTORY_STORED',
          inventoryId: id,
          warehouseId: location.warehouseId,
          description: storedReason.replace(/^Stored/, 'Inventory stored'),
          performedBy,
        },
        manager,
      );
    });

    return this.findById(id);
  }

  async reserve(dto: ReserveInventoryDto): Promise<WarehouseInventory> {
    const item = await this.findById(dto.inventoryId);

    if (item.status !== 'STORED') {
      throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`);
    }

    const status = await this.getBookingStatus(dto.bookingId);
    if (!status) {
      throw new NotFoundException(`Booking ${dto.bookingId} not found`);
    }
    if (status !== 'PAID') {
      throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`);
    }

    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(WarehouseInventory).update(dto.inventoryId, {
        status: 'RESERVED',
        bookingId: dto.bookingId,
        reservedAt: new Date(),
      });
      await this.activityLog.record(
        {
          activityType: 'INVENTORY_RESERVED',
          inventoryId: dto.inventoryId,
          warehouseId: item.warehouseId,
          description: `Reserved for booking ${dto.bookingId}`,
          performedBy: dto.performedBy,
        },
        manager,
      );
    });

    return this.findById(dto.inventoryId);
  }

  async readyForLoading(id: string, performedBy?: string): Promise<WarehouseInventory> {
    const item = await this.findById(id);
    if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
      throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
    }
    if (item.inspectionStatus !== 'PASSED') {
      throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading');
    }
    return this.transition(id, 'READY_FOR_LOADING', {
      timestampField: 'readyForLoadingAt',
      activityType: 'READY_FOR_LOADING',
      description: 'Inventory ready for loading',
      performedBy,
      preloaded: item,
    });
  }

  // ── Import branch (READY_FOR_PICKUP → DELIVERED) ───────────────────────────

  /** Mark inspected IMPORT inventory ready for customer pickup (RECEIVED → READY_FOR_PICKUP). */
  async readyForPickup(id: string, performedBy?: string): Promise<WarehouseInventory> {
    const item = await this.findById(id);

    if (item.inspectionStatus !== 'PASSED') {
      throw new BadRequestException('Inventory must pass inspection before it can be marked ready for pickup');
    }

    const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
    if (direction !== 'IMPORT') {
      throw new BadRequestException('Only IMPORT inventory can be marked ready for pickup');
    }

    return this.transition(id, 'READY_FOR_PICKUP', {
      timestampField: 'readyForPickupAt',
      activityType: 'READY_FOR_PICKUP',
      description: 'Inventory ready for customer pickup',
      performedBy,
      preloaded: item,
    });
  }

  /**
   * Self-haul = the customer's own truck collects the goods: either a truck
   * assigned via the portal (customer_truck_assigned_at), or a walk-in truck
   * registered at the gate on a booking with no EDR last-mile leg. EDR
   * last-mile bookings are never self-haul.
   */
  private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
    const runner = manager ?? this.dataSource;
    const [row]: Array<{ ok: number }> = await runner.query(
      `SELECT 1 AS ok
         FROM freight.bookings b
         LEFT JOIN freight.service_types st ON st.id = b.service_type_id
        WHERE b.id = $1 AND b.deleted_at IS NULL
          AND (b.customer_truck_assigned_at IS NOT NULL
               OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
                   AND COALESCE(st.includes_last_mile, false) = false))`,
      [bookingId],
    );
    return Boolean(row);
  }

  /** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
  async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
    const item = await this.findById(id);
    if (item.status !== 'READY_FOR_PICKUP') {
      throw new BadRequestException(
        `Inventory must be READY_FOR_PICKUP to issue a release order (current: ${item.status})`,
      );
    }

    // Leaving = gate-out captured, with either a weighed gross or an explicit
    // container weighing skip (bulk always weighs).
    const isTruckLeaving =
      Boolean(dto.gateOutTime) && (dto.grossWeight !== undefined || dto.weighingSkipped === true);
    if (isTruckLeaving) {
      await this.invoices.assertClearanceAllowed(id);

      if (item.bookingId) {
        // Self-haul = customer collects: a truck assigned via the portal, OR a
        // walk-in truck registered at the gate on a booking with no EDR last mile.
        const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId);
        // Self-haul: the handover must be signed before the exit paper is issued.
        // Prefer the structured handover record; fall back to the legacy note.
        const handoverSigned =
          (await this.handover.isFullySigned(item.bookingId)) ||
          Boolean(this.extractCustomerDeliveryApproval(item.notes));
        if (usesCustomerTruck && !handoverSigned) {
          throw new BadRequestException(
            'Customer must sign the handover before the exit paper can be generated',
          );
        }

        // A truck leaves with the containers ASSIGNED to it — never another
        // truck's. Enforced whenever the truck has an assigned load on file
        // (customer self-haul or EDR last-mile).
        if (dto.containerNumber && dto.truckPlateNumber?.trim()) {
          const selectedNumbers = dto.containerNumber
            .split(/[,;\n]+/)
            .map((n) => n.trim().toUpperCase())
            .filter(Boolean);
          const assigned = await this.truckAssignedContainers(
            item.bookingId,
            dto.truckPlateNumber.trim(),
          );
          if (assigned.length && selectedNumbers.length) {
            const foreign = selectedNumbers.filter((n) => !assigned.includes(n));
            if (foreign.length) {
              throw new BadRequestException(
                `Container${foreign.length > 1 ? 's' : ''} ${foreign.join(', ')} ` +
                  `not assigned to truck ${dto.truckPlateNumber.trim()} — each truck may only carry out its own assigned containers`,
              );
            }
          }
        }

        // Authoritative weight match: the truck's net (gross − tare) must equal the
        // total VGM cargo weight of the containers selected as loaded on it.
        // Skipped when the operator chose not to weigh (containers only).
        if (!dto.weighingSkipped && dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
          const selected = dto.containerNumber
            .split(/[,;\n]+/)
            .map((n) => n.trim())
            .filter(Boolean);
          if (selected.length) {
            const weights = await this.bookingContainerWeights(item.bookingId);
            const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
            const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0);
            const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3));
            if (expected > 0 && Math.abs(computedNet - expected) > 0.001) {
              throw new BadRequestException(
                `Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`,
              );
            }
          }
        }
      }
    }
    const releaseDate = isTruckLeaving
      ? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
      : item.releaseDate ?? null;
    // One reference per item — the first truck's arrival mints it, later trucks
    // (arrival or exit) reuse it so all exit papers share the release order.
    const reference =
      item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item));
    const exitInspectionDto = {
      ...(isTruckLeaving ? this.preserveTruckArrivalForExit(dto, item.notes) : dto),
    };
    // Weighbridge skipped on exit: the recorded net still comes from what the
    // truck is holding — the summed cargo weight of its selected containers.
    if (isTruckLeaving && exitInspectionDto.weighingSkipped && item.bookingId) {
      const selected = (exitInspectionDto.containerNumber ?? '')
        .split(/[,;\n]+/)
        .map((n) => n.trim())
        .filter(Boolean);
      if (selected.length) {
        const weights = await this.bookingContainerWeights(item.bookingId);
        const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
        const heldTons = Number(
          selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0).toFixed(3),
        );
        if (heldTons > 0) exitInspectionDto.netWeight = heldTons;
      }
    }
    const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);

    // The load actually leaving on this truck, in TONNES (the weighing UI is in
    // t). Null when the operator skipped weighing — containers may skip, bulk
    // never does.
    const grossTons = exitInspectionDto.grossWeight ?? null;
    const tareTons = exitInspectionDto.tareWeight ?? null;
    const netTons =
      grossTons != null && tareTons != null
        ? Math.round((grossTons - tareTons) * 1000) / 1000
        : (exitInspectionDto.netWeight ?? null);

    // The weight to record on the inventory when this truck leaves: prefer the
    // item's own container cargo weight (a truck may carry other items too);
    // fall back to the truck's recorded net. Fills an empty weight only.
    let recordedItemTons: number | null = null;
    if (isTruckLeaving && netTons != null) {
      recordedItemTons = netTons;
      if (item.containerId && item.bookingId) {
        const [cont]: Array<{ containerNumber: string | null }> = await this.dataSource.query(
          `SELECT container_number AS "containerNumber" FROM freight.containers WHERE id = $1`,
          [item.containerId],
        );
        const ownNumber = cont?.containerNumber?.trim().toUpperCase();
        if (ownNumber) {
          const weights = await this.bookingContainerWeights(item.bookingId);
          const own = weights.find((w) => w.containerNumber.toUpperCase() === ownNumber);
          if (own && own.weightTons > 0) recordedItemTons = own.weightTons;
        }
      }
    }

    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(WarehouseInventory).update(id, {
        releaseDate,
        releaseOrderReference: reference,
        notes: this.replaceExitInspectionNote(
          item.notes,
          exitInspectionNote,
          exitInspectionDto.truckPlateNumber,
        ),
      });
      // Even an unweighed truck records the inventory weight it is holding —
      // without this the handover/exit papers print "0 t" for skipped weighings.
      if (recordedItemTons != null) {
        await manager.query(
          `UPDATE freight.warehouse_inventory
              SET weight = $2, updated_at = NOW()
            WHERE id = $1 AND COALESCE(weight, 0) = 0`,
          [id, recordedItemTons],
        );
      }
      if (!isTruckLeaving && item.bookingId) {
        // Per-truck arrival: mark the customer truck carrying THIS item's
        // container as arrived (matched via the physical container number).
        if (item.containerId) {
          await manager.query(
            `UPDATE freight.customer_truck_assignments a
                SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW()
               FROM freight.customer_truck_containers c
               JOIN freight.containers cont ON cont.container_number = c.container_number
              WHERE c.assignment_id = a.id
                AND c.deleted_at IS NULL
                AND c.booking_id = $1
                AND cont.id = $2
                AND a.arrived_at IS NULL
                AND a.deleted_at IS NULL`,
            [item.bookingId, item.containerId],
          );
          // NB: import arrival changes nothing on the goods — received_to_port is
          // an EXPORT concept (set when a truck delivers into the port). Import
          // load + weight are captured on truck departure, not arrival.
        }
        // EDR last-mile: stamp THIS truck's arrival. Matched by plate rather than
        // container so it works for bulk too (bulk trucks carry no container).
        if (dto.truckPlateNumber?.trim()) {
          await manager.query(
            `UPDATE freight.last_mile_vehicle_assignments va
                SET arrived_at = COALESCE(va.arrived_at, NOW()), updated_at = NOW()
               FROM freight.last_mile lm, freight.vehicles v
              WHERE va.last_mile_id = lm.id
                AND lm.booking_id = $1
                AND lm.deleted_at IS NULL
                AND v.id = va.vehicle_id
                AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
                AND va.arrived_at IS NULL
                AND va.deleted_at IS NULL`,
            [item.bookingId, dto.truckPlateNumber.trim()],
          );
        }
        // Booking-level flag stamped on the FIRST truck arrival. The import
        // handover is signed ONCE (before the first truck leaves), even though
        // trucks pick up per-container — COALESCE keeps the first timestamp.
        await manager.query(
          `UPDATE freight.bookings
              SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
                  updated_at = NOW()
            WHERE id = $1
              AND customer_truck_assigned_at IS NOT NULL
              AND deleted_at IS NULL`,
          [item.bookingId],
        );
        // Self-haul: generate the per-booking handover on first truck arrival
        // (idempotent) and notify the customer to sign it. Covers BOTH portal-
        // assigned trucks and walk-in trucks registered manually at the gate
        // (no portal assignment, no EDR last mile). Must be signed before leaving.
        if (await this.isSelfHaulBooking(item.bookingId, manager)) {
          await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
        }
      }
      if (isTruckLeaving && item.bookingId && dto.truckPlateNumber?.trim()) {
        // EDR last-mile: this truck is leaving — record its exit and the load it
        // actually took. net_weight_tons drives the bulk drawdown (booking VGM
        // minus everything already hauled away).
        const [edrDeparted] = (await manager.query(
          `UPDATE freight.last_mile_vehicle_assignments va
              SET departed_at = COALESCE($3::timestamptz, NOW()),
                  arrived_at = COALESCE(va.arrived_at, NOW()),
                  gross_weight_tons = $4,
                  net_weight_tons = $5,
                  updated_at = NOW()
             FROM freight.last_mile lm, freight.vehicles v
            WHERE va.last_mile_id = lm.id
              AND lm.booking_id = $1
              AND lm.deleted_at IS NULL
              AND v.id = va.vehicle_id
              AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
              AND va.departed_at IS NULL
              AND va.deleted_at IS NULL
            RETURNING va.id`,
          [
            item.bookingId,
            dto.truckPlateNumber.trim(),
            dto.gateOutTime ?? null,
            grossTons,
            netTons,
          ],
        )) as [Array<{ id: string }>, unknown];
        // EDR last-mile: the handover is generated the moment the truck exits
        // (with its exit paper) — one per truck — and the customer is asked to
        // sign it from the portal. Booking-level fallback when the plate matched
        // no live assignment (e.g. exit re-recorded) but the booking is EDR-hauled.
        for (const row of edrDeparted) {
          await this.handover.ensureForDepartedEdrTruck(
            item.bookingId,
            { truckPlate: dto.truckPlateNumber.trim(), edrAssignmentId: row.id },
            manager,
          );
        }
        if (!edrDeparted.length) {
          const [lm]: Array<{ id: string }> = await manager.query(
            `SELECT id FROM freight.last_mile WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
            [item.bookingId],
          );
          if (lm) {
            await this.handover.ensureForDepartedEdrTruck(
              item.bookingId,
              { truckPlate: dto.truckPlateNumber.trim() },
              manager,
            );
          }
        }
        // Customer self-haul: the same exit record on the customer's own truck.
        // Without it a self-haul bulk booking never draws down — hauled tonnage
        // summed to zero and the booking could take unlimited trucks. Matched by
        // plate rather than container so bulk trucks (which carry none) count.
        await manager.query(
          `UPDATE freight.customer_truck_assignments a
              SET departed_at = COALESCE($3::timestamptz, NOW()),
                  arrived_at = COALESCE(a.arrived_at, NOW()),
                  gross_weight_kg = $4,
                  tare_weight_tons = $5,
                  net_weight_tons = $6,
                  updated_at = NOW()
            WHERE a.booking_id = $1
              AND UPPER(a.plate_number) = UPPER($2)
              AND a.departed_at IS NULL
              AND a.deleted_at IS NULL`,
          [
            item.bookingId,
            dto.truckPlateNumber.trim(),
            dto.gateOutTime ?? null,
            grossTons,
            tareTons,
            netTons,
          ],
        );
      }
      await this.activityLog.record(
        {
          activityType: 'INVENTORY_RELEASED',
          inventoryId: id,
          warehouseId: item.warehouseId,
          description: isTruckLeaving
            ? reference
              ? `Exit paper ${reference} generated`
              : 'Exit paper generated'
            : reference
              ? `Truck arrival ${reference} registered`
              : 'Truck arrival registered',
          performedBy: dto.performedBy,
        },
        manager,
      );
    });

    // Tell the customer their truck has left — one hook covers BOTH self-haul and
    // EDR last-mile, since release() is the single exit path for either. Outside
    // the transaction and fire-and-forget: notifying must never fail the exit.
    if (isTruckLeaving && item.bookingId) {
      void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
    } else if (item.bookingId) {
      // Gate-in: same single-path hook for the arrival side (self-haul + EDR).
      void this.notifyTruckArrival(item.bookingId, dto.truckPlateNumber?.trim() ?? null);
    }

    return this.findById(id);
  }

  /** Best-effort truck-arrival notification (gate-in), mirror of the departure one. */
  private async notifyTruckArrival(bookingId: string, plateNumber: string | null): Promise<void> {
    try {
      const [booking]: Array<{ companyId: string | null; reference: string | null }> =
        await this.dataSource.query(
          `SELECT company_id AS "companyId", reference
             FROM freight.bookings
            WHERE id = $1 AND deleted_at IS NULL`,
          [bookingId],
        );
      if (!booking?.companyId) return;
      const ref = booking.reference ?? bookingId;
      const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
      const body = `${truck} has arrived at the warehouse for booking ${ref}.`;
      await this.inbox.notify({
        recipients: { companyId: booking.companyId },
        audience: NotificationAudience.PORTAL,
        type: NotificationType.BOOKING_STATUS,
        title: 'Truck arrived at the warehouse',
        body,
        link: `/bookings/${bookingId}`,
        data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
      });
      await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
    } catch (err) {
      this.logger.warn(`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`);
    }
  }

  /**
   * Best-effort truck-departure notification to the booking's company across
   * every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
   * provider or contact must not break the exit flow.
   */
  private async notifyTruckDeparture(
    bookingId: string,
    plateNumber: string | null,
    netTons: number | null,
  ): Promise<void> {
    try {
      const [booking]: Array<{ companyId: string | null; reference: string | null }> =
        await this.dataSource.query(
          `SELECT company_id AS "companyId", reference
             FROM freight.bookings
            WHERE id = $1 AND deleted_at IS NULL`,
          [bookingId],
        );
      if (!booking?.companyId) return;
      const ref = booking.reference ?? bookingId;
      const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
      const load = netTons != null && netTons > 0 ? ` carrying ${netTons} t` : '';
      const body = `${truck} has left the warehouse for booking ${ref}${load}.`;
      await this.inbox.notify({
        recipients: { companyId: booking.companyId },
        audience: NotificationAudience.PORTAL,
        type: NotificationType.BOOKING_STATUS,
        title: 'Truck left the warehouse',
        body,
        link: `/bookings/${bookingId}`,
        data: { bookingId, plateNumber, netTons, action: 'TRUCK_LEFT' },
      });
      await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
    } catch (err) {
      this.logger.warn(
        `Truck-departure notify failed for ${bookingId}: ${(err as Error).message}`,
      );
    }
  }

  /**
   * customer_truck_assignments.gross_weight_kg holds TONNES for gate-out
   * recorded exits but real KG for legacy departTruck rows. Exit papers always
   * print tonnes — normalise on read.
   */
  // ponytail: >1000 heuristic (no truck hauls 1000+ t, no weighbridge reads <1000 kg);
  // migrate the column to tonnes if it ever bites.
  private grossAsTons(value: number): number {
    return value > 1000 ? Math.round(value) / 1000 : value;
  }

  async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
    const [row] = await this.dataSource.query(
      `SELECT inv.id,
              inv.release_date AS "releaseDate",
              inv.release_order_reference AS "releaseOrderReference",
              inv.quantity,
              inv.weight,
              inv.status,
              inv.notes,
              b.id AS "bookingId",
              b.reference AS "bookingReference",
              b.status AS "bookingStatus",
              b.freight_type AS "freightType",
              b.trade_direction AS "tradeDirection",
              company.name AS "customerName",
              COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
              COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
              wh.name AS "warehouseName",
              wh.code AS "warehouseCode",
              yard.name AS "yardName",
              yard.code AS "yardCode",
              zone.name AS "zoneName",
              zone.code AS "zoneCode"
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.bookings b ON b.id = inv.booking_id
         LEFT JOIN freight.companies company ON company.id = b.company_id
         LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
         LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
         LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
         LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
         LEFT JOIN freight.booking_container booking_container ON (
           booking_container.booking_id = b.id
           AND booking_container.deleted_at IS NULL
         )
         LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
         LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
        WHERE inv.id = $1 AND inv.deleted_at IS NULL
        LIMIT 1`,
      [id],
    );
    if (!row) {
      throw new NotFoundException(`Inventory item ${id} not found`);
    }
    if (!row.releaseDate) {
      throw new BadRequestException('A release order must be issued before downloading the exit paper');
    }
    await this.invoices.assertClearanceAllowed(id);

    // Import self-haul: the exit paper names the pickup truck + all containers it
    // carries, so gate staff can verify the goods leaving on that truck.
    let truck: {
      plateNumber: string;
      driverName: string;
      truckType: string;
      containerNumbers: string;
      truckWeightTons: string | number | null;
      grossWeightKg: string | number | null;
      departedAt: string | null;
    } | null = null;
    // The exit-inspection note written at gate-out names the truck doing THIS
    // exit — resolve by its plate first. The item's own container may not be on
    // the departing truck at all (trucks pick containers freely per trip).
    const notePlates = [...String(row?.notes ?? '').matchAll(/Truck Plate:\s*(\S+)/gi)];
    const exitPlate = notePlates.length ? notePlates[notePlates.length - 1][1] : null;
    if (row?.tradeDirection === 'IMPORT' && row?.bookingId && exitPlate) {
      const [truckRow] = await this.dataSource.query(
        `SELECT a.plate_number AS "plateNumber",
                a.driver_name AS "driverName",
                a.truck_type AS "truckType",
                a.gross_weight_kg AS "grossWeightKg",
                a.departed_at AS "departedAt",
                string_agg(DISTINCT c.container_number, ', ' ORDER BY c.container_number) AS "containerNumbers",
                COALESCE((
                  SELECT SUM(bcu.vgm_tons)
                    FROM freight.customer_truck_containers cc
                    JOIN freight.booking_container_units bcu
                      ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
                    JOIN freight.booking_container bc
                      ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
                     AND bc.booking_id = a.booking_id
                   WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
                ), 0) AS "truckWeightTons"
           FROM freight.customer_truck_assignments a
           LEFT JOIN freight.customer_truck_containers c
             ON c.assignment_id = a.id AND c.deleted_at IS NULL
          WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL
          GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.gross_weight_kg, a.departed_at
          LIMIT 1`,
        [row.bookingId, exitPlate],
      );
      truck = truckRow ?? null;
    }
    if (!truck && row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) {
      const [truckRow] = await this.dataSource.query(
        `SELECT a.plate_number AS "plateNumber",
                a.driver_name AS "driverName",
                a.truck_type AS "truckType",
                a.gross_weight_kg AS "grossWeightKg",
                a.departed_at AS "departedAt",
                string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers",
                COALESCE((
                  SELECT SUM(bcu.vgm_tons)
                    FROM freight.customer_truck_containers cc
                    JOIN freight.booking_container_units bcu
                      ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
                    JOIN freight.booking_container bc
                      ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
                     AND bc.booking_id = c.booking_id
                   WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
                ), 0) AS "truckWeightTons"
           FROM freight.customer_truck_containers c
           JOIN freight.customer_truck_assignments a
             ON a.id = c.assignment_id AND a.deleted_at IS NULL
           JOIN freight.customer_truck_containers c2
             ON c2.assignment_id = a.id AND c2.deleted_at IS NULL
          WHERE c.booking_id = $1 AND c.container_number = $2 AND c.deleted_at IS NULL
          GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, c.booking_id
          LIMIT 1`,
        [row.bookingId, row.containerNumber],
      );
      truck = truckRow ?? null;
    }
    // Bulk self-haul (no container to match) or an unmatched container: the exit
    // paper is still PER TRUCK — use the latest departed customer truck and its
    // weighed gross, never the booking's declared total.
    if (!truck && row?.tradeDirection === 'IMPORT' && row?.bookingId) {
      const [truckRow] = await this.dataSource.query(
        `SELECT a.plate_number AS "plateNumber",
                a.driver_name AS "driverName",
                a.truck_type AS "truckType",
                a.gross_weight_kg AS "grossWeightKg",
                a.departed_at AS "departedAt",
                NULL AS "containerNumbers",
                a.net_weight_tons AS "truckWeightTons"
           FROM freight.customer_truck_assignments a
          WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.departed_at IS NOT NULL
          ORDER BY a.departed_at DESC
          LIMIT 1`,
        [row.bookingId],
      );
      truck = truckRow ?? null;
    }

    const bookingReference = row?.bookingReference || 'N/A';
    const reference =
      row?.releaseOrderReference ||
      (row?.bookingReference ? `REL-${String(row.bookingReference).replace(/^BK-?/i, '')}` : 'REL-N/A');
    const issuedAt = new Date(row.releaseDate);
    const html = this.buildReleaseDocumentHtml({
      reference,
      issuedAt,
      bookingReference,
      bookingStatus: row?.bookingStatus ?? null,
      customerName: row?.customerName ?? null,
      freightType: row?.freightType ?? null,
      tradeDirection: row?.tradeDirection ?? null,
      // Per-truck exit: list every container leaving on THIS truck, not just
      // the inventory item's own container.
      containerNumber: truck?.containerNumbers ?? row?.containerNumber ?? null,
      cargoDescription: row?.cargoDescription ?? null,
      quantity: Number(row?.quantity ?? 0),
      weight: Number(row?.weight ?? 0),
      warehouse: [row?.warehouseName, row?.warehouseCode].filter(Boolean).join(' / ') || null,
      yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null,
      zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
      inventoryStatus: row?.status ?? null,
      clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
      exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
      truckPlateNumber: truck?.plateNumber ?? null,
      truckDriverName: truck?.driverName ?? null,
      truckType: truck?.truckType ?? null,
      truckGateOut: truck?.departedAt ?? null,
      // Per-truck load in tonnes: the summed VGM of the containers on this truck
      // (recorded net for bulk); the weighed gross only as fallback.
      truckWeightTons: truck
        ? Number(truck.truckWeightTons ?? 0) > 0
          ? Number(truck.truckWeightTons)
          : this.grossAsTons(Number(truck.grossWeightKg ?? 0))
        : null,
    });

    return {
      filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
      buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
    };
  }

  /**
   * Per-container (or bulk) items of a booking with their lifecycle stage and
   * reference sources — drives the container-level detail datatable (stage tabs,
   * multiselect load-to-truck, per-item actions).
   */
  async containerItems(bookingId: string): Promise<
    Array<{
      containerNumber: string;
      goods: string | null;
      containerSize: string | null;
      stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
      grnNumber: string | null;
      truckAssignmentId: string | null;
      truckPlate: string | null;
      truckArrived: boolean;
      truckLeft: boolean;
      loaded: boolean;
      bookingReference: string | null;
      contractId: string | null;
      hasLastMile: boolean;
      handoverSigned: boolean;
    }>
  > {
    const rows: Array<{
      containerNumber: string;
      goods: string | null;
      containerSize: string | null;
      received: boolean;
      grnNumber: string | null;
      truckAssignmentId: string | null;
      truckPlate: string | null;
      truckArrived: boolean;
      truckLeft: boolean;
      loaded: boolean;
      bookingReference: string | null;
      contractId: string | null;
      hasLastMile: boolean;
      delivered: boolean;
    }> = await this.dataSource.query(
      `SELECT bcu.container_number AS "containerNumber",
              COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
              bc.container_size AS "containerSize",
              bcu.received_to_port AS received,
              bcu.grn_number AS "grnNumber",
              ctc.assignment_id AS "truckAssignmentId",
              a.plate_number AS "truckPlate",
              (a.arrived_at IS NOT NULL) AS "truckArrived",
              (a.departed_at IS NOT NULL) AS "truckLeft",
              (ctc.loaded_at IS NOT NULL) AS loaded,
              b.reference AS "bookingReference",
              b.contract_id AS "contractId",
              (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
              COALESCE(inv.status = 'DELIVERED', false) AS delivered
         FROM freight.booking_container_units bcu
         JOIN freight.booking_container bc
           ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
         JOIN freight.bookings b ON b.id = bc.booking_id
         LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
         LEFT JOIN freight.customer_truck_containers ctc
           ON ctc.container_number = bcu.container_number
          AND ctc.booking_id = b.id AND ctc.deleted_at IS NULL
         LEFT JOIN freight.customer_truck_assignments a
           ON a.id = ctc.assignment_id AND a.deleted_at IS NULL
         LEFT JOIN freight.containers cont ON cont.container_number = bcu.container_number
         LEFT JOIN freight.warehouse_inventory inv
           ON inv.container_id = cont.id AND inv.deleted_at IS NULL
        WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
        ORDER BY bcu.container_number`,
      [bookingId],
    );

    // Booking-level gate: the per-truck exit paper is blocked until the handover
    // is fully signed, so the UI can disable "Exit Paper" with a clear reason.
    const handoverSigned = await this.handover.isFullySigned(bookingId);

    return rows.map((r) => ({
      containerNumber: r.containerNumber,
      goods: r.goods,
      containerSize: r.containerSize,
      // A container the customer assigned to a truck is ASSIGNED (planned); it
      // only becomes LOADED once the operator loads it (loaded_at) on truck
      // leaving. Departed → LEFT, delivered → DELIVERED.
      stage: r.delivered
        ? 'DELIVERED'
        : r.truckLeft
          ? 'LEFT'
          : r.loaded
            ? 'LOADED'
            : r.truckAssignmentId
              ? 'ASSIGNED'
              : r.grnNumber
                ? 'GRN'
                : r.received
                  ? 'RECEIVED'
                  : 'PENDING',
      grnNumber: r.grnNumber,
      truckAssignmentId: r.truckAssignmentId,
      truckPlate: r.truckPlate,
      truckArrived: r.truckArrived,
      truckLeft: r.truckLeft,
      loaded: r.loaded,
      bookingReference: r.bookingReference,
      contractId: r.contractId,
      hasLastMile: r.hasLastMile,
      handoverSigned,
    }));
  }

  /**
   * Container numbers assigned to a truck (by plate) on this booking, from both
   * haulage paths: customer self-haul (customer_truck_containers) and EDR
   * last-mile (last_mile_vehicle_containers / legacy scalar). Uppercased.
   */
  private async truckAssignedContainers(bookingId: string, plate: string): Promise<string[]> {
    const rows: Array<{ cn: string | null }> = await this.dataSource.query(
      `SELECT UPPER(cc.container_number) AS cn
         FROM freight.customer_truck_assignments a
         JOIN freight.customer_truck_containers cc
           ON cc.assignment_id = a.id AND cc.deleted_at IS NULL
        WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL
       UNION
       SELECT UPPER(COALESCE(vc.container_number, va.container_number)) AS cn
         FROM freight.last_mile_vehicle_assignments va
         JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
         LEFT JOIN freight.last_mile_vehicle_containers vc
           ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
         JOIN freight.vehicles v ON v.id = va.vehicle_id
        WHERE l.booking_id = $1 AND va.deleted_at IS NULL
          AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))`,
      [bookingId, plate],
    );
    return rows.map((r) => r.cn).filter((n): n is string => Boolean(n));
  }

  /**
   * The booking's containers with their VGM cargo weight (tonnes), keyed by
   * container number. Drives the truck-leaving exit weighing: the selected
   * containers' total cargo weight must match (gross − tare).
   */
  async bookingContainerWeights(
    bookingId: string,
  ): Promise<Array<{ containerNumber: string; weightTons: number }>> {
    const rows: Array<{ containerNumber: string; weightTons: string }> =
      await this.dataSource.query(
        `SELECT bcu.container_number AS "containerNumber",
                MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons"
           FROM freight.booking_container_units bcu
           JOIN freight.booking_container bc
             ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
          WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
          GROUP BY bcu.container_number
          ORDER BY bcu.container_number`,
        [bookingId],
      );
    return rows.map((r) => ({
      containerNumber: r.containerNumber,
      weightTons: Number(r.weightTons) || 0,
    }));
  }

  /**
   * Per-truck exit paper: one paper covering the containers loaded on a specific
   * customer truck (used when multiple trucks leave separately). Gated on the
   * handover being signed and warehouse fees paid.
   */
  async truckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
    const [truck] = await this.dataSource.query(
      `SELECT a.booking_id AS "bookingId", a.plate_number AS "plateNumber",
              a.driver_name AS "driverName", a.truck_type AS "truckType",
              a.gross_weight_kg AS "grossWeightKg", a.departed_at AS "departedAt",
              b.reference AS "bookingReference", company.name AS "customerName"
         FROM freight.customer_truck_assignments a
         JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
         LEFT JOIN freight.companies company ON company.id = b.company_id
        WHERE a.id = $1 AND a.deleted_at IS NULL`,
      [assignmentId],
    );
    if (!truck) throw new NotFoundException(`Truck assignment ${assignmentId} not found`);

    if (!(await this.handover.isFullySigned(truck.bookingId))) {
      throw new BadRequestException('Handover must be signed before the exit paper can be generated');
    }
    const [inv]: Array<{ id: string }> = await this.dataSource.query(
      `SELECT id FROM freight.warehouse_inventory
        WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
      [truck.bookingId],
    );
    if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);

    const containers: Array<{ containerNumber: string; goods: string | null; vgmTons: string | null }> =
      await this.dataSource.query(
        `SELECT c.container_number AS "containerNumber",
                COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
                (SELECT SUM(u.vgm_tons)
                   FROM freight.booking_container_units u
                   JOIN freight.booking_container bc
                     ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL
                  WHERE u.container_number = c.container_number
                    AND bc.booking_id = c.booking_id
                    AND u.deleted_at IS NULL) AS "vgmTons"
           FROM freight.customer_truck_containers c
           JOIN freight.bookings b ON b.id = c.booking_id
           LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
          WHERE c.assignment_id = $1 AND c.deleted_at IS NULL
          ORDER BY c.container_number`,
        [assignmentId],
      );

    // Truck load in tonnes: summed container VGM; the weighed gross only as
    // fallback (bulk trucks carry no containers).
    const vgmSum = containers.reduce((s, c) => s + (Number(c.vgmTons) || 0), 0);
    const html = this.buildTruckExitPaperHtml({
      reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
      bookingReference: truck.bookingReference,
      customerName: truck.customerName,
      plateNumber: truck.plateNumber,
      driverName: truck.driverName,
      truckType: truck.truckType,
      grossWeightKg: vgmSum > 0 ? vgmSum : this.grossAsTons(Number(truck.grossWeightKg ?? 0)),
      gateOut: truck.departedAt,
      containers,
    });
    return {
      filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
      buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
    };
  }

  /**
   * Exit paper for an EDR last-mile truck (one per truck, keyed on the vehicle
   * assignment). Deliberately NOT gated on the handover: EDR handovers are
   * generated at delivery — i.e. after the truck has already left — so there is
   * nothing to sign at exit time. Warehouse-fee clearance still applies.
   */
  async edrTruckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
    const [truck] = await this.dataSource.query(
      `SELECT lm.booking_id AS "bookingId",
              COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
              COALESCE(
                v.assigned_driver_name,
                NULLIF(TRIM(CONCAT(d.first_name, ' ', d.last_name)), '')
              ) AS "driverName",
              v.vehicle_type AS "truckType",
              va.gross_weight_tons AS "grossWeightKg",
              va.departed_at AS "departedAt",
              b.reference AS "bookingReference",
              company.name AS "customerName"
         FROM freight.last_mile_vehicle_assignments va
         JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
         JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
         JOIN freight.vehicles v ON v.id = va.vehicle_id
         LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id AND d.deleted_at IS NULL
         LEFT JOIN freight.companies company ON company.id = b.company_id
        WHERE va.id = $1 AND va.deleted_at IS NULL`,
      [assignmentId],
    );
    if (!truck) throw new NotFoundException(`EDR truck assignment ${assignmentId} not found`);

    const [inv]: Array<{ id: string }> = await this.dataSource.query(
      `SELECT id FROM freight.warehouse_inventory
        WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
      [truck.bookingId],
    );
    if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);

    // Bulk trucks carry no containers — the table is then empty and the paper
    // stands on the weighed gross alone.
    const containers: Array<{ containerNumber: string; goods: string | null }> =
      await this.dataSource.query(
        `SELECT vc.container_number AS "containerNumber",
                COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
           FROM freight.last_mile_vehicle_containers vc
           JOIN freight.last_mile lm ON lm.id = vc.last_mile_id AND lm.deleted_at IS NULL
           JOIN freight.bookings b ON b.id = lm.booking_id
           LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
          WHERE vc.assignment_id = $1 AND vc.deleted_at IS NULL
          ORDER BY vc.container_number`,
        [assignmentId],
      );

    const html = this.buildTruckExitPaperHtml({
      reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
      bookingReference: truck.bookingReference,
      customerName: truck.customerName,
      plateNumber: truck.plateNumber,
      driverName: truck.driverName ?? '-',
      truckType: truck.truckType ?? '-',
      grossWeightKg: Number(truck.grossWeightKg ?? 0),
      gateOut: truck.departedAt,
      containers,
    });
    return {
      filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
      buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
    };
  }

  private buildTruckExitPaperHtml(data: {
    reference: string;
    bookingReference: string;
    customerName: string | null;
    plateNumber: string;
    driverName: string;
    truckType: string;
    grossWeightKg: number;
    gateOut: string | Date | null;
    containers: Array<{ containerNumber: string; goods: string | null }>;
  }): string {
    const esc = (v: unknown) =>
      String(v ?? '-').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    const gateOut = data.gateOut ? new Date(data.gateOut).toLocaleString('en-GB') : '-';
    const rows: Array<[string, string]> = [
      ['Booking Reference', data.bookingReference],
      ['Customer / Consignee', data.customerName ?? '-'],
      ['Pickup Truck Plate', data.plateNumber],
      ['Driver', data.driverName],
      ['Truck Type', data.truckType],
      ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} t`],
      ['Gate-Out Time', gateOut],
      ['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
    ];
    const containerRows = data.containers.length
      ? data.containers
          .map((c) => `<tr><td>${esc(c.containerNumber)}</td><td>${esc(c.goods)}</td></tr>`)
          .join('')
      : '<tr><td colspan="2">No containers loaded on this truck.</td></tr>';
    return `<!doctype html><html><head><meta charset="utf-8" /><title>Warehouse Exit Paper</title>
      <style>
        body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 24px; }
        h1 { font-size: 24px; text-transform: uppercase; margin: 0 0 4px; }
        table { width: 100%; border-collapse: collapse; margin-top: 8px; }
        th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12px; text-align: left; vertical-align: top; }
        th { background: #f8fafc; width: 34%; font-weight: 800; }
        .section { margin-top: 18px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .1em; }
        .ref strong { font-size: 16px; }
      </style></head>
      <body>
        <div style="color:#064c27;font-weight:800;text-transform:uppercase;">Ethio-Djibouti Railway S.C.</div>
        <h1>Warehouse Release / Exit Paper</h1>
        <div class="ref">Document / Release No. <strong>${esc(data.reference)}</strong></div>
        <div class="section">Release Particulars</div>
        <table><tbody>${rows.map(([l, v]) => `<tr><th>${esc(l)}</th><td>${esc(v)}</td></tr>`).join('')}</tbody></table>
        <div class="section">Containers Leaving on This Truck</div>
        <table><thead><tr><th style="width:40%">Container Number</th><th>Goods</th></tr></thead>
          <tbody>${containerRows}</tbody></table>
      </body></html>`;
  }

  /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
  async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
    const [row] = await this.dataSource.query(
      `SELECT inv.id,
              COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
              COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
              inv.quantity,
              -- An unweighed item still reports the cargo weight it holds: fall
              -- back to the item's container VGM, then the booking's declared
              -- weight, so a GRN never prints "0 t" for goods that are present.
              COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight,
              inv.volume,
              inv.status,
              inv.notes,
              b.id AS "bookingId",
              b.reference AS "bookingReference",
              b.status AS "bookingStatus",
              b.freight_type AS "freightType",
              b.trade_direction AS "tradeDirection",
              b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
              company.name AS "customerName",
              company.tin AS "customerTin",
              service_type.service_name AS "serviceType",
              origin_yard.label AS "originYardLabel",
              origin_yard.code AS "originYardCode",
              destination_yard.label AS "destinationYardLabel",
              destination_yard.code AS "destinationYardCode",
              COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
              booking_container."containerSummary" AS "bookingContainerSummary",
              COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
              wh.name AS "warehouseName",
              wh.code AS "warehouseCode",
              yard.name AS "yardName",
              yard.code AS "yardCode",
              zone.name AS "zoneName",
              zone.code AS "zoneCode"
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.bookings b ON b.id = inv.booking_id
         LEFT JOIN freight.companies company ON company.id = b.company_id
         LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
         LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
         LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
         LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
         LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
         LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
         LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
         LEFT JOIN LATERAL (
           SELECT MIN(bc.container_number) AS container_number,
                  STRING_AGG(
                    CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
                    ', '
                    ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
                  ) AS "containerSummary"
             FROM freight.booking_container bc
             LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
            WHERE bc.booking_id = b.id
              AND bc.deleted_at IS NULL
         ) booking_container ON true
         LEFT JOIN LATERAL (
           SELECT SUM(bcu.vgm_tons) AS tons
             FROM freight.booking_container_units bcu
             JOIN freight.booking_container bc2
               ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
            WHERE bc2.booking_id = b.id
              AND bcu.deleted_at IS NULL
              AND (container.container_number IS NULL
                   OR bcu.container_number = container.container_number)
         ) item_vgm ON true
         LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
         LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
        WHERE inv.id = $1 AND inv.deleted_at IS NULL
        LIMIT 1`,
      [id],
    );
    if (!row) {
      throw new NotFoundException(`Inventory item ${id} not found`);
    }
    if (!row.grnNumber) {
      throw new BadRequestException('GRN number is missing for this inventory item');
    }

    const html = this.buildGrnDocumentHtml({
      grnNumber: row.grnNumber,
      receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(),
      bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A',
      bookingStatus: row.bookingStatus ?? null,
      customerName: row.customerName ?? null,
      customerTin: row.customerTin ?? null,
      serviceType: row.serviceType ?? null,
      freightType: row.freightType ?? null,
      tradeDirection: row.tradeDirection ?? null,
      route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
        .filter(Boolean)
        .join(' to ') || null,
      containerNumber: row.containerNumber ?? null,
      bookingContainerSummary: row.bookingContainerSummary ?? null,
      cargoDescription: row.cargoDescription ?? null,
      quantity: Number(row.quantity ?? 0),
      weight: Number(row.weight ?? 0),
      volume: row.volume == null ? null : Number(row.volume),
      bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
      warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
      yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
      zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
      inventoryStatus: row.status ?? null,
      receiveSummary: this.extractReceiveSummary(row.notes),
    });

    return {
      filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
      // Styled fallback titled as a GRN (not a release order) for Chromium-less render.
      buffer: await this.releaseDocuments.renderStyledDocument(
        html,
        {
          titleLines: ['GOODS RECEIVED', 'NOTE'],
          subtitle: 'OFFICIAL WAREHOUSE GOODS RECEIVED NOTE',
          sectionTitle: 'RECEIVED PARTICULARS',
          refLabel: 'GRN No.',
        },
        'Goods Received Note',
      ),
    };
  }

  async approveDeliveryForBooking(
    bookingId: string,
    userId?: string,
    signerName?: string,
  ): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
    if (!userId) {
      throw new BadRequestException('Authentication is required to approve delivery');
    }
    const name = signerName?.trim();
    if (!name) {
      throw new BadRequestException('Please enter your full name to approve delivery');
    }

    // A saved signature is applied when available; otherwise the typed full name
    // is the record of who approved (self-haul customers may have no signature).
    const signature = await this.signatures.getForUser(userId).catch(() => null);

    const [item]: Array<{
      id: string;
      warehouseId: string | null;
      notes: string | null;
      customerTruckAssignedAt: string | null;
      customerTruckArrivedAt: string | null;
    }> =
      await this.dataSource.query(
        `SELECT inv.id,
                inv.warehouse_id AS "warehouseId",
                inv.notes,
                b.customer_truck_assigned_at AS "customerTruckAssignedAt",
                b.customer_truck_arrived_at AS "customerTruckArrivedAt"
           FROM freight.warehouse_inventory inv
           JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
          WHERE inv.booking_id = $1
            AND inv.deleted_at IS NULL
            AND inv.inspection_status = 'PASSED'
          ORDER BY inv.updated_at DESC NULLS LAST, inv.created_at DESC
          LIMIT 1`,
        [bookingId],
      );

    if (!item) {
      throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
    }
    if (item.customerTruckAssignedAt && !item.customerTruckArrivedAt) {
      throw new BadRequestException('Customer truck arrival must be recorded before delivery approval');
    }
    await this.invoices.assertClearanceAllowed(item.id);

    const approvedAt = new Date();
    const signatureImageUrl = signature?.signatureImageUrl ?? null;
    const approval = {
      approvedAt: approvedAt.toISOString(),
      signerDisplayName: name,
      signatureImageUrl,
      userId,
    };
    const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
    const approvalNote = `${CUSTOMER_DELIVERY_APPROVAL_PREFIX}${JSON.stringify(approval)}`;

    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(WarehouseInventory).update(item.id, {
        notes: this.appendNote(existingNotes, approvalNote),
      });
      await this.activityLog.record(
        {
          activityType: 'INVENTORY_RELEASED',
          inventoryId: item.id,
          warehouseId: item.warehouseId,
          description: `Customer approved delivery as ${name}`,
          performedBy: name,
        },
        manager,
      );
    });

    // Sign the structured handover record(s) for this booking (self-haul: before
    // the truck leaves). Kept alongside the legacy approval note.
    await this.handover.signForBooking(bookingId, userId, name, signatureImageUrl);

    return {
      bookingId,
      inventoryId: item.id,
      approvedAt: approval.approvedAt,
      signerDisplayName: name,
    };
  }

  /**
   * Customer signs ONE handover from the portal (EDR last-mile: one per truck).
   * When the last one is signed — and every EDR truck has left the warehouse —
   * the delivery completes automatically: inventory + cargo delivered, last-mile
   * leg DELIVERED (trucks freed), booking completed ("shipment delivered").
   */
  async signHandover(
    handoverId: string,
    userId?: string,
    signerName?: string,
  ): Promise<{
    handoverId: string;
    bookingId: string;
    signedAt: string | null;
    signerDisplayName: string;
    allSigned: boolean;
  }> {
    if (!userId) {
      throw new BadRequestException('Authentication is required to sign the handover');
    }
    const name = signerName?.trim();
    if (!name) {
      throw new BadRequestException('Please enter your full name to sign the handover');
    }

    const signature = await this.signatures.getForUser(userId).catch(() => null);

    const [h]: Array<{
      bookingId: string;
      reference: string;
      truckPlate: string | null;
      mileType: string;
      edrAssignmentId: string | null;
    }> = await this.dataSource.query(
      `SELECT booking_id AS "bookingId", reference, truck_plate AS "truckPlate",
              mile_type AS "mileType", edr_assignment_id AS "edrAssignmentId"
         FROM freight.booking_handovers
        WHERE id = $1 AND deleted_at IS NULL`,
      [handoverId],
    );
    if (!h) throw new NotFoundException(`Handover ${handoverId} not found`);
    // Self-haul stays a single booking-level signature via approve-delivery,
    // which also enforces inspection-passed + truck-arrived. Per-truck signing
    // is an EDR last-mile flow only.
    if (h.mileType !== 'EDR_LAST_MILE') {
      throw new BadRequestException(
        'This handover is signed through Approve delivery, not per truck',
      );
    }

    // Same gate as approve-delivery: storage/demurrage must be settled first.
    const [inv]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
      `SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory
        WHERE booking_id = $1 AND deleted_at IS NULL
        ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 1`,
      [h.bookingId],
    );
    if (inv) await this.invoices.assertClearanceAllowed(inv.id);

    const signed = await this.handover.sign(handoverId, userId, name, signature?.signatureImageUrl ?? null);
    const allSigned = await this.handover.isFullySigned(h.bookingId);

    if (inv) {
      await this.activityLog.record({
        activityType: 'INVENTORY_RELEASED',
        inventoryId: inv.id,
        warehouseId: inv.warehouseId,
        description: `Customer signed handover ${h.reference}${h.truckPlate ? ` (truck ${h.truckPlate})` : ''} as ${name}`,
        performedBy: name,
      });
    }

    // EDR last-mile delivers PER TRUCK: this signature confirms receipt of the
    // goods THIS truck carried, so only its containers become DELIVERED now.
    // (Self-haul keeps the single booking-level handover + manual Deliver.)
    if (h.mileType === 'EDR_LAST_MILE') {
      try {
        await this.deliverEdrTruckContainers(h, name);
      } catch (err) {
        this.logger.warn(
          `Per-truck auto-deliver after handover sign failed for ${h.bookingId}: ${(err as Error).message}`,
        );
      }
    }

    if (allSigned) {
      void this.completeEdrDeliveryIfReady(h.bookingId, name).catch((err: Error) =>
        this.logger.warn(`Auto-complete after handover sign failed for ${h.bookingId}: ${err.message}`),
      );
    }

    return {
      handoverId,
      bookingId: h.bookingId,
      signedAt: signed.signedAt ? new Date(signed.signedAt).toISOString() : null,
      signerDisplayName: name,
      allSigned,
    };
  }

  /**
   * EDR last-mile auto-completion: once every handover is signed and every EDR
   * truck has departed, deliver the remaining inventory, mark the last-mile leg
   * DELIVERED and complete the booking. Self-haul bookings keep their manual
   * Deliver flow (no last_mile record ⇒ no-op).
   */
  private async completeEdrDeliveryIfReady(bookingId: string, signerName: string): Promise<void> {
    const [lm]: Array<{ id: string; status: string }> = await this.dataSource.query(
      `SELECT id, status FROM freight.last_mile
        WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
      [bookingId],
    );
    if (!lm) return;

    const [pending]: Array<{ notDeparted: string }> = await this.dataSource.query(
      `SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS "notDeparted"
         FROM freight.last_mile_vehicle_assignments va
         JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
        WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
      [bookingId],
    );
    if (Number(pending?.notDeparted ?? 0) > 0) return;
    if (!(await this.handover.isFullySigned(bookingId))) return;

    const items: Array<{ id: string }> = await this.dataSource.query(
      `SELECT id FROM freight.warehouse_inventory
        WHERE booking_id = $1 AND status = 'READY_FOR_PICKUP' AND deleted_at IS NULL`,
      [bookingId],
    );
    for (const it of items) {
      try {
        await this.deliver(it.id, {
          receiverName: signerName,
          remarks: 'Auto-delivered on customer handover signature',
          performedBy: signerName,
        } as DeliverInventoryDto);
      } catch (err) {
        this.logger.warn(`Auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`);
      }
    }

    if (lm.status !== 'DELIVERED') {
      try {
        await this.lastMileService.update(lm.id, { status: 'DELIVERED' } as UpdateLastMileDto);
      } catch (err) {
        this.logger.warn(`Auto-deliver of last-mile ${lm.id} failed: ${(err as Error).message}`);
      }
    }

    // Booking → COMPLETED ("shipment delivered" notification) — owned by the
    // bookings module; evented to avoid a warehouses→bookings service dependency.
    this.events.emit('import.handover.completed', { bookingId });
  }

  /**
   * EDR last-mile per-truck delivery: the customer signed THIS truck's handover,
   * so only the container items that truck carried become DELIVERED. Bulk cargo
   * (no container rows) is delivered by completeEdrDeliveryIfReady once every
   * truck is signed off.
   */
  private async deliverEdrTruckContainers(
    h: { bookingId: string; edrAssignmentId: string | null; truckPlate: string | null },
    signerName: string,
  ): Promise<void> {
    if (!h.edrAssignmentId && !h.truckPlate) return;
    const items: Array<{ id: string }> = await this.dataSource.query(
      `SELECT DISTINCT inv.id
         FROM freight.last_mile_vehicle_assignments va
         JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
         LEFT JOIN freight.last_mile_vehicle_containers vc
           ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
         LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
         JOIN freight.containers c
           ON c.container_number = COALESCE(vc.container_number, va.container_number)
          AND c.deleted_at IS NULL
         JOIN freight.warehouse_inventory inv
           ON inv.container_id = c.id AND inv.booking_id = l.booking_id AND inv.deleted_at IS NULL
        WHERE l.booking_id = $1
          AND va.deleted_at IS NULL
          AND inv.status = 'READY_FOR_PICKUP'
          AND (va.id = $2::uuid
               OR ($2::uuid IS NULL
                   AND (UPPER(v.power_plate_no) = UPPER($3) OR UPPER(v.plate_number) = UPPER($3))))`,
      [h.bookingId, h.edrAssignmentId, h.truckPlate ?? ''],
    );
    for (const it of items) {
      try {
        await this.deliver(it.id, {
          receiverName: signerName,
          remarks: `Auto-delivered on customer handover signature${h.truckPlate ? ` (truck ${h.truckPlate})` : ''}`,
          performedBy: signerName,
        } as DeliverInventoryDto);
      } catch (err) {
        this.logger.warn(
          `Per-truck auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`,
        );
      }
    }
  }

  /**
   * Handover PDF resolved by booking (for the portal, which only has bookingId).
   * With `handoverId` the document is rendered for that specific handover — the
   * per-truck EDR last-mile variant (truck plate + that truck's signature state).
   */
  async handoverDocumentForBooking(
    bookingId: string,
    handoverId?: string,
  ): Promise<{ filename: string; buffer: Buffer }> {
    const [inv]: Array<{ id: string }> = await this.dataSource.query(
      `SELECT id FROM freight.warehouse_inventory
        WHERE booking_id = $1 AND deleted_at IS NULL
        ORDER BY updated_at DESC NULLS LAST, created_at DESC
        LIMIT 1`,
      [bookingId],
    );
    if (!inv) {
      throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
    }
    if (!handoverId) return this.handoverDocument(inv.id);

    const [h]: Array<{
      reference: string;
      truckPlate: string | null;
      signedAt: string | null;
      signerName: string | null;
    }> = await this.dataSource.query(
      `SELECT reference, truck_plate AS "truckPlate",
              signed_at AS "signedAt", signer_name AS "signerName"
         FROM freight.booking_handovers
        WHERE id = $1 AND booking_id = $2 AND deleted_at IS NULL`,
      [handoverId, bookingId],
    );
    if (!h) {
      throw new NotFoundException(`Handover ${handoverId} not found for booking ${bookingId}`);
    }
    return this.handoverDocument(inv.id, {
      reference: h.reference,
      truckPlate: h.truckPlate,
      signedAt: h.signedAt ? new Date(h.signedAt) : null,
      signerName: h.signerName,
    });
  }

  /**
   * Record whether a booking's goods needed double handling. Answered by
   * warehouse staff once the goods are unloaded — only Yes bills the
   * DOUBLE_HANDLING_FEE rule (see WarehouseFeeService.computeDoubleHandling).
   * Locked once the fee has been invoiced, so a billed charge can't be
   * retro-cancelled from the operations screen.
   */
  async setDoubleHandling(
    bookingId: string,
    doubleHandling: boolean,
    performedBy?: string,
  ): Promise<{ bookingId: string; doubleHandling: boolean; setAt: string }> {
    const [booking]: Array<{ id: string; tradeDirection: string | null; reference: string | null }> =
      await this.dataSource.query(
        `SELECT id, trade_direction AS "tradeDirection", reference
           FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
        [bookingId],
      );
    if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
    if ((booking.tradeDirection ?? '').toUpperCase() !== 'IMPORT') {
      throw new BadRequestException('Double handling applies to import bookings only');
    }

    // Warehouse fees are billed per inventory row (invoices.source = 'warehouse',
    // source_id = the inventory id), with the fee type on the line's charge_type.
    const [invoiced]: Array<{ one: number }> = await this.dataSource.query(
      `SELECT 1 AS one
         FROM freight.invoices i
         JOIN freight.invoice_lines il ON il.invoice_id = i.id AND il.deleted_at IS NULL
         JOIN freight.warehouse_inventory inv
           ON inv.id::text = i.source_id AND inv.deleted_at IS NULL
        WHERE inv.booking_id = $1
          AND i.source = 'warehouse'
          AND i.deleted_at IS NULL
          AND i.status <> 'CANCELLED'
          AND il.charge_type = 'DOUBLE_HANDLING'
        LIMIT 1`,
      [bookingId],
    );
    if (invoiced) {
      throw new BadRequestException(
        'Double handling has already been invoiced for this booking — cancel the invoice to change it',
      );
    }

    const setAt = new Date();
    await this.dataSource.query(
      `UPDATE freight.bookings
          SET double_handling = $2,
              double_handling_set_at = $3,
              double_handling_set_by = $4,
              updated_at = NOW()
        WHERE id = $1`,
      [bookingId, doubleHandling, setAt, performedBy ?? null],
    );

    // Audit on the booking's inventory rows so it shows in warehouse history.
    const items: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
      `SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory
        WHERE booking_id = $1 AND deleted_at IS NULL`,
      [bookingId],
    );
    for (const item of items) {
      await this.activityLog.record({
        activityType: 'INVENTORY_STORED',
        inventoryId: item.id,
        warehouseId: item.warehouseId,
        description: `Double handling set to ${doubleHandling ? 'YES — fee rule applies' : 'NO'}`,
        performedBy,
      });
    }

    return { bookingId, doubleHandling, setAt: setAt.toISOString() };
  }

  /** Resolve the primary warehouse-inventory item for a booking (most recent). */
  private async primaryInventoryIdForBooking(bookingId: string): Promise<string> {
    const [inv]: Array<{ id: string }> = await this.dataSource.query(
      `SELECT id FROM freight.warehouse_inventory
        WHERE booking_id = $1 AND deleted_at IS NULL
        ORDER BY updated_at DESC NULLS LAST, created_at DESC
        LIMIT 1`,
      [bookingId],
    );
    if (!inv) {
      throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
    }
    return inv.id;
  }

  /** Booking-scoped GRN document (customer portal). */
  async grnDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
    return this.grnDocument(await this.primaryInventoryIdForBooking(bookingId));
  }

  /** Booking-scoped gate-clearance / release document (customer portal). */
  async releaseDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
    return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
  }

  async handoverDocument(
    id: string,
    perTruck?: {
      reference: string;
      truckPlate: string | null;
      signedAt: Date | null;
      signerName: string | null;
    },
  ): Promise<{ filename: string; buffer: Buffer }> {
    const [row] = await this.dataSource.query(
      `SELECT inv.id,
              inv.booking_id AS "bookingId",
              inv.quantity,
              -- An unweighed item still reports the cargo weight it holds: fall
              -- back to the item's container VGM when no weight was recorded.
              COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, 0) AS weight,
              inv.status,
              inv.notes,
              inv.inspection_status AS "inspectionStatus",
              inv.release_date AS "releaseDate",
              inv.release_order_reference AS "releaseOrderReference",
              COALESCE(inv.unloaded_at, inv.arrived_at, inv.created_at) AS "handoverDate",
              b.reference AS "bookingReference",
              b.status AS "bookingStatus",
              b.freight_type AS "freightType",
              b.trade_direction AS "tradeDirection",
              b.scheduled_date AS "scheduledDate",
              b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
              b.last_mile_delivery_address AS "lastMileDeliveryAddress",
              company.name AS "customerName",
              service_type.service_name AS "serviceType",
              origin_yard.label AS "originYardLabel",
              origin_yard.code AS "originYardCode",
              destination_yard.label AS "destinationYardLabel",
              destination_yard.code AS "destinationYardCode",
              COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
              booking_container."containerSummary" AS "bookingContainerSummary",
              COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
              wh.name AS "warehouseName",
              wh.code AS "warehouseCode",
              yard.name AS "yardName",
              yard.code AS "yardCode",
              zone.name AS "zoneName",
              zone.code AS "zoneCode",
              ts.train_number AS "trainSchedule"
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.bookings b ON b.id = inv.booking_id
         LEFT JOIN freight.companies company ON company.id = b.company_id
         LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
         LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
         LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
         LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
         LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
         LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
         LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
         LEFT JOIN LATERAL (
           SELECT MIN(bc.container_number) AS container_number,
                  STRING_AGG(
                    CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
                    ', '
                    ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
                  ) AS "containerSummary"
             FROM freight.booking_container bc
             LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
            WHERE bc.booking_id = b.id
              AND bc.deleted_at IS NULL
         ) booking_container ON true
         LEFT JOIN LATERAL (
           SELECT SUM(bcu.vgm_tons) AS tons
             FROM freight.booking_container_units bcu
             JOIN freight.booking_container bc2
               ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
            WHERE bc2.booking_id = b.id
              AND bcu.deleted_at IS NULL
              AND (container.container_number IS NULL
                   OR bcu.container_number = container.container_number)
         ) item_vgm ON true
         LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
         LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
         LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
         LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
        WHERE inv.id = $1 AND inv.deleted_at IS NULL
        LIMIT 1`,
      [id],
    );
    if (!row) {
      throw new NotFoundException(`Inventory item ${id} not found`);
    }
    await this.invoices.assertClearanceAllowed(id);
    if (row.inspectionStatus !== 'PASSED') {
      throw new BadRequestException('Handover document is available after inspection has passed');
    }

    const bookingReference = row.bookingReference || row.bookingId || 'N/A';
    const reference =
      perTruck?.reference ||
      this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
      `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
    const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
    const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
    const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
    // Per-truck renders must not stamp their reference into the shared item notes.
    if (!generatedAtValue && !perTruck) {
      await this.inventoryRepository.update(id, {
        notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
      });
    }

    const html = this.buildHandoverDocumentHtml({
      reference,
      handedOverAt,
      bookingReference,
      bookingStatus: row.bookingStatus ?? null,
      customerName: row.customerName ?? null,
      serviceType: row.serviceType ?? null,
      freightType: row.freightType ?? null,
      tradeDirection: row.tradeDirection ?? null,
      route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
        .filter(Boolean)
        .join(' to ') || null,
      scheduledDate: row.scheduledDate ? new Date(row.scheduledDate) : null,
      containerNumber: row.containerNumber ?? null,
      bookingContainerSummary: row.bookingContainerSummary ?? null,
      cargoDescription: row.cargoDescription ?? null,
      quantity: Number(row.quantity ?? 0),
      weight: Number(row.weight ?? 0),
      bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
      warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
      yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
      zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
      inventoryStatus: row.status ?? null,
      inspectionStatus: row.inspectionStatus ?? null,
      releaseOrderReference: row.releaseOrderReference ?? null,
      releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
      trainSchedule: row.trainSchedule ?? null,
      lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
      truckPlate: perTruck?.truckPlate ?? null,
      customerApproval: perTruck
        ? perTruck.signedAt
          ? {
              approvedAt: perTruck.signedAt.toISOString(),
              signerDisplayName: perTruck.signerName ?? '-',
              signatureImageUrl: null,
            }
          : null
        : this.extractCustomerDeliveryApproval(row.notes),
    });

    return {
      filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
      // Styled fallback titled as a handover (not a release order) for Chromium-less render.
      buffer: await this.releaseDocuments.renderStyledDocument(
        html,
        {
          titleLines: ['IMPORT GOODS', 'HANDOVER', 'DOCUMENT'],
          subtitle: 'EDR TO CUSTOMER WAREHOUSE HANDOVER',
          sectionTitle: 'HANDOVER PARTICULARS',
          refLabel: 'Document / Handover No.',
        },
        'Import Goods Handover',
      ),
    };
  }

  async deliver(id: string, dto: DeliverInventoryDto): Promise<WarehouseInventory> {
    const item = await this.findById(id);
    this.assertTransition(item.status, 'DELIVERED');

    if (!item.releaseDate) {
      throw new BadRequestException('A release order must be issued before the goods can be delivered');
    }

    // Self-haul: the customer's own truck delivers — deliver only after the
    // handover is signed AND the truck has left the warehouse holding the goods.
    if (item.bookingId) {
      const [sh]: Array<{ assignedAt: string | null }> = await this.dataSource.query(
        `SELECT customer_truck_assigned_at AS "assignedAt"
           FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
        [item.bookingId],
      );
      if (sh?.assignedAt) {
        if (!(await this.handover.isFullySigned(item.bookingId))) {
          throw new BadRequestException('Handover must be signed before delivery');
        }
        const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query(
          `SELECT COUNT(*) AS total,
                  COUNT(*) FILTER (WHERE departed_at IS NOT NULL) AS "left"
             FROM freight.customer_truck_assignments
            WHERE booking_id = $1 AND deleted_at IS NULL`,
          [item.bookingId],
        );
        const totalTrucks = Number(trucks?.total ?? 0);
        const leftTrucks = Number(trucks?.left ?? 0);
        if (leftTrucks === 0) {
          throw new BadRequestException('Deliver is available only after the customer truck has left');
        }
        // Multi-truck booking: every assigned truck must arrive and leave —
        // each is weighed out separately before the goods count as delivered.
        if (leftTrucks < totalTrucks) {
          throw new BadRequestException(
            `Deliver is available only after every assigned truck has left (${leftTrucks} of ${totalTrucks} so far)`,
          );
        }
      }
      // EDR last-mile delivers per truck: a container item only needs the truck
      // CARRYING IT to have left; bulk (no container) waits for every truck.
      if (item.containerId) {
        const [own]: Array<{ pending: string }> = await this.dataSource.query(
          `SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS pending
             FROM freight.last_mile_vehicle_assignments va
             JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
             LEFT JOIN freight.last_mile_vehicle_containers vc
               ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
             JOIN freight.containers c ON c.id = $2 AND c.deleted_at IS NULL
            WHERE l.booking_id = $1 AND va.deleted_at IS NULL
              AND COALESCE(vc.container_number, va.container_number) = c.container_number`,
          [item.bookingId, item.containerId],
        );
        if (Number(own?.pending ?? 0) > 0) {
          throw new BadRequestException(
            'Deliver is available only after the EDR truck carrying this container has left',
          );
        }
      } else {
        const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query(
          `SELECT COUNT(*) AS total,
                  COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left"
             FROM freight.last_mile_vehicle_assignments va
             JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
            WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
          [item.bookingId],
        );
        const lmTotal = Number(lm?.total ?? 0);
        const lmLeft = Number(lm?.left ?? 0);
        if (lmTotal > 0 && lmLeft < lmTotal) {
          throw new BadRequestException(
            `Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`,
          );
        }
      }
    }

    const receiverName = dto.receiverName.trim();
    const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
    const weight = Number(item.weight) || 0;
    const volume = Number(item.volume) || 0;
    const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;

    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(WarehouseInventory).update(id, {
        status: 'DELIVERED',
        deliveredAt,
      });

      // Goods physically leave the warehouse on pickup — free up capacity.
      await this.applyCapacityDelta(
        manager,
        {
          warehouseId: item.warehouseId,
          yardId: item.yardId,
          zoneId: item.zoneId,
        },
        -weight,
        -volume,
        -containerCount,
      );

      // Proof of delivery is captured on the linked cargo.
      if (item.cargoId) {
        await manager.getRepository(Cargo).update(item.cargoId, {
          receiverName,
          deliveredAt,
          deliveryRemarks: dto.remarks?.trim() ?? null,
        });
      }

      await this.activityLog.record(
        {
          activityType: 'INVENTORY_DELIVERED',
          inventoryId: id,
          warehouseId: item.warehouseId,
          description: `Delivered to ${receiverName}`,
          performedBy: dto.performedBy,
        },
        manager,
      );

      // Handover on delivery. EDR last-mile generates its handover HERE (after
      // exit, on delivery). Self-haul handovers were generated on arrival —
      // stamp them delivered.
      if (item.bookingId) {
        const [b]: Array<{ selfHaul: string | null }> = await manager.query(
          `SELECT customer_truck_assigned_at AS "selfHaul"
             FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
          [item.bookingId],
        );
        if (b?.selfHaul) {
          await manager.query(
            `UPDATE freight.booking_handovers
                SET delivered_at = COALESCE(delivered_at, NOW()), updated_at = NOW()
              WHERE booking_id = $1 AND deleted_at IS NULL`,
            [item.bookingId],
          );
        } else {
          // EDR last-mile: the handover is per delivering truck. Resolve the
          // vehicle from the truck's own container list (the earlier lookup went
          // through last_mile_container_allocations, which nothing ever writes —
          // so truckPlate was always null and every booking collapsed to a single
          // booking-level handover). Bulk has no container, so fall back to the
          // delivery's single truck; a booking-level handover when unresolvable.
          let truckPlate: string | null = null;
          const [veh]: Array<{ plate: string | null }> = await manager.query(
            `SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
               FROM freight.last_mile_vehicle_assignments va
               JOIN freight.last_mile lm
                 ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
               JOIN freight.vehicles v ON v.id = va.vehicle_id
               LEFT JOIN freight.last_mile_vehicle_containers vc
                 ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
               LEFT JOIN freight.containers cont
                 ON cont.container_number = vc.container_number AND cont.deleted_at IS NULL
              WHERE lm.booking_id = $1
                AND va.deleted_at IS NULL
                AND ($2::uuid IS NULL OR cont.id = $2::uuid)
              ORDER BY (cont.id IS NOT NULL) DESC, va.created_at ASC
              LIMIT 1`,
            [item.bookingId, item.containerId ?? null],
          );
          truckPlate = veh?.plate ?? null;
          await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
        }
      }
    });

    // "Approve delivery" nudge: on Deliver the customer is reminded to sign any
    // handover still unsigned (per truck for EDR last-mile). Fire-and-forget.
    if (item.bookingId) {
      void this.handover.notifyUnsignedForBooking(item.bookingId).catch(() => undefined);
    }

    return this.findById(id);
  }

  /**
   * Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record.
   * Reads wagon/schedule data read-only — never modifies scheduling.
   */
  async load(id: string, dto: LoadInventoryDto): Promise<WarehouseInventory> {
    const item = await this.findById(id);

    // 1. inventory status must be READY_FOR_LOADING (and not already LOADED).
    this.assertTransition(item.status, 'LOADED');

    // 2. inventory is at a valid warehouse/yard/zone location.
    if (!item.warehouseId || !item.yardId || !item.zoneId) {
      throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading');
    }

    // 3. wagon must exist.
    const wagon = await this.scheduling.findWagon(dto.wagonId);
    if (!wagon) {
      throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
    }

    // 4. wagon must be available, or already selected by an existing train schedule.
    const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId);
    if (!isLoadableWagonStatus(wagon.status) && !scheduled) {
      throw new BadRequestException(
        `Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`,
      );
    }

    // 5. inventory must not already have a loading record.
    const existing = await this.loadingRepository.findAll({ where: { warehouseInventoryId: id } });
    if (existing.length > 0) {
      throw new BadRequestException('Inventory has already been loaded');
    }

    const loadedWeight = dto.loadedWeight ?? (Number(item.weight) || 0);

    await this.dataSource.transaction(async (manager) => {
      const now = new Date();
      await manager.getRepository(WarehouseInventory).update(id, {
        status: 'LOADED',
        loadedAt: now,
      });

      await manager.getRepository(WarehouseLoading).save(
        manager.getRepository(WarehouseLoading).create({
          warehouseInventoryId: id,
          bookingId: item.bookingId ?? null,
          wagonId: dto.wagonId,
          // Which train this load belongs to — durable even if wagons reshuffle.
          trainScheduleId: dto.trainScheduleId ?? null,
          loadedAt: now,
          loadedBy: dto.loadedBy ?? null,
          loadedWeight,
          notes: dto.notes?.trim() ?? null,
        }),
      );

      await this.activityLog.record(
        {
          activityType: 'INVENTORY_LOADED',
          inventoryId: id,
          warehouseId: item.warehouseId,
          description: `Loaded onto wagon ${wagon.wagonNumber}`,
          performedBy: dto.loadedBy,
        },
        manager,
      );
    });

    return this.findById(id);
  }

  // ── Loading records (Batch 3) ─────────────────────────────────────────────

  async findLoadings(
    filter: { bookingId?: string; wagonId?: string },
  ): Promise<Array<WarehouseLoading & { wagonNumber: string | null }>> {
    const where = {
      ...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
      ...(filter.wagonId ? { wagonId: filter.wagonId } : {}),
    };
    const loadings = await this.loadingRepository.findAll({
      where,
      relations: { inventory: { warehouse: true, yard: true, zone: true } },
      order: { loadedAt: 'DESC' },
    });

    // Enrich with wagon numbers (read-only lookup into the scheduling domain).
    const wagonIds = [...new Set(loadings.map((l) => l.wagonId).filter((id): id is string => Boolean(id)))];
    const wagonNumbers = new Map<string, string>();
    if (wagonIds.length > 0) {
      const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query(
        'SELECT id, wagon_number FROM freight.wagons WHERE id = ANY($1)',
        [wagonIds],
      );
      rows.forEach((r) => wagonNumbers.set(r.id, r.wagon_number));
    }

    return loadings.map((loading) =>
      Object.assign(loading, { wagonNumber: (loading.wagonId && wagonNumbers.get(loading.wagonId)) ?? null }),
    );
  }

  findLoadingsByInventory(inventoryId: string): Promise<WarehouseLoading[]> {
    return this.loadingRepository.findAll({
      where: { warehouseInventoryId: inventoryId },
      order: { loadedAt: 'DESC' },
    });
  }

  dispatch(id: string, performedBy?: string): Promise<WarehouseInventory> {
    return this.transition(id, 'DISPATCHED', {
      timestampField: 'dispatchedAt',
      activityType: 'INVENTORY_DISPATCHED',
      description: 'Inventory dispatched',
      performedBy,
    });
  }

  async dashboardSummary(filter: FilterWarehouseInventoryDto): Promise<WarehouseDashboardSummary> {
    const warehouses = await this.dataSource.getRepository(Warehouse).find({
      where: {
        status: 'ACTIVE',
        ...(filter.facilityId ? { stationId: filter.facilityId } : {}),
        ...(filter.warehouseId ? { id: filter.warehouseId } : {}),
      },
    });
    const inventory = await this.findAll(filter);
    const today = new Date();

    const byStatus = inventory.reduce<Record<string, number>>((acc, item) => {
      acc[item.status] = (acc[item.status] ?? 0) + 1;
      return acc;
    }, {});

    return {
      totalWarehouses: warehouses.length,
      totalInventory: inventory.length,
      receivedToday: inventory.filter((item) => {
        const arrivedAt = item.arrivedAt ?? item.createdAt;
        return (
          arrivedAt.getFullYear() === today.getFullYear() &&
          arrivedAt.getMonth() === today.getMonth() &&
          arrivedAt.getDate() === today.getDate()
        );
      }).length,
      stored: byStatus.STORED ?? 0,
      reserved: byStatus.RESERVED ?? 0,
      readyForLoading: byStatus.READY_FOR_LOADING ?? 0,
      loaded: byStatus.LOADED ?? 0,
      dispatched: byStatus.DISPATCHED ?? 0,
    };
  }

  findMovements(id: string): Promise<WarehouseInventoryMovement[]> {
    return this.dataSource.getRepository(WarehouseInventoryMovement).find({
      where: { inventoryId: id },
      order: { movedAt: 'DESC' },
    });
  }

  findActivity(id: string): Promise<WarehouseActivityLog[]> {
    return this.activityLog.findByInventory(id);
  }

  // ── Inquiry (Batch 1) ──────────────────────────────────────────────────

  async inquiry(filter: InquiryWarehouseInventoryDto): Promise<InventoryInquiryResult[]> {
    const bookingReference = (filter.bookingReference ?? filter.bookingNumber)?.trim();
    if (bookingReference) {
      const params: unknown[] = [`%${bookingReference}%`];
      const where = ['b.reference ILIKE $1', 'b.deleted_at IS NULL'];

      if (filter.containerNumber?.trim()) {
        params.push(`%${filter.containerNumber.trim()}%`);
        where.push(`container.container_number ILIKE $${params.length}`);
      }
      if (filter.cargoType?.trim()) {
        params.push(`%${filter.cargoType.trim()}%`);
        where.push(`cargo_type.cargo_type_name ILIKE $${params.length}`);
      }
      if (filter.goodsName?.trim()) {
        params.push(`%${filter.goodsName.trim()}%`);
        where.push(`(inv.notes ILIKE $${params.length} OR cargo.description ILIKE $${params.length})`);
      }
      if (filter.warehouseId) {
        params.push(filter.warehouseId);
        where.push(`inv.warehouse_id = $${params.length}`);
      }
      if (filter.yardId) {
        params.push(filter.yardId);
        where.push(`inv.yard_id = $${params.length}`);
      }
      if (filter.zoneId) {
        params.push(filter.zoneId);
        where.push(`inv.zone_id = $${params.length}`);
      }
      if (filter.status) {
        params.push(filter.status);
        where.push(`inv.status = $${params.length}`);
      }

      const rows = await this.dataSource.query(
        `SELECT COALESCE(inv.id::text, b.id::text) AS "id",
                inv.id AS "inventoryId",
                b.id AS "bookingId",
                b.reference AS "bookingReference",
                b.reference AS "bookingNumber",
                b.status AS "bookingStatus",
                company.name AS "customerName",
                container.container_number AS "containerNumber",
                cargo_type.cargo_type_name AS "cargoType",
                cargo.description AS "cargoDescription",
                inv.goods_id AS "goodsId",
                wh.id AS "warehouseId",
                wh.name AS "warehouseName",
                wh.code AS "warehouseCode",
                yard.id AS "yardId",
                yard.name AS "yardName",
                yard.code AS "yardCode",
                zone.id AS "zoneId",
                zone.name AS "zoneName",
                zone.code AS "zoneCode",
                inv.status,
                ts.train_number AS "trainNumber",
                ts.status AS "trainStatus",
                oy.code AS "originCode",
                dy.code AS "destinationCode",
                CASE
                  WHEN inv.id IS NOT NULL THEN concat_ws(' / ', wh.code, yard.code, zone.code)
                  WHEN ts.status = 'ARRIVED' THEN concat('Arrived at ', COALESCE(dy.code, 'destination'), ' - awaiting unload')
                  WHEN ts.status = 'DISPATCHED' THEN concat('In transit: ', COALESCE(oy.code, '?'), ' -> ', COALESCE(dy.code, '?'))
                  WHEN ts.id IS NOT NULL THEN concat('Scheduled: ', COALESCE(oy.code, '?'), ' -> ', COALESCE(dy.code, '?'))
                  ELSE 'No warehouse inventory yet'
                END AS "locationSummary",
                COALESCE(inv.quantity, 0) AS quantity,
                COALESCE(inv.weight, b.cargo_total_weight_vgm, 0) AS weight,
                inv.arrived_at AS "arrivedAt",
                inv.ready_for_loading_at AS "readyForLoadingAt"
           FROM freight.bookings b
           LEFT JOIN freight.companies company ON company.id = b.company_id
           LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
           LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
           LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
           LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
           LEFT JOIN freight.containers container ON (
             (inv.container_id IS NOT NULL AND container.id = inv.container_id)
             OR (inv.container_id IS NULL AND container.booking_id = b.id)
           ) AND container.deleted_at IS NULL
           LEFT JOIN freight.cargoes cargo ON (
             (inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id)
             OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id)
           ) AND cargo.deleted_at IS NULL
           LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
           LEFT JOIN LATERAL (
             SELECT ts_inner.*
               FROM freight.train_schedule_bookings tsb
               JOIN freight.train_schedules ts_inner ON ts_inner.id = tsb.train_schedule_id
              WHERE tsb.booking_id = b.id
                AND tsb.deleted_at IS NULL
                AND ts_inner.deleted_at IS NULL
              ORDER BY ts_inner.scheduled_departure_date DESC NULLS LAST
              LIMIT 1
           ) ts ON TRUE
           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 inv.created_at DESC NULLS LAST, b.created_at DESC`,
        params,
      );

      return rows.map((row: Record<string, unknown>) => ({
        id: String(row.id),
        inventoryId: (row.inventoryId as string | null) ?? null,
        bookingId: (row.bookingId as string | null) ?? null,
        bookingReference: (row.bookingReference as string | null) ?? null,
        bookingNumber: (row.bookingNumber as string | null) ?? null,
        bookingStatus: (row.bookingStatus as string | null) ?? null,
        customerName: (row.customerName as string | null) ?? null,
        containerNumber: (row.containerNumber as string | null) ?? null,
        cargoType: (row.cargoType as string | null) ?? null,
        cargoDescription: (row.cargoDescription as string | null) ?? null,
        goodsId: (row.goodsId as string | null) ?? null,
        warehouse: row.warehouseId
          ? { id: row.warehouseId as string, name: row.warehouseName as string, code: row.warehouseCode as string }
          : null,
        yard: row.yardId
          ? { id: row.yardId as string, name: row.yardName as string, code: row.yardCode as string }
          : null,
        zone: row.zoneId
          ? { id: row.zoneId as string, name: row.zoneName as string, code: row.zoneCode as string }
          : null,
        status: (row.status as string | null) ?? null,
        trainNumber: (row.trainNumber as string | null) ?? null,
        trainStatus: (row.trainStatus as string | null) ?? null,
        route:
          row.originCode || row.destinationCode
            ? `${row.originCode ?? '?'} -> ${row.destinationCode ?? '?'}`
            : null,
        locationSummary: (row.locationSummary as string | null) ?? null,
        quantity: Number(row.quantity) || 0,
        weight: Number(row.weight) || 0,
        arrivedAt: (row.arrivedAt as Date | null) ?? null,
        readyForLoadingAt: (row.readyForLoadingAt as Date | null) ?? null,
      }));
    }

    const qb = this.dataSource
      .getRepository(WarehouseInventory)
      .createQueryBuilder('inv')
      .leftJoinAndSelect('inv.warehouse', 'warehouse')
      .leftJoinAndSelect('inv.yard', 'yard')
      .leftJoinAndSelect('inv.zone', 'zone')
      // Entity-class joins: TypeORM parses a raw 'freight.table' string as an
      // alias.property path ("freight" alias was not found) — runtime 500.
      .leftJoin(Booking, 'booking', 'booking.id = inv.booking_id')
      .leftJoin(Company, 'company', 'company.id = booking.company_id')
      .leftJoin(
        Container,
        'container',
        `((inv.container_id IS NOT NULL AND container.id = inv.container_id)
          OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id))
          AND container.deleted_at IS NULL`,
      )
      .leftJoin(
        Cargo,
        'cargo',
        `((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id)
          OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id))
          AND cargo.deleted_at IS NULL`,
      )
      .leftJoin(CargoType, 'cargo_type', 'cargo_type.id = cargo.cargo_type_id')
      .addSelect('booking.reference', 'b_reference')
      .addSelect('company.name', 'c_name')
      .addSelect('container.container_number', 'ct_number')
      .addSelect('cargo.description', 'cg_description')
      .addSelect('cargo_type.cargo_type_name', 'cgt_name')
      .orderBy('inv.created_at', 'DESC');

    if (filter.containerNumber?.trim()) {
      qb.andWhere('container.container_number ILIKE :cn', { cn: `%${filter.containerNumber.trim()}%` });
    }
    if (filter.cargoType?.trim()) {
      qb.andWhere('cargo_type.cargo_type_name ILIKE :ctype', { ctype: `%${filter.cargoType.trim()}%` });
    }
    if (filter.goodsName?.trim()) {
      qb.andWhere('(inv.notes ILIKE :gn OR cargo.description ILIKE :gn)', { gn: `%${filter.goodsName.trim()}%` });
    }
    if (filter.warehouseId) qb.andWhere('inv.warehouse_id = :wid', { wid: filter.warehouseId });
    if (filter.yardId) qb.andWhere('inv.yard_id = :yid', { yid: filter.yardId });
    if (filter.zoneId) qb.andWhere('inv.zone_id = :zid', { zid: filter.zoneId });
    if (filter.status) qb.andWhere('inv.status = :status', { status: filter.status });

    const { entities, raw } = await qb.getRawAndEntities();

    return entities.map((inv, index) => {
      const row = raw[index] ?? {};
      return {
        id: inv.id,
        inventoryId: inv.id,
        bookingId: inv.bookingId ?? null,
        bookingReference: row.b_reference ?? null,
        bookingNumber: row.b_reference ?? null,
        bookingStatus: null,
        customerName: row.c_name ?? null,
        containerNumber: row.ct_number ?? null,
        cargoType: row.cgt_name ?? null,
        cargoDescription: row.cg_description ?? null,
        goodsId: inv.goodsId ?? null,
        warehouse: inv.warehouse
          ? { id: inv.warehouse.id, name: inv.warehouse.name, code: inv.warehouse.code }
          : null,
        yard: inv.yard ? { id: inv.yard.id, name: inv.yard.name, code: inv.yard.code } : null,
        zone: inv.zone ? { id: inv.zone.id, name: inv.zone.name, code: inv.zone.code } : null,
        status: inv.status,
        trainNumber: null,
        trainStatus: null,
        route: null,
        locationSummary: inv.warehouse
          ? [inv.warehouse.code, inv.yard?.code, inv.zone?.code].filter(Boolean).join(' / ')
          : null,
        quantity: Number(inv.quantity),
        weight: Number(inv.weight),
        arrivedAt: inv.arrivedAt ?? null,
        readyForLoadingAt: inv.readyForLoadingAt ?? null,
      };
    });
  }

  // ── Helpers ──────────────────────────────────────────────────────────────

  private async transition(
    id: string,
    to: WarehouseInventoryStatus,
    opts: {
      timestampField: keyof WarehouseInventory;
      activityType: Parameters<WarehouseActivityLogService['record']>[0]['activityType'];
      description: string;
      performedBy?: string;
      preloaded?: WarehouseInventory;
    },
  ): Promise<WarehouseInventory> {
    const item = opts.preloaded ?? (await this.findById(id));
    this.assertTransition(item.status, to);

    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(WarehouseInventory).update(id, {
        status: to,
        [opts.timestampField]: new Date(),
      });
      await this.activityLog.record(
        {
          activityType: opts.activityType,
          inventoryId: id,
          warehouseId: item.warehouseId,
          description: opts.description,
          performedBy: opts.performedBy,
        },
        manager,
      );
    });

    return this.findById(id);
  }

  private buildGrnDocumentHtml(data: {
    grnNumber: string;
    receivedAt: Date;
    bookingReference: string;
    bookingStatus: string | null;
    customerName: string | null;
    customerTin: string | null;
    serviceType: string | null;
    freightType: string | null;
    tradeDirection: string | null;
    route: string | null;
    containerNumber: string | null;
    bookingContainerSummary: string | null;
    cargoDescription: string | null;
    quantity: number;
    weight: number;
    volume: number | null;
    bookingDeclaredWeight: number;
    warehouse: string | null;
    yard: string | null;
    zone: string | null;
    inventoryStatus: string | null;
    receiveSummary: string | null;
  }): string {
    const esc = (value: unknown) =>
      String(value ?? '-')
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
    const receivedAt = data.receivedAt.toLocaleString('en-GB', {
      year: 'numeric',
      month: 'short',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
    });
    const rows: Array<[string, unknown]> = [
      ['Booking Reference', data.bookingReference],
      ['Customer / Consignee', data.customerName],
      ['Customer TIN', data.customerTin],
      ['Booking Status', data.bookingStatus],
      ['Service Type', data.serviceType],
      ['Freight Type', data.freightType],
      ['Trade Direction', data.tradeDirection],
      ['Route', data.route],
      ['Container Number', data.containerNumber],
      ['Booking Containers', data.bookingContainerSummary],
      ['Cargo / Goods Description', data.cargoDescription],
      ['Quantity', data.quantity],
      ['Received Weight', `${data.weight.toLocaleString()} t`],
      ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
      ['Volume', data.volume == null ? null : data.volume.toLocaleString()],
      ['Warehouse', data.warehouse],
      ['Yard', data.yard],
      ['Zone', data.zone],
      ['Inventory Status', data.inventoryStatus],
      ...(data.receiveSummary ? [['Receive Details', data.receiveSummary] as [string, string]] : []),
    ];

    return `<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Goods Received Note</title>
  <style>
    * { box-sizing: border-box; }
    @page { size: A4; margin: 12mm 15mm 14mm; }
    body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
    .top { display: grid; grid-template-columns: 1fr 210px; gap: 24px; border-top: 5px solid #0f766e; padding-top: 18px; }
    .brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
    h1 { margin: 8px 0 0; font-size: 31px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
    .subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
    .ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
    .ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
    .rule { height: 3px; background: #0f766e; margin: 16px 0 22px; }
    .notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; }
    .section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; text-transform: uppercase; letter-spacing: .12em; }
    table { width: 100%; border-collapse: collapse; }
    th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
    th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; white-space: pre-line; }
    .clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
    .signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 34px; align-items: start; margin-top: 42px; }
    .line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
  </style>
</head>
<body>
  <div class="top">
    <div>
      <div class="brand">Ethio-Djibouti Railway S.C.</div>
      <h1>Goods Received Note</h1>
      <div class="subtitle">Warehouse receiving confirmation</div>
    </div>
    <div class="ref">
      GRN Number
      <strong>${esc(data.grnNumber)}</strong>
      Received: ${esc(receivedAt)}
    </div>
  </div>
  <div class="rule"></div>
  <div class="notice">
    This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location.
  </div>
  <div class="section-title">Receiving Particulars</div>
  <table>
    <tbody>
      ${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
    </tbody>
  </table>
  <div class="section-title">Receipt Clause</div>
  <div class="clause">
    This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals.
  </div>
  <div class="signatures">
    <div class="line">Warehouse receiver name / signature / date</div>
    <div class="line">Driver or customer representative name / signature / date</div>
  </div>
</body>
</html>`;
  }

  private buildReleaseDocumentHtml(data: {
    reference: string;
    issuedAt: Date;
    bookingReference: string;
    bookingStatus: string | null;
    customerName: string | null;
    freightType: string | null;
    tradeDirection: string | null;
    containerNumber: string | null;
    cargoDescription: string | null;
    quantity: number;
    weight: number;
    warehouse: string | null;
    yard: string | null;
    zone: string | null;
    inventoryStatus: string | null;
    clearanceStatus: string;
    exitInspectionSummary?: string | null;
    truckPlateNumber?: string | null;
    truckDriverName?: string | null;
    truckType?: string | null;
    truckGateOut?: string | null;
    truckWeightTons?: number | null;
  }): string {
    const esc = (value: unknown) =>
      String(value ?? '-')
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
    const issuedAt = data.issuedAt.toLocaleString('en-GB', {
      year: 'numeric',
      month: 'short',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
    });
    const rows = [
      ['Booking Reference', data.bookingReference],
      ['Customer / Consignee', data.customerName],
      ['Booking Status', data.bookingStatus],
      ['Freight Type', data.freightType],
      ['Trade Direction', data.tradeDirection],
      ['Container Number', data.containerNumber],
      ['Cargo / Goods Description', data.cargoDescription],
      ['Quantity', data.quantity],
      [
        data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight',
        `${(data.truckPlateNumber && data.truckWeightTons
          ? data.truckWeightTons
          : data.weight
        ).toLocaleString()} t`,
      ],
      ['Warehouse', data.warehouse],
      ['Yard', data.yard],
      ['Zone', data.zone],
      ['Inventory Status', data.inventoryStatus],
      ['Clearance Status', data.clearanceStatus],
      ...(data.truckPlateNumber
        ? ([
            ['Pickup Truck Plate', data.truckPlateNumber],
            ['Truck Driver', data.truckDriverName],
            ['Truck Type', data.truckType],
            [
              'Gate-Out Time',
              data.truckGateOut
                ? new Date(data.truckGateOut).toLocaleString('en-GB', {
                    year: 'numeric',
                    month: 'short',
                    day: '2-digit',
                    hour: '2-digit',
                    minute: '2-digit',
                  })
                : null,
            ],
          ] as [string, string | null][])
        : []),
      ...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
    ];

    return `<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Warehouse Release / Exit Paper</title>
  <style>
    * { box-sizing: border-box; }
    @page { size: A4; margin: 12mm 15mm 14mm; }
    body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
    .doc { position: relative; padding: 0; }
    .top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
    .brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
    h1 { margin: 8px 0 0; max-width: 360px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
    .subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
    .ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
    .ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
    .rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
    .notice { width: 74%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
    .section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
    table { width: 100%; border-collapse: collapse; }
    th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
    th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
    .clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
    .signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
    .line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
    .seal { width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
    .seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
    .seal span { position: relative; }
  </style>
</head>
<body>
  <div class="doc">
    <div class="top">
      <div>
        <div class="brand">Ethio-Djibouti Railway S.C.</div>
        <h1>Warehouse Release / Exit Paper</h1>
        <div class="subtitle">Official gate clearance and warehouse exit authorization</div>
      </div>
      <div class="ref">
        Document / Release No.
        <strong>${esc(data.reference)}</strong>
        Issued: ${esc(issuedAt)}
      </div>
    </div>
    <div class="rule"></div>
    <div class="notice">
      This Exit Paper confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
    </div>
    <div class="section-title">Release Particulars</div>
    <table>
      <tbody>
        ${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
      </tbody>
    </table>
    <div class="section-title">Authorization Clause</div>
    <div class="clause">
      The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity,
      cargo details, clearance status, and payment records before permitting exit from the warehouse premises.
    </div>
    <div class="signatures">
      <div class="line">Officer in charge name / signature / date</div>
      <div class="seal"><span>EDR<br />Warehouse<br />Cleared</span></div>
      <div class="line">Customer or driver name / signature / date</div>
    </div>
  </div>
</body>
</html>`;
  }

  private buildHandoverDocumentHtml(data: {
    reference: string;
    handedOverAt: Date;
    bookingReference: string;
    bookingStatus: string | null;
    customerName: string | null;
    serviceType: string | null;
    freightType: string | null;
    tradeDirection: string | null;
    route: string | null;
    scheduledDate: Date | null;
    containerNumber: string | null;
    bookingContainerSummary: string | null;
    cargoDescription: string | null;
    quantity: number;
    weight: number;
    bookingDeclaredWeight: number;
    warehouse: string | null;
    yard: string | null;
    zone: string | null;
    inventoryStatus: string | null;
    inspectionStatus: string | null;
    releaseOrderReference: string | null;
    releaseDate: Date | null;
    trainSchedule: string | null;
    lastMileDeliveryAddress: string | null;
    truckPlate?: string | null;
    customerApproval: {
      approvedAt: string;
      signerDisplayName: string;
      signatureImageUrl: string | null;
    } | null;
  }): string {
    const esc = (value: unknown) =>
      String(value ?? '-')
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
    const fmt = (date: Date | string | null) => {
      if (!date) return '-';
      const parsed = date instanceof Date ? date : new Date(date);
      if (Number.isNaN(parsed.getTime())) return '-';
      return parsed.toLocaleString('en-GB', {
        year: 'numeric',
        month: 'short',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
      });
    };
    const rows = [
      ['Booking Reference', data.bookingReference],
      ['Customer / Consignee', data.customerName],
      ['Booking Status', data.bookingStatus],
      ['Service Type', data.serviceType],
      ['Freight Type', data.freightType],
      ['Trade Direction', data.tradeDirection],
      ['Route', data.route],
      ['Scheduled Date', fmt(data.scheduledDate)],
      ['Train Schedule', data.trainSchedule],
      ['Container Number', data.containerNumber],
      ['Booking Containers', data.bookingContainerSummary],
      ['Cargo / Goods Description', data.cargoDescription],
      ['Quantity', data.quantity],
      ['Inventory Weight', `${data.weight.toLocaleString()} t`],
      ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
      ['Warehouse', data.warehouse],
      ['Yard', data.yard],
      ['Zone', data.zone],
      ['Inventory Status', data.inventoryStatus],
      ['Inspection Status', data.inspectionStatus],
      ['Release Order', data.releaseOrderReference],
      ['Release Date', fmt(data.releaseDate)],
      ['Last-mile Delivery Address', data.lastMileDeliveryAddress],
      ...(data.truckPlate ? [['Delivering Truck Plate', data.truckPlate]] : []),
    ];
    const approval = data.customerApproval;

    return `<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Import Goods Handover Document</title>
  <style>
    * { box-sizing: border-box; }
    @page { size: A4; margin: 12mm 15mm 14mm; }
    body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
    .top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
    .brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
    h1 { margin: 8px 0 0; max-width: 380px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
    .subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
    .ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
    .ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
    .rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
    .notice { width: 78%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
    .section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
    table { width: 100%; border-collapse: collapse; }
    th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
    th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
    .clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
    .signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
    .line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 72px; }
    .signature-img { display: block; max-width: 210px; max-height: 58px; margin: 2px 0 6px; object-fit: contain; }
    .signature-meta { font-size: 11px; color: #061323; }
    .seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
    .seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
    .seal span { position: relative; }
  </style>
</head>
<body>
  <div class="top">
    <div>
      <div class="brand">Ethio-Djibouti Railway S.C.</div>
      <h1>Import Goods Handover Document</h1>
      <div class="subtitle">EDR to customer warehouse handover</div>
    </div>
    <div class="ref">
      Document No.
      <strong>${esc(data.reference)}</strong>
      Handover: ${esc(fmt(data.handedOverAt))}
    </div>
  </div>
  <div class="rule"></div>
  <div class="notice">
    This handover document is separate from the warehouse Exit Paper. It records the booking, route, cargo, container,
    inspection, release, and customer approval details for the goods being handed to the customer.
  </div>
  <div class="section-title">Handover Particulars</div>
  <table>
    <tbody>
      ${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
    </tbody>
  </table>
  <div class="section-title">Goods List</div>
  <table>
    <tbody>
      <tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
      <tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
      <tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
      <tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} t`)}</td></tr>
      <tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null)}</td></tr>
    </tbody>
  </table>
  <div class="section-title">Handover Clause</div>
  <div class="clause">
    The customer acknowledges receipt of the goods listed above. Warehouse staff shall verify identity, booking reference,
    inspection status, and release records before final physical handover.
  </div>
  <div class="signatures">
    <div class="line">Officer in charge name / signature / date</div>
    <div class="seal"><span>EDR<br />Warehouse<br />Handover</span></div>
    <div class="line">
      ${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
      <div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>
      <div class="signature-meta">${approval ? `Approved: ${esc(fmt(approval.approvedAt))}` : ''}</div>
    </div>
  </div>
</body>
</html>`;
  }

  private extractCustomerDeliveryApproval(notes?: string | null): {
    approvedAt: string;
    signerDisplayName: string;
    signatureImageUrl: string;
  } | null {
    if (!notes) return null;
    const line = notes
      .split(/\r?\n/)
      .find((entry) => entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
    if (!line) return null;
    try {
      const parsed = JSON.parse(line.slice(CUSTOMER_DELIVERY_APPROVAL_PREFIX.length));
      if (!parsed?.approvedAt || !parsed?.signerDisplayName || !parsed?.signatureImageUrl) return null;
      return {
        approvedAt: String(parsed.approvedAt),
        signerDisplayName: String(parsed.signerDisplayName),
        signatureImageUrl: String(parsed.signatureImageUrl),
      };
    } catch {
      return null;
    }
  }

  private stripCustomerDeliveryApproval(notes?: string | null): string | null {
    if (!notes?.trim()) return null;
    const lines = notes
      .split(/\r?\n/)
      .filter((entry) => !entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
    return lines.join('\n').trim() || null;
  }

  private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
    if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
      throw new BadRequestException(`Invalid transition ${from} → ${to}`);
    }
  }

  private async validateLocation(
    manager: EntityManager,
    dto: LocationRef,
  ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> {
    const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } });
    if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`);
    const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: dto.yardId } });
    if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`);
    const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } });
    if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`);
    return { warehouse, yard, zone };
  }

  private appendNote(existing: string | null | undefined, note: string): string {
    const trimmed = existing?.trim();
    return trimmed ? `${trimmed}\n${note}` : note;
  }

  private assertTruckEntrance(truckEntrance?: TruckEntranceDto): void {
    if (!truckEntrance?.truckPlateNumber?.trim()) {
      throw new BadRequestException('Truck plate number is required for entrance registration');
    }
    if (!truckEntrance.driverName?.trim()) {
      throw new BadRequestException('Driver name is required for entrance registration');
    }
    if (!truckEntrance.driverPhone?.trim()) {
      throw new BadRequestException('Driver phone is required for entrance registration');
    }
    if (truckEntrance.weighingRequired) {
      if (truckEntrance.grossWeightKg === undefined || Number(truckEntrance.grossWeightKg) < 0) {
        throw new BadRequestException('Gross weight is required when customer truck weighing is Yes');
      }
      if (truckEntrance.exitTareWeightKg === undefined || Number(truckEntrance.exitTareWeightKg) < 0) {
        throw new BadRequestException('Exit tare weight is required when customer truck weighing is Yes');
      }
    }
  }

  private mergeSystemTruckEntrance(
    submitted: TruckEntranceDto,
    booking: {
      reference?: string | null;
      customer?: string | null;
      customerTin?: string | null;
      customerPhone?: string | null;
      containerNumber?: string | null;
      containerQuantity?: number | string | null;
      containerPackagingType?: string | null;
      cargoDescription?: string | null;
      weight?: number | string | null;
      firstMileTruckPlateNumber?: string | null;
      firstMileTrailerPlateNumber?: string | null;
      firstMileDriverName?: string | null;
      firstMileDriverPhone?: string | null;
      firstMileDriverLicenseNumber?: string | null;
      firstMileTruckType?: string | null;
      customerTruckPlateNumber?: string | null;
      customerTruckDriverName?: string | null;
      customerTruckType?: string | null;
      customerTruckContainerNumber?: string | null;
      customerTruckAssignedAt?: string | null;
    },
  ): TruckEntranceDto {
    return {
      ...submitted,
      ownerName: booking.customer?.trim() || submitted.ownerName,
      edrDigitalBookingId: booking.reference?.trim() || submitted.edrDigitalBookingId,
      tin: booking.customerTin?.trim() || submitted.tin,
      customerPhone: booking.customerPhone?.trim() || submitted.customerPhone,
      assignedEquipmentNumber: booking.containerNumber?.trim() || submitted.assignedEquipmentNumber,
      itemDescription: booking.cargoDescription?.trim() || submitted.itemDescription,
      packagingType: booking.containerPackagingType?.trim() || submitted.packagingType,
      unitCount:
        booking.containerQuantity !== undefined && booking.containerQuantity !== null
          ? Number(booking.containerQuantity)
          : submitted.unitCount,
      grossWeightKg: submitted.grossWeightKg,
      truckPlateNumber:
        booking.firstMileTruckPlateNumber?.trim() ||
        booking.customerTruckPlateNumber?.trim() ||
        submitted.truckPlateNumber,
      trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
      driverName:
        booking.firstMileDriverName?.trim() ||
        booking.customerTruckDriverName?.trim() ||
        submitted.driverName,
      driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
      driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
      truckType:
        booking.firstMileTruckType?.trim() ||
        booking.customerTruckType?.trim() ||
        submitted.truckType,
    };
  }

  private async getBookingTruckEntranceSource(
    manager: EntityManager,
    bookingId: string,
  ): Promise<{
    reference?: string | null;
    customer?: string | null;
    customerTin?: string | null;
    customerPhone?: string | null;
    containerNumber?: string | null;
    containerQuantity?: number | string | null;
    containerPackagingType?: string | null;
    cargoDescription?: string | null;
    weight?: number | string | null;
    firstMileTruckPlateNumber?: string | null;
    firstMileTrailerPlateNumber?: string | null;
    firstMileDriverName?: string | null;
    firstMileDriverPhone?: string | null;
    firstMileDriverLicenseNumber?: string | null;
    firstMileTruckType?: string | null;
  }> {
    const [booking] = await manager.query(
      `SELECT b.reference AS "reference",
              company.name AS "customer",
              company.tin AS "customerTin",
              ${companyNotifyPhoneExpr('company')} AS "customerPhone",
              b.cargo_total_weight_vgm AS "weight",
              COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
              bc.container_numbers AS "containerNumber",
              bc.container_quantity AS "containerQuantity",
              bc.container_packaging_type AS "containerPackagingType",
              v.plate_number AS "firstMileTruckPlateNumber",
              v.trailer_plate_no AS "firstMileTrailerPlateNumber",
              COALESCE(
                NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
                v.assigned_driver_name
              ) AS "firstMileDriverName",
              driver.phone_number AS "firstMileDriverPhone",
              driver.license_number AS "firstMileDriverLicenseNumber",
              v.vehicle_type AS "firstMileTruckType"
         FROM freight.bookings b
         LEFT JOIN freight.companies company ON company.id = b.company_id
         ${primaryContactUserJoin('company')}
         LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id
         LEFT JOIN LATERAL (
           SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
                  SUM(booking_container.quantity)::int AS container_quantity,
                  CASE
                    WHEN COUNT(booking_container.id) = 0 THEN NULL
                    WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
                    WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
                    WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
                    WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
                    WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
                    ELSE 'OTHER_CONTAINER'
                  END AS container_packaging_type
             FROM freight.booking_container booking_container
             LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
            WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
         ) bc ON true
         LEFT JOIN LATERAL (
           SELECT first_mile.vehicle_id
             FROM freight.first_mile first_mile
            WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
            ORDER BY first_mile.created_at DESC
            LIMIT 1
         ) fm ON true
         LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
         LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
        WHERE b.id = $1 AND b.deleted_at IS NULL
        LIMIT 1`,
      [bookingId],
    );
    return booking ?? {};
  }

  private async notifyOwnerInventoryReceived(params: {
    phone?: string | null;
    ownerName?: string | null;
    bookingReference?: string | null;
    grnNumber: string;
    direction?: string | null;
    warehouseId?: string | null;
  }): Promise<void> {
    const phone = params.phone?.trim();
    if (!phone) return;

    const ownerName = params.ownerName?.trim() || 'Customer';
    const bookingReference = params.bookingReference?.trim();
    const message =
      `Dear ${ownerName}, your cargo has been received by EDR warehouse. ` +
      (bookingReference ? `Booking: ${bookingReference}. ` : '') +
      `GRN: ${params.grnNumber}. ` +
      (params.direction ? `Direction: ${params.direction}. ` : '') +
      `Thank you.`;

    try {
      await this.notifications.directSend('sms', phone, message);
    } catch (error) {
      // Receiving inventory must not be rolled back because an SMS provider is unavailable.
      this.logger.error(`Failed to notify owner for GRN ${params.grnNumber}: ${String(error)}`);
    }
  }

  /** Shared with the facility handling flow — see common/grn.util.ts. */
  private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
    return generateGrnNumber(direction, referenceId, date);
  }

  private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
    let bookingReference = item.booking?.reference;
    if (!bookingReference && item.bookingId) {
      const [booking]: Array<{ reference: string | null }> = await this.dataSource.query(
        `SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
        [item.bookingId],
      );
      bookingReference = booking?.reference ?? undefined;
    }
    if (bookingReference) {
      return `REL-${String(bookingReference).replace(/^BK-?/i, '')}`;
    }
    return `REL-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${item.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
  }

  private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
    const hasExitInspection =
      Boolean(dto.truckPlateNumber?.trim()) ||
      Boolean(dto.trailerPlateNumber?.trim()) ||
      Boolean(dto.driverName?.trim()) ||
      Boolean(dto.driverLicense?.trim()) ||
      Boolean(dto.driverPhone?.trim()) ||
      Boolean(dto.truckType?.trim()) ||
      Boolean(dto.containerNumber?.trim()) ||
      dto.tareWeight !== undefined ||
      dto.grossWeight !== undefined ||
      dto.netWeight !== undefined ||
      Boolean(dto.gateInTime) ||
      Boolean(dto.gateOutTime);

    if (!hasExitInspection) return null;

    if (!dto.truckPlateNumber?.trim()) {
      throw new BadRequestException('Truck plate number is required for exit inspection');
    }
    if (!dto.driverName?.trim()) {
      throw new BadRequestException('Driver name is required for exit inspection');
    }
    // Container bookings may skip the weighbridge entirely (weighingSkipped);
    // bulk always weighs.
    const weighingSkipped = dto.weighingSkipped === true;
    if (dto.tareWeight === undefined && !weighingSkipped) {
      throw new BadRequestException('Tare weight is required for truck arrival');
    }

    const tareWeight = dto.tareWeight === undefined ? null : Number(dto.tareWeight);
    const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
    const computedNetWeight =
      grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
    const submittedNetWeight =
      dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);

    if (grossWeight != null && !dto.gateOutTime) {
      throw new BadRequestException('Gate out time is required for truck exit');
    }
    if (grossWeight != null && computedNetWeight != null && submittedNetWeight != null) {
      if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
        throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
      }
    }
    if (
      !weighingSkipped &&
      (dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) &&
      grossWeight == null
    ) {
      throw new BadRequestException('Gross weight is required for truck exit');
    }

    const rows = [
      '[Exit Inspection]',
      dto.bookingId?.trim() ? `Booking ID: ${dto.bookingId.trim()}` : null,
      dto.customerId?.trim() ? `Customer ID: ${dto.customerId.trim()}` : null,
      `Truck Plate: ${dto.truckPlateNumber.trim()}`,
      dto.trailerPlateNumber?.trim() ? `Trailer Plate: ${dto.trailerPlateNumber.trim()}` : null,
      `Driver: ${dto.driverName.trim()}`,
      dto.driverLicense?.trim() ? `Driver License: ${dto.driverLicense.trim()}` : null,
      dto.driverPhone?.trim() ? `Driver Phone: ${dto.driverPhone.trim()}` : null,
      dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
      dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
      dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
      weighingSkipped ? 'Weighing: SKIPPED' : null,
      tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
      grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
      // Skipped weighing still records a net — the cargo weight of the
      // containers the truck is holding, resolved by the caller.
      (computedNetWeight ?? (weighingSkipped ? dto.netWeight : null)) == null
        ? null
        : `Net Weight: ${computedNetWeight ?? Number(dto.netWeight)} t`,
      dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
    ];

    return rows.filter(Boolean).join('\n');
  }

  private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
    const inspection = this.extractExitInspectionForPlate(notes, dto.truckPlateNumber);
    if (!inspection) return dto;

    return {
      ...dto,
      // The submitted plate wins: a legacy block may store a comma-joined list
      // of plates, and the exit must be recorded against the ONE truck leaving.
      truckPlateNumber: dto.truckPlateNumber?.trim() || this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
      trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
      driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
      driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
      driverPhone: this.extractExitInspectionLine(inspection, 'Driver Phone') || dto.driverPhone,
      truckType: this.extractExitInspectionLine(inspection, 'Truck Type') || dto.truckType,
      containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
      gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
      tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
      // The weigh/skip decision is made at arrival and sticks for the exit.
      weighingSkipped: dto.weighingSkipped || /^Weighing:\s*SKIPPED/im.test(inspection) || undefined,
    };
  }

  /**
   * Split notes into exit-inspection blocks (one per truck, in order) and
   * everything else. A block ends at the first line that isn't one of the
   * known inspection labels, so appended notes (delivery approval, handover
   * marker) are preserved as "other" content instead of being swallowed by
   * the block they happen to follow.
   */
  private splitExitInspectionSections(notes?: string | null): { others: string[]; blocks: string[] } {
    const trimmed = notes?.trim();
    if (!trimmed) return { others: [], blocks: [] };
    const labelPattern =
      /^(Booking ID|Customer ID|Truck Plate|Trailer Plate|Driver|Driver License|Driver Phone|Truck Type|Container Number|Gate In Time|Weighing|Tare Weight|Gross Weight|Net Weight|Gate Out Time):/i;
    const parts = trimmed.split(EXIT_INSPECTION_MARKER);
    const others: string[] = [];
    const blocks: string[] = [];
    if (parts[0]?.trim()) others.push(parts[0].trim());
    for (const part of parts.slice(1)) {
      const lines = part.split('\n');
      const kept: string[] = [];
      let i = 0;
      while (i < lines.length && !lines[i].trim()) i += 1;
      for (; i < lines.length; i += 1) {
        const line = lines[i].trim();
        if (!line || !labelPattern.test(line)) break;
        kept.push(line);
      }
      if (kept.length) blocks.push(kept.join('\n'));
      const tail = lines.slice(i).join('\n').trim();
      if (tail) others.push(tail);
    }
    return { others, blocks };
  }

  /**
   * A block belongs to a plate when its stored `Truck Plate` equals it, or is a
   * legacy comma-joined list ("P1, P2") containing it.
   */
  private blockMatchesPlate(block: string, plateNumber?: string | null): boolean {
    const plate = plateNumber?.trim().toUpperCase();
    if (!plate) return false;
    const stored = this.extractExitInspectionLine(block, 'Truck Plate')?.toUpperCase();
    if (!stored) return false;
    if (stored === plate) return true;
    return stored.split(/[,;]+/).map((p) => p.trim()).includes(plate);
  }

  /**
   * Replace THIS truck's inspection block (matched by plate), keeping every
   * other truck's block untouched; append when the plate has no block yet.
   * A single legacy block (comma-joined plates or plate-less caller) is
   * replaced in place so old single-truck items keep their behaviour.
   */
  private replaceExitInspectionNote(
    notes: string | null | undefined,
    exitInspectionNote: string | null,
    plateNumber?: string | null,
  ): string | null {
    const { others, blocks } = this.splitExitInspectionSections(notes);
    if (exitInspectionNote) {
      const content = exitInspectionNote.replace(EXIT_INSPECTION_MARKER, '').trim();
      const index = plateNumber
        ? blocks.findIndex((b) => this.blockMatchesPlate(b, plateNumber))
        : blocks.length - 1;
      if (index >= 0) blocks[index] = content;
      else blocks.push(content);
    }
    const sections = [...others, ...blocks.map((b) => `${EXIT_INSPECTION_MARKER}\n${b}`)];
    return sections.join('\n\n') || null;
  }

  /** Latest truck's inspection block — legacy summary for documents. */
  private extractExitInspectionNote(notes?: string | null): string | null {
    const { blocks } = this.splitExitInspectionSections(notes);
    return blocks.length ? blocks[blocks.length - 1] : null;
  }

  /**
   * The inspection block for one truck. Falls back to a lone existing block so
   * legacy single-truck items (saved before per-plate blocks) keep working.
   */
  private extractExitInspectionForPlate(
    notes: string | null | undefined,
    plateNumber?: string | null,
  ): string | null {
    const { blocks } = this.splitExitInspectionSections(notes);
    const match = blocks.find((b) => this.blockMatchesPlate(b, plateNumber));
    if (match) return match;
    return blocks.length === 1 ? blocks[0] : null;
  }

  private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {
    const match = note?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
    return match?.[1]?.trim() || null;
  }

  private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
    const value = this.extractExitInspectionLine(note, label)?.replace(/\s*(kg|t)$/i, '');
    if (!value) return undefined;
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : undefined;
  }

  private extractReceiveSummary(notes?: string | null): string | null {
    if (!notes?.trim()) return null;
    const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
    const withoutHandover = withoutExit.split(`\n\n${HANDOVER_DOCUMENT_MARKER}`)[0] ?? withoutExit;
    return this.stripCustomerDeliveryApproval(withoutHandover)?.trim() || withoutHandover.trim() || null;
  }

  private buildHandoverDocumentNote(reference: string, generatedAt: Date): string {
    return [
      HANDOVER_DOCUMENT_MARKER,
      `Handover Reference: ${reference}`,
      `Generated At: ${generatedAt.toISOString()}`,
    ].join('\n');
  }

  private replaceHandoverDocumentNote(notes: string | null | undefined, handoverDocumentNote: string): string {
    const trimmed = notes?.trim();
    if (!trimmed) return handoverDocumentNote;
    const index = trimmed.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
    if (index < 0) {
      return `${trimmed}\n\n${handoverDocumentNote}`;
    }
    return [trimmed.slice(0, index).trim(), handoverDocumentNote].filter(Boolean).join('\n\n');
  }

  private extractHandoverDocumentLine(notes: string | null | undefined, label: string): string | null {
    if (!notes) return null;
    const index = notes.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
    if (index < 0) return null;
    const section = notes.slice(index + HANDOVER_DOCUMENT_MARKER.length);
    const match = section.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
    return match?.[1]?.trim() || null;
  }

  private buildReceiveNote(input: {
    grnNumber: string;
    direction?: string | null;
    notes?: string | null;
    truckEntrance?: TruckEntranceDto;
  }): string {
    const truck = input.truckEntrance;
    const rows = [
      `GRN Number: ${input.grnNumber}`,
      input.direction ? `Direction: ${input.direction}` : null,
      truck?.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
      truck?.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
      truck?.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
      truck?.tin ? `TIN: ${truck.tin}` : null,
      truck?.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null,
      truck?.truckPlateNumber ? `Truck Plate: ${truck.truckPlateNumber}` : null,
      truck?.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
      truck?.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
      truck?.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
      truck?.truckType ? `Truck Type: ${truck.truckType}` : null,
      truck?.driverName ? `Driver: ${truck.driverName}` : null,
      truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
      truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
      truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} t` : null,
      truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
      truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} t` : null,
      truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
      truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
      truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
      truck?.itemCode ? `Item Code: ${truck.itemCode}` : null,
      truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
      truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
      truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
      truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} t` : null,
      truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} t` : null,
      truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
      truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
      truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
      truck?.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
      truck?.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
      truck?.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
      input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null,
    ];
    return rows.filter(Boolean).join('\n');
  }

  private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise<InventoryAllocationCriteria> {
    const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null;

    if (!item.bookingId) {
      return {
        freightType: fallbackFreightType,
        requiresInspection: item.inspectionStatus !== 'PASSED',
      };
    }

    const [row]: Array<{
      freightType: string | null;
      tradeDirection: string | null;
      cargoTypeCode: string | null;
      containerStatus: string | null;
      originCountry: string | null;
      destinationCountry: string | null;
    }> = await this.dataSource.query(
      `SELECT b.freight_type AS "freightType",
              b.trade_direction AS "tradeDirection",
              cgt.code AS "cargoTypeCode",
              COALESCE(selected_container.status, booking_container.status) AS "containerStatus",
              oy.country AS "originCountry",
              dy.country AS "destinationCountry"
         FROM freight.bookings b
         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.containers selected_container
                ON selected_container.id = $2 AND selected_container.deleted_at IS NULL
         LEFT JOIN LATERAL (
           SELECT c.status
             FROM freight.containers c
            WHERE c.booking_id = b.id AND c.deleted_at IS NULL
            ORDER BY c.created_at ASC
            LIMIT 1
         ) booking_container ON true
        WHERE b.id = $1 AND b.deleted_at IS NULL
        LIMIT 1`,
      [item.bookingId, item.containerId],
    );

    if (!row) {
      return {
        freightType: fallbackFreightType,
        requiresInspection: item.inspectionStatus !== 'PASSED',
      };
    }

    const derivedDirection = deriveTradeDirection(
      { country: row.originCountry },
      { country: row.destinationCountry },
    );

    return {
      freightType: row.freightType ?? fallbackFreightType,
      tradeDirection: row.tradeDirection ?? derivedDirection,
      cargoTypeCode: row.cargoTypeCode,
      containerStatus: row.containerStatus,
      requiresInspection: item.inspectionStatus !== 'PASSED',
    };
  }

  private yardTypeFor(criteria: InventoryAllocationCriteria): string {
    const freightType = criteria.freightType?.toUpperCase();
    if (freightType === 'CONTAINER') return 'CONTAINER_YARD';
    if (freightType === 'BULK') return 'BULK_YARD';
    return 'GENERAL_CARGO_YARD';
  }

  private zoneTypeFor(criteria: InventoryAllocationCriteria): string {
    const freightType = criteria.freightType?.toUpperCase();
    if (freightType === 'CONTAINER') return 'CONTAINER_ZONE';
    if (freightType === 'BULK') return 'BULK_ZONE';
    return 'GENERAL_CARGO_ZONE';
  }

  private async pickCapacityBalancedStorageLocation(
    item: WarehouseInventory,
    criteria: InventoryAllocationCriteria,
  ): Promise<StorageAllocationLocation | null> {
    const weight = Number(item.weight) || 0;
    const containerCount = item.containerId ? Math.max(1, Math.round(Number(item.quantity) || 1)) : 0;
    const yardType = this.yardTypeFor(criteria);
    const zoneType = this.zoneTypeFor(criteria);

    const query = async (warehouseId: string | null) => {
      const [row]: Array<{
        warehouseId: string;
        facilityId: string | null;
        warehouseName: string | null;
        yardId: string;
        yardName: string | null;
        yardCode: string | null;
        zoneId: string;
        zoneName: string | null;
        zoneCode: string | null;
      }> = await this.dataSource.query(
        `SELECT wh.id AS "warehouseId",
                wh.facility_id AS "facilityId",
                wh.name AS "warehouseName",
                yard.id AS "yardId",
                yard.name AS "yardName",
                yard.code AS "yardCode",
                zone.id AS "zoneId",
                zone.name AS "zoneName",
                zone.code AS "zoneCode"
           FROM freight.warehouses wh
           JOIN freight.warehouse_yards yard
             ON yard.warehouse_id = wh.id
            AND yard.deleted_at IS NULL
            AND yard.status = 'ACTIVE'
            AND yard.is_active = true
           JOIN freight.warehouse_zones zone
             ON zone.yard_id = yard.id
            AND zone.deleted_at IS NULL
            AND zone.status = 'ACTIVE'
            AND zone.is_active = true
          WHERE wh.deleted_at IS NULL
            AND wh.status = 'ACTIVE'
            AND wh.is_active = true
            AND ($1::uuid IS NULL OR wh.id = $1::uuid)
            AND (COALESCE(yard.max_weight, yard.capacity_weight) IS NULL
                 OR yard.current_weight::numeric + $4::numeric <= COALESCE(yard.max_weight, yard.capacity_weight))
            AND (COALESCE(zone.max_weight, zone.capacity_weight) IS NULL
                 OR zone.current_weight::numeric + $4::numeric <= COALESCE(zone.max_weight, zone.capacity_weight))
            AND (yard.capacity_containers IS NULL
                 OR yard.current_containers + $5::int <= yard.capacity_containers)
            AND (zone.capacity_containers IS NULL
                 OR zone.current_containers + $5::int <= zone.capacity_containers)
          ORDER BY
            CASE WHEN yard.type = $2 THEN 0 ELSE 1 END,
            CASE WHEN zone.type = $3 THEN 0 ELSE 1 END,
            (
              CASE WHEN yard.capacity_weight IS NULL OR yard.capacity_weight = 0 THEN 0
                   ELSE yard.current_weight::numeric / yard.capacity_weight::numeric END
              +
              CASE WHEN yard.capacity_containers IS NULL OR yard.capacity_containers = 0 THEN 0
                   ELSE yard.current_containers::numeric / yard.capacity_containers::numeric END
              +
              CASE WHEN zone.capacity_weight IS NULL OR zone.capacity_weight = 0 THEN 0
                   ELSE zone.current_weight::numeric / zone.capacity_weight::numeric END
              +
              CASE WHEN zone.capacity_containers IS NULL OR zone.capacity_containers = 0 THEN 0
                   ELSE zone.current_containers::numeric / zone.capacity_containers::numeric END
            ) ASC,
            yard.code ASC,
            zone.code ASC
          LIMIT 1`,
        [warehouseId, yardType, zoneType, weight, containerCount],
      );
      return row;
    };

    const row = (await query(item.warehouseId)) ?? (await query(null));
    if (!row) return null;

    return {
      warehouseId: row.warehouseId,
      facilityId: row.facilityId,
      yardId: row.yardId,
      zoneId: row.zoneId,
      rule: null,
      path: [row.warehouseName, row.yardCode ?? row.yardName, row.zoneCode ?? row.zoneName]
        .filter(Boolean)
        .join(' -> '),
    };
  }

  private async getBookingStatus(bookingId: string): Promise<string | null> {
    const [row]: Array<{ status: string | null }> = await this.dataSource.query(
      'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
      [bookingId],
    );
    return row?.status ?? null;
  }

  private async attachBookingSummaries(items: WarehouseInventory[]): Promise<void> {
    const bookingIds = [...new Set(items.map((item) => item.bookingId).filter(Boolean))] as string[];
    if (bookingIds.length === 0) return;

    const rows: BookingSummaryRow[] = await this.dataSource.query(
      `SELECT b.id, b.reference, b.status, company.name AS customer
         FROM freight.bookings b
         LEFT JOIN freight.companies company ON company.id = b.company_id
        WHERE b.id = ANY($1) AND b.deleted_at IS NULL`,
      [bookingIds],
    );
    const summaries = new Map(rows.map((row) => [row.id, row]));

    items.forEach((item) => {
      const summary = item.bookingId ? summaries.get(item.bookingId) : undefined;
      if (!summary) return;
      Object.assign(item, {
        bookingReference: summary.reference,
        bookingStatus: summary.status,
        customerName: summary.customer,
      });
    });
  }

  /** IMPORT | EXPORT | DOMESTIC derived from the booking ROUTE (yard countries), or null if missing. */
  private async getBookingDirection(bookingId: string): Promise<string | null> {
    const rows = await this.dataSource.query(
      `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
         FROM freight.bookings b
         LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
         LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
        WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
      [bookingId],
    );
    if (!rows?.[0]) return null;
    return deriveTradeDirection(
      { country: rows[0].originCountry },
      { country: rows[0].destinationCountry },
    );
  }

  /**
   * Export cargo is received into the warehouse to wait for its train, and it is
   * received only once the booking is paid — receiving an unpaid export booking
   * would start storage and mint a GRN against cargo the customer has not settled.
   *
   * Export only: import cargo arrives OFF a train and its receive is the unload,
   * so gating that on payment would strand cargo already at the yard.
   */
  private async assertExportBookingPaid(
    bookingId: string | null | undefined,
    direction: string | null,
  ): Promise<void> {
    if (!bookingId || direction !== 'EXPORT') return;
    const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query(
      `SELECT payment_status AS "paymentStatus"
         FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
      [bookingId],
    );
    if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') {
      throw new BadRequestException(
        'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.',
      );
    }
  }

  private assertCapacity(
    label: string,
    node: LocationNode,
    weightAdd: number,
    volumeAdd: number,
    containerAdd: number,
  ): void {
    const maxWeight = node.maxWeight ?? node.capacityWeight;
    if (maxWeight != null) {
      const projected = Number(node.currentWeight) + weightAdd;
      if (projected > Number(maxWeight)) {
        throw new BadRequestException(`${label} weight capacity exceeded (${projected} / ${maxWeight})`);
      }
    }
    if (node.maxVolume != null && volumeAdd > 0) {
      const projected = Number(node.currentVolume) + volumeAdd;
      if (projected > Number(node.maxVolume)) {
        throw new BadRequestException(`${label} volume capacity exceeded (${projected} / ${node.maxVolume})`);
      }
    }
    if (node.capacityContainers != null && containerAdd > 0) {
      const projected = Number(node.currentContainers) + containerAdd;
      if (projected > Number(node.capacityContainers)) {
        throw new BadRequestException(`${label} container capacity exceeded (${projected} / ${node.capacityContainers})`);
      }
    }
  }

  private async applyCapacityDelta(
    manager: EntityManager,
    location: LocationRef,
    weightAdd: number,
    volumeAdd: number,
    containerAdd: number,
  ): Promise<void> {
    const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [
      [Warehouse, location.warehouseId],
      [WarehouseYard, location.yardId],
      [WarehouseZone, location.zoneId],
    ];

    for (const [entity, id] of targets) {
      if (weightAdd > 0) await manager.increment(entity, { id }, 'currentWeight', weightAdd);
      if (weightAdd < 0) await manager.decrement(entity, { id }, 'currentWeight', Math.abs(weightAdd));
      if (volumeAdd > 0) await manager.increment(entity, { id }, 'currentVolume', volumeAdd);
      if (volumeAdd < 0) await manager.decrement(entity, { id }, 'currentVolume', Math.abs(volumeAdd));
      if (containerAdd > 0) await manager.increment(entity, { id }, 'currentContainers', containerAdd);
      if (containerAdd < 0) await manager.decrement(entity, { id }, 'currentContainers', Math.abs(containerAdd));
    }
  }
}
