<?php


if (!function_exists('pc_runtime_config')) {
    $__pc_d = __DIR__;
    for ($__i=0; $__i<8; $__i++) {
        if (is_file($__pc_d.'/_runtime.php')) { require_once $__pc_d.'/_runtime.php'; break; }
        $__pc_d = dirname($__pc_d);
    }
}
// AUTH_GUARD_AUTOGEN
if (!function_exists('tp_guard')) {
  require_once (is_file(__DIR__ . '/_auth_guard.php') ? __DIR__ . '/_auth_guard.php' : dirname(__DIR__) . '/_auth_guard.php');
}
$TP  = tp_guard();
$pdo = $TP['pdo'];

require_once __DIR__ . '/decimal_pallets_middleware.php';
require_once __DIR__ . '/_transport_lock.php';

/**
 * Transports API (GET list by date, PATCH update, CREATE new)
 * Decimal pallets preserved (supports 1,2 and 1.2 -> stored as 1.20).
 */

header('Content-Type: application/json; charset=utf-8');

// ---- DB CONFIG (env first, fallback constants) ----
$DB_HOST = (pc_runtime_config()['db']['host'] ?? '127.0.0.1');
$DB_NAME = (pc_runtime_config()['db']['name'] ?? '');
$DB_USER = (pc_runtime_config()['db']['user'] ?? '');
$DB_PASS = (pc_runtime_config()['db']['pass'] ?? '');
$DB_DSN  = "mysql:host={$DB_HOST};dbname={$DB_NAME};charset=utf8mb4";

try {
  $pdo = new PDO($DB_DSN, $DB_USER, $DB_PASS, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  ]);
} catch (Throwable $e) {
  http_response_code(500);
  echo json_encode(['ok'=>false, 'error'=>'DB connect failed: '.$e->getMessage()]);
  exit;
}

$tpRole = (string)($TP['role'] ?? 'planner');
$tpActiveCompanyId = (int)($TP['active_company_id'] ?? 0);
$tpAllowedCompanyIds = array_values(array_unique(array_map('intval', (array)($TP['allowed_company_ids'] ?? []))));

// ---- Cost helpers ----
function tp_day_type_from_date(string $dateISO): string {
  $ts = strtotime($dateISO);
  if ($ts === false) return 'week';
  $w = (int)date('w', $ts); // 0=Sun,6=Sat
  if ($w === 6) return 'sat';
  if ($w === 0) return 'sun';
  return 'week';
}

function tp_norm_combo_key(string $text): string {
  $s = trim($text);
  if ($s === '') return '';
  // lowercase (UTF-8 safe)
  if (function_exists('mb_strtolower')) $s = mb_strtolower($s, 'UTF-8'); else $s = strtolower($s);
  // arrows/separators to |
  $s = str_replace(['→','->'], '>', $s);
  $s = str_replace([' > ', '>', '›'], '|', $s);
  // allow dash separator too (user input like "A - B")
  $s = str_replace([' - ', ' – ', ' — '], '|', $s);
  $s = preg_replace('/\s*\|\s*/', '|', $s);
  // keep a-z0-9 and |
  $s = preg_replace('/[^a-z0-9|]+/u', '_', $s);
  $s = preg_replace('/_+/', '_', $s);
  $s = trim($s, '_');
  return $s;
}

// ---- Tariff scenario helpers ----
function tp_tariff_scenario(?string $loadDate, ?string $deliverDate): ?string {
  $ld = $loadDate ? trim($loadDate) : '';
  $dd = $deliverDate ? trim($deliverDate) : '';
  if ($ld === '' || $dd === '') return null;

  // Normalize to YYYY-MM-DD
  $ld = substr($ld, 0, 10);
  $dd = substr($dd, 0, 10);

  if ($ld === $dd) {
    $dow = (int)date('N', strtotime($ld)); // 6=Sat, 7=Sun
    if ($dow === 6) return 'sat_same_day';
    if ($dow === 7) return 'sun_same_day';
    return null;
  }

  // Specific preload: Sat -> Sun next day
  $ldDow = (int)date('N', strtotime($ld));
  $ddDow = (int)date('N', strtotime($dd));
  $diffDays = (int)round((strtotime($dd) - strtotime($ld)) / 86400);
  if ($ldDow === 6 && $ddDow === 7 && $diffDays === 1) return 'sat_to_sun_preload';

  return null;
}

// ---- Decimal helpers ----
if (!function_exists('tp_normalize_decimal2')) {
  /**
   * Normaliseert decimale invoer naar string met punt en 2 decimalen.
   * - accepteert "1,2" of "1.2"
   * - geeft "1.20" terug (string) of "0.00" bij lege invoer indien $defaultZero = true
   */
  function tp_normalize_decimal2($v, bool $defaultZero = true) {
    if ($v === null) return $defaultZero ? '0.00' : null;
    if (is_string($v)) {
      $v = trim($v);
      if ($v === '') return $defaultZero ? '0.00' : null;
      $v = str_replace(',', '.', $v);
    }
    $f = (float)$v;
    return number_format($f, 2, '.', '');
  }
}

// ---- Helpers ----
function read_payload(): array {
  $raw = file_get_contents('php://input') ?: '';
  $ct  = $_SERVER['CONTENT_TYPE'] ?? '';
  if (stripos($ct, 'application/json') !== false) {
    $j = json_decode($raw, true);
    return is_array($j) ? $j : [];
  }
  if ($_POST) return $_POST;
  parse_str($raw, $arr);
  return is_array($arr) ? $arr : [];
}

function norm_date(?string $v): ?string {
  if (!$v) return null;
  $v = trim($v);
  if ($v === '') return null;
  if (preg_match('~^\d{4}-\d{2}-\d{2}$~', $v)) return $v; // YYYY-MM-DD
  if (preg_match('~^(\d{2})-(\d{2})-(\d{4})$~', $v, $m)) return "{$m[3]}-{$m[2]}-{$m[1]}"; // DD-MM-YYYY
  if (strpos($v, 'T') !== false) return substr($v, 0, 10); // ISO
  return null;
}
function norm_time(?string $v): ?string {
  if (!$v) return null;
  $v = trim($v);
  if ($v === '') return null;
  if (preg_match('~^(\d{1,2}):?(\d{2})$~', $v, $m)) {
    $h = str_pad($m[1], 2, '0', STR_PAD_LEFT);
    $i = str_pad($m[2], 2, '0', STR_PAD_LEFT);
    return "$h:$i:00";
  }
  if (strpos($v, 'T') !== false) {
    $t = substr($v, 11, 8);
    if (preg_match('~^\d{2}:\d{2}:\d{2}$~', $t)) return $t;
  }
  if (preg_match('~^\d{2}:\d{2}:\d{2}$~', $v)) return $v;
  return null;
}
function norm_datetime(?string $v): ?string {
  if (!$v) return null;
  $v = trim($v);
  if ($v === '') return null;
  if (preg_match('~^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$~', $v)) return $v; // DATETIME
  if (preg_match('~^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}~', $v)) return str_replace('T',' ', substr($v,0,19));
  if (preg_match('~^\d{4}-\d{2}-\d{2}$~', $v)) return $v.' 00:00:00';
  return null;
}
function json_ok($data){ echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; }
function json_err($msg,$code=400){ http_response_code($code); echo json_encode(['ok'=>false,'error'=>$msg]); exit; }

function tp_boolish($v): bool {
  if (is_bool($v)) return $v;
  if (is_int($v) || is_float($v)) return ((int)$v) !== 0;
  $s = strtolower(trim((string)$v));
  return in_array($s, ['1','true','yes','ja','on'], true);
}
function tp_has_value($v): bool {
  return trim((string)$v) !== '';
}
function tp_customer_mail_endpoint(): string {
  $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ((string)($_SERVER['SERVER_PORT'] ?? '') === '443');
  $scheme = $https ? 'https' : 'http';
  $host = (string)($_SERVER['HTTP_HOST'] ?? '');
  if ($host === '') return '';
  $scriptDir = str_replace('\\', '/', dirname((string)($_SERVER['SCRIPT_NAME'] ?? '/api/transports.php')));
  $scriptDir = rtrim($scriptDir, '/');
  if ($scriptDir === '') $scriptDir = '/api';
  return $scheme . '://' . $host . $scriptDir . '/customer_delay_mail.php?action=send';
}
function tp_http_post_json(string $url, array $payload): array {
  $body = json_encode($payload, JSON_UNESCAPED_UNICODE);
  if ($body === false) return ['ok'=>false, 'error'=>'JSON encode failed'];

  if (function_exists('curl_init')) {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
      CURLOPT_POST => true,
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'],
      CURLOPT_POSTFIELDS => $body,
      CURLOPT_CONNECTTIMEOUT => 4,
      CURLOPT_TIMEOUT => 12,
      CURLOPT_SSL_VERIFYPEER => false,
      CURLOPT_SSL_VERIFYHOST => 0,
    ]);
    $resp = curl_exec($ch);
    $errno = curl_errno($ch);
    $err = curl_error($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($errno) return ['ok'=>false, 'error'=>'cURL error: '.$err, 'http_code'=>$code];
    $data = json_decode((string)$resp, true);
    return ['ok'=>($code >= 200 && $code < 300), 'http_code'=>$code, 'data'=>$data, 'raw'=>(string)$resp];
  }

  $ctx = stream_context_create([
    'http' => [
      'method' => 'POST',
      'header' => "Content-Type: application/json; charset=utf-8
",
      'content' => $body,
      'timeout' => 12,
      'ignore_errors' => true,
    ]
  ]);
  $resp = @file_get_contents($url, false, $ctx);
  $code = 0;
  foreach (($http_response_header ?? []) as $hdr) {
    if (preg_match('~HTTP/\S+\s+(\d{3})~', $hdr, $m)) { $code = (int)$m[1]; break; }
  }
  $data = json_decode((string)$resp, true);
  return ['ok'=>($code >= 200 && $code < 300), 'http_code'=>$code, 'data'=>$data, 'raw'=>(string)$resp];
}
function tp_trigger_customer_delay_mail(int $id, string $template, array $extraPayload = []): array {
  $endpoint = tp_customer_mail_endpoint();
  if ($endpoint === '') return ['ok'=>false, 'error'=>'Cannot resolve customer mail endpoint'];

  $payload = array_merge([
    'id' => $id,
    'template' => $template,
    'cron_token' => (getenv('CRON_TOKEN') ?: (getenv('CRON_TOKEN') ?: getenv('CRON_TOKEN_SUNDAY') ?: '')),
  ], $extraPayload);

  return tp_http_post_json($endpoint, $payload);
}


function tp_groupage_date_field(array $row): ?string {
  foreach (['datum_laden','datum_lossen','datum'] as $field) {
    $v = norm_date(isset($row[$field]) ? (string)$row[$field] : null);
    if ($v) return $field;
  }
  return null;
}

function tp_groupage_date_value(array $row): ?string {
  $field = tp_groupage_date_field($row);
  if (!$field) return null;
  return norm_date(isset($row[$field]) ? (string)$row[$field] : null);
}

function tp_groupage_key_from_row(array $row): ?string {
  $carrier = trim((string)($row['vervoerder'] ?? ''));
  $dock = (int)($row['dock'] ?? 0);
  $time = norm_time(isset($row['vertrektijd']) ? (string)$row['vertrektijd'] : null);
  $date = tp_groupage_date_value($row);
  if ($carrier === '' || $dock <= 0 || !$time || !$date) return null;
  $carrierKey = function_exists('mb_strtolower') ? mb_strtolower($carrier, 'UTF-8') : strtolower($carrier);
  return implode('|', [$date, (string)$dock, substr($time,0,5), $carrierKey]);
}

function tp_groupage_attach_meta(array &$rows): void {
  $groups = [];
  foreach ($rows as $idx => $row) {
    $key = tp_groupage_key_from_row($row);
    if (!$key) continue;
    if (!isset($groups[$key])) {
      $groups[$key] = ['indexes'=>[], 'ids'=>[], 'total_pallets'=>0.0];
    }
    $groups[$key]['indexes'][] = $idx;
    $groups[$key]['ids'][] = (int)($row['id'] ?? 0);
    $groups[$key]['total_pallets'] += (float)str_replace(',', '.', (string)($row['pallets'] ?? 0));
  }

  foreach ($groups as $key => $group) {
    $count = count($group['indexes']);
    if ($count < 2) continue;
    $ids = array_values(array_filter(array_map('intval', $group['ids'])));
    $total = number_format((float)$group['total_pallets'], 2, '.', '');
    foreach ($group['indexes'] as $pos => $idx) {
      $rows[$idx]['is_groupage'] = 1;
      $rows[$idx]['groupage_key'] = $key;
      $rows[$idx]['groupage_count'] = $count;
      $rows[$idx]['groupage_pos'] = $pos + 1;
      $rows[$idx]['groupage_ids'] = $ids;
      $rows[$idx]['groupage_total_pallets'] = $total;
    }
  }
}

function tp_find_groupage_ids_from_row(PDO $pdo, array $row, string $scopeWhere, array $scopeVals): array {
  // PATCH-34 (2026-04-17): alleen écht gelinkte transports samen muteren.
  //
  // VOORHEEN: groepeerde op vervoerder + dock + vertrektijd + datum_laden.
  // Gevolg: twee losse ritten naar hetzelfde DC met toevallig dezelfde tijd/dock
  // werden automatisch als "één wagen" gezien. Een wijziging op rit A drukte
  // dan shared fields (datum_laden, vertrektijd, dock, vervoerder, trailernummer,
  // chauffeur_*) óók naar rit B. Concreet gemeld: 2x Lidl DC Heerenveen met
  // AB Texel op dock 13 om 11:30 — wijziging op 33-plts rit synchroniseerde
  // laadtijd/datum naar de 22,75-plts rit. Dezelfde fix is eerder toegepast
  // in Dockplanner/api/outbound/update.php als PATCH-30b.
  //
  // NIEUW: alleen rijen die expliciet bij elkaar horen:
  //   1. combo_id > 0  → echte combivracht via combo_rules/combo_proposals
  //   2. reference_number gelijk en niet-leeg → trailer met meerdere lossingen
  //      (zo'n combivracht deelt dezelfde VLS#####L code op alle rijen)
  // Zonder één van beide: géén auto-groepering, alleen deze ene rij.

  $comboId = (int)($row['combo_id'] ?? 0);
  $refRaw  = trim((string)($row['reference_number'] ?? ''));

  if ($comboId <= 0 && $refRaw === '') {
    return [];
  }

  $conds  = [];
  $params = array_merge([], $scopeVals);

  if ($comboId > 0) {
    $conds[] = 'combo_id = :combo_id';
    $params[':combo_id'] = $comboId;
  }
  if ($refRaw !== '') {
    $conds[] = 'reference_number = :ref_number';
    $params[':ref_number'] = $refRaw;
  }

  $where = '(' . implode(' OR ', $conds) . ')';

  $sql = "SELECT id
          FROM planned_transports
          WHERE {$where}
            AND (status IS NULL OR status <> 'vervallen')
            AND cancelled_at IS NULL
            AND {$scopeWhere}
          ORDER BY id";

  $st = $pdo->prepare($sql);
  $st->execute($params);
  $ids = array_values(array_unique(array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN) ?: [])));
  return array_values(array_filter($ids));
}


/**
 * Lightweight settings getter for server-side enforcement.
 * Tries app_settings first, then settings.
 */
function tp_get_setting(PDO $pdo, string $key) {
  // app_settings
  try {
    $stmt = $pdo->prepare("SELECT value FROM app_settings WHERE `key` = :k LIMIT 1");
    $stmt->execute([':k'=>$key]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    if ($row && array_key_exists('value',$row)) return (string)$row['value'];
  } catch (Throwable $e) { /* table may not exist; ignore */ }

  // fallback: settings
  try {
    $stmt = $pdo->prepare("SELECT value FROM settings WHERE `key` = :k LIMIT 1");
    $stmt->execute([':k'=>$key]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    if ($row && array_key_exists('value',$row)) return (string)$row['value'];
  } catch (Throwable $e) { /* ignore */ }

  return null;
}
function has_column(PDO $pdo, string $table, string $column): bool {
  static $cache = [];
  $key = "$table::$column";
  if (array_key_exists($key, $cache)) return $cache[$key];
  try {
    $stmt = $pdo->prepare("SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND COLUMN_NAME = :c");
    $stmt->execute([':t'=>$table, ':c'=>$column]);
    $cache[$key] = (bool)$stmt->fetchColumn();
  } catch (Throwable $e) {
    $cache[$key] = false;
  }
  return $cache[$key];
}

function tp_company_prefix(PDO $pdo, ?int $companyId): string {
  $cid = (int)($companyId ?? 0);
  if ($cid > 0) {
    try {
      $st = $pdo->prepare("SELECT name, slug, code FROM companies WHERE id = :id LIMIT 1");
      $st->execute([':id' => $cid]);
      $row = $st->fetch(PDO::FETCH_ASSOC) ?: [];
      $hay = strtolower(trim((string)(($row['code'] ?? '') . ' ' . ($row['slug'] ?? '') . ' ' . ($row['name'] ?? ''))));
      if ($hay !== '' && (strpos($hay, 'best') !== false || strpos($hay, 'vlb') !== false)) return 'VLB';
    } catch (Throwable $e) {
      // ignore, fall back below
    }
  }
  return ($cid === 1) ? 'VLB' : 'VLS';
}

function tp_generate_reference_number(PDO $pdo, ?int $companyId = null): string {
  $prefix = tp_company_prefix($pdo, $companyId);
  for ($i=0; $i<20; $i++) {
    $num = random_int(0, 99999);
    $ref = sprintf('%s%05dL', $prefix, $num);
    $st = $pdo->prepare("SELECT COUNT(*) FROM planned_transports WHERE reference_number = :r");
    $st->execute([':r' => $ref]);
    if ((int)$st->fetchColumn() === 0) return $ref;
  }
  return $prefix . substr((string)time(), -5) . 'L';
}

function tp_company_scope_clause(string $alias, string $role, int $activeCompanyId, array $allowedCompanyIds, array &$params): string {
  $col = $alias !== '' ? ($alias . '.company_id') : 'company_id';
  $groupCol = $alias !== '' ? ($alias . '.group_id') : 'group_id';
  if ($role === 'carrier') {
    $ids = array_values(array_unique(array_map('intval', $allowedCompanyIds)));
    $ids = array_values(array_filter($ids, fn($v) => $v > 0));

    // Carrier-accounts hebben in deze database vaak een eigen transport-company (bijv. Welte=3, AB Texel=4).
    // Planned transports hangen echter aan site-companies (Best/Son). Gebruik daarom alleen niet-transport companies
    // voor company-scope; als die ontbreken vallen we terug op group-scope + verplichte carrier-filter.
    if ($ids) {
      try {
        global $pdo;
        if ($pdo instanceof PDO) {
          $placeholders = [];
          $bind = [];
          foreach ($ids as $i => $cid) {
            $ph = ':sitecmp_' . $i;
            $placeholders[] = $ph;
            $bind[$ph] = $cid;
          }
          $st = $pdo->prepare('SELECT id FROM companies WHERE id IN (' . implode(',', $placeholders) . ') AND (company_type IS NULL OR company_type <> "transport")');
          $st->execute($bind);
          $ids = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN) ?: []);
        }
      } catch (Throwable $e) {
        // laat ids dan ongemoeid
      }
    }

    if ($ids) {
      $parts = [];
      foreach ($ids as $i => $cid) {
        $ph = ':cmp_' . $i;
        $parts[] = $col . ' = ' . $ph;
        $params[$ph] = $cid;
      }
      return '(' . implode(' OR ', $parts) . ')';
    }

    global $TP;
    $groupId = (int)(($TP['carrier']['group_id'] ?? 0) ?: 0);
    if ($groupId > 0) {
      // PATCH-27 v1.17 (2026-04-22): ook NULL group_id meetellen.
      // Sommige transports zijn aangemaakt via bulk-import / templates waarbij
      // group_id niet werd gezet (blijft NULL in DB). Die vielen voorheen door
      // deze filter en werden onzichtbaar voor carriers, terwijl carriers ze
      // wel zouden moeten zien (er is maar 1 group in het systeem).
      // Dit maakt de filter tolerant voor missende group_id-waarden.
      $params[':carrier_group_id'] = $groupId;
      return "({$groupCol} = :carrier_group_id OR {$groupCol} IS NULL)";
    }

    return '1=1';
  }
  if ($activeCompanyId > 0) {
    $params[':active_company_id'] = $activeCompanyId;
    return $col . ' = :active_company_id';
  }
  return '1=1';
}

// ---- Router ----
$action = $_GET['action'] ?? '';

// Carrier security context (via tp_guard)
$isCarrier = (($TP['role'] ?? '') === 'carrier');
$myCarrier = $isCarrier ? (string)($_GET['carrier'] ?? '') : '';

if ($isCarrier && $action === 'create') {
  json_err('Carriers mogen geen transporten aanmaken via deze endpoint.', 403);
}



// GET list for day
if ($action === '' && isset($_GET['date'])) {
  $d = norm_date($_GET['date']);

  $enforce = (isset($_GET['require_approved']) && $_GET['require_approved'] === '1');
  // Optionele vervoerder-filter (voor vervoerdersportaal)
  $carrier = isset($_GET['carrier']) ? trim((string)$_GET['carrier']) : '';
  $hasCarrierFilter = ($carrier !== '');

  // --- Server-side enforcement (conditional) ---
  if ($enforce) {
    $serverKey = 'planning_approved:' . $d;
    $approvedValRaw = tp_get_setting($pdo, $serverKey);

    // app_settings.value is JSON text; historically this has been stored as 1 or "1".
    $approvedOk = false;
    if ($approvedValRaw !== null) {
      $s = trim((string)$approvedValRaw);
      if ($s === '1' || $s === '"1"') {
        $approvedOk = true;
      } else {
        $sl = strtolower($s);
        if ($sl === 'true' || $sl === '"true"') {
          $approvedOk = true;
        } else {
          $dec = json_decode($s, true);
          if ($dec === 1 || $dec === '1' || $dec === true) $approvedOk = true;
        }
      }
    }

    if (!$approvedOk) {
      json_err('Planning voor deze datum is (nog) niet goedgekeurd', 403);
    }
  }
  // --- End enforcement ---
  if (!$d) json_err('Ongeldige datum', 400);

  try {
    $hasDatum = has_column($pdo, 'planned_transports', 'datum');
    $extra = $hasDatum ? " OR datum = :d " : "";

    $hasComboId  = has_column($pdo, 'planned_transports', 'combo_id');
    $hasComboPos = has_column($pdo, 'planned_transports', 'combo_pos');
    $params = [':d' => $d];
    $companyWhere = tp_company_scope_clause('', $tpRole, $tpActiveCompanyId, $tpAllowedCompanyIds, $params);
    $sql = "SELECT id, reference_number, vervoerder, dock, vertrektijd, datum_laden, datum_lossen, tijd_lossen, aankomsttijd,
                   losbestemming, losnummer, trailernummer, laadvolgorde, pallets, is_pendel" . ($hasComboId ? ", combo_id" : "") . ($hasComboPos ? ", combo_pos" : "") . ", opmerkingen,
                   status, chauffeur_aangemeld_at, chauffeur_vertrokken_at, geleverd_at, vertrek_te_laat_reden" . ($hasLateDetails ? ", vertrek_te_laat_details" : "") . ", te_laat_geleverd_reden" .
                   ($hasDatum ? ", datum" : "") . "
            FROM planned_transports
            WHERE " . $companyWhere . "
              AND (datum_laden = :d OR datum_lossen = :d $extra)
              AND (status IS NULL OR status <> 'vervallen')";
    if ($hasCarrierFilter) {
      $sql .= " AND vervoerder = :carrier";
    }
    $sql .= "
            ORDER BY COALESCE(NULLIF(laadvolgorde,''), '999999'), id";
    $st = $pdo->prepare($sql);
    if ($hasCarrierFilter) {
      $params[':carrier'] = $carrier;
    }
    $st->execute($params);
    $rows = $st->fetchAll();
    foreach ($rows as &$r) {
      foreach (['vertrektijd','tijd_lossen','aankomsttijd'] as $tf) {
        if ($r[$tf] !== null && strlen($r[$tf])===5) $r[$tf] .= ':00';
      }
      // Pallets consistent als string met 2 decimalen
      if (array_key_exists('pallets', $r) && $r['pallets'] !== null) {
        $r['pallets'] = tp_normalize_decimal2($r['pallets']);
      }
      $r['is_locked'] = tp_transport_is_locked_row($r) ? 1 : 0;
      if ($r['is_locked']) {
        $refDate = tp_transport_refdate_from_row($r);
        $r['lock_reason'] = 'Vergrendeld: ouder dan 4 weken' . ($refDate ? (' (' . $refDate . ')') : '');
      }
    }
    // ---- Costs (fixed transport & combo) ----
    $canCosts = true;
    try {
      // quick existence checks
      $pdo->query("SELECT 1 FROM transport_cost_rules LIMIT 1");
      $pdo->query("SELECT 1 FROM combo_cost_rules LIMIT 1");
    } catch (Throwable $e) {
      $canCosts = false;
    }

    if ($canCosts) {
      // Prepare statements (single + combo)
      $stSingleScenario = $pdo->prepare("
        SELECT id, amount, valid_from
        FROM transport_cost_rules
        WHERE carrier = :carrier
          AND destination = :destination
          AND tariff_scenario = :tariff_scenario
          AND valid_from <= :base_date
          AND (valid_to IS NULL OR valid_to >= :base_date)
        ORDER BY valid_from DESC
        LIMIT 1
      ");
      $stSingleDay = $pdo->prepare("
        SELECT id, amount, valid_from
        FROM transport_cost_rules
        WHERE carrier = :carrier
          AND destination = :destination
          AND day_type = :day_type
          AND valid_from <= :base_date
          AND (valid_to IS NULL OR valid_to >= :base_date)
        ORDER BY valid_from DESC
        LIMIT 1
      ");
      $comboHasKey = true;
      try { $pdo->query("SELECT combo_key FROM combo_cost_rules LIMIT 1"); }
      catch (Throwable $e) { $comboHasKey = false; }

      $sqlComboScenario = $comboHasKey ? "
        SELECT id, amount, valid_from
        FROM combo_cost_rules
        WHERE carrier = :carrier
          AND combo_key = :combo_key
          AND tariff_scenario = :tariff_scenario
          AND valid_from <= :base_date
          AND (valid_to IS NULL OR valid_to >= :base_date)
        ORDER BY valid_from DESC
        LIMIT 1
      " : "
        SELECT id, amount, valid_from
        FROM combo_cost_rules
        WHERE carrier = :carrier
          AND combo_description = :combo_key
          AND tariff_scenario = :tariff_scenario
          AND valid_from <= :base_date
          AND (valid_to IS NULL OR valid_to >= :base_date)
        ORDER BY valid_from DESC
        LIMIT 1
      ";

      $sqlComboDay = $comboHasKey ? "
        SELECT id, amount, valid_from
        FROM combo_cost_rules
        WHERE carrier = :carrier
          AND combo_key = :combo_key
          AND day_type = :day_type
          AND valid_from <= :base_date
          AND (valid_to IS NULL OR valid_to >= :base_date)
        ORDER BY valid_from DESC
        LIMIT 1
      " : "
        SELECT id, amount, valid_from
        FROM combo_cost_rules
        WHERE carrier = :carrier
          AND combo_description = :combo_key
          AND day_type = :day_type
          AND valid_from <= :base_date
          AND (valid_to IS NULL OR valid_to >= :base_date)
        ORDER BY valid_from DESC
        LIMIT 1
      ";

      $stComboScenario = $pdo->prepare($sqlComboScenario);
      $stComboDay = $pdo->prepare($sqlComboDay);
$stComboOrder = $pdo->prepare("SELECT unload_order FROM combo_proposals WHERE id = ? LIMIT 1");

      foreach ($rows as &$r) {
        $r['cost_amount'] = null;
        $r['cost_source'] = null;
        $r['cost_day_type'] = null;
        $r['cost_tariff_scenario'] = null;
        $r['cost_rule_id'] = null;
        $r['cost_rule_valid_from'] = null;

        // Daytype should be based on loading/departure day, not the view date
        $baseDate = $r['datum_laden'] ?? ($r['datum'] ?? $d);
        if (!$baseDate) $baseDate = $d;

        $dayType = tp_day_type_from_date($baseDate);
        $r['cost_day_type'] = $dayType;

        $scenario = tp_tariff_scenario($r['datum_laden'] ?? null, $r['datum_lossen'] ?? null);
        $r['cost_tariff_scenario'] = $scenario;

        $carrier = (string)($r['vervoerder'] ?? '');
        $dest    = (string)($r['losbestemming'] ?? '');

        $tryDayTypes = [$dayType];
        if ($dayType === 'sat' || $dayType === 'sun') {
          // fallback: if weekend rule not present, use weekday tariff
          $tryDayTypes[] = 'week';
        }

        // Combo first if we have combo_id
        $comboId = $r['combo_id'] ?? null;
        if (!empty($comboId)) {
          $comboOrder = null;
          try {
            $stComboOrder->execute([(int)$comboId]);
            $comboOrder = $stComboOrder->fetchColumn();
          } catch (Throwable $e) { /* ignore */ }

          if (!$comboOrder) {
            // fallback to laadvolgorde (contains the stops text)
            $comboOrder = $r['laadvolgorde'] ?? null;
          }

          if ($comboOrder) {
            $comboKey = tp_norm_combo_key((string)$comboOrder);

            // 1) Probeer eerst scenario-tarief (bijv. za→zo preload)
            if ($scenario) {
              $stComboScenario->execute([
                'carrier'         => $carrier,
                'combo_key'       => $comboKey,
                'tariff_scenario' => $scenario,
                'base_date'       => $baseDate,
              ]);
              $hit = $stComboScenario->fetch(PDO::FETCH_ASSOC);
              if ($hit) {
                $r['cost_amount'] = (float)$hit['amount'];
                $r['cost_source'] = 'combo';
                $r['cost_rule_id'] = (int)$hit['id'];
                $r['cost_rule_valid_from'] = $hit['valid_from'];
                $r['cost_day_type'] = $dayType; // informatief
              }
            }

            // 2) Fallback: dagtype (sat/sun/week)
            if ($r['cost_amount'] === null) {
              foreach ($tryDayTypes as $dtTry) {
                $stComboDay->execute([
                  'carrier'   => $carrier,
                  'combo_key' => $comboKey,
                  'day_type'  => $dtTry,
                  'base_date' => $baseDate,
                ]);
                $hit = $stComboDay->fetch(PDO::FETCH_ASSOC);
                if ($hit) {
                  $r['cost_amount'] = (float)$hit['amount'];
                  $r['cost_source'] = 'combo';
                  $r['cost_rule_id'] = (int)$hit['id'];
                  $r['cost_rule_valid_from'] = $hit['valid_from'];
                  $r['cost_day_type'] = $dtTry;
                  break;
                }
              }
            }
          }
        }

        // If still no cost: single
        if ($r['cost_amount'] === null && $carrier !== '' && $dest !== '') {
          // 1) Probeer eerst scenario-tarief (bijv. za→zo preload)
          if ($scenario) {
            $stSingleScenario->execute([
              'carrier'         => $carrier,
              'destination'     => $dest,
              'tariff_scenario' => $scenario,
              'base_date'       => $baseDate,
            ]);
            $hit = $stSingleScenario->fetch(PDO::FETCH_ASSOC);
            if ($hit) {
              $r['cost_amount'] = (float)$hit['amount'];
              $r['cost_source'] = 'single';
              $r['cost_rule_id'] = (int)$hit['id'];
              $r['cost_rule_valid_from'] = $hit['valid_from'];
              $r['cost_day_type'] = $dayType; // informatief
            }
          }

          // 2) Fallback: dagtype (sat/sun/week)
          if ($r['cost_amount'] === null) {
            foreach ($tryDayTypes as $dtTry) {
              $stSingleDay->execute([
                'carrier'      => $carrier,
                'destination'  => $dest,
                'day_type'     => $dtTry,
                'base_date'    => $baseDate,
              ]);
              $hit = $stSingleDay->fetch(PDO::FETCH_ASSOC);
              if ($hit) {
              $r['cost_amount'] = (float)$hit['amount'];
              $r['cost_source'] = 'single';
              $r['cost_rule_id'] = (int)$hit['id'];
              $r['cost_rule_valid_from'] = $hit['valid_from'];
              $r['cost_day_type'] = $dtTry;
              break;
              }
            }
          }
        }
      }
      unset($r);
    }

    tp_groupage_attach_meta($rows);

    json_ok($rows);
  } catch (Throwable $e) {
    json_err('Fetch failed: '.$e->getMessage(), 500);
  }
}

// PATCH (also allow 'datum' if present)
if ($action === 'patch') {
  $id = isset($_GET['id']) ? (int)$_GET['id'] : (isset($_POST['id'])?(int)$_POST['id']:0);
  if ($id <= 0) json_err('Missing id', 400);
  $p = read_payload();
  if (array_key_exists('vertrokken', $p) && !array_key_exists('chauffeur_vertrokken', $p)) {
    $p['chauffeur_vertrokken'] = $p['vertrokken'];
  }

  // Autorisatie: carriers mogen alleen hun eigen records aanpassen + beperkte velden
  if ($isCarrier) {
    if ($myCarrier === '') json_err('Carrier context ontbreekt', 403);

    $chkParams = [];
    $chkWhere = tp_company_scope_clause('', $tpRole, $tpActiveCompanyId, $tpAllowedCompanyIds, $chkParams);
    $chk = $pdo->prepare('SELECT id FROM planned_transports WHERE id = :id AND vervoerder = :carrier AND ' . $chkWhere . ' LIMIT 1');
    $chk->execute(array_merge([':id'=>$id, ':carrier'=>$myCarrier], $chkParams));
    if (!$chk->fetchColumn()) json_err('Geen toegang tot dit transport', 403);

    $carrierAllowed = ['aankomsttijd','aankomst_at','geleverd_at','te_laat_geleverd_reden','trailernummer','chauffeur_vertrokken','chauffeur_vertrokken_at','vertrek_te_laat_reden'];
    foreach (array_keys($p) as $k) {
      if (!in_array($k, $carrierAllowed, true)) {
        json_err('Je mag dit veld niet aanpassen: '.$k, 403);
      }
    }
  }



  $hasRef = has_column($pdo,'planned_transports','reference_number');

  // Zorg dat optioneel veld voor extra toelichting bestaat (wordt aangemaakt bij eerste gebruik)
  if (array_key_exists('vertrek_te_laat_details', $p) && !has_column($pdo,'planned_transports','vertrek_te_laat_details')) {
    try {
      $pdo->exec("ALTER TABLE planned_transports ADD COLUMN vertrek_te_laat_details TEXT NULL");
    } catch (Throwable $e) { /* ignore */ }
  }

  $scopeVals = [];
  $scopeWhere = tp_company_scope_clause('', $tpRole, $tpActiveCompanyId, $tpAllowedCompanyIds, $scopeVals);

  $prev = null;
  try {
    $prev = tp_transport_assert_mutable($pdo, $id, $TP, 'bewerkt');
  } catch (TPTransportLockedException $e) {
    json_err($e->getMessage(), 403);
  } catch (Throwable $e) {
    $prev = null;
  }

  // PATCH-33a (combi-ritten fix): velden splitsen in shared en per-row.
  //
  // Shared = één wagen, één waarde — moet op alle combo-leden gelijk blijven:
  //   - dock, vertrektijd, datum_laden, vervoerder, trailernummer
  //   - chauffeur-aanmeld/vertrek-velden (één chauffeur rijdt de hele combi)
  //   - vertrek-te-laat-velden (vertrek is vertrek, ongeacht aantal lossingen)
  //
  // Per-row = kan per losadres verschillen — alleen op $id schrijven:
  //   - tijd_lossen, aankomsttijd  (iedere losbestemming heeft eigen venster)
  //   - datum_lossen (theoretisch kan lossing de volgende dag vallen)
  //   - losbestemming, losnummer, pallets, laadvolgorde, opmerkingen
  //   - status, geleverd_at, te_laat_geleverd_reden
  //     (rij A kan al geleverd zijn terwijl B nog onderweg is)
  //
  // Bug vóór patch-33a: tijd_lossen stond in $groupageSyncFields, dus een
  // wijziging van de lostijd van ETT werd óók naar WAD geschreven.
  $groupageSharedFields = [
    'dock','vertrektijd','datum_laden','vervoerder','trailernummer',
    'chauffeur_aangemeld_at','chauffeur_vertrokken','chauffeur_vertrokken_at',
    'vertrek_te_laat_reden','vertrek_te_laat_details'
  ];
  $shouldSyncGroupage = false;
  foreach ($groupageSharedFields as $field) {
    if (array_key_exists($field, $p)) { $shouldSyncGroupage = true; break; }
  }

  $targetIds = [$id];
  if ($shouldSyncGroupage && $prev) {
    try {
      $groupIds = tp_find_groupage_ids_from_row($pdo, $prev, $scopeWhere, $scopeVals);
      if (count($groupIds) >= 2) $targetIds = $groupIds;
    } catch (Throwable $e) { /* fallback to single id */ }
  }
  $targetIds = array_values(array_unique(array_map('intval', $targetIds)));

  foreach ($targetIds as $tid) {
    try {
      tp_transport_assert_mutable($pdo, $tid, $TP, 'bewerkt');
    } catch (TPTransportLockedException $e) {
      json_err($e->getMessage(), 403);
    } catch (Throwable $e) {
      /* ignore non-fatal mutability check errors for secondary ids */
    }
  }

  if (array_key_exists('chauffeur_vertrokken', $p) && tp_boolish($p['chauffeur_vertrokken']) && !array_key_exists('chauffeur_vertrokken_at', $p)) {
    $p['chauffeur_vertrokken_at'] = date('Y-m-d H:i:s');
  }

/* GELEVERD_AT HH:MM NORMALIZE (minimal, safe) */
if (isset($p['geleverd_at']) && is_string($p['geleverd_at']) && preg_match('~^\d{2}:\d{2}$~', $p['geleverd_at'])) {
    try {
        $st0 = $pdo->prepare("SELECT datum_lossen FROM planned_transports WHERE id = :id");
        $st0->execute(array(':id' => (int)$id));
        $dl = $st0->fetchColumn();
        if ($dl) {
            $p['geleverd_at'] = trim($dl).' '.trim($p['geleverd_at']).':00';
        }
    } catch (Exception $e) { /* ignore */ }
}


  $map = [
    'vervoerder'   => fn($v)=> trim((string)$v),
    'dock'         => fn($v)=> ($v === '' || $v === null) ? null : (int)$v,
    'vertrektijd'  => fn($v)=> norm_time($v),
    'datum_laden'  => fn($v)=> norm_date($v),
    'datum_lossen' => fn($v)=> norm_date($v),
    'tijd_lossen'  => fn($v)=> norm_time($v),
    'aankomsttijd' => fn($v)=> norm_time($v),
    'losbestemming'=> fn($v)=> trim((string)$v),
    'losnummer'    => fn($v)=> trim((string)$v),
    'trailernummer'=> fn($v)=> trim((string)$v),
    'laadvolgorde' => fn($v)=> trim((string)$v),
    // DECIMAL veilig: niet naar int casten
    'pallets'      => fn($v)=> tp_normalize_decimal2($v, true),
    'opmerkingen'  => fn($v)=> trim((string)$v),
    'status'       => fn($v)=> trim((string)$v),
    'chauffeur_aangemeld_at' => fn($v)=> norm_datetime($v),
    'chauffeur_vertrokken'   => fn($v)=> tp_boolish($v) ? 1 : 0,
    'chauffeur_vertrokken_at'=> fn($v)=> norm_datetime($v),
    'cancelled_at' => fn($v)=> norm_datetime($v),
    'datum'       => fn($v)=> norm_date($v),
    'vertrek_te_laat_reden' => fn($v)=> trim((string)$v),
        'vertrek_te_laat_details' => fn($v)=> trim((string)$v),
'geleverd_at' => fn($v)=> norm_datetime($v),
    'te_laat_geleverd_reden' => fn($v)=> trim((string)$v)
];

  // PATCH-33a: splits velden in twee sets zodat per-row velden (tijd_lossen,
  // aankomsttijd, losbestemming, losnummer, pallets, laadvolgorde, opmerkingen,
  // status, geleverd_at, te_laat_geleverd_reden, datum_lossen) alleen op de
  // originele $id worden geschreven, en shared velden op alle combo-leden.
  $sharedSet = [];
  $sharedVals = [];
  $perRowSet = [];
  $perRowVals = [];
  foreach ($map as $col => $fn) {
    if (!array_key_exists($col, $p)) continue;
    $normalized = $fn($p[$col]);
    if (in_array($col, $groupageSharedFields, true)) {
      $sharedSet[] = "$col = :$col";
      $sharedVals[":$col"] = $normalized;
    } else {
      $perRowSet[] = "$col = :$col";
      $perRowVals[":$col"] = $normalized;
    }
  }
  if (!$sharedSet && !$perRowSet) json_err('No valid fields to update');

  $sqlShared = $sharedSet
    ? "UPDATE planned_transports SET ".implode(', ', $sharedSet).", updated_at = NOW() WHERE id = :id AND " . $scopeWhere
    : null;
  $sqlPerRow = $perRowSet
    ? "UPDATE planned_transports SET ".implode(', ', $perRowSet).", updated_at = NOW() WHERE id = :id AND " . $scopeWhere
    : null;

  $mailTriggered = false;
  $mailTemplate = null;
  $mailResults = [];
  $updatedIds = [];

  try {
    if (!$pdo->inTransaction()) $pdo->beginTransaction();

    $prevRows = [];
    foreach ($targetIds as $tid) {
      try { $prevRows[$tid] = tp_transport_fetch_row($pdo, $tid); } catch (Throwable $e) { $prevRows[$tid] = null; }
    }

    // Step 1: shared velden (dock/vertrektijd/vervoerder/etc) naar alle combo-rijen
    if ($sqlShared) {
      $stShared = $pdo->prepare($sqlShared);
      foreach ($targetIds as $tid) {
        $vals = array_merge([':id' => $tid], $sharedVals, $scopeVals);
        $stShared->execute($vals);
      }
    }

    // Step 2: per-row velden (tijd_lossen/aankomsttijd/losbestemming/etc) ALLEEN op $id
    if ($sqlPerRow) {
      $stPerRow = $pdo->prepare($sqlPerRow);
      $vals = array_merge([':id' => $id], $perRowVals, $scopeVals);
      $stPerRow->execute($vals);
    }

    // Step 3: logging + mail-trigger per rij (zelfde als voorheen)
    foreach ($targetIds as $tid) {
      $updatedIds[] = $tid;

      $after = null;
      try {
        $after = tp_transport_fetch_row($pdo, $tid);
        if (($prevRows[$tid] ?? null) && $after) {
          tp_transport_log_update($pdo, $TP, $tid, $prevRows[$tid], $after, [
            'source' => 'transports.php?action=patch',
            'groupage_sync' => (count($targetIds) >= 2 ? 1 : 0),
            'patch33a_split' => 1,
            'is_origin_row' => ($tid === $id ? 1 : 0),
          ]);
        }
      } catch (Throwable $e) {
        $after = null;
      }

      $prevDeparted = ($prevRows[$tid] ?? null) ? (tp_has_value($prevRows[$tid]['chauffeur_vertrokken_at'] ?? '') || tp_boolish($prevRows[$tid]['chauffeur_vertrokken'] ?? 0)) : false;
      $afterDeparted = $after ? (tp_has_value($after['chauffeur_vertrokken_at'] ?? '') || tp_boolish($after['chauffeur_vertrokken'] ?? 0)) : false;
      $prevDelay = ($prevRows[$tid] ?? null) ? tp_has_value($prevRows[$tid]['vertrek_te_laat_reden'] ?? '') : false;
      $afterDelay = $after ? tp_has_value($after['vertrek_te_laat_reden'] ?? '') : false;

      $rowMailTemplate = null;
      if ($afterDelay && !$prevDelay) {
        $rowMailTemplate = 'delay';
      } elseif ($afterDeparted && !$prevDeparted) {
        $rowMailTemplate = 'departed';
      }

      if ($rowMailTemplate !== null) {
        $mailTriggered = true;
        $mailTemplate = $rowMailTemplate;
        $mailPayload = [];
        if ($after && (int)($after['combo_id'] ?? 0) > 0) {
          $mailPayload['send_all_combo'] = 1;
        }
        $mailResults[(string)$tid] = tp_trigger_customer_delay_mail($tid, $rowMailTemplate, $mailPayload);
      }
    }

    if ($pdo->inTransaction()) $pdo->commit();

    json_ok([
      'ok'=>true,
      'groupage_synced' => (count($updatedIds) >= 2 && !empty($sharedSet)),
      'updated_ids' => $updatedIds,
      'origin_id' => $id,
      'shared_fields' => array_keys($sharedVals),
      'per_row_fields' => array_keys($perRowVals),
      'mail_triggered' => $mailTriggered,
      'mail_template' => $mailTemplate,
      'mail_result' => $mailResults,
    ]);
  } catch (Throwable $e) {
    if ($pdo->inTransaction()) $pdo->rollBack();
    json_err('Patch failed: '.$e->getMessage(), 400);
  }
}

// CREATE (auto-fill optional single `datum` column with selected day)
if ($action === 'create') {
  $p = read_payload();

  $hasRef = has_column($pdo,'planned_transports','reference_number');

  // Zorg dat optioneel veld voor extra toelichting bestaat (wordt aangemaakt bij eerste gebruik)
  if (array_key_exists('vertrek_te_laat_details', $p) && !has_column($pdo,'planned_transports','vertrek_te_laat_details')) {
    try {
      $pdo->exec("ALTER TABLE planned_transports ADD COLUMN vertrek_te_laat_details TEXT NULL");
    } catch (Throwable $e) { /* ignore */ }
  }


  $data = [
    'vervoerder'    => trim($p['vervoerder'] ?? ''),
    'dock'          => ($p['dock'] ?? null) !== '' ? (int)$p['dock'] : null,
    'vertrektijd'   => norm_time($p['vertrektijd'] ?? null),
    'datum_laden'   => norm_date($p['datum_laden'] ?? null),
    'datum_lossen'  => norm_date($p['datum_lossen'] ?? null),
    'tijd_lossen'   => norm_time($p['tijd_lossen'] ?? null),
    'losbestemming' => trim($p['losbestemming'] ?? ''),
    'losnummer'     => trim($p['losnummer'] ?? ''),
    'trailernummer' => trim($p['trailernummer'] ?? ''),
    'laadvolgorde'  => trim($p['laadvolgorde'] ?? ''),
    // DECIMAL veilig
    'pallets'       => tp_normalize_decimal2($p['pallets'] ?? null, true),
    'opmerkingen'   => trim($p['opmerkingen'] ?? ''),
    'status'        => trim($p['status'] ?? 'gepland'),
  ];

  // if table has single 'datum' column, set it from:
  // payload.datum OR ?date OR datum_laden OR datum_lossen OR today
  $hasDatum = has_column($pdo, 'planned_transports', 'datum');
  $datum_single = null;
  if ($hasDatum) {
    $datum_single = norm_date($p['datum'] ?? null)
        ?: norm_date($_GET['date'] ?? null)
        ?: ($data['datum_laden'] ?? null)
        ?: ($data['datum_lossen'] ?? null)
        ?: date('Y-m-d');
  }

  if (!$data['datum_laden'] && !$data['datum_lossen']) {
    if ($hasDatum && $datum_single) {
      $data['datum_laden'] = $datum_single;
    } else {
      json_err('datum_laden of datum_lossen is verplicht');
    }
  }

  // Genereer referentie indien kolom bestaat en niet meegegeven
  $companyId = (int)($tpActiveCompanyId ?: ($TP['planner']['default_company_id'] ?? 0) ?: ($TP['carrier']['company_id'] ?? 0));
  $groupId = (int)(($TP['planner']['group_id'] ?? 0) ?: ($TP['carrier']['group_id'] ?? 0));

  if ($hasRef && (!isset($data['reference_number']) || trim((string)$data['reference_number'])==='')) {
    $data['reference_number'] = tp_generate_reference_number($pdo, $companyId);
  }

  // Bouw dynamische INSERT zodat we 'datum' alleen toevoegen als de kolom bestaat
  $cols = ['group_id','company_id','vervoerder','dock','vertrektijd','datum_laden','datum_lossen','tijd_lossen',
           'losbestemming','losnummer','trailernummer','laadvolgorde','pallets','opmerkingen',
           'status','chauffeur_aangemeld_at','chauffeur_vertrokken_at','cancelled_at','created_at'];
  $ph   = [':group_id',':company_id',':vervoerder',':dock',':vertrektijd',':datum_laden',':datum_lossen',':tijd_lossen',
           ':losbestemming',':losnummer',':trailernummer',':laadvolgorde',':pallets',':opmerkingen',
           ':status', 'NULL', 'NULL', 'NULL', 'NOW()'];
  if ($hasRef) { $cols[]='reference_number'; $ph[]=':reference_number'; }
  $vals = [
    ':group_id'=>($groupId > 0 ? $groupId : null),
    ':company_id'=>($companyId > 0 ? $companyId : null),
    ':vervoerder'=>$data['vervoerder'],
    ':dock'=>$data['dock'],
    ':vertrektijd'=>$data['vertrektijd'],
    ':datum_laden'=>$data['datum_laden'],
    ':datum_lossen'=>$data['datum_lossen'],
    ':tijd_lossen'=>$data['tijd_lossen'],
    ':losbestemming'=>$data['losbestemming'],
    ':losnummer'=>$data['losnummer'],
    ':trailernummer'=>$data['trailernummer'],
    ':laadvolgorde'=>$data['laadvolgorde'],
    ':pallets'=>$data['pallets'], // string "1.20"
    ':opmerkingen'=>$data['opmerkingen'],
    ':status'=>$data['status'],
    ':reference_number'=> ($hasRef ? ($data['reference_number'] ?? null) : null),
  ];

  if ($hasDatum) {
    $cols[] = 'datum';
    $ph[]   = ':datum';
    $vals[':datum'] = $datum_single;
  }

  $sql = "INSERT INTO planned_transports (".implode(',', $cols).")
          VALUES (".implode(',', $ph).")";

  try {
    $st = $pdo->prepare($sql);
    $st->execute($vals);
    $id = (int)$pdo->lastInsertId();
    json_ok(['ok'=>true, 'id'=>$id]);
  } catch (Throwable $e) {
    json_err('Create failed: '.$e->getMessage(), 400);
  }
}

json_err('Unknown action', 400);