import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
  Repository,
  In,
  IsNull,
  type EntityManager,
  type FindOptionsWhere,
  type ObjectLiteral,
  type UpdateResult,
} from 'typeorm';
import { ClsService } from 'nestjs-cls';
import { BookingRepository } from '../repositories/booking.repository';
import {
  AssignTransportItemDto,
  BulkAssignTransportItemDto,
  PatchActivityItemDto,
  BulkPatchActivityItemDto,
  PatchHotelItemDto,
  PatchSpecialItemDto,
} from '../dto';
import { BookingStatus } from '../../../entities/booking.entity';
import { BookingTransportItemEntity } from '../../../entities/booking-transport-item.entity';
import { BookingActivityItemEntity } from '../../../entities/booking-activity-item.entity';
import { BookingHotelItemEntity } from '../../../entities/booking-hotel-item.entity';
import { BookingSpecialItemEntity } from '../../../entities/booking-special-item.entity';
import { BookingFlightDetailEntity } from '../../../entities/booking-flight-detail.entity';
import { ServicePaymentCollectionEntity } from '../../../entities/service-payment-collection.entity';
import { BookingCashCollectionEntity } from '../../../entities/booking-cash-collection.entity';
import { QueryEntity } from '../../../entities/query.entity';
import {
  ActivityTicketItemEntity,
  TicketItemStatus,
} from '../../../entities/activity-ticket-item.entity';
import { AuditService } from '../../audit/audit.service';
import { AuditEntityType, AuditAction } from '../../audit/audit-types';
import {
  OpStatus,
  canTransition,
  OP_STATUS_TERMINAL,
  OP_STATUS_BASE,
  autoBaseStatus,
} from '../../../common/constants/op-status';
import { CLS_USER_ROLE_NAME } from '../../../common/cls/cls.constants';

/** Any non-status field set on the patch DTO. Used to keep the
 *  terminal-row guard tight — ops can still send a (no-op) op_status
 *  but every other column is locked once the row is in a terminal
 *  state. */
/**
 * TypeORM 0.3 throws when a find-where value is raw `null`. Activity
 * rows booked without a master ticket type — typical after a single
 * MD ticket upload — store `ticket_id` as NULL, so the sibling fan-out
 * must use IsNull() or the status patch 500s before any column is written.
 */
function matchNullable<T>(value: T | null | undefined): T | ReturnType<typeof IsNull> {
  return value == null ? IsNull() : value;
}

function hasNonStatusEdits(dto: object): boolean {
  const rec = dto as Record<string, unknown>;
  for (const key of Object.keys(rec)) {
    // Status fields stay editable on terminal rows — the ticketing team
    // still updates ticket_status after the trip is marked done.
    if (key === 'op_status' || key === 'ticket_status') continue;
    if (rec[key] !== undefined) return true;
  }
  return false;
}

/**
 * Does the row END UP with a supplier after this patch? Drives the
 * Unplanned ⇄ Planned auto-rule. The Movement Dashboard sends supplier
 * changes as an explicit `suppliers[]` array (the primary entry mirrors
 * to the legacy supplier_id column AFTER this check), so when that array
 * is present it is the source of truth — an empty / all-blank array means
 * the supplier was removed → Unplanned. Legacy clients that patch the
 * supplier_id / supplier_name_text columns directly fall through to those.
 */
function resolveHasSupplier(
  dto: {
    suppliers?: Array<{ supplier_id?: string | null; supplier_name_text?: string | null }>;
  },
  updateData: Record<string, unknown>,
  item: { supplier_id?: string | null; supplier_name_text?: string | null },
): boolean {
  if (dto.suppliers !== undefined) {
    return (dto.suppliers ?? []).some(
      (s) => !!s.supplier_id || !!s.supplier_name_text?.trim(),
    );
  }
  const finalSupplierId =
    updateData.supplier_id !== undefined
      ? (updateData.supplier_id as string | null)
      : (item.supplier_id ?? null);
  const finalSupplierText =
    updateData.supplier_name_text !== undefined
      ? (updateData.supplier_name_text as string | null)
      : (item.supplier_name_text ?? null);
  return !!finalSupplierId || !!finalSupplierText?.trim();
}
import { PaymentSettlementService } from '../../payment-settlement/payment-settlement.service';
import { resolveFloorSnapshot } from '../../transport-pricing/floor-snapshot.helper';
import { TaxSlabResolverService } from '../../../common/services/tax-slab-resolver.service';
import { BookingItineraryService } from './booking-itinerary.service';
import { resolvePaymentGate } from './booking-payment-gate';
import { assertRowPaxWithinParty, partyFromQuery } from '../../../common/itinerary/row-pax';
import { addonSupplierCost, computeAddonChange } from '../../../common/itinerary/addon-pax';

/**
 * DriverAssignmentService — driver/vehicle/supplier assignment on
 * transport + activity items, plus the Movement Chart aggregation.
 *
 * Extracted from BookingService (2026-05-12 refactor). Logic moved
 * verbatim from BookingService — no behaviour change. Reasonable
 * grouping per the architecture audit: every method here is part of
 * the operations-team workflow (assign drivers, patch activities,
 * view the day's movements).
 *
 * Audit-field constants and pickFields helper moved here because
 * they were used only by the assign/patch methods.
 *
 * No injection back into BookingService — methods are self-contained.
 * recalculateTotals stays on BookingService since it's also used by
 * BookingItineraryService.update.
 */

/**
 * Subset of booking_transport_items fields that are interesting in
 * audit output. We snapshot only these so the diff stays tight and
 * tenant-scope/audit-managed columns never appear as "changes".
 */
const TRANSPORT_ITEM_AUDIT_FIELDS = [
  'supplier_id',
  'supplier_name_text',
  'supplier_2_id',
  'supplier_2_name_text',
  'driver_id',
  'driver_name_text',
  'driver_phone_text',
  'vehicle_id',
  'vehicle_no_text',
  'vehicle_type_id',
  'pickup_time',
  'dropoff_time',
  'return_pickup_time',
  'pickup_address',
  'dropoff_address',
  'pickup_notes',
  'dropoff_notes',
  'notes',
  'quantity',
  'unit_price',
  'subtotal',
  'actual_supplier_cost',
  'adults_override',
  'children_override',
  'op_status',
  'assignment_status',
];

const ACTIVITY_ITEM_AUDIT_FIELDS = [
  'supplier_id',
  'supplier_name_text',
  'supplier_phone_text',
  'slot',
  'notes',
  'quantity',
  'unit_price',
  'subtotal',
  'ticket_id',
  'assignment_status',
  'ticket_status',
  'op_status',
  'adults_override',
  'children_override',
];

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any persisted entity row; reads only string-keyed columns
function pickFields(obj: any | null | undefined, fields: string[]): Record<string, unknown> {
  if (!obj) return {};
  const out: Record<string, unknown> = {};
  for (const f of fields) out[f] = obj[f];
  return out;
}

type MovementPcMeta = {
  collection_id: string | null;
  required: boolean;
  expected_amount: number;
  currency_id: string | null;
  currency_code: string;
  status: 'PENDING' | 'COLLECTED' | 'CANCELLED' | null;
  notes: string | null;
  latest_actual_amount: number | null;
  latest_difference_reason: string | null;
  latest_collected_at: string | null;
};

function emptyMovementPc(): MovementPcMeta {
  return {
    collection_id: null,
    required: false,
    expected_amount: 0,
    currency_id: null,
    currency_code: '',
    status: null,
    notes: null,
    latest_actual_amount: null,
    latest_difference_reason: null,
    latest_collected_at: null,
  };
}

function toMovementPc(
  pc: {
    id: string;
    status: 'PENDING' | 'COLLECTED' | 'CANCELLED';
    expected_amount: number;
    currency_id: string;
    notes: string | null;
    currency?: { code?: string } | null;
  },
  latest?: {
    actual_amount: number;
    difference_reason: string | null;
    collected_at: string;
  },
): MovementPcMeta {
  return {
    collection_id: pc.id,
    required: pc.status !== 'CANCELLED',
    expected_amount: Number(pc.expected_amount),
    currency_id: pc.currency_id,
    currency_code: pc.currency?.code ?? '',
    status: pc.status,
    notes: pc.notes,
    latest_actual_amount: latest?.actual_amount ?? null,
    latest_difference_reason: latest?.difference_reason ?? null,
    latest_collected_at: latest?.collected_at ?? null,
  };
}

@Injectable()
export class DriverAssignmentService {
  constructor(
    private readonly repo: BookingRepository,
    private readonly cls: ClsService,
    private readonly auditService: AuditService,
    @InjectRepository(BookingTransportItemEntity)
    private readonly transportItemRepo: Repository<BookingTransportItemEntity>,
    @InjectRepository(BookingActivityItemEntity)
    private readonly activityItemRepo: Repository<BookingActivityItemEntity>,
    @InjectRepository(BookingHotelItemEntity)
    private readonly hotelItemRepo: Repository<BookingHotelItemEntity>,
    @InjectRepository(BookingSpecialItemEntity)
    private readonly specialItemRepo: Repository<BookingSpecialItemEntity>,
    @InjectRepository(BookingFlightDetailEntity)
    private readonly flightDetailRepo: Repository<BookingFlightDetailEntity>,
    @InjectRepository(ActivityTicketItemEntity)
    private readonly ticketItemRepo: Repository<ActivityTicketItemEntity>,
    // MD Payment Collection — surface each op's cash-at-service
    // requirement (expected amount, currency, status) directly on the
    // Movement Chart response so the FE can render a status chip +
    // filter without a separate call per row.
    @InjectRepository(ServicePaymentCollectionEntity)
    private readonly paymentCollectionRepo: Repository<ServicePaymentCollectionEntity>,
    @InjectRepository(BookingCashCollectionEntity)
    private readonly bookingCashCollectionRepo: Repository<BookingCashCollectionEntity>,
    // Query lookup for destination_id on supplier-payable fallback —
    // destination lives on Query, not Booking. See the cost-resolution
    // branch in assignTransportItem.
    @InjectRepository(QueryEntity)
    private readonly queryRepo: Repository<QueryEntity>,
    // §12 Phase F — supplier-side settlement is raised when ops picks
    // a supplier on the Movement Chart. Same-supplier reassignments
    // are idempotent via the partial unique index.
    private readonly settlements: PaymentSettlementService,
    // Step 3g — supplier-side slab snapshot when ops sets
    // actual_supplier_cost on a Movement Chart transport / activity
    // row. Walks suppliers.tax_slab_id → destinations.tax_slab_id.
    private readonly taxResolver: TaxSlabResolverService,
    // Booking-level rollups (hotel_total / transport_total /
    // activity_total / selling_*) live on bookings and drive the
    // Booking Detail summary. Per-row MD edits to qty / unit_price /
    // subtotal must trigger this recalc — otherwise the line is fresh
    // but the booking totals (and Booking Detail) keep showing stale
    // values from the original booking-create snapshot.
    private readonly itinerary: BookingItineraryService,
  ) {}

  /**
   * Resolve the supplier's slab for the booking-line's destination so
   * the cost-side bifurcation can be captured on the cost_tax_snapshot
   * column. Returns null when there's no slab anywhere (exempt path —
   * we still write the value to clear any prior snapshot).
   */
  private async resolveSupplierSnapshot(
    bookingId: string,
    supplierId: string | null,
  ) {
    const booking = await this.repo.findById(bookingId);
    if (!booking?.query_id) return null;
    const query = await this.queryRepo.findOne({
      where: { id: booking.query_id },
      select: { id: true, destination_id: true } as any,
    });
    if (!query?.destination_id) return null;
    return this.taxResolver.resolveForSupplier(supplierId, query.destination_id);
  }

  private getTenantId(): string {
    return this.cls.get('tenant_id');
  }

  /**
   * Admins may override a row's status to ANY value (e.g. revert a
   * mistaken Picked Up / No-Show back to Planned) — client policy
   * 2026-10-10. Everyone else follows the forward transition graph.
   */
  private canOverrideStatus(): boolean {
    return (this.cls.get<string>(CLS_USER_ROLE_NAME) || '').trim().toLowerCase() === 'admin';
  }

  async getTransportDates(destinationId: string) {
    const tenantId = this.getTenantId();
    const result = await this.transportItemRepo
      .createQueryBuilder('ti')
      .select('DISTINCT ti.date', 'date')
      .innerJoin('bookings', 'b', 'b.id = ti.booking_id')
      .innerJoin('queries', 'q', 'q.id = b.query_id')
      .where('b.tenant_id = :tenantId', { tenantId })
      .andWhere('q.destination_id = :destinationId', { destinationId })
      .andWhere('b.is_deleted = false')
      .andWhere('ti.is_deleted = false')
      .andWhere('ti.date IS NOT NULL')
      .orderBy('ti.date', 'ASC')
      .getRawMany<{ date: string }>();
    return result.map((r) => r.date);
  }

  /**
   * Phase 3 helper — accept an explicit suppliers[] array from the FE
   * DTO and rebuild the junction rows to match, then mirror the first
   * entry to the legacy supplier_id / supplier_name_text columns
   * (transport also mirrors the second to supplier_2_id) so read paths
   * that still use the columns keep working through Phase 4. Returns
   * a partial-column update the caller merges into its own updateData
   * before the item-table write.
   */
  private itemSupplierCfg(itemType: 'transport' | 'activity' | 'special') {
    return {
      transport: {
        table: 'booking_transport_item_suppliers',
        itemCol: 'transport_item_id',
        itemTable: 'booking_transport_items',
        junctionFk: 'transport_item_supplier_id',
      },
      activity: {
        table: 'booking_activity_item_suppliers',
        itemCol: 'activity_item_id',
        itemTable: 'booking_activity_items',
        junctionFk: 'activity_item_supplier_id',
      },
      special: {
        table: 'booking_special_item_suppliers',
        itemCol: 'special_item_id',
        itemTable: 'booking_special_items',
        junctionFk: 'special_item_supplier_id',
      },
    }[itemType];
  }

  private async replaceItemSuppliersFromArray(
    itemType: 'transport' | 'activity' | 'special',
    itemId: string,
    tenantId: string,
    suppliers: Array<{
      id?: string | null;
      supplier_id?: string | null;
      supplier_name_text?: string | null;
      supplier_phone_text?: string | null;
      driver_id?: string | null;
      driver_name_text?: string | null;
      driver_phone_text?: string | null;
      role?: string;
      sort_order?: number;
      actual_supplier_cost?: number | null;
    }>,
    manager: EntityManager,
  ): Promise<Record<string, unknown>> {
    const cfg = this.itemSupplierCfg(itemType);

    // Match by junction id only. Reusing leftover slots by array
    // index would rewrite the wrong row after a reorder/remove and
    // reattribute that supplier's cash collection.
    const existing: Array<{ id: string; sort_order: number }> = await manager.query(
      `SELECT id, sort_order FROM ${cfg.table}
        WHERE tenant_id = $1 AND "${cfg.itemCol}" = $2 AND is_deleted = false
        ORDER BY sort_order ASC`,
      [tenantId, itemId],
    );
    const existingById = new Map(existing.map((r) => [r.id, r]));

    const cleaned = suppliers.filter(
      (s) => (s.supplier_id ?? null) !== null || (s.supplier_name_text ?? null) !== null,
    );

    const keepIds = new Set<string>();

    for (let i = 0; i < cleaned.length; i++) {
      const s = cleaned[i];
      const sortOrder = s.sort_order ?? i;
      const role = s.role ?? (i === 0 ? 'primary' : 'secondary');
      let rowId: string | null = null;
      if (s.id) {
        if (!existingById.has(s.id)) {
          throw new BadRequestException(
            'Supplier assignment not found on this service item',
          );
        }
        rowId = s.id;
      } else if (i === 0) {
        const primary = existing.find((r) => Number(r.sort_order) === 0) ?? existing[0];
        if (primary) rowId = primary.id;
      }

      if (rowId) {
        await manager.query(
          `UPDATE ${cfg.table}
              SET supplier_id = $3,
                  supplier_name_text = $4,
                  supplier_phone_text = $5,
                  role = $6,
                  sort_order = $7,
                  actual_supplier_cost = $8,
                  driver_id = $9,
                  driver_name_text = $10,
                  driver_phone_text = $11,
                  updated_at = now()
            WHERE id = $2 AND tenant_id = $1`,
          [
            tenantId,
            rowId,
            s.supplier_id ?? null,
            s.supplier_name_text ?? null,
            s.supplier_phone_text ?? null,
            role,
            sortOrder,
            s.actual_supplier_cost ?? null,
            s.driver_id ?? null,
            s.driver_name_text ?? null,
            s.driver_phone_text ?? null,
          ],
        );
        keepIds.add(rowId);
      } else {
        const inserted: Array<{ id: string }> = await manager.query(
          `INSERT INTO ${cfg.table}
             (tenant_id, "${cfg.itemCol}", supplier_id, supplier_name_text,
              supplier_phone_text, role, sort_order, actual_supplier_cost,
              driver_id, driver_name_text, driver_phone_text,
              created_at, updated_at)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now(), now())
           RETURNING id`,
          [
            tenantId,
            itemId,
            s.supplier_id ?? null,
            s.supplier_name_text ?? null,
            s.supplier_phone_text ?? null,
            role,
            sortOrder,
            s.actual_supplier_cost ?? null,
            s.driver_id ?? null,
            s.driver_name_text ?? null,
            s.driver_phone_text ?? null,
          ],
        );
        if (inserted[0]?.id) keepIds.add(inserted[0].id);
      }
    }

    const toRemove = existing.filter((r) => !keepIds.has(r.id));
    if (toRemove.length > 0) {
      const removeIds = toRemove.map((r) => r.id);
      const livePc = (await manager.query(
        `SELECT COALESCE(
                  transport_item_supplier_id,
                  activity_item_supplier_id,
                  special_item_supplier_id
                ) AS junction_id
           FROM service_payment_collections
          WHERE tenant_id = $1
            AND is_deleted = false
            AND status != 'CANCELLED'
            AND (
              transport_item_supplier_id = ANY($2::uuid[])
              OR activity_item_supplier_id = ANY($2::uuid[])
              OR special_item_supplier_id = ANY($2::uuid[])
            )`,
        [tenantId, removeIds],
      )) as Array<{ junction_id: string }>;
      if (livePc.length > 0) {
        throw new BadRequestException(
          'Cannot remove a supplier that has a live cash collection — cancel the collection first',
        );
      }
      await manager.query(
        `UPDATE ${cfg.table}
            SET is_deleted = true, updated_at = now()
          WHERE tenant_id = $1 AND id = ANY($2::uuid[])`,
        [tenantId, removeIds],
      );
    }

    // Mirror the primary (first entry) to the legacy columns so
    // Phase 4 read paths keep working. Empty array clears everything.
    const primary = cleaned[0];
    const secondary = cleaned[1];
    const patch: Record<string, unknown> = {
      supplier_id: primary?.supplier_id ?? null,
      supplier_name_text: primary?.supplier_name_text ?? null,
    };
    if (itemType === 'transport') {
      patch.supplier_2_id = secondary?.supplier_id ?? null;
      patch.supplier_2_name_text = secondary?.supplier_name_text ?? null;
    }
    if (itemType === 'activity' || itemType === 'special') {
      patch.supplier_phone_text = primary?.supplier_phone_text ?? null;
    }
    // Only mirror actual_supplier_cost from the primary when the
    // caller didn't set it explicitly on the DTO — the caller's
    // updateData takes precedence in the merge on the write site.
    if (primary?.actual_supplier_cost !== undefined) {
      patch.actual_supplier_cost = primary.actual_supplier_cost;
    }
    return patch;
  }

  /**
   * Dual-write helper — after any assign / patch writes the item's
   * supplier columns, we (re)build the junction rows to match so the
   * Movement Dashboard's `suppliers: [...]` array stays consistent.
   * Called from bulkAssignTransportItems / patchActivityItem /
   * patchSpecialItem. Runs on the caller's transaction manager when
   * provided.
   *
   * Strategy: upsert junction rows from the current column values via
   * replaceItemSuppliersFromArray so collection FKs keep stable ids.
   * Idempotent — repeated calls converge on the same state.
   */
  private async syncItemSuppliersFromColumns(
    itemType: 'transport' | 'activity' | 'special',
    itemId: string,
    tenantId: string,
    manager: EntityManager,
  ): Promise<void> {
    const cfg = this.itemSupplierCfg(itemType);

    // Read current column values
    const [item]: Array<{
      supplier_id: string | null;
      supplier_name_text: string | null;
      supplier_phone_text: string | null;
      supplier_2_id: string | null;
      supplier_2_name_text: string | null;
      actual_supplier_cost: string | null;
      driver_id: string | null;
      driver_name_text: string | null;
      driver_phone_text: string | null;
    }> = await manager.query(
      `SELECT supplier_id, supplier_name_text,
              ${itemType === 'transport' ? 'NULL::varchar' : 'supplier_phone_text'} AS supplier_phone_text,
              ${itemType === 'transport' ? 'supplier_2_id, supplier_2_name_text' : 'NULL::uuid AS supplier_2_id, NULL::varchar AS supplier_2_name_text'},
              actual_supplier_cost,
              ${itemType === 'transport' ? 'driver_id, driver_name_text, driver_phone_text' : 'NULL::uuid AS driver_id, NULL::varchar AS driver_name_text, NULL::varchar AS driver_phone_text'}
         FROM ${cfg.itemTable}
        WHERE id = $1 AND tenant_id = $2`,
      [itemId, tenantId],
    );
    if (!item) return;

    const existing: Array<{ id: string; sort_order: number }> = await manager.query(
      `SELECT id, sort_order FROM ${cfg.table}
        WHERE tenant_id = $1 AND "${cfg.itemCol}" = $2 AND is_deleted = false
        ORDER BY sort_order ASC`,
      [tenantId, itemId],
    );

    const hasPrimary =
      (item.supplier_id ?? null) !== null || (item.supplier_name_text ?? null) !== null;

    // Legacy column writes must never prune extra junction rows —
    // editing pickup time / primary supplier would otherwise delete
    // extra suppliers and steal (or block on) their cash collections.
    if (!hasPrimary) {
      if (existing.length <= 1) {
        await this.replaceItemSuppliersFromArray(
          itemType,
          itemId,
          tenantId,
          [],
          manager,
        );
      }
      return;
    }

    const primaryRow =
      existing.find((r) => Number(r.sort_order) === 0) ?? existing[0] ?? null;
    const primaryCost =
      item.actual_supplier_cost != null ? Number(item.actual_supplier_cost) : null;
    if (primaryRow) {
      await manager.query(
        `UPDATE ${cfg.table}
            SET supplier_id = $3,
                supplier_name_text = $4,
                supplier_phone_text = $5,
                role = 'primary',
                sort_order = 0,
                actual_supplier_cost = $6,
                driver_id = $7,
                driver_name_text = $8,
                driver_phone_text = $9,
                updated_at = now()
          WHERE id = $2 AND tenant_id = $1`,
        [
          tenantId,
          primaryRow.id,
          item.supplier_id ?? null,
          item.supplier_name_text ?? null,
          item.supplier_phone_text ?? null,
          primaryCost,
          item.driver_id ?? null,
          item.driver_name_text ?? null,
          item.driver_phone_text ?? null,
        ],
      );
    } else {
      await manager.query(
        `INSERT INTO ${cfg.table}
           (tenant_id, "${cfg.itemCol}", supplier_id, supplier_name_text,
            supplier_phone_text, role, sort_order, actual_supplier_cost,
            driver_id, driver_name_text, driver_phone_text,
            created_at, updated_at)
         VALUES ($1, $2, $3, $4, $5, 'primary', 0, $6, $7, $8, $9, now(), now())`,
        [
          tenantId,
          itemId,
          item.supplier_id ?? null,
          item.supplier_name_text ?? null,
          item.supplier_phone_text ?? null,
          primaryCost,
          item.driver_id ?? null,
          item.driver_name_text ?? null,
          item.driver_phone_text ?? null,
        ],
      );
    }

    const hasSecondary =
      itemType === 'transport' &&
      ((item.supplier_2_id ?? null) !== null ||
        (item.supplier_2_name_text ?? null) !== null);
    // Legacy supplier_2 only maps onto sort_order 1 when this item
    // still looks like the 1–2 column model. Never rewrite extra
    // suppliers (sort_order >= 1 on a 3-way split) from those columns.
    if (hasSecondary && existing.length <= 2) {
      const secondRow = existing.find((r) => Number(r.sort_order) === 1) ?? null;
      if (secondRow) {
        await manager.query(
          `UPDATE ${cfg.table}
              SET supplier_id = $3,
                  supplier_name_text = $4,
                  supplier_phone_text = NULL,
                  role = 'secondary',
                  updated_at = now()
            WHERE id = $2 AND tenant_id = $1`,
          [
            tenantId,
            secondRow.id,
            item.supplier_2_id ?? null,
            item.supplier_2_name_text ?? null,
          ],
        );
      } else if (existing.length <= 1) {
        await manager.query(
          `INSERT INTO ${cfg.table}
             (tenant_id, "${cfg.itemCol}", supplier_id, supplier_name_text,
              supplier_phone_text, role, sort_order, actual_supplier_cost,
              created_at, updated_at)
           VALUES ($1, $2, $3, $4, NULL, 'secondary', 1, NULL, now(), now())`,
          [
            tenantId,
            itemId,
            item.supplier_2_id ?? null,
            item.supplier_2_name_text ?? null,
          ],
        );
      }
    }

  }

  async getMovementChart(
    destinationId: string | undefined,
    date: string,
    filters?: {
      payment_collection_status?: 'REQUIRED' | 'PENDING' | 'COLLECTED';
      date_to?: string;
    },
  ) {
    const tenantId = this.getTenantId();
    const dateTo = filters?.date_to && filters.date_to >= date ? filters.date_to : undefined;
    // When destinationId is empty/undefined, the Movement Dashboard's
    // "All destinations" filter is in play — drop the destination
    // predicate so the planner shows every active row for the date
    // across the tenant. Ops asked for this so they can spot
    // cross-destination conflicts (same driver / vehicle double-booked
    // in different cities). Tenant scope still applies via ti/ai.
    const allDestinations = !destinationId;

    const transportQb = this.transportItemRepo
      .createQueryBuilder('ti')
      .leftJoinAndSelect('ti.booking', 'b')
      .leftJoinAndSelect('b.query', 'q')
      .leftJoinAndSelect('ti.transport_service', 'ts')
      .leftJoinAndSelect('ti.vehicle_type', 'vt')
      .leftJoinAndSelect('ti.driver', 'd')
      .leftJoinAndSelect('ti.vehicle', 'v')
      .leftJoinAndSelect('ti.supplier', 's')
      .where('ti.tenant_id = :tenantId', { tenantId })
      .andWhere('ti.is_deleted = false')
      .andWhere(dateTo ? 'ti.date BETWEEN :date AND :dateTo' : 'ti.date = :date', {
        date,
        dateTo,
      })
      .andWhere('b.is_deleted = false')
      .andWhere('b.status != :cancelled', { cancelled: BookingStatus.CANCELLED });
    if (!allDestinations) {
      transportQb.andWhere('q.destination_id = :destinationId', { destinationId });
    }
    const transportItems = await transportQb
      .orderBy('ti.pickup_time', 'ASC')
      .addOrderBy('ti.sort_order', 'ASC')
      .getMany();

    const activityQb = this.activityItemRepo
      .createQueryBuilder('ai')
      .leftJoinAndSelect('ai.booking', 'b')
      .leftJoinAndSelect('b.selling_currency', 'bsc')
      .leftJoinAndSelect('b.query', 'q')
      .leftJoinAndSelect('ai.activity', 'a')
      .leftJoinAndSelect('ai.ticket', 't')
      .leftJoinAndSelect('ai.supplier', 's')
      .where('ai.tenant_id = :tenantId', { tenantId })
      .andWhere('ai.is_deleted = false')
      .andWhere(dateTo ? 'ai.date BETWEEN :date AND :dateTo' : 'ai.date = :date', {
        date,
        dateTo,
      })
      .andWhere('b.is_deleted = false')
      .andWhere('b.status != :cancelled', { cancelled: BookingStatus.CANCELLED });
    if (!allDestinations) {
      activityQb.andWhere('q.destination_id = :destinationId', { destinationId });
    }
    const activityItems = await activityQb
      .orderBy('ai.slot', 'ASC')
      .addOrderBy('ai.sort_order', 'ASC')
      .getMany();

    // Special items scheduled on this date. Same destination filter as
    // transport / activity so the All-destinations toggle reaches them.
    // Stakeholder ask 2026-06-30: ops needs a Special lens on the
    // Movement Dashboard to plan ad-hoc services (e.g. visa drop-offs,
    // photographer, custom tour add-ons).
    const specialQb = this.specialItemRepo
      .createQueryBuilder('si')
      .leftJoinAndSelect('si.booking', 'b')
      .leftJoinAndSelect('b.query', 'q')
      .leftJoinAndSelect('si.supplier', 's')
      .where('si.tenant_id = :tenantId', { tenantId })
      .andWhere('si.is_deleted = false')
      .andWhere(dateTo ? 'si.date BETWEEN :date AND :dateTo' : 'si.date = :date', {
        date,
        dateTo,
      })
      .andWhere('b.is_deleted = false')
      .andWhere('b.status != :cancelled', { cancelled: BookingStatus.CANCELLED });
    if (!allDestinations) {
      specialQb.andWhere('q.destination_id = :destinationId', { destinationId });
    }
    const specialItems = await specialQb
      .orderBy('si.sort_order', 'ASC')
      .getMany();

    // ── Multi-supplier junction load ────────────────────────────────
    // Phase 1 of the multi-supplier refactor (migration 1784000) —
    // fetch all junction rows for the items we just loaded, indexed
    // by item_id so each op can emit `suppliers: [...]` alongside
    // the legacy `supplier_id` / `supplier_name` fields. Doing this
    // as a follow-up query instead of a leftJoinAndSelect avoids row
    // multiplication on items that have multiple suppliers.
    const junctionTransportIds = transportItems.map((t) => t.id);
    const junctionActivityIds = activityItems.map((a) => a.id);
    const junctionSpecialIds = specialItems.map((s) => s.id);
    type SupplierJoinRow = {
      junction_id: string;
      item_id: string;
      supplier_id: string | null;
      supplier_name: string | null;
      supplier_phone: string | null;
      supplier_name_text: string | null;
      supplier_phone_text: string | null;
      role: string;
      sort_order: number;
      actual_supplier_cost: string | null;
      driver_id: string | null;
      driver_name: string | null;
      driver_phone: string | null;
      driver_name_text: string | null;
      driver_phone_text: string | null;
    };
    const fetchSuppliers = async (
      table: string,
      itemCol: string,
      ids: string[],
    ): Promise<SupplierJoinRow[]> => {
      if (ids.length === 0) return [];
      return this.transportItemRepo.manager.query(
        `SELECT j.id AS junction_id, j."${itemCol}" AS item_id, j.supplier_id,
                m.name AS supplier_name, m.phone AS supplier_phone,
                j.supplier_name_text, j.supplier_phone_text,
                j.role, j.sort_order, j.actual_supplier_cost,
                j.driver_id, j.driver_name_text, j.driver_phone_text,
                drv.name AS driver_name, drv.phone AS driver_phone
           FROM ${table} j
           LEFT JOIN suppliers m ON m.id = j.supplier_id
           LEFT JOIN drivers drv ON drv.id = j.driver_id
          WHERE j.tenant_id = $1
            AND j.is_deleted = false
            AND j."${itemCol}" = ANY($2::uuid[])
          ORDER BY j."${itemCol}", j.sort_order ASC`,
        [tenantId, ids],
      );
    };
    const [transportSuppRows, activitySuppRows, specialSuppRows] = await Promise.all([
      fetchSuppliers('booking_transport_item_suppliers', 'transport_item_id', junctionTransportIds),
      fetchSuppliers('booking_activity_item_suppliers', 'activity_item_id', junctionActivityIds),
      fetchSuppliers('booking_special_item_suppliers', 'special_item_id', junctionSpecialIds),
    ]);
    const shapeSupplier = (r: SupplierJoinRow) => ({
      id: r.junction_id,
      supplier_id: r.supplier_id,
      name: r.supplier_name ?? r.supplier_name_text ?? '',
      phone: r.supplier_phone ?? r.supplier_phone_text ?? '',
      role: r.role,
      sort_order: r.sort_order,
      actual_supplier_cost:
        r.actual_supplier_cost != null ? Number(r.actual_supplier_cost) : null,
      driver_id: r.driver_id,
      driver_name: r.driver_name ?? r.driver_name_text ?? '',
      driver_phone: r.driver_phone ?? r.driver_phone_text ?? '',
    });
    const groupByItem = (rows: SupplierJoinRow[]) => {
      const out = new Map<string, ReturnType<typeof shapeSupplier>[]>();
      for (const r of rows) {
        const list = out.get(r.item_id) || [];
        list.push(shapeSupplier(r));
        out.set(r.item_id, list);
      }
      return out;
    };
    const suppliersByTransport = groupByItem(transportSuppRows);
    const suppliersByActivity = groupByItem(activitySuppRows);
    const suppliersBySpecial = groupByItem(specialSuppRows);

    const getPax = (b: BookingTransportItemEntity['booking'] | null | undefined) => {
      const q = b?.query;
      return (q?.adults || 0) + (q?.children_ages?.length || 0) + (q?.infants || 0);
    };

    // -- Tentative RTN backfill ---------------------------------------
    // For any transport row that has a pickup_time + a master service
    // duration but no return_pickup_time yet, compute the estimate
    // (pickup + duration) and persist it directly. This makes the
    // tentative time show up everywhere (WhatsApp share, exports,
    // other views) — not just the planner. User can still edit it
    // later; committing a new value overwrites.
    const estimateHhMm = (
      pickup: string | null | undefined,
      mins: number | null | undefined,
    ): string | null => {
      if (!pickup || !mins) return null;
      const m = /^(\d{1,2}):(\d{2})$/.exec(pickup.trim());
      if (!m) return null;
      const start = Number(m[1]) * 60 + Number(m[2]);
      const end = (start + mins) % (24 * 60);
      const h = Math.floor(end / 60);
      const mm = end % 60;
      return `${String(h).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
    };
    const needsBackfill = transportItems.filter(
      (ti) => !ti.return_pickup_time && ti.pickup_time && ti.transport_service?.duration_mins,
    );
    if (needsBackfill.length > 0) {
      const updates = needsBackfill
        .map((ti) => {
          const est = estimateHhMm(ti.pickup_time, ti.transport_service?.duration_mins);
          if (!est) return null;
          ti.return_pickup_time = est; // mutate so the response includes it
          return this.transportItemRepo.update(ti.id, { return_pickup_time: est });
        })
        .filter((p): p is Promise<UpdateResult> => p !== null);
      await Promise.all(updates);
    }

    // Flight details for all transport items on this date — one round-
    // trip lookup so the Planner can surface flight info (number,
    // terminal, time, PNR) on hover for airport-flagged transport rows.
    // ── MD Payment Collection lookup ───────────────────────────────
    // Single JOIN LATERAL avoided — the polymorphic FK column layout
    // needs three separate IN-list matches. Composite live-index on
    // (tenant_id, is_deleted, transport_item_id | activity_item_id |
    // special_item_id) keeps this cheap. Currency joined for the
    // alphabetic code so the FE renders "125 AED", not a UUID.
    type PCRow = ServicePaymentCollectionEntity & {
      currency?: { code?: string } | null;
    };
    const collectAllIds = [
      ...transportItems.map((t) => t.id),
      ...activityItems.map((a) => a.id),
      ...specialItems.map((s) => s.id),
    ];
    const paymentCollections: PCRow[] =
      collectAllIds.length === 0
        ? []
        : ((await this.paymentCollectionRepo
            .createQueryBuilder('pc')
            .leftJoinAndSelect('pc.currency', 'c')
            .where('pc.tenant_id = :tenantId', { tenantId })
            .andWhere('pc.is_deleted = false')
            .andWhere(
              '(pc.transport_item_id = ANY(:ids) OR pc.activity_item_id = ANY(:ids) OR pc.special_item_id = ANY(:ids))',
              { ids: collectAllIds },
            )
            .getMany()) as unknown as PCRow[]);

    // Latest live confirmation per collection — surfaces the actual
    // amount + difference_reason on the MD row so ops sees why the
    // driver collected less/more without opening the amend dialog.
    // Single raw query with DISTINCT ON (collection_id) picks the
    // freshest live row per parent. Cheap because the partial
    // (collection_id) live index already ranks them.
    const collectionIds = paymentCollections.map((pc) => pc.id);
    const latestConfirmations: Array<{
      collection_id: string;
      actual_amount: string;
      difference_reason: string | null;
      collected_at: string;
    }> = collectionIds.length === 0
      ? []
      : await this.transportItemRepo.manager.query(
          `SELECT DISTINCT ON (collection_id)
                  collection_id,
                  actual_amount,
                  difference_reason,
                  collected_at
             FROM service_payment_collection_confirmations
            WHERE tenant_id = $1
              AND is_deleted = false
              AND voided_at IS NULL
              AND collection_id = ANY($2::uuid[])
            ORDER BY collection_id, collected_at DESC`,
          [tenantId, collectionIds],
        );
    const latestByCollection = new Map<string, {
      actual_amount: number;
      difference_reason: string | null;
      collected_at: string;
    }>();
    for (const row of latestConfirmations) {
      latestByCollection.set(row.collection_id, {
        actual_amount: Number(row.actual_amount),
        difference_reason: row.difference_reason,
        collected_at: row.collected_at,
      });
    }

    const pcByJunction = new Map<string, MovementPcMeta>();
    for (const pc of paymentCollections) {
      if (pc.status === 'CANCELLED') continue;
      const latest = latestByCollection.get(pc.id);
      const meta = toMovementPc(pc, latest);
      const junctionId =
        pc.transport_item_supplier_id ||
        pc.activity_item_supplier_id ||
        pc.special_item_supplier_id;
      if (junctionId) {
        pcByJunction.set(junctionId, meta);
      }
    }
    const paymentCollectionForJunction = (
      junctionId: string | null | undefined,
    ) =>
      junctionId
        ? (pcByJunction.get(junctionId) ?? emptyMovementPc())
        : emptyMovementPc();
    const enrichSuppliersWithPc = (
      suppliers: ReturnType<typeof shapeSupplier>[],
    ) =>
      suppliers.map((s) => ({
        ...s,
        payment_collection: paymentCollectionForJunction(s.id),
      }));
    const primaryPaymentCollection = (
      suppliers: ReturnType<typeof shapeSupplier>[],
    ) => paymentCollectionForJunction(suppliers[0]?.id);

    // Batch-fetch destination currency for every destination referenced
    // by the loaded ops. Enrichment lets the FE default the SetRequired
    // currency picker to the destination's currency (e.g. AED for Dubai)
    // instead of the first alphabetical currency in the tenant. Works
    // for the "All destinations" filter too — each op keeps its own
    // destination_currency_id.
    const opDestinationIds = new Set<string>();
    for (const t of transportItems) {
      const id = (t as any).booking?.query?.destination_id as string | undefined;
      if (id) opDestinationIds.add(id);
    }
    for (const a of activityItems) {
      const id = (a as any).booking?.query?.destination_id as string | undefined;
      if (id) opDestinationIds.add(id);
    }
    for (const s of specialItems) {
      const id = (s as any).booking?.query?.destination_id as string | undefined;
      if (id) opDestinationIds.add(id);
    }
    const destCurrencyMap = new Map<string, string | null>();
    if (opDestinationIds.size > 0) {
      const rows: Array<{ id: string; currency_id: string | null }> =
        await this.transportItemRepo.manager.query(
          `SELECT id, currency_id FROM destinations
            WHERE tenant_id = $1
              AND id = ANY($2::uuid[])`,
          [tenantId, Array.from(opDestinationIds)],
        );
      for (const r of rows) destCurrencyMap.set(r.id, r.currency_id);
    }
    const destinationCurrencyFor = (destId: string | null | undefined) =>
      destId ? destCurrencyMap.get(destId) ?? null : null;

    const bookingIds = [
      ...new Set([
        ...transportItems.map((t) => t.booking_id),
        ...activityItems.map((a) => a.booking_id),
        ...specialItems.map((s) => s.booking_id),
      ]),
    ];
    const cashRows =
      bookingIds.length === 0
        ? []
        : await this.bookingCashCollectionRepo.find({
            where: {
              tenant_id: tenantId,
              is_deleted: false,
              booking_id: In(bookingIds),
            },
            relations: {
              currency: true,
              expense_supplier: true,
              expense_driver: true,
              expense_activity: true,
              expense_collected_by_user: true,
              cash_supplier: true,
              cash_driver: true,
              cash_collected_by_user: true,
            },
          });
    const cashByLine = new Map<string, {
      id: string;
      booking_id: string;
      cash_collection_id?: string;
      transport_item_id: string | null;
      activity_item_id: string | null;
      special_item_id: string | null;
      currency_id: string;
      currency_code: string;
      has_expense: boolean;
      has_cash: boolean;
      collected_by_type: 'OWN' | 'SUPPLIER' | null;
      collector_name: string | null;
      supplier_id: string | null;
      supplier_name: string | null;
      source_kind: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL' | null;
      activity_id: string | null;
      activity_name: string | null;
      driver_id: string | null;
      driver_name: string | null;
      expected_amount: number;
      notes: string | null;
      status: 'PENDING' | 'COLLECTED' | 'CANCELLED' | null;
      actual_amount: number | null;
      collected_at: string | null;
      difference_reason: string | null;
      collected_by_name: string | null;
      collection_date: string;
      cash_expected_amount: number;
      cash_status: 'PENDING' | 'COLLECTED' | null;
      cash_actual_amount: number | null;
      cash_collected_by_type: 'OWN' | 'SUPPLIER' | null;
      cash_collector_name: string | null;
    }>();
    const cashByBooking = new Map<string, (typeof cashByLine extends Map<string, infer V> ? V : never)>();
    const cashDateKey = (value: string | Date | null | undefined): string => {
      if (!value) return date;
      if (typeof value === 'string') return value.slice(0, 10);
      const y = value.getUTCFullYear();
      const m = String(value.getUTCMonth() + 1).padStart(2, '0');
      const d = String(value.getUTCDate()).padStart(2, '0');
      return `${y}-${m}-${d}`;
    };
    const cashLineKey = (
      kind: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL',
      itemId: string,
    ) => `${kind}:${itemId}`;
    for (const row of cashRows) {
      const expenseAmt = Number(row.expense_expected_amount ?? 0);
      const cashAmt = Number(row.cash_expected_amount ?? 0);
      const hasExpense =
        !!row.expense_status &&
        row.expense_status !== 'CANCELLED' &&
        expenseAmt > 0.005;
      const hasCash = cashAmt > 0.005;
      if (!hasExpense && !hasCash) continue;
      const collectionDate = cashDateKey(row.collection_date);
      const view = {
        id: row.id,
        booking_id: row.booking_id,
        transport_item_id: row.transport_item_id ?? null,
        activity_item_id: row.activity_item_id ?? null,
        special_item_id: row.special_item_id ?? null,
        currency_id: row.currency_id,
        currency_code: row.currency?.code ?? '',
        has_expense: hasExpense,
        has_cash: hasCash,
        collected_by_type: row.expense_collected_by_type,
        collector_name: row.expense_collector_name,
        supplier_id: row.expense_supplier_id,
        supplier_name: row.expense_supplier?.name ?? null,
        source_kind: row.expense_source_kind,
        activity_id: row.expense_activity_id,
        activity_name: row.expense_activity?.name ?? null,
        driver_id: row.expense_driver_id,
        driver_name: row.expense_driver?.name ?? null,
        expected_amount: expenseAmt,
        notes: row.expense_notes,
        status: hasExpense ? row.expense_status : null,
        actual_amount:
          row.expense_actual_amount == null ? null : Number(row.expense_actual_amount),
        collected_at: row.expense_collected_at
          ? new Date(row.expense_collected_at).toISOString()
          : null,
        difference_reason: row.expense_difference_reason,
        collected_by_name: row.expense_collected_by_user?.name ?? null,
        collection_date: collectionDate,
        cash_expected_amount: cashAmt,
        cash_status: hasCash ? row.cash_status : null,
        cash_actual_amount:
          row.cash_actual_amount == null ? null : Number(row.cash_actual_amount),
        cash_collected_by_type: row.cash_collected_by_type,
        cash_collector_name: row.cash_collector_name,
      };
      if (row.transport_item_id) {
        cashByLine.set(cashLineKey('TRANSPORT', row.transport_item_id), view);
      } else if (row.activity_item_id) {
        cashByLine.set(cashLineKey('ACTIVITY', row.activity_item_id), view);
      } else if (row.special_item_id) {
        cashByLine.set(cashLineKey('SPECIAL', row.special_item_id), view);
      } else if (hasCash) {
        cashByBooking.set(row.booking_id, view);
      }
    }
    const mdCashByBooking = new Map<string, Array<Record<string, unknown>>>();
    if (bookingIds.length > 0) {
      const mdRows = (await this.bookingCashCollectionRepo.manager.query(
        `SELECT m.id,
                m.booking_id,
                m.amount::numeric AS amount,
                m.status,
                m.collected_by_type,
                m.collector_name,
                m.currency_id,
                COALESCE(c.code, '') AS currency_code
           FROM md_cash_collections m
           LEFT JOIN currencies c ON c.id = m.currency_id
          WHERE m.tenant_id = $1
            AND m.is_deleted = false
            AND m.booking_id = ANY($2::uuid[])
          ORDER BY m.created_at ASC`,
        [tenantId, bookingIds],
      )) as Array<{
        id: string;
        booking_id: string;
        amount: string;
        status: 'PENDING' | 'COLLECTED';
        collected_by_type: 'OWN' | 'SUPPLIER';
        collector_name: string | null;
        currency_id: string;
        currency_code: string;
      }>;
      for (const row of mdRows) {
        const amount = Number(row.amount) || 0;
        if (amount <= 0.005) continue;
        const list = mdCashByBooking.get(row.booking_id) ?? [];
        list.push({
          id: row.id,
          booking_id: row.booking_id,
          has_cash: true,
          currency_id: row.currency_id,
          currency_code: row.currency_code,
          cash_expected_amount: amount,
          cash_status: row.status,
          cash_actual_amount: row.status === 'COLLECTED' ? amount : null,
          cash_collected_by_type: row.collected_by_type,
          cash_collector_name: row.collector_name,
          cash_collection_id: row.id,
        });
        mdCashByBooking.set(row.booking_id, list);
      }
    }
    const cashCollectionsFor = (bookingId: string) => mdCashByBooking.get(bookingId) ?? [];
    const paymentGateInputs = await this.repo.findPaymentGateInputs(bookingIds);
    const paymentGateFor = (bookingId: string) =>
      resolvePaymentGate(paymentGateInputs.get(bookingId), date);
    const cashCollectionFor = (
      kind: 'TRANSPORT' | 'ACTIVITY' | 'SPECIAL',
      itemId: string,
      bookingId: string,
    ) => {
      const line = cashByLine.get(cashLineKey(kind, itemId)) ?? null;
      const bookingCash = cashByBooking.get(bookingId) ?? null;
      if (line?.has_cash) return line;
      if (bookingCash?.has_cash && line) {
        return {
          ...line,
          has_cash: true,
          cash_expected_amount: bookingCash.cash_expected_amount,
          cash_status: bookingCash.cash_status,
          cash_actual_amount: bookingCash.cash_actual_amount,
          cash_collected_by_type: bookingCash.cash_collected_by_type,
          cash_collector_name: bookingCash.cash_collector_name,
          cash_collection_id: bookingCash.id,
        };
      }
      if (bookingCash?.has_cash) {
        return { ...bookingCash, cash_collection_id: bookingCash.id };
      }
      return line;
    };

    const transportItemIds = transportItems.map((ti) => ti.id);
    const flightRows =
      transportItemIds.length > 0
        ? await this.flightDetailRepo.find({
            where: {
              tenant_id: tenantId,
              is_deleted: false,
              transport_item_id: In(transportItemIds),
            },
          })
        : [];
    const flightByItem = new Map<string, BookingFlightDetailEntity>();
    for (const f of flightRows) {
      if (f.transport_item_id) flightByItem.set(f.transport_item_id, f);
    }

    const salesNameById = await this.resolveSalesNames(tenantId, [
      ...transportItems,
      ...activityItems,
      ...specialItems,
    ]);

    const transportOps = transportItems.map((ti) => ({
      id: ti.id,
      type: 'transport' as const,
      booking_id: ti.booking_id,
      booking_number: ti.booking?.booking_number || '',
      date: ti.date || date,
      destination_id: (ti as { booking?: { query?: { destination_id?: string } } }).booking?.query
        ?.destination_id || null,
      salesperson_name: formatSalesNames(
        (ti as { booking?: { query?: { sales_team?: string[] } } }).booking?.query?.sales_team,
        salesNameById,
      ),
      // Parent booking's status + hold_reason surface here so the
      // Planner can paint an amber row + lock supplier/driver/vehicle
      // cells when the booking is on_hold. Also used by the read-only
      // BE guard in bulkAssignTransportItems.
      booking_status: ti.booking?.status || null,
      booking_hold_reason: ti.booking?.hold_reason || null,
      payment_gate: paymentGateFor(ti.booking_id),
      guest_name: ti.booking?.query?.guest_name || '',
      guest_mobile: ti.booking?.query?.guest_mobile || '',
      guest_hotel: '',
      pax: getPax(ti.booking),
      // Per-row adult/child counts — frontend Planner needs these
      // separately for the editable Adult/Child columns. A non-null
      // override on the item wins; otherwise we inherit the booking's
      // pax. This models partial cancellations (e.g. 2 of 4 pax skip
      // this leg) without touching the booking header.
      adults: ti.adults_override ?? (ti.booking?.query?.adults || 0),
      children: ti.children_override ?? ((ti.booking?.query?.children_ages?.length ?? 0) || 0),
      adults_override: ti.adults_override,
      children_override: ti.children_override,
      time: ti.pickup_time || null,
      // Per-item dropoff time captured at booking-create — fills the
      // RTN column on one-way trips. Two-way trips still prefer the
      // `return_time` (return-leg pickup) below; FE picks whichever
      // is set, falling back to estimated free-at last.
      dropoff_time: ti.dropoff_time || null,
      // Return-leg pickup time (RTN) — only populated when the master
      // service is TWO_WAY. Frontend decides whether to surface the
      // editor based on `is_two_way`.
      return_time: ti.return_pickup_time || null,
      is_two_way: ti.transport_service?.category === 'TWO_WAY',
      // Trip duration (mins) from the master service — frontend uses
      // this to estimate driver-free time when RTN isn't set manually
      // (pickup_time + duration = tentative free-at time).
      duration_mins: ti.transport_service?.duration_mins || null,
      pickup_address: ti.pickup_address || null,
      dropoff_address: ti.dropoff_address || null,
      service_name: ti.transport_service
        ? ti.transport_service.duty_type
          ? `${ti.transport_service.from_city || 'Disposal'} · ${
              ti.transport_service.duration_hours != null
                ? Number(ti.transport_service.duration_hours) + ' h Disposal'
                : 'Disposal'
            }`
          : `${ti.transport_service.from_city} → ${ti.transport_service.to_city}`
        : '',
      service_type: ti.transport_service?.category || '',
      // Disposal-only fields — drives the time-band rendering on the
      // Movement Chart (start + start+duration). NULL on point-to-point
      // services.
      duty_type: ti.transport_service?.duty_type || null,
      duration_hours:
        ti.transport_service?.duration_hours != null
          ? Number(ti.transport_service.duration_hours)
          : null,
      // Transport service id + cities + is_shared fuel the Planner
      // grouping (PVT by service id, SIC by dropoff address).
      transport_service_id: ti.transport_service_id,
      from_city: ti.transport_service?.from_city || '',
      to_city: ti.transport_service?.to_city || '',
      is_shared: !!ti.is_shared,
      // Pricing — Planner cells are editable; these feed the initial
      // values and round-trip through PATCH /bookings/transport-items/
      // :id/assign (which already accepts unit_price/subtotal).
      // FK + denormalised name. id keys the cost-master badge lookup
      // on the FE (Phase 3d), name is what the planner cell displays.
      vehicle_type_id: ti.vehicle_type_id || null,
      vehicle_type_label: ti.vehicle_type?.name || null,
      quantity: ti.quantity || 1,
      unit_price: Number(ti.unit_price) || 0,
      subtotal: Number(ti.subtotal) || 0,
      // Snapshotted floor at booking-create + ops overrides. Null
      // means no snapshot was captured (legacy bookings or no floor
      // configured at the time); FE falls back to the master cost
      // hint badge then.
      actual_supplier_cost:
        ti.actual_supplier_cost != null ? Number(ti.actual_supplier_cost) : null,
      driver_id: ti.driver_id,
      driver_name: ti.driver?.name || ti.driver_name_text || null,
      driver_phone: ti.driver?.phone || ti.driver_phone_text || null,
      vehicle_id: ti.vehicle_id,
      vehicle_no: ti.vehicle?.vehicle_no || ti.vehicle_no_text || null,
      vehicle_type: ti.vehicle?.type || null,
      vehicle_capacity: ti.vehicle?.capacity || null,
      supplier_id: ti.supplier_id,
      supplier_name: ti.supplier?.name || ti.supplier_name_text || null,
      supplier_phone: ti.supplier?.phone || ti.driver_phone_text || null,
      // Multi-supplier junction rows — the scalable replacement for
      // supplier_id / supplier_2_id. FE Planner cutover in Phase 2
      // reads this array; legacy fields above stay in the payload
      // through the dual-write window.
      suppliers: enrichSuppliersWithPc(suppliersByTransport.get(ti.id) || []),
      status: ti.assignment_status || 'scheduled',
      // Operational lifecycle — Planner renders a chip + transition
      // popover driven by these two fields.
      op_status: ti.op_status || OpStatus.UNPLANNED,
      op_status_at: ti.op_status_at,
      notes: ti.notes,
      pickup_notes: ti.pickup_notes,
      dropoff_notes: ti.dropoff_notes,
      // Airport flag + linked flight detail surface to the Planner.
      // Frontend renders a small ✈ pill on the row when is_airport_service,
      // and a hover tooltip with flight number / terminal / time when
      // a flight detail row exists.
      is_airport_service: !!ti.transport_service?.is_airport_service,
      flight_detail: flightByItem.get(ti.id) || null,
      payment_collection: primaryPaymentCollection(
        suppliersByTransport.get(ti.id) || [],
      ),
      destination_currency_id: destinationCurrencyFor(
        (ti as any).booking?.query?.destination_id,
      ),
      cash_collection: cashCollectionFor('TRANSPORT', ti.id, ti.booking_id),
      cash_collections: cashCollectionsFor(ti.booking_id),
    }));

    // Activity ops returned in the response — built from grouped BookingActivityItemEntity
    // rows. The shape is API-facing (denormalised + age_groups map) so we keep it loose.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- API response shape composed at runtime from joined relations
    const activityMap = new Map<string, any>();
    for (const ai of activityItems) {
      const key = `${ai.booking_id}_${ai.activity_id}_${ai.ticket_id || 'none'}_${ai.date}`;
      if (!activityMap.has(key)) {
        activityMap.set(key, {
          id: ai.id,
          type: 'activity' as const,
          booking_id: ai.booking_id,
          booking_number: ai.booking?.booking_number || '',
          booking_status: ai.booking?.status || null,
          booking_hold_reason: ai.booking?.hold_reason || null,
          payment_gate: paymentGateFor(ai.booking_id),
          guest_name: ai.booking?.query?.guest_name || '',
          guest_mobile: ai.booking?.query?.guest_mobile || '',
          destination_id: ai.booking?.query?.destination_id || null,
          salesperson_name: formatSalesNames(ai.booking?.query?.sales_team, salesNameById),
          is_special: !!(ai as { activity?: { is_special?: boolean } }).activity?.is_special,
          ticket_file_url: ai.ticket_file_url || null,
          ticket_status: ai.ticket_status || 'Pending',
          pax: getPax(ai.booking),
          // Per-row pax override applied across sibling rows by
          // patchActivityItem's fan-out, so reading the first sibling
          // is sufficient here.
          adults: ai.adults_override ?? (ai.booking?.query?.adults || 0),
          children: ai.children_override ?? ((ai.booking?.query?.children_ages?.length ?? 0) || 0),
          adults_override: ai.adults_override,
          children_override: ai.children_override,
          // Add-on pax (beyond the line's booked count) — billed
          // separately; see patchActivityItem.
          addon_adults: ai.addon_adults || 0,
          addon_children: ai.addon_children || 0,
          addon_billed_amount: Number(ai.addon_billed_amount) || 0,
          // Per-pax selling price by age (booking selling currency) —
          // pre-fills the add-on charge in the planner.
          addon_unit_prices: {} as Record<string, number>,
          addon_currency_code: ai.booking?.selling_currency?.code || null,
          time: ai.slot || null,
          // Scheduled date for the activity — used by the FE Cost-cell
          // refresh button to hit the right seasonal bracket on the
          // activity_pricing lookup.
          date: ai.date,
          activity_id: ai.activity_id,
          activity_name: ai.activity?.name || '',
          activity_address: ai.activity?.address || '',
          ticket_id: ai.ticket_id,
          ticket_name: ai.ticket?.name || null,
          slot: ai.slot,
          supplier_id: ai.supplier_id,
          supplier_name: ai.supplier?.name || ai.supplier_name_text || null,
          supplier_phone: ai.supplier?.phone || ai.supplier_phone_text || null,
          suppliers: enrichSuppliersWithPc(suppliersByActivity.get(ai.id) || []),
          // Pricing — Planner cells are editable on activity rows too.
          // unit_price is per-pax (not accumulated); subtotal starts at 0
          // and the loop below sums it across sibling age-group rows.
          // Initialising subtotal with ai.subtotal would double-count the
          // FIRST sibling: it'd be set here AND added in the accumulator
          // below, making activity Selling 2× the booking value.
          unit_price: Number(ai.unit_price) || 0,
          subtotal: 0,
          // Internal cost basis — Σ actual_supplier_cost across sibling
          // age_group rows (started here at 0, accumulated below).
          // Snapshotted from activity_pricing.floor at booking-create;
          // ops overrides via the Planner Cost cell when assigning a
          // supplier. Drives the SUPPLIER REFUND amount.
          actual_supplier_cost: 0,
          status: ai.assignment_status || 'scheduled',
          op_status: ai.op_status || OpStatus.UNPLANNED,
          op_status_at: ai.op_status_at,
          notes: ai.notes,
          age_groups: {} as Record<string, number>,
          payment_collection: primaryPaymentCollection(
            suppliersByActivity.get(ai.id) || [],
          ),
          destination_currency_id: destinationCurrencyFor(
            (ai as any).booking?.query?.destination_id,
          ),
          cash_collection: cashCollectionFor('ACTIVITY', ai.id, ai.booking_id),
          cash_collections: cashCollectionsFor(ai.booking_id),
        });
      }
      const entry = activityMap.get(key);
      entry.age_groups[ai.age_group] = (entry.age_groups[ai.age_group] || 0) + ai.quantity;
      entry.addon_unit_prices[ai.age_group] =
        Math.round(Number(ai.unit_price) * (Number(ai.booking?.exchange_rate) || 1) * 100) / 100;
      // Accumulate subtotal across age groups so the grouped row shows
      // the sum (adult + child + senior) rather than just the first hit.
      if (Number(ai.subtotal)) entry.subtotal = (entry.subtotal || 0) + Number(ai.subtotal);
      // Same accumulation for actual_supplier_cost — siblings store
      // independent per-age-group values; the Planner row shows the
      // total cost-to-supplier for the logical line.
      if (ai.actual_supplier_cost != null)
        entry.actual_supplier_cost =
          (entry.actual_supplier_cost || 0) + Number(ai.actual_supplier_cost);
    }
    // Enrich each activity op with the assigned physical ticket from inventory
    const activityItemIds = Array.from(activityMap.values()).map((op) => op.id);
    const assignedTickets =
      activityItemIds.length > 0
        ? await this.ticketItemRepo.find({
            where: {
              tenant_id: tenantId,
              booking_activity_item_id: In(activityItemIds),
              status: TicketItemStatus.ASSIGNED,
              is_deleted: false,
            },
          })
        : [];
    const ticketByItem = new Map(assignedTickets.map((t) => [t.booking_activity_item_id, t]));
    for (const op of activityMap.values()) {
      const t = ticketByItem.get(op.id);
      op.assigned_ticket_ref = t?.ticket_ref_no ?? null;
      op.assigned_ticket_item_id = t?.id ?? null;
      op.assigned_ticket_url = t?.document_url || op.ticket_file_url || null;
    }

    // Adult / Child shown on the planner = THIS activity line's own pax
    // (adults + seniors, children) from its age-group rows — not the
    // booking total. A per-row override (partial cancel) still wins.
    for (const op of activityMap.values()) {
      const ag = op.age_groups as Record<string, number>;
      op.adults = op.adults_override ?? (ag.adult || 0) + (ag.senior || 0);
      op.children = op.children_override ?? (ag.child || 0);
    }

    const activityOps = Array.from(activityMap.values());

    // Flat list of special items for the date. Shape mirrors
    // transportOps / activityOps where it can; ad-hoc fields kept
    // optional (no driver / no slot).
    const specialOps = specialItems.map((si) => ({
      id: si.id,
      type: 'special' as const,
      booking_id: si.booking_id,
      booking_number: si.booking?.booking_number || '',
      booking_status: si.booking?.status || null,
      booking_hold_reason: si.booking?.hold_reason || null,
      payment_gate: paymentGateFor(si.booking_id),
      guest_name: si.booking?.query?.guest_name || '',
      guest_mobile: si.booking?.query?.guest_mobile || '',
      salesperson_name: formatSalesNames(si.booking?.query?.sales_team, salesNameById),
      pax: getPax(si.booking),
      service_name: si.service_name,
      date: si.date,
      total_price: Number(si.total_price ?? 0),
      actual_supplier_cost:
        si.actual_supplier_cost === null
          ? null
          : Number(si.actual_supplier_cost),
      supplier_id: si.supplier_id,
      supplier_name:
        si.supplier?.name ||
        si.supplier_name_text ||
        null,
      supplier_phone: si.supplier?.phone || si.supplier_phone_text || null,
      suppliers: enrichSuppliersWithPc(suppliersBySpecial.get(si.id) || []),
      comments: si.comments || null,
      payment_collection: primaryPaymentCollection(
        suppliersBySpecial.get(si.id) || [],
      ),
      destination_currency_id: destinationCurrencyFor(
        (si as any).booking?.query?.destination_id,
      ),
      cash_collection: cashCollectionFor('SPECIAL', si.id, si.booking_id),
      cash_collections: cashCollectionsFor(si.booking_id),
    }));

    // ── Filter: payment_collection_status ─────────────────────────
    // Accepts three values from the FE filter pills:
    //   REQUIRED  → any live requirement (status IN PENDING, COLLECTED)
    //   PENDING   → requirement not yet collected
    //   COLLECTED → confirmed collections only
    // Filter applied AFTER enrichment + BEFORE grouping so
    // by_driver / by_booking / by_activity all reflect the same lens.
    const pcFilter = filters?.payment_collection_status;
    const matchesPc = (op: {
      payment_collection: { required: boolean; status: string | null };
      suppliers?: Array<{ payment_collection?: { required: boolean; status: string | null } }>;
    }) => {
      if (!pcFilter) return true;
      const candidates = [
        op.payment_collection,
        ...(op.suppliers || []).map((s) => s.payment_collection).filter(Boolean),
      ];
      if (pcFilter === 'REQUIRED') {
        return candidates.some((pc) => pc?.required);
      }
      if (pcFilter === 'PENDING') {
        return candidates.some((pc) => pc?.status === 'PENDING');
      }
      if (pcFilter === 'COLLECTED') {
        return candidates.some((pc) => pc?.status === 'COLLECTED');
      }
      return true;
    };
    const transportOpsFiltered = transportOps.filter(matchesPc);
    const activityOpsFiltered = activityOps.filter(matchesPc);
    const specialOpsFiltered = specialOps.filter(matchesPc);

    const allOps = [...transportOpsFiltered, ...activityOpsFiltered, ...specialOpsFiltered];
    const completedOps = allOps.filter(
      (o) => 'status' in o && (o as { status?: string }).status === 'completed',
    );
    const uniqueDrivers = new Set(
      transportOpsFiltered.filter((t) => t.driver_id).map((t) => t.driver_id),
    );

    const summary = {
      total_ops: allOps.length,
      transfers: transportOpsFiltered.length,
      activities: activityOpsFiltered.length,
      specials: specialOpsFiltered.length,
      active_drivers: uniqueDrivers.size,
      completed: completedOps.length,
      scheduled: allOps.length - completedOps.length,
    };

    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- grouped response shape (driver row + nested transport ops)
    const driverMap = new Map<string, any>();
    for (const op of transportOpsFiltered) {
      // Three buckets:
      //   - registered driver       → driver_id (UUID)
      //   - free-text driver        → `text:${name}|${phone}` so the same
      //                                ad-hoc driver groups across rows
      //   - truly unassigned        → `__unassigned__`
      // Without the text bucket, a Planner-side free-text driver
      // assignment would render on the Chart tab as "Unassigned" — the
      // Chart tab was previously keying on driver_id alone.
      const driverKey =
        op.driver_id ||
        (op.driver_name
          ? `text:${op.driver_name}|${op.driver_phone || ''}`
          : '__unassigned__');
      if (!driverMap.has(driverKey)) {
        driverMap.set(driverKey, {
          driver_id: op.driver_id,
          // Surface the ad-hoc name verbatim (no "Unassigned" placeholder)
          // when a text driver is set; the FE distinguishes assigned vs
          // unassigned by name+id together, not by id alone.
          driver_name: op.driver_name || (op.driver_id ? null : 'Unassigned'),
          driver_phone: op.driver_phone,
          vehicle_no: op.vehicle_no,
          vehicle_type: op.vehicle_type,
          vehicle_capacity: op.vehicle_capacity,
          ops: [],
        });
      }
      driverMap.get(driverKey).ops.push(op);
    }
    const byDriver = Array.from(driverMap.values()).sort((a, b) => {
      const aAssigned = !!(a.driver_id || (a.driver_name && a.driver_name !== 'Unassigned'));
      const bAssigned = !!(b.driver_id || (b.driver_name && b.driver_name !== 'Unassigned'));
      if (aAssigned && !bAssigned) return -1;
      if (!aAssigned && bAssigned) return 1;
      return (a.driver_name || '').localeCompare(b.driver_name || '');
    });

    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- grouped response shape (booking row + mixed transport/activity ops)
    const bookingMap = new Map<string, any>();
    for (const op of allOps) {
      if (!bookingMap.has(op.booking_id)) {
        bookingMap.set(op.booking_id, {
          booking_id: op.booking_id,
          booking_number: op.booking_number,
          guest_name: op.guest_name,
          guest_mobile: op.guest_mobile,
          pax: op.pax,
          ops: [],
        });
      }
      bookingMap.get(op.booking_id).ops.push(op);
    }
    const byBooking = Array.from(bookingMap.values()).sort((a, b) =>
      a.booking_number.localeCompare(b.booking_number),
    );

    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- grouped response shape (activity header + items array)
    const actGroupMap = new Map<string, any>();
    for (const op of activityOpsFiltered) {
      if (!actGroupMap.has(op.activity_id)) {
        actGroupMap.set(op.activity_id, {
          activity_id: op.activity_id,
          activity_name: op.activity_name,
          activity_address: op.activity_address,
          items: [],
        });
      }
      actGroupMap.get(op.activity_id).items.push(op);
    }
    const byActivity = Array.from(actGroupMap.values()).sort((a, b) =>
      a.activity_name.localeCompare(b.activity_name),
    );

    return {
      summary,
      by_driver: byDriver,
      by_booking: byBooking,
      by_activity: byActivity,
      by_special: specialOpsFiltered,
    };
  }

  // getDriverAssignments() removed 2026-05-13 — the standalone Driver
  // Assignment page was retired in favour of the Movement Chart's
  // unified transport + activity view.

  async assignTransportItem(itemId: string, dto: AssignTransportItemDto) {
    const tenantId = this.getTenantId();
    const userId = this.cls.get('user_id') || null;

    const item = await this.transportItemRepo.findOne({
      where: { id: itemId, tenant_id: tenantId, is_deleted: false },
    });
    if (!item) throw new NotFoundException('Transport item not found');
    await this.assertOverrideWithinParty(item.booking_id, dto.adults_override, dto.children_override);
    // Terminal-status guard — once a row is DROPPED / NO_SHOW /
    // CANCELLED every non-status edit is rejected. The FE already
    // dims the row + disables interactions; this is the defence-in-
    // depth so a stale tab or scripted call can't mutate finished
    // ops. op_status itself stays writable in case ops needs to
    // record a status transition (canTransition will reject if
    // there's no forward path; for terminal that means rejection).
    if (
      item.op_status &&
      (OP_STATUS_TERMINAL as readonly string[]).includes(item.op_status) &&
      hasNonStatusEdits(dto)
    ) {
      throw new BadRequestException(
        'This operation is marked ' +
          item.op_status.toLowerCase() +
          ' and cannot be edited.',
      );
    }

    if (dto.driver_id && item.date) {
      const existingAssignments = await this.transportItemRepo
        .createQueryBuilder('ti')
        .innerJoin('bookings', 'b', 'b.id = ti.booking_id')
        .where('ti.driver_id = :driverId', { driverId: dto.driver_id })
        .andWhere('ti.date = :date', { date: item.date })
        .andWhere('ti.transport_service_id = :tsId', { tsId: item.transport_service_id })
        .andWhere('ti.id != :itemId', { itemId })
        .andWhere('ti.tenant_id = :tenantId', { tenantId })
        .andWhere('ti.is_deleted = false')
        .andWhere('b.is_deleted = false')
        .getMany();

      if (existingAssignments.length > 0) {
        const vehicleId = dto.vehicle_id || item.vehicle_id;
        if (vehicleId) {
          // findOne<T>(string, ...) is not type-checked against an entity schema —
          // we just need the runtime row shape.
          const vehicle = (await this.transportItemRepo.manager.findOne('vehicles', {
            where: { id: vehicleId } as FindOptionsWhere<ObjectLiteral>,
          })) as { capacity: number | null; type: string | null } | null;
          // Effective capacity = per-vehicle override OR the type's
          // master default. If neither is set, skip the guard rather
          // than defaulting to a silent 999 — better to under-enforce
          // a missing rule than to over-state what we know.
          let capacity: number | null = vehicle?.capacity ?? null;
          if (!capacity && vehicle?.type) {
            const typeRow: Array<{ seating_capacity: number | null }> =
              await this.transportItemRepo.manager.query(
                'SELECT seating_capacity FROM vehicle_types WHERE name = $1 AND tenant_id = $2 AND is_deleted = false LIMIT 1',
                [vehicle.type, tenantId],
              );
            capacity = typeRow?.[0]?.seating_capacity ?? null;
          }
          if (capacity != null) {
            // Pull pax via query
            const booking = await this.repo.ormRepo
              .createQueryBuilder('b')
              .leftJoinAndSelect('b.query', 'q')
              .where('b.id = :id AND b.tenant_id = :t', { id: item.booking_id, t: tenantId })
              .getOne();
            const q = booking?.query;
            const currentPax = (q?.adults || 0) + (q?.children_ages?.length || 0);
            // existingAssignments rows have an ad-hoc `pax` field projected on the row;
            // not a column on BookingTransportItemEntity. Cast inline for the read.
            const assignedPax = existingAssignments.reduce(
              (sum, a) => sum + ((a as unknown as { pax?: number }).pax || 0),
              0,
            );

            if (assignedPax + currentPax > capacity) {
              throw new BadRequestException(
                `Driver already assigned to ${existingAssignments.length} booking(s) on this route for this date. Vehicle capacity (${capacity} pax) would be exceeded (${assignedPax + currentPax} pax needed).`,
              );
            }
          }
        }
      }
    }

    // Partial<BookingTransportItemEntity> is too strict (omits dynamic flags + audit-only
    // fields). We type this as an open record of unknown-valued patches.
    const updateData: Record<string, unknown> = { updated_by: userId };
    if (dto.supplier_id !== undefined) updateData.supplier_id = dto.supplier_id;
    if (dto.supplier_name_text !== undefined)
      updateData.supplier_name_text = dto.supplier_name_text || null;
    if (dto.supplier_2_id !== undefined) updateData.supplier_2_id = dto.supplier_2_id;
    if (dto.supplier_2_name_text !== undefined)
      updateData.supplier_2_name_text = dto.supplier_2_name_text || null;
    if (dto.driver_id !== undefined) updateData.driver_id = dto.driver_id;
    if (dto.driver_name_text !== undefined)
      updateData.driver_name_text = dto.driver_name_text || null;
    if (dto.driver_phone_text !== undefined)
      updateData.driver_phone_text = dto.driver_phone_text || null;
    if (dto.vehicle_id !== undefined) updateData.vehicle_id = dto.vehicle_id;
    if (dto.vehicle_no_text !== undefined) updateData.vehicle_no_text = dto.vehicle_no_text || null;
    if (dto.pickup_time !== undefined) updateData.pickup_time = dto.pickup_time;
    if (dto.dropoff_time !== undefined) updateData.dropoff_time = dto.dropoff_time;
    if (dto.return_pickup_time !== undefined)
      updateData.return_pickup_time = dto.return_pickup_time;
    // When pickup_time changes and the row either has no stored RTN
    // OR its current RTN was just the auto-estimate (matches old
    // pickup + duration), recompute and persist. An explicit
    // return_pickup_time in the DTO still wins (user override).
    if (dto.pickup_time !== undefined && dto.return_pickup_time === undefined) {
      const tsEntity = (await this.repo.ormRepo.manager.findOne('transport_services', {
        where: { id: item.transport_service_id } as FindOptionsWhere<ObjectLiteral>,
      })) as { duration_mins: number | null } | null;
      const durationMins = tsEntity?.duration_mins;
      if (dto.pickup_time && durationMins) {
        const m = /^(\d{1,2}):(\d{2})$/.exec(dto.pickup_time.trim());
        if (m) {
          const start = Number(m[1]) * 60 + Number(m[2]);
          const end = (start + durationMins) % (24 * 60);
          const h = Math.floor(end / 60);
          const mm = end % 60;
          const newEstimate = `${String(h).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
          // Only auto-update RTN if it was empty OR was the estimate
          // computed from the PREVIOUS pickup (= we own the value,
          // no user override to protect).
          const prevEstimate = (() => {
            if (!item.pickup_time) return null;
            const pm = /^(\d{1,2}):(\d{2})$/.exec(item.pickup_time);
            if (!pm) return null;
            const ps = Number(pm[1]) * 60 + Number(pm[2]);
            const pe = (ps + durationMins) % (24 * 60);
            return `${String(Math.floor(pe / 60)).padStart(2, '0')}:${String(pe % 60).padStart(2, '0')}`;
          })();
          if (!item.return_pickup_time || item.return_pickup_time === prevEstimate) {
            updateData.return_pickup_time = newEstimate;
          }
        }
      }
    }
    if (dto.pickup_address !== undefined) updateData.pickup_address = dto.pickup_address;
    if (dto.dropoff_address !== undefined) updateData.dropoff_address = dto.dropoff_address;
    // Planner row edits — pricing + qty + notes + vehicle type label.
    if (dto.quantity !== undefined) updateData.quantity = dto.quantity;
    if (dto.unit_price !== undefined) updateData.unit_price = dto.unit_price;
    if (dto.subtotal !== undefined) updateData.subtotal = dto.subtotal;
    // Per-trip actual supplier cost — Movement Chart Cost cell. Stays
    // separate from unit_price (customer selling). Null clears the
    // override and falls back to the booked-time floor snapshot.
    if (dto.actual_supplier_cost !== undefined) {
      updateData.actual_supplier_cost = dto.actual_supplier_cost;
      // Step 3g — capture the supplier slab snapshot at cost-cell save
      // time. Stored separately from the customer-side tax_snapshot so
      // reports can split "what we owe in tax to supplier" from "what
      // we collected in tax from customer". Resolved against whichever
      // supplier the row ends up assigned to — the new supplier id
      // from the DTO if the assignment is changing in the same call,
      // else the row's existing supplier.
      const effectiveSupplierId =
        dto.supplier_id !== undefined ? (dto.supplier_id ?? null) : (item.supplier_id ?? null);
      updateData.cost_tax_snapshot = await this.resolveSupplierSnapshot(
        item.booking_id,
        effectiveSupplierId,
      );
    }
    if (dto.notes !== undefined) updateData.notes = dto.notes;
    if (dto.pickup_notes !== undefined) updateData.pickup_notes = dto.pickup_notes;
    if (dto.dropoff_notes !== undefined) updateData.dropoff_notes = dto.dropoff_notes;
    if (dto.vehicle_type_id !== undefined) updateData.vehicle_type_id = dto.vehicle_type_id;
    // Per-row pax override from Planner (partial cancellations). Null
    // clears the override; undefined leaves it untouched.
    if (dto.adults_override !== undefined) updateData.adults_override = dto.adults_override;
    if (dto.children_override !== undefined) updateData.children_override = dto.children_override;
    // Op status — guarded transition. Throw on illegal jumps so the
    // UI can show a precise error rather than silently resetting.
    // Admins may override to any status (fix a mistaken update).
    if (dto.op_status !== undefined) {
      const next = dto.op_status as OpStatus;
      const current = (item.op_status || OpStatus.UNPLANNED) as OpStatus;
      if (next !== current && !canTransition(current, next, this.canOverrideStatus())) {
        throw new BadRequestException(`Illegal status transition: ${current} → ${next}.`);
      }
      updateData.op_status = next;
      updateData.op_status_at = new Date();
    }

    // Consider it assigned if ANY of: registered or free-text supplier/driver/vehicle
    const hasAnyAssignment = !!(
      dto.driver_id ||
      dto.driver_name_text ||
      dto.supplier_id ||
      dto.supplier_name_text ||
      dto.vehicle_id ||
      dto.vehicle_no_text
    );
    if (hasAnyAssignment) {
      updateData.assignment_status = 'assigned';
    } else if (
      dto.driver_id === null &&
      !dto.driver_name_text &&
      !dto.supplier_id &&
      !dto.supplier_name_text &&
      !dto.vehicle_id &&
      !dto.vehicle_no_text
    ) {
      updateData.assignment_status = null;
    }

    // Supplier-driven base status (Unplanned ⇄ Planned). Applied only
    // when the caller didn't set op_status explicitly AND the row is in
    // a base state — a manual Hold / Picked Up / No-Show / Cancelled is
    // sticky. Assigning a supplier → Planned; removing it → Unplanned.
    if (dto.op_status === undefined) {
      const current = (item.op_status || OpStatus.UNPLANNED) as OpStatus;
      if ((OP_STATUS_BASE as readonly OpStatus[]).includes(current)) {
        const hasSupplier = resolveHasSupplier(dto, updateData, item);
        const auto = autoBaseStatus(hasSupplier);
        if (auto !== current) {
          updateData.op_status = auto;
          updateData.op_status_at = new Date();
        }
      }
    }

    const before = pickFields(item, TRANSPORT_ITEM_AUDIT_FIELDS);
    let finalUpdate = updateData;
    if (dto.suppliers) {
      // Phase 3 — explicit suppliers[] is the source of truth. Rebuild
      // junction rows, mirror the primary back to the legacy columns
      // so downstream reads keep working.
      const mirrored = await this.replaceItemSuppliersFromArray(
        'transport',
        itemId,
        tenantId,
        dto.suppliers,
        this.transportItemRepo.manager,
      );
      finalUpdate = { ...mirrored, ...updateData };
    }
    await this.transportItemRepo.update(itemId, finalUpdate);
    if (!dto.suppliers) {
      // Phase 1 dual-write path — legacy client, mirror columns → junction.
      await this.syncItemSuppliersFromColumns(
        'transport',
        itemId,
        tenantId,
        this.transportItemRepo.manager,
      );
    }
    // Booking-level rollups (transport_total / selling_transport_total
    // / grand_total) feed the Booking Detail summary. Recompute when
    // pricing inputs change; skip for pure assignment / address / time
    // edits to avoid an unnecessary round-trip per blur.
    if (
      dto.quantity !== undefined ||
      dto.unit_price !== undefined ||
      dto.subtotal !== undefined
    ) {
      await this.itinerary.recalculateTotals(item.booking_id, tenantId);
    }
    const afterRow = await this.transportItemRepo.findOne({
      where: { id: itemId, tenant_id: tenantId },
    });
    const after = pickFields(afterRow, TRANSPORT_ITEM_AUDIT_FIELDS);
    // Choose the most descriptive action so the audit timeline reads
    // naturally on the Movement Chart history view.
    const isAssignmentEdit =
      dto.driver_id !== undefined ||
      dto.supplier_id !== undefined ||
      dto.vehicle_id !== undefined ||
      dto.vehicle_type_id !== undefined;
    const isReschedule = dto.pickup_time !== undefined || dto.return_pickup_time !== undefined;
    const action = isAssignmentEdit
      ? AuditAction.ASSIGNED
      : isReschedule
        ? AuditAction.RESCHEDULED
        : AuditAction.UPDATED;
    await this.auditService.diffAndLog(
      AuditEntityType.BOOKING_TRANSPORT_ITEM,
      itemId,
      before,
      after,
      action,
    );

    // ── §12 Phase F: supplier-side payable ─────────────────────────
    // When ops picks a supplier on this row, raise a SUPPLIER-side
    // settlement so finance sees the payable. Idempotent — same supplier
    // re-assigned is a no-op. Wrapped in try/catch so an outage on the
    // settlement side never blocks the assignment.
    try {
      const newSupplierId = afterRow?.supplier_id;
      if (newSupplierId && newSupplierId !== before.supplier_id) {
        const booking = await this.repo.findById(item.booking_id);
        const currencyId = booking?.selling_currency_id;
        if (booking && currencyId) {
          // Cost basis resolution:
          //   1. row's actual_supplier_cost snapshot (set by Phase B
          //      at booking-create OR by ops on the day) — preferred
          //   2. live Floor lookup for the row's (route × cab × mode
          //      × date) — for LEGACY rows missing the snapshot
          //   3. 0 — only if even the master has no floor configured;
          //      ops will fill via Movement Chart Cost cell
          // The legacy fallback to `unit_price * quantity` was wrong —
          // unit_price is per-vehicle SELLING, never the supplier
          // cost. Audit B4 in the E2E review.
          // actual_supplier_cost is already TRIP TOTAL (qty baked in
          // at Phase B / Phase 5 snapshot OR by ops on Movement Chart).
          // Live-floor fallback for legacy rows multiplies floor × qty.
          // destination_id is on the linked query, not the booking row.
          let cost: number;
          if (afterRow?.actual_supplier_cost != null) {
            cost = Number(afterRow.actual_supplier_cost);
          } else {
            const linkedQuery = booking.query_id
              ? await this.queryRepo.findOne({
                  where: { id: booking.query_id, tenant_id: this.getTenantId() },
                })
              : null;
            const liveFloorPerUnit = linkedQuery
              ? await resolveFloorSnapshot(this.transportItemRepo.manager, {
                  tenant_id: this.getTenantId(),
                  destination_id: linkedQuery.destination_id,
                  transport_service_id: afterRow?.transport_service_id ?? '',
                  vehicle_type_id: afterRow?.vehicle_type_id ?? null,
                  is_shared: !!afterRow?.is_shared,
                  date: afterRow?.date ?? null,
                })
              : null;
            cost = (liveFloorPerUnit ?? 0) * Number(afterRow?.quantity ?? 1);
          }
          const dueDate = await this.getBookingDueDate(item.booking_id);
          await this.settlements.recordSupplierPayable({
            booking_id: item.booking_id,
            supplier_id: newSupplierId,
            amount: cost,
            currency_id: currencyId,
            due_date: dueDate,
            source_item_type: 'booking_transport_item',
            source_item_id: itemId,
          });
        }
      }

      // ── Cost-change delta (Phase D follow-up) ────────────────────
      // When ops edits actual_supplier_cost on a row that already has a
      // supplier assigned (no supplier swap), reconcile the original
      // payable:
      //   - PENDING parent → updated in place
      //   - PARTIAL/SETTLED parent → delta row raised
      // Skipped on supplier swap (the new supplier's payable was just
      // raised above with the up-to-date cost) and on no-op edits.
      const currentSupplierId = afterRow?.supplier_id;
      const costChanged =
        dto.actual_supplier_cost !== undefined &&
        Number(before.actual_supplier_cost ?? 0) !== Number(afterRow?.actual_supplier_cost ?? 0);
      const supplierUnchanged = currentSupplierId && currentSupplierId === before.supplier_id;
      if (costChanged && supplierUnchanged && currentSupplierId) {
        const booking = await this.repo.findById(item.booking_id);
        const currencyId = booking?.selling_currency_id;
        if (booking && currencyId && afterRow?.actual_supplier_cost != null) {
          const dueDate = await this.getBookingDueDate(item.booking_id);
          await this.settlements.adjustSupplierPayable({
            booking_id: item.booking_id,
            supplier_id: currentSupplierId,
            source_item_type: 'booking_transport_item',
            source_item_id: itemId,
            new_amount: Number(afterRow.actual_supplier_cost),
            currency_id: currencyId,
            due_date: dueDate,
          });
        }
      }
    } catch (err) {
      const e = err as { message?: string };
      await this.auditService.log(AuditEntityType.BOOKING_TRANSPORT_ITEM, itemId, 'updated', [
        {
          field: 'supplier_settlement_init_error',
          old_value: null,
          new_value: e?.message ?? 'unknown',
        },
      ]);
    }

    return { id: itemId, message: 'Driver assigned successfully' };
  }

  /**
   * Due date for a supplier-side settlement. Mirrors the helper on
   * BookingItineraryService; copied locally to avoid a cross-service
   * import cycle. Reads the booking's query.start_date; falls back to
   * today when unresolvable.
   */
  private async getBookingDueDate(bookingId: string): Promise<string> {
    const b = await this.repo.findById(bookingId);
    if (!b?.query_id) return new Date().toISOString().slice(0, 10);
    const q = await this.repo.ormRepo.manager.findOne('queries', {
      where: { id: b.query_id } as FindOptionsWhere<ObjectLiteral>,
    });
    const start = (q as { start_date?: string } | null)?.start_date;
    return start || new Date().toISOString().slice(0, 10);
  }

  /**
   * Partial update of a single booking_activity_items row from the
   * Movement Chart Planner tab. Only the fields present in the DTO
   * are written — autosave-on-blur semantics. Affects all rows that
   * share the same (booking_id, activity_id, ticket_id) since the
   * frontend groups ages into one logical row; the rule is "update
   * the supplier/notes/etc on every age_group row of this group".
   */
  /** Planner pax edits can't exceed the customer's party on the query. */
  private async assertOverrideWithinParty(
    bookingId: string,
    adults: number | null | undefined,
    children: number | null | undefined,
  ): Promise<void> {
    if (adults == null && children == null) return;
    const rows: Array<{ adults: number | null; children_ages: unknown[] | null }> =
      await this.transportItemRepo.manager.query(
        `SELECT q.adults, q.children_ages
           FROM bookings b
           JOIN queries q ON q.id = b.query_id AND q.tenant_id = b.tenant_id
          WHERE b.id = $1 AND b.tenant_id = $2
          LIMIT 1`,
        [bookingId, this.getTenantId()],
      );
    if (!rows[0]) return;
    assertRowPaxWithinParty(partyFromQuery(rows[0]), [{ adults, children }], []);
  }

  async patchActivityItem(itemId: string, dto: PatchActivityItemDto) {
    const tenantId = this.getTenantId();
    const userId = this.cls.get('user_id') || null;
    const item = await this.activityItemRepo.findOne({
      where: { id: itemId, tenant_id: tenantId, is_deleted: false },
    });
    if (!item) throw new NotFoundException('Activity item not found');
    // No party cap here — extra pax on an activity line are add-ons
    // (billed separately, see below). Transport keeps the cap.
    // Terminal-status guard — same defence-in-depth as transport.
    if (
      item.op_status &&
      (OP_STATUS_TERMINAL as readonly string[]).includes(item.op_status) &&
      hasNonStatusEdits(dto)
    ) {
      throw new BadRequestException(
        'This activity is marked ' +
          item.op_status.toLowerCase() +
          ' and cannot be edited.',
      );
    }

    const updateData: Record<string, unknown> = { updated_by: userId };
    const fields: Array<keyof PatchActivityItemDto> = [
      'supplier_id',
      'supplier_name_text',
      'supplier_phone_text',
      'slot',
      'notes',
      'quantity',
      'unit_price',
      'subtotal',
      'ticket_id',
      'assignment_status',
      'ticket_status',
      'ticket_file_url',
      'adults_override',
      'children_override',
      // Per-line supplier cost — drives the SUPPLIER REFUND amount when
      // a supplier is assigned. Fan-out so every age_group sibling
      // carries the same value (one cost per logical Planner row).
      'actual_supplier_cost',
    ];
    // Op status — guarded transition; admins may override to any status.
    if (dto.op_status !== undefined) {
      const next = dto.op_status as OpStatus;
      const current = (item.op_status || OpStatus.UNPLANNED) as OpStatus;
      if (next !== current && !canTransition(current, next, this.canOverrideStatus())) {
        throw new BadRequestException(`Illegal status transition: ${current} → ${next}.`);
      }
      updateData.op_status = next;
      updateData.op_status_at = new Date();
    }
    const dtoRecord = dto as unknown as Record<string, unknown>;
    for (const f of fields) {
      if (dtoRecord[f] !== undefined) updateData[f] = dtoRecord[f];
    }
    // When supplier is reassigned, clear any stale text placeholders
    // so we don't keep both an id and a label.
    if (dto.supplier_id !== undefined && dto.supplier_id) {
      updateData.supplier_name_text = null;
    }

    // Supplier-driven base status (Unplanned ⇄ Planned) — only when
    // op_status wasn't set explicitly and the row is in a base state.
    if (dto.op_status === undefined) {
      const current = (item.op_status || OpStatus.UNPLANNED) as OpStatus;
      if ((OP_STATUS_BASE as readonly OpStatus[]).includes(current)) {
        const hasSupplier = resolveHasSupplier(dto, updateData, item);
        const auto = autoBaseStatus(hasSupplier);
        if (auto !== current) {
          updateData.op_status = auto;
          updateData.op_status_at = new Date();
        }
      }
    }

    // Step 3g — supplier-side slab snapshot when actual_supplier_cost
    // is touched on an activity row. Same fan-out as the rest of the
    // updateData below — every age-group sibling gets the same
    // snapshot. Stored separately from the customer-side tax_snapshot.
    if (dto.actual_supplier_cost !== undefined) {
      const effectiveSupplierId =
        dto.supplier_id !== undefined ? (dto.supplier_id ?? null) : (item.supplier_id ?? null);
      updateData.cost_tax_snapshot = await this.resolveSupplierSnapshot(
        item.booking_id,
        effectiveSupplierId,
      );
    }

    // Fan-out: the planner shows one row per (booking × activity ×
    // ticket) but the DB has separate rows per age_group. Apply the
    // patch to every row in that logical group so they stay in sync
    // (same supplier, notes, etc).
    // ticket_id / date are nullable. A raw null in the where throws
    // (see matchNullable) and the patch never lands — ticket status
    // looked stuck after a single upload on an activity with no master ticket.
    const siblings = await this.activityItemRepo.find({
      where: {
        booking_id: item.booking_id,
        activity_id: item.activity_id,
        ticket_id: matchNullable(item.ticket_id),
        date: matchNullable(item.date),
        tenant_id: tenantId,
        is_deleted: false,
      } as Record<string, unknown>,
    });
    // ── Add-on pax (client rule 2026-10-15) ─────────────────────────
    // Pax above the line's own booked count are add-ons: billed as a
    // separate customer settlement row (MD_ADDON); booking pax / total
    // untouched. Increasing add-ons resets the ticket to Pending so the
    // extra tickets get issued.
    let addonDelta = 0;
    let addonChange: ReturnType<typeof computeAddonChange> = null;
    let addonCostChanged = false;
    if (dto.adults_override !== undefined || dto.children_override !== undefined) {
      const base = { adults: 0, children: 0 };
      for (const sib of siblings) {
        const q = Number(sib.quantity) || 0;
        if (sib.age_group === 'child') base.children += q;
        else base.adults += q;
      }
      const eff = (v: number | null | undefined, prev: number | null, fallback: number) =>
        v !== undefined ? (v ?? fallback) : (prev ?? fallback);
      addonChange = computeAddonChange({
        base,
        effective: {
          adults: eff(dto.adults_override, item.adults_override, base.adults),
          children: eff(dto.children_override, item.children_override, base.children),
        },
        oldAddon: { adults: item.addon_adults || 0, children: item.addon_children || 0 },
        oldBilled: Number(item.addon_billed_amount) || 0,
        charge: dto.addon_charge,
      });
      if (addonChange) {
        // Supplier cost follows the pax on the line, priced by age:
        // each added / removed pax moves the cost by its age group's
        // per-pax cost. Skipped when no cost is set yet or when ops is
        // editing the cost in this same request.
        const lineCost = siblings.reduce((sum, r) => sum + Number(r.actual_supplier_cost ?? 0), 0);
        const hasCost = siblings.some((r) => r.actual_supplier_cost != null);
        const oldPax = {
          adults: item.adults_override ?? base.adults,
          children: item.children_override ?? base.children,
        };
        const newPax = {
          adults: eff(dto.adults_override, item.adults_override, base.adults),
          children: eff(dto.children_override, item.children_override, base.children),
        };
        if (
          dto.actual_supplier_cost === undefined &&
          hasCost &&
          lineCost > 0 &&
          oldPax.adults + oldPax.children > 0
        ) {
          // unit_price is base-currency cost; the line cost (and the
          // supplier payable) is in the booking's selling currency.
          const fx = Number((await this.repo.findById(item.booking_id))?.exchange_rate) || 1;
          const rateOf = (...ages: string[]) =>
            (Number(siblings.find((r) => ages.includes(r.age_group))?.unit_price) || 0) * fx;
          updateData.actual_supplier_cost = addonSupplierCost({
            lineCost,
            oldPax,
            newPax,
            rates: { adult: rateOf('adult', 'senior'), child: rateOf('child') },
          });
          addonCostChanged = true;
        }
        addonDelta = addonChange.billedDelta;
        updateData.addon_adults = addonChange.addon.adults;
        updateData.addon_children = addonChange.addon.children;
        updateData.addon_billed_amount =
          Math.round(((Number(item.addon_billed_amount) || 0) + addonDelta) * 100) / 100;
        if (addonChange.added > 0) updateData.ticket_status = 'Pending';
      }
    }

    const before = pickFields(item, ACTIVITY_ITEM_AUDIT_FIELDS);
    let finalUpdate = updateData;
    if (dto.suppliers) {
      // Phase 3 — explicit suppliers[] wins. Junction rows are
      // scoped to itemId (siblings share supplier state via the
      // fan-out below, so one write is enough).
      const mirrored = await this.replaceItemSuppliersFromArray(
        'activity',
        itemId,
        tenantId,
        dto.suppliers,
        this.activityItemRepo.manager,
      );
      finalUpdate = { ...mirrored, ...updateData };
    }
    // Line supplier cost lives on the patched row; its age-group
    // siblings carry 0 so every Σ (planner Cost cell, supplier payable)
    // equals the line cost. Fanning the same value to each sibling
    // double-counted mixed adult + child lines.
    const lineCostSet = Object.prototype.hasOwnProperty.call(finalUpdate, 'actual_supplier_cost');
    // Group cost BEFORE the write — the payable hook compares totals.
    const oldGroupCost = siblings.reduce(
      (sum, r) => sum + Number(r.actual_supplier_cost ?? r.subtotal ?? 0),
      0,
    );
    const targets = siblings.length > 0 ? siblings : [item];
    for (const s of targets) {
      const rowUpdate =
        lineCostSet && s.id !== itemId && finalUpdate.actual_supplier_cost != null
          ? { ...finalUpdate, actual_supplier_cost: 0 }
          : finalUpdate;
      await this.activityItemRepo.update(s.id, rowUpdate);
    }

    if (addonChange && Math.abs(addonDelta) > 0.004) {
      const booking = await this.repo.findById(item.booking_id);
      if (!booking?.selling_currency_id) {
        throw new BadRequestException('Booking has no selling currency; add-on cannot be billed');
      }
      await this.settlements.recordItineraryDelta({
        booking_id: item.booking_id,
        delta: addonDelta,
        currency_id: booking.selling_currency_id,
        due_date: await this.getBookingDueDate(item.booking_id),
        source: 'MD_ADDON',
        breakdown: {
          [addonDelta > 0 ? 'added' : 'dropped']: [
            {
              kind: 'activity_addon',
              activity_id: item.activity_id,
              ticket_id: item.ticket_id,
              date: item.date,
              addon_adults: addonChange.addon.adults,
              addon_children: addonChange.addon.children,
              amount: addonDelta,
            },
          ],
        },
      });
      await this.auditService.log(AuditEntityType.BOOKING, item.booking_id, 'updated', [
        {
          field: 'activity_addon',
          old_value: `${item.addon_adults || 0}A ${item.addon_children || 0}C`,
          new_value: `${addonChange.addon.adults}A ${addonChange.addon.children}C (${
            addonDelta > 0 ? '+' : ''
          }${addonDelta.toFixed(2)})`,
        },
      ]);
    }

    if (!dto.suppliers) {
      // Phase 1 dual-write path — legacy client.
      await this.syncItemSuppliersFromColumns(
        'activity',
        itemId,
        tenantId,
        this.activityItemRepo.manager,
      );
    }
    // Roll up the per-line subtotal change into the booking-level
    // activity_total + selling_activity_total + grand_total so the
    // Booking Detail summary tracks the MD edit. Same guard as
    // transport: skip the recalc for non-pricing edits.
    if (
      dto.quantity !== undefined ||
      dto.unit_price !== undefined ||
      dto.subtotal !== undefined
    ) {
      await this.itinerary.recalculateTotals(item.booking_id, tenantId);
    }
    const afterRow = await this.activityItemRepo.findOne({
      where: { id: itemId, tenant_id: tenantId },
    });

    // ── §12: activity-side supplier-payable ──────────────────────────
    // One SUPPLIER REFUND per (logical Planner row = booking × activity
    // × ticket × date) — siblings (age_groups) get rolled up into one
    // settlement. Mirrors the transport hook in shape; differs only in
    // that the amount sums across siblings rather than using a single
    // row's actual_supplier_cost.
    //
    // Triggers:
    //   - Supplier just got assigned (was null → set) → recordSupplierPayable
    //   - Supplier swapped (id A → id B) → recordSupplierPayable for B
    //     (the original A row remains; finance manually voids it if
    //     they had agreed cost with A — same behaviour as transport)
    //   - Cost edited on already-assigned supplier → adjustSupplierPayable
    //
    // Wrapped in try/catch so a settlement failure never blocks the
    // patch. Errors land on the timeline for finance recovery.
    try {
      const beforeSupplierId = item.supplier_id;
      const afterSupplierId = afterRow?.supplier_id;
      const supplierChanged =
        dto.supplier_id !== undefined && afterSupplierId !== beforeSupplierId;
      const costChanged = dto.actual_supplier_cost !== undefined || addonCostChanged;

      if (supplierChanged || costChanged) {
        const booking = await this.repo.findById(item.booking_id);
        const currencyId = booking?.selling_currency_id;
        if (booking && currencyId && afterSupplierId) {
          // Cost basis = Σ actual_supplier_cost across siblings (one
          // payable for the logical Planner row).
          // UAT 2026-06-30 — fall back to subtotal when
          // actual_supplier_cost is null. Earlier behaviour: if floor
          // never snapshotted at booking-create, groupCost = 0,
          // recordSupplierPayable short-circuits, supplier never
          // appears on Pay Supplier page. Selling subtotal is a sane
          // placeholder ops can override via the Cost cell later.
          const freshSiblings = await this.activityItemRepo.find({
            where: {
              booking_id: item.booking_id,
              activity_id: item.activity_id,
              ticket_id: matchNullable(item.ticket_id),
              date: matchNullable(item.date),
              tenant_id: tenantId,
              is_deleted: false,
            } as Record<string, unknown>,
          });
          const groupCost = freshSiblings.reduce(
            (s, r) =>
              s +
              Number(
                r.actual_supplier_cost ?? r.subtotal ?? 0,
              ),
            0,
          );
          const dueDate = await this.getBookingDueDate(item.booking_id);
          // source_item_id = the patched itemId (stable handle for the
          // logical row; siblings share supplier so any sibling id
          // resolves the same payable from finance's POV).
          if (supplierChanged) {
            await this.settlements.recordSupplierPayable({
              booking_id: item.booking_id,
              supplier_id: afterSupplierId,
              amount: +groupCost.toFixed(2),
              currency_id: currencyId,
              due_date: dueDate,
              source_item_type: 'booking_activity_item',
              source_item_id: itemId,
            });
          } else if (costChanged && groupCost > 0 && Math.abs(groupCost - oldGroupCost) > 0.004) {
            await this.settlements.adjustSupplierPayable({
              booking_id: item.booking_id,
              supplier_id: afterSupplierId,
              source_item_type: 'booking_activity_item',
              source_item_id: itemId,
              new_amount: +groupCost.toFixed(2),
              currency_id: currencyId,
              due_date: dueDate,
            });
          }
        }
      }
    } catch (err) {
      const e = err as { message?: string };
      await this.auditService.log(AuditEntityType.BOOKING_ACTIVITY_ITEM, itemId, 'updated', [
        {
          field: 'activity_supplier_settlement_error',
          old_value: null,
          new_value: e?.message ?? 'unknown',
        },
      ]);
    }

    const after = pickFields(afterRow, ACTIVITY_ITEM_AUDIT_FIELDS);
    const action = dto.supplier_id !== undefined ? AuditAction.ASSIGNED : AuditAction.UPDATED;
    await this.auditService.diffAndLog(
      AuditEntityType.BOOKING_ACTIVITY_ITEM,
      itemId,
      before,
      after,
      action,
    );
    return { id: itemId, message: 'Activity item updated' };
  }

  /**
   * Patch a hotel item — supplier assignment + cost edit. Simpler than
   * activity: no fan-out (one row per hotel stay). Same supplier-payable
   * pattern: recordSupplierPayable on first assignment / supplier swap,
   * adjustSupplierPayable on cost edit when supplier unchanged.
   */
  async patchHotelItem(itemId: string, dto: PatchHotelItemDto) {
    const tenantId = this.getTenantId();
    const userId = this.cls.get('user_id') || null;
    const before = await this.hotelItemRepo.findOne({
      where: { id: itemId, tenant_id: tenantId, is_deleted: false },
    });
    if (!before) throw new NotFoundException('Hotel item not found');

    const updateData: Record<string, unknown> = { updated_by: userId };
    if (dto.supplier_id !== undefined) updateData.supplier_id = dto.supplier_id;
    if (dto.supplier_name_text !== undefined)
      updateData.supplier_name_text = dto.supplier_name_text || null;
    if (dto.supplier_phone_text !== undefined)
      updateData.supplier_phone_text = dto.supplier_phone_text || null;
    if (dto.notes !== undefined) updateData.notes = dto.notes || null;
    if (dto.actual_supplier_cost !== undefined)
      updateData.actual_supplier_cost = dto.actual_supplier_cost;
    if (dto.supplier_id) updateData.supplier_name_text = null;

    await this.hotelItemRepo.update(itemId, updateData);
    const afterRow = await this.hotelItemRepo.findOne({
      where: { id: itemId, tenant_id: tenantId },
    });

    // Settlement hook — same shape as activity but without sibling fan-out.
    // UAT 2026-06-30 — fall back to subtotal when actual_supplier_cost
    // is null so the supplier still appears on Pay Supplier with a
    // sensible placeholder amount ops can correct from MD.
    try {
      const supplierChanged =
        dto.supplier_id !== undefined && afterRow?.supplier_id !== before.supplier_id;
      const costChanged =
        dto.actual_supplier_cost !== undefined &&
        Number(before.actual_supplier_cost ?? 0) !==
          Number(afterRow?.actual_supplier_cost ?? 0);
      if ((supplierChanged || costChanged) && afterRow?.supplier_id) {
        const booking = await this.repo.findById(before.booking_id);
        const currencyId = booking?.selling_currency_id;
        const cost = Number(
          afterRow.actual_supplier_cost ?? afterRow.subtotal ?? 0,
        );
        if (booking && currencyId && cost > 0) {
          const dueDate = await this.getBookingDueDate(before.booking_id);
          if (supplierChanged) {
            await this.settlements.recordSupplierPayable({
              booking_id: before.booking_id,
              supplier_id: afterRow.supplier_id,
              amount: cost,
              currency_id: currencyId,
              due_date: dueDate,
              source_item_type: 'booking_hotel_item',
              source_item_id: itemId,
            });
          } else {
            await this.settlements.adjustSupplierPayable({
              booking_id: before.booking_id,
              supplier_id: afterRow.supplier_id,
              source_item_type: 'booking_hotel_item',
              source_item_id: itemId,
              new_amount: cost,
              currency_id: currencyId,
              due_date: dueDate,
            });
          }
        }
      }
    } catch (err) {
      const e = err as { message?: string };
      await this.auditService.log(AuditEntityType.BOOKING_HOTEL_ITEM, itemId, 'updated', [
        {
          field: 'hotel_supplier_settlement_error',
          old_value: null,
          new_value: e?.message ?? 'unknown',
        },
      ]);
    }

    return { id: itemId, message: 'Hotel item updated' };
  }

  /**
   * Patch a special item — supplier assignment + cost edit. Identical
   * shape to patchHotelItem: ad-hoc one-off services with no fan-out.
   */
  async patchSpecialItem(itemId: string, dto: PatchSpecialItemDto) {
    const tenantId = this.getTenantId();
    const userId = this.cls.get('user_id') || null;
    const before = await this.specialItemRepo.findOne({
      where: { id: itemId, tenant_id: tenantId, is_deleted: false },
    });
    if (!before) throw new NotFoundException('Special item not found');

    const updateData: Record<string, unknown> = { updated_by: userId };
    if (dto.supplier_id !== undefined) updateData.supplier_id = dto.supplier_id;
    if (dto.supplier_name_text !== undefined)
      updateData.supplier_name_text = dto.supplier_name_text || null;
    if (dto.supplier_phone_text !== undefined)
      updateData.supplier_phone_text = dto.supplier_phone_text || null;
    if (dto.comments !== undefined) updateData.comments = dto.comments || null;
    if (dto.actual_supplier_cost !== undefined)
      updateData.actual_supplier_cost = dto.actual_supplier_cost;
    if (dto.supplier_id) updateData.supplier_name_text = null;

    let finalUpdate = updateData;
    if (dto.suppliers) {
      const mirrored = await this.replaceItemSuppliersFromArray(
        'special',
        itemId,
        tenantId,
        dto.suppliers,
        this.specialItemRepo.manager,
      );
      finalUpdate = { ...mirrored, ...updateData };
    }
    await this.specialItemRepo.update(itemId, finalUpdate);
    if (!dto.suppliers) {
      await this.syncItemSuppliersFromColumns(
        'special',
        itemId,
        tenantId,
        this.specialItemRepo.manager,
      );
    }
    const afterRow = await this.specialItemRepo.findOne({
      where: { id: itemId, tenant_id: tenantId },
    });

    try {
      const supplierChanged =
        dto.supplier_id !== undefined && afterRow?.supplier_id !== before.supplier_id;
      const costChanged =
        dto.actual_supplier_cost !== undefined &&
        Number(before.actual_supplier_cost ?? 0) !==
          Number(afterRow?.actual_supplier_cost ?? 0);
      if ((supplierChanged || costChanged) && afterRow?.supplier_id) {
        const booking = await this.repo.findById(before.booking_id);
        const currencyId = booking?.selling_currency_id;
        // UAT 2026-06-30 — fall back to total_price so a special-item
        // supplier always surfaces on Pay Supplier even before ops has
        // entered the real cost via MD.
        const cost = Number(
          afterRow.actual_supplier_cost ?? afterRow.total_price ?? 0,
        );
        if (booking && currencyId && cost > 0) {
          const dueDate = await this.getBookingDueDate(before.booking_id);
          if (supplierChanged) {
            await this.settlements.recordSupplierPayable({
              booking_id: before.booking_id,
              supplier_id: afterRow.supplier_id,
              amount: cost,
              currency_id: currencyId,
              due_date: dueDate,
              source_item_type: 'booking_special_item',
              source_item_id: itemId,
            });
          } else {
            await this.settlements.adjustSupplierPayable({
              booking_id: before.booking_id,
              supplier_id: afterRow.supplier_id,
              source_item_type: 'booking_special_item',
              source_item_id: itemId,
              new_amount: cost,
              currency_id: currencyId,
              due_date: dueDate,
            });
          }
        }
      }
    } catch (err) {
      const e = err as { message?: string };
      await this.auditService.log(AuditEntityType.BOOKING_SPECIAL_ITEM, itemId, 'updated', [
        {
          field: 'special_supplier_settlement_error',
          old_value: null,
          new_value: e?.message ?? 'unknown',
        },
      ]);
    }

    return { id: itemId, message: 'Special item updated' };
  }

  async bulkPatchActivityItems(dto: BulkPatchActivityItemDto) {
    const tenantId = this.getTenantId();
    const userId = this.cls.get('user_id') || null;
    if (!dto.item_ids?.length) return { updated: 0, message: 'No items' };

    const updateData: Record<string, unknown> = { updated_by: userId };
    if (dto.supplier_id !== undefined) updateData.supplier_id = dto.supplier_id;
    if (dto.supplier_name_text !== undefined)
      updateData.supplier_name_text = dto.supplier_name_text || null;
    if (dto.supplier_phone_text !== undefined)
      updateData.supplier_phone_text = dto.supplier_phone_text || null;
    if (dto.supplier_id) updateData.supplier_name_text = null;

    // Same fan-out trick as patchActivityItem — for every selected
    // planner row, touch all sibling age_group rows.
    const items = await this.activityItemRepo.find({
      where: { id: In(dto.item_ids), tenant_id: tenantId, is_deleted: false },
    });
    const groupKeys = new Set(
      items.map((i) => `${i.booking_id}:${i.activity_id}:${i.ticket_id ?? 'n'}:${i.date}`),
    );
    const allSiblings = await this.activityItemRepo
      .createQueryBuilder('ai')
      .where('ai.tenant_id = :tenantId', { tenantId })
      .andWhere('ai.is_deleted = false')
      .getMany();
    const targets = allSiblings.filter((s) =>
      groupKeys.has(`${s.booking_id}:${s.activity_id}:${s.ticket_id ?? 'n'}:${s.date}`),
    );
    for (const t of targets) await this.activityItemRepo.update(t.id, updateData);

    // One audit row per logical group (the planner's notion of a row),
    // not per fanned-out age_group sibling — keeps the timeline readable.
    const seenIds = new Set<string>();
    for (const id of dto.item_ids) {
      if (seenIds.has(id)) continue;
      seenIds.add(id);
      const before = items.find((i) => i.id === id);
      if (!before) continue;
      await this.auditService.log(AuditEntityType.BOOKING_ACTIVITY_ITEM, id, AuditAction.ASSIGNED, [
        { field: 'supplier_id', old_value: before.supplier_id, new_value: dto.supplier_id ?? null },
        { field: 'bulk', old_value: null, new_value: `applied to ${dto.item_ids.length} groups` },
      ]);
    }

    return { updated: targets.length, message: `Updated ${targets.length} activity items` };
  }

  async bulkAssignTransportItems(dto: BulkAssignTransportItemDto) {
    const tenantId = this.getTenantId();
    const userId = this.cls.get('user_id') || null;

    // Held-booking guard — an on_hold parent should never receive a new
    // driver / supplier / vehicle assignment. FE already renders those
    // cells read-only on held rows; this is the server-side backstop
    // for anyone bypassing the UI. Query the parent bookings once and
    // 400 if any are held.
    if (dto.item_ids?.length > 0) {
      const heldRows: Array<{ booking_number: string | null }> =
        await this.transportItemRepo.manager.query(
          `SELECT DISTINCT b.booking_number
             FROM booking_transport_items ti
             JOIN bookings b ON b.id = ti.booking_id
            WHERE ti.tenant_id = $1
              AND ti.is_deleted = false
              AND ti.id = ANY($2::uuid[])
              AND b.status = 'on_hold'`,
          [tenantId, dto.item_ids],
        );
      if (heldRows.length > 0) {
        const list = heldRows
          .map((r) => r.booking_number)
          .filter(Boolean)
          .join(', ');
        throw new BadRequestException(
          `Cannot assign — booking${heldRows.length > 1 ? 's' : ''} ${list} on hold. Resume the booking before assigning.`,
        );
      }
    }

    const updateData: Record<string, unknown> = {
      supplier_id: dto.supplier_id ?? null,
      driver_id: dto.driver_id ?? null,
      assignment_status: 'assigned',
      updated_by: userId,
    };
    if (dto.supplier_name_text !== undefined)
      updateData.supplier_name_text = dto.supplier_name_text || null;
    if (dto.driver_name_text !== undefined)
      updateData.driver_name_text = dto.driver_name_text || null;
    if (dto.driver_phone_text !== undefined)
      updateData.driver_phone_text = dto.driver_phone_text || null;
    if (dto.vehicle_id) updateData.vehicle_id = dto.vehicle_id;
    if (dto.vehicle_no_text !== undefined) updateData.vehicle_no_text = dto.vehicle_no_text || null;

    // Capacity check + writes run inside a single transaction so a
    // concurrent bulk-assign from another user can't slip rows in
    // between our SELECT (existingPax) and our UPDATE — that race
    // would let two ops collectively exceed capacity. Inside the
    // transaction we still recheck after the writes settle so an
    // overlapping conflict raises rather than silently over-fills.
    await this.transportItemRepo.manager.transaction(async (manager) => {
      const txItemRepo = manager.getRepository(BookingTransportItemEntity);

      let effectiveCapacity: number | null = null;
      if (dto.vehicle_id) {
        const vehicle = (await manager.findOne('vehicles', {
          where: { id: dto.vehicle_id } as FindOptionsWhere<ObjectLiteral>,
        })) as { capacity: number | null; type: string | null } | null;
        effectiveCapacity = vehicle?.capacity ?? null;
        if (!effectiveCapacity && vehicle?.type) {
          const typeRow: Array<{ seating_capacity: number | null }> = await manager.query(
            'SELECT seating_capacity FROM vehicle_types WHERE name = $1 AND tenant_id = $2 AND is_deleted = false LIMIT 1',
            [vehicle.type, tenantId],
          );
          effectiveCapacity = typeRow?.[0]?.seating_capacity ?? null;
        }
      }

      // Pre-write check — gives the caller a clean 400 when they're
      // exceeding capacity outright. Capacity guard only meaningful
      // when both a vehicle and driver are being assigned (no driver
      // = no double-booking risk on this route+date).
      if (dto.vehicle_id && dto.driver_id && effectiveCapacity != null) {
        const totalPax = await this.sumPaxForItems(manager, dto.item_ids, tenantId);
        const sampleItem = await txItemRepo.findOne({
          where: { id: dto.item_ids[0], tenant_id: tenantId },
        });
        if (sampleItem?.date) {
          const existingTotal = await this.sumExistingDriverPax(
            manager,
            dto.driver_id,
            sampleItem.date,
            sampleItem.transport_service_id,
            dto.item_ids,
            tenantId,
          );
          if (existingTotal + totalPax > effectiveCapacity) {
            throw new BadRequestException(
              `Vehicle capacity exceeded. ${effectiveCapacity} pax capacity, but ${existingTotal + totalPax} pax needed (${existingTotal} existing + ${totalPax} new).`,
            );
          }
        }
      }

      // Apply the writes. When the DTO carries an explicit
      // `suppliers[]` array (Phase 3), it is the source of truth —
      // the array is written to the junction and the primary entry
      // is mirrored back to the legacy columns so read paths still
      // work. Otherwise the legacy single-supplier flow runs and
      // syncItemSuppliersFromColumns keeps the junction fresh.
      for (const itemId of dto.item_ids) {
        const item = await txItemRepo.findOne({
          where: { id: itemId, tenant_id: tenantId, is_deleted: false },
        });
        if (!item) continue;
        let mergedUpdate = updateData;
        if (dto.suppliers) {
          const mirrored = await this.replaceItemSuppliersFromArray(
            'transport',
            itemId,
            tenantId,
            dto.suppliers,
            manager,
          );
          // Caller's DTO fields still win — spread mirrored FIRST so
          // any explicit supplier_id / actual_supplier_cost in
          // updateData overrides.
          mergedUpdate = { ...mirrored, ...updateData };
        }
        await txItemRepo.update(itemId, mergedUpdate);
        if (!dto.suppliers) {
          await this.syncItemSuppliersFromColumns('transport', itemId, tenantId, manager);
        }
      }

      // Post-write validation — covers the race where another bulk-
      // assign committed between our pre-check and our writes. If
      // capacity was breached by the combined effect, throw and
      // roll the transaction back.
      if (dto.vehicle_id && effectiveCapacity != null && dto.driver_id) {
        const sampleItem = await txItemRepo.findOne({
          where: { id: dto.item_ids[0], tenant_id: tenantId },
        });
        if (sampleItem?.date) {
          const totalAssignedPax = await this.sumDriverDayPax(
            manager,
            dto.driver_id,
            sampleItem.date,
            sampleItem.transport_service_id,
            tenantId,
          );
          if (totalAssignedPax > effectiveCapacity) {
            throw new BadRequestException(
              `Vehicle capacity exceeded after assignment (${totalAssignedPax} > ${effectiveCapacity}). Concurrent assignment may have completed — please refresh and try again.`,
            );
          }
        }
      }
    });

    // One audit row per affected transport item — same pattern as the
    // single assignTransportItem path so the per-record history reads
    // identically whether the assignment was a single or bulk action.
    for (const itemId of dto.item_ids) {
      await this.auditService.log(
        AuditEntityType.BOOKING_TRANSPORT_ITEM,
        itemId,
        AuditAction.ASSIGNED,
        [
          { field: 'supplier_id', old_value: null, new_value: dto.supplier_id ?? null },
          { field: 'driver_id', old_value: null, new_value: dto.driver_id ?? null },
          { field: 'vehicle_id', old_value: null, new_value: dto.vehicle_id ?? null },
          { field: 'bulk', old_value: null, new_value: `applied to ${dto.item_ids.length} items` },
        ],
      );
    }

    return {
      count: dto.item_ids.length,
      message: `${dto.item_ids.length} items assigned successfully`,
    };
  }

  /** Sum pax across the given booking_transport_item ids. */
  private async sumPaxForItems(
    manager: EntityManager,
    itemIds: string[],
    tenantId: string,
  ): Promise<number> {
    if (itemIds.length === 0) return 0;
    const row = await manager.query(
      `SELECT COALESCE(SUM(q.adults + COALESCE(jsonb_array_length(q.children_ages::jsonb), 0)), 0) AS pax
       FROM booking_transport_items ti
       JOIN bookings b ON b.id = ti.booking_id
       JOIN queries q ON q.id = b.query_id
       WHERE ti.id = ANY($1::uuid[])
         AND ti.tenant_id = $2
         AND ti.is_deleted = false
         AND b.is_deleted = false`,
      [itemIds, tenantId],
    );
    return parseInt(row?.[0]?.pax || '0', 10);
  }

  /** Pax already assigned to a driver on a specific date+route, EXCLUDING the items being changed. */
  private async sumExistingDriverPax(
    manager: EntityManager,
    driverId: string,
    date: string,
    transportServiceId: string,
    excludeItemIds: string[],
    tenantId: string,
  ): Promise<number> {
    const row = await manager.query(
      `SELECT COALESCE(SUM(q.adults + COALESCE(jsonb_array_length(q.children_ages::jsonb), 0)), 0) AS pax
       FROM booking_transport_items ti
       JOIN bookings b ON b.id = ti.booking_id
       JOIN queries q ON q.id = b.query_id
       WHERE ti.driver_id = $1
         AND ti.date = $2
         AND ti.transport_service_id = $3
         AND ti.id <> ALL($4::uuid[])
         AND ti.tenant_id = $5
         AND ti.is_deleted = false
         AND b.is_deleted = false`,
      [driverId, date, transportServiceId, excludeItemIds, tenantId],
    );
    return parseInt(row?.[0]?.pax || '0', 10);
  }

  /** Pax assigned to a driver on a specific date+route INCLUDING all current items. */
  private async sumDriverDayPax(
    manager: EntityManager,
    driverId: string,
    date: string,
    transportServiceId: string,
    tenantId: string,
  ): Promise<number> {
    const row = await manager.query(
      `SELECT COALESCE(SUM(q.adults + COALESCE(jsonb_array_length(q.children_ages::jsonb), 0)), 0) AS pax
       FROM booking_transport_items ti
       JOIN bookings b ON b.id = ti.booking_id
       JOIN queries q ON q.id = b.query_id
       WHERE ti.driver_id = $1
         AND ti.date = $2
         AND ti.transport_service_id = $3
         AND ti.tenant_id = $4
         AND ti.is_deleted = false
         AND b.is_deleted = false`,
      [driverId, date, transportServiceId, tenantId],
    );
    return parseInt(row?.[0]?.pax || '0', 10);
  }

  private async resolveSalesNames(
    tenantId: string,
    items: Array<{ booking?: { query?: { sales_team?: string[] | null } } }>,
  ): Promise<Map<string, string>> {
    const ids = new Set<string>();
    for (const item of items) {
      const team = item.booking?.query?.sales_team;
      if (Array.isArray(team)) {
        for (const id of team) {
          if (id) ids.add(id);
        }
      }
    }
    if (ids.size === 0) return new Map();
    const rows: Array<{ id: string; name: string }> = await this.queryRepo.manager.query(
      `SELECT id::text AS id, name FROM users
       WHERE tenant_id = $1 AND is_deleted = false AND id = ANY($2::uuid[])`,
      [tenantId, [...ids]],
    );
    return new Map(rows.map((r) => [r.id, r.name]));
  }
}

function formatSalesNames(
  team: string[] | null | undefined,
  names: Map<string, string>,
): string {
  if (!Array.isArray(team) || team.length === 0) return '';
  return team
    .map((id) => names.get(id))
    .filter((n): n is string => !!n)
    .join(', ');
}
