<?php
/* ================== MIGRAR MYSQL -> POSTGRES (sin CSV) ==================
 * Archivo: /api_comercial/migrar_mysql_a_pg.php
 * ====================================================================== */

error_reporting(E_ALL & ~E_NOTICE);
ini_set('display_errors','0');
ini_set('memory_limit','1024M');
set_time_limit(0);
header("Content-Type: application/json; charset=utf-8");
ob_start();

/* ---- Manejo de fatales → siempre JSON ---- */
register_shutdown_function(function () {
  $e = error_get_last();
  if ($e && in_array($e['type'], [E_ERROR,E_PARSE,E_CORE_ERROR,E_COMPILE_ERROR])) {
    while (ob_get_level() > 0) { @ob_end_clean(); }
    http_response_code(500);
    echo json_encode([
      "ok"=>false,
      "fatal"=>$e['message'],
      "file"=>$e['file'],
      "line"=>$e['line']
    ], JSON_UNESCAPED_UNICODE);
  }
});
function out_json($arr,$code=200){ http_response_code($code); echo json_encode($arr,JSON_UNESCAPED_UNICODE); exit; }

/* ---- Entrada (GET/POST JSON/POST form) ---- */
$RAW = [];
if ($_SERVER['REQUEST_METHOD']==='POST' && stripos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json')!==false){
  $RAW = json_decode(file_get_contents("php://input"), true) ?: [];
} else {
  $RAW = array_merge($_GET ?? [], $_POST ?? []);
  if (!empty($RAW['only']) && is_string($RAW['only'])) {
    $RAW['only'] = array_filter(array_map('trim', preg_split('/[,\s]+/', $RAW['only'])));
  }
}

/* ================== Rutas de conexiones ================== */
$mysqlConnFile = realpath(__DIR__.'/../api/conexion.php');   // MySQL (mysqli)
$pgConnFile    = realpath(__DIR__.'/conexion.php');          // PostgreSQL (PDO)

if (!$mysqlConnFile) out_json(["ok"=>false,"error"=>"No existe ../api/conexion.php (MySQL) desde api_comercial"],500);
if (!$pgConnFile)    out_json(["ok"=>false,"error"=>"No existe conexion.php (PG) en api_comercial"],500);

/* ================== Cargar conexiones ================== */
require_once $mysqlConnFile;
$mysql = null;
foreach (['conn','con','mysqli','link','db'] as $v) {
  if (isset(${$v}) && ${$v} instanceof mysqli) { $mysql = ${$v}; break; }
}
if (!$mysql) out_json(["ok"=>false,"error"=>"Se cargó $mysqlConnFile pero no se encontró una instancia mysqli (revisa el nombre de la variable)"],500);

require_once $pgConnFile; // debe exponer $conn (PDO)
if (!isset($conn) || !($conn instanceof PDO)) {
  out_json(["ok"=>false,"error"=>"DB PG: \$conn no disponible o no es PDO en $pgConnFile"],500);
}
$pg = $conn;
$pg->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

/* ================== Helpers MySQL ================== */
function mysql_column_exists(mysqli $db, string $table, string $col): bool {
  $table = $db->real_escape_string($table);
  $col   = $db->real_escape_string($col);
  $sql = "SHOW COLUMNS FROM `{$table}` LIKE '{$col}'";
  if (!$res = $db->query($sql)) return false;
  $ok = (bool)$res->fetch_assoc();
  $res->free();
  return $ok;
}

/* ================== Acción: cleanup (blanqueo GLOBAL) ================== */
if (isset($RAW['action']) && $RAW['action'] === 'cleanup') {
  $res = cleanup_datos_global($pg);
  if (!$res["ok"]) out_json($res, 500);
  out_json(["ok"=>true, "accion"=>"cleanup_global", "resumen"=>$res]);
}

/* ================== Diagnóstico rápido ================== */
if (isset($RAW['diag'])) {
  $okMysql = @$mysql->ping();
  try { $pg->query("SELECT 1"); $okPg = true; } catch(Throwable $e){ $okPg = false; $pgErr=$e->getMessage(); }
  out_json([
    "ok"=>true,
    "diag"=>[
      "mysql_conn_file"=>$mysqlConnFile,
      "pg_conn_file"=>$pgConnFile,
      "mysql_ping"=>$okMysql,
      "pg_ok"=>$okPg,
      "pg_error"=> $pgErr ?? null,
      "php_version"=>PHP_VERSION
    ]
  ]);
}

/* ================== Config y parámetros ================== */
define('DEFAULT_LIMIT', 2000);

$CFG = [
  "empresa_id"  => isset($RAW['empresa_id'])  ? (int)$RAW['empresa_id']  : 1,
  "sucursal_id" => isset($RAW['sucursal_id']) ? (int)$RAW['sucursal_id'] : 1,
  "almacen_id"  => isset($RAW['almacen_id'])  ? (int)$RAW['almacen_id']  : 1,
  "kardex" => [
    "nserie"        => "INV1",
    "ndocumento"    => "00000001",
    "docfiscal_id"  => 11,
    "operacion"     => "INV",
    "razon_social"  => "AJUSTE",
    "observacion"   => "Saldo inicial por migración directa MySQL→PG",
    "fecha"         => date('Y-m-d'),
    "unidad_default"=> "UND",
  ],
  "offset"     => isset($RAW['offset']) ? max(0,(int)$RAW['offset']) : 0,
  "limit"      => array_key_exists('limit',$RAW)
                  ? ((((int)$RAW['limit']) <= 0) ? null : (int)$RAW['limit'])
                  : DEFAULT_LIMIT,
  "chunk_size" => isset($RAW['chunk_size']) ? max(1,(int)$RAW['chunk_size']) : 500,
];

$ONLY = [];
if (!empty($RAW['only']) && is_array($RAW['only'])) $ONLY = array_flip(array_map('mb_strtolower',$RAW['only']));
function run_section(array $ONLY,string $name):bool{ if(!$ONLY) return true; return isset($ONLY[mb_strtolower($name)]); }

/* ================== Helpers compartidos ================== */
function nval($v){ if($v===null) return null; $v=trim((string)$v); if($v==='') return null; $v=str_replace([' ', ','], ['', '.'], $v); return is_numeric($v)?(float)$v:null; }
function sval_or_null($s){ $s=(string)$s; return (trim($s)==='')?null:$s; }
function norm_text($s){ $s=mb_strtoupper(trim((string)$s)); return preg_replace('/\s+/u',' ',$s); }
function prefixify(array $a){ $out=[]; foreach($a as $k=>$v){ $out[ is_string($k)&&$k[0]===':' ? $k : ":$k"]=$v; } return $out; }

/* ================== Helpers de limpieza (GLOBAL) ================== */
function reset_pk_seq_auto(PDO $db, string $table, string $schema='public'){
  $sql = "
    SELECT seq.relname AS seqname, col.attname AS colname
    FROM pg_class AS tbl
    JOIN pg_namespace AS ns ON ns.oid = tbl.relnamespace
    LEFT JOIN pg_depend AS dep ON dep.refobjid = tbl.oid AND dep.deptype = 'a'
    LEFT JOIN pg_class AS seq ON seq.oid = dep.objid AND seq.relkind = 'S'
    LEFT JOIN pg_attribute AS col ON col.attrelid = tbl.oid AND col.attnum = dep.refobjsubid
    WHERE ns.nspname = :schema AND tbl.relname = :table AND seq.oid IS NOT NULL";
  $st = $db->prepare($sql);
  $st->execute([":schema"=>$schema,":table"=>$table]);
  $rows = $st->fetchAll(PDO::FETCH_ASSOC);
  if (!$rows) return;
  foreach ($rows as $r) {
    $seq = $schema . '.' . $r['seqname'];
    $col = $r['colname'];
    $db->exec("
      SELECT setval(
        '{$seq}',
        COALESCE((SELECT MAX(\"{$col}\") FROM \"{$schema}\".\"{$table}\"), 0) + 1,
        false
      )");
  }
}
function ensure_general(PDO $db, string $table, int $empresa_id): int {
  $colEmpresa = in_array($table, ['categorias','marcas','modelos']) ? "empresa_id" : null;
  $sel = $db->prepare("SELECT id FROM public.$table WHERE UPPER(nombre)='GENERAL'".($colEmpresa? " AND $colEmpresa=:e":"")." LIMIT 1");
  $sel->execute($colEmpresa? [":e"=>$empresa_id] : []);
  $id = $sel->fetchColumn();
  if ($id) return (int)$id;
  $sql = "INSERT INTO public.$table (nombre".($colEmpresa?",empresa_id":"").",activo) VALUES ('GENERAL'".($colEmpresa?",:e":"").",true) RETURNING id";
  $ins = $db->prepare($sql);
  $ins->execute($colEmpresa? [":e"=>$empresa_id] : []);
  return (int)$ins->fetchColumn();
}
function ensure_generales_por_empresa(PDO $db): array {
  $empresas = $db->query("SELECT DISTINCT empresa_id FROM public.productos")->fetchAll(PDO::FETCH_COLUMN);
  $out = [];
  foreach ($empresas as $e){
    $e = (int)$e;
    $out[$e] = [
      "cat"   => ensure_general($db, "categorias", $e),
      "marca" => ensure_general($db, "marcas",     $e),
      "modelo"=> ensure_general($db, "modelos",    $e),
    ];
  }
  return $out;
}
function cleanup_datos_global(PDO $pg): array {
  $res = ["ok"=>true,"detalle"=>[]];
  $pg->beginTransaction();
  try{
    $generales = ensure_generales_por_empresa($pg);
    $upd = $pg->prepare("
      UPDATE public.productos p
         SET categoria_id = :cat, marca_id = :marca, modelo_id = :modelo
       WHERE UPPER(p.nombre) = 'SERVICIO' AND p.empresa_id = :e");
    foreach ($generales as $e=>$ids){
      $upd->execute([":cat"=>$ids["cat"],":marca"=>$ids["marca"],":modelo"=>$ids["modelo"],":e"=>$e]);
    }

    $res["detalle"]["equivalencias"]     = ["deleted" => (int)$pg->exec("DELETE FROM public.equivalencias")];
    reset_pk_seq_auto($pg, "equivalencias");

    $res["detalle"]["precios_sucursal"]  = ["deleted" => (int)$pg->exec("DELETE FROM public.precios_sucursal")];
    reset_pk_seq_auto($pg, "precios_sucursal");

    $tblCostos = $pg->query("SELECT to_regclass('public.costos')")->fetchColumn();
    if ($tblCostos){
      $res["detalle"]["costos"] = ["deleted" => (int)$pg->exec("DELETE FROM public.costos")];
      reset_pk_seq_auto($pg, "costos");
    }

    $res["detalle"]["kardex"]            = ["deleted" => (int)$pg->exec("DELETE FROM public.kardex")];
    reset_pk_seq_auto($pg, "kardex");

    $res["detalle"]["productoalmacen"]   = ["deleted" => (int)$pg->exec("DELETE FROM public.productoalmacen")];
    try { reset_pk_seq_auto($pg, "productoalmacen"); } catch(Throwable $e){}

    $res["detalle"]["productos"] = [
      "deleted" => (int)$pg->exec("DELETE FROM public.productos WHERE UPPER(nombre) <> 'SERVICIO'")
    ];
    reset_pk_seq_auto($pg, "productos");

    $res["detalle"]["categorias"] = [
      "deleted" => (int)$pg->exec("DELETE FROM public.categorias WHERE UPPER(nombre) <> 'GENERAL'")
    ];
    reset_pk_seq_auto($pg, "categorias");

    $res["detalle"]["marcas"] = [
      "deleted" => (int)$pg->exec("DELETE FROM public.marcas WHERE UPPER(nombre) <> 'GENERAL'")
    ];
    reset_pk_seq_auto($pg, "marcas");

    $res["detalle"]["modelos"] = [
      "deleted" => (int)$pg->exec("DELETE FROM public.modelos WHERE UPPER(nombre) <> 'GENERAL'")
    ];
    reset_pk_seq_auto($pg, "modelos");
    
    $res["detalle"]["clientes"] = [
      "deleted" => (int)$pg->exec("
        DELETE FROM public.clientes
        WHERE NOT (
          UPPER(nombre)='CLIENTES VARIOS'
          AND COALESCE(documento_identidad,'')='00000000'
        )
      ")
    ];
    reset_pk_seq_auto($pg, "clientes");
    
    $res["detalle"]["proveedores"] = [
      "deleted" => (int)$pg->exec("
        DELETE FROM public.proveedores
        WHERE UPPER(nombre) <> 'AJUSTE'
      ")
    ];
    reset_pk_seq_auto($pg, "proveedores");

    $pg->commit();
    return $res;
  }catch(Throwable $e){
    if ($pg->inTransaction()) $pg->rollBack();
    return ["ok"=>false,"error"=>$e->getMessage(),"detalle"=>$res["detalle"] ?? []];
  }
}

/* ================== Cachés ================== */
$cacheUnidad=[]; $cacheMarca=[]; $cacheProd=[]; $cacheCat=[]; $cacheMod=[];

/* ================== Utilidades PG ================== */
function unidad_id(PDO $db,array &$cache,string $code=null){
  $code=$code?mb_strtoupper($code):""; if($code==="") return null; if(isset($cache[$code])) return $cache[$code];
  $q=$db->prepare("SELECT id FROM unidadmedida WHERE UPPER(abrev)=:c OR UPPER(abrev_universal)=:c LIMIT 1");
  $q->execute([":c"=>$code]); $id=$q->fetchColumn(); if($id){ $cache[$code]=(int)$id; return (int)$id; } return null;
}
function marca_id(PDO $db,array &$cache,string $nombre,int $empresa_id,bool $activo=true){
  $k=mb_strtoupper($nombre)."|".$empresa_id; if(isset($cache[$k])) return $cache[$k];
  $sel=$db->prepare("SELECT id FROM marcas WHERE empresa_id=:e AND UPPER(nombre)=:n LIMIT 1");
  $sel->execute([":e"=>$empresa_id,":n"=>mb_strtoupper($nombre)]); $id=$sel->fetchColumn();
  if($id){ $cache[$k]=(int)$id; $db->prepare("UPDATE marcas SET activo=:a WHERE id=:id")->execute([":a"=>$activo,":id"=>$id]); return (int)$id; }
  if(mb_strtoupper($nombre)==='GENERAL'){
    $sel=$db->prepare("SELECT id FROM marcas WHERE empresa_id=:e AND UPPER(nombre)='GENERAL' LIMIT 1");
    $sel->execute([":e"=>$empresa_id]); $id=$sel->fetchColumn();
    if($id){ $cache[$k]=(int)$id; return (int)$id; }
  }
  $ins=$db->prepare("INSERT INTO marcas(nombre,activo,empresa_id) VALUES(:n,:a,:e) RETURNING id");
  $ins->execute([":n"=>$nombre,":a"=>$activo,":e"=>$empresa_id]); $id=(int)$ins->fetchColumn(); $cache[$k]=$id; return $id;
}
function categoria_id(PDO $db,array &$cache,string $nombre,int $empresa_id,bool $activo=true){
  $k=mb_strtoupper($nombre)."|".$empresa_id; if(isset($cache[$k])) return $cache[$k];
  $sel=$db->prepare("SELECT id FROM categorias WHERE empresa_id=:e AND UPPER(nombre)=:n LIMIT 1");
  $sel->execute([":e"=>$empresa_id,":n"=>mb_strtoupper($nombre)]); $id=$sel->fetchColumn();
  if($id){ $cache[$k]=(int)$id; $db->prepare("UPDATE categorias SET activo=:a WHERE id=:id")->execute([":a"=>$activo,":id"=>$id]); return (int)$id; }
  $ins=$db->prepare("INSERT INTO categorias(nombre,activo,empresa_id) VALUES(:n,:a,:e) RETURNING id");
  $ins->execute([":n"=>$nombre,":a"=>$activo,":e"=>$empresa_id]); $id=(int)$ins->fetchColumn(); $cache[$k]=$id; return $id;
}
function modelo_id(PDO $db,array &$cache,string $nombre,int $empresa_id,?int $marca_id=null,bool $activo=true){
  $k=mb_strtoupper($nombre)."|".$empresa_id; if(isset($cache[$k])) return $cache[$k];
  $sel=$db->prepare("SELECT id FROM modelos WHERE empresa_id=:e AND UPPER(nombre)=:n LIMIT 1");
  $sel->execute([":e"=>$empresa_id,":n"=>mb_strtoupper($nombre)]); $id=$sel->fetchColumn();
  if($id){ $cache[$k]=(int)$id; $db->prepare("UPDATE modelos SET activo=:a WHERE id=:id")->execute([":a"=>$activo,":id"=>$id]); return (int)$id; }
  $ins=$db->prepare("INSERT INTO modelos(nombre,activo,empresa_id) VALUES(:n,:a,:e) RETURNING id");
  $ins->execute([":n"=>$nombre,":a"=>$activo,":e"=>$empresa_id]); $id=(int)$ins->fetchColumn(); $cache[$k]=$id; return $id;
}

/* ================== MAPEO MySQL→PG ================== */
function ensure_map_table(PDO $db){
  $db->exec("
    CREATE TABLE IF NOT EXISTS public.migr_map_productos (
      empresa_id  integer NOT NULL,
      mysql_id    integer NOT NULL,
      pg_id       integer NOT NULL,
      PRIMARY KEY (empresa_id, mysql_id)
    );
    CREATE INDEX IF NOT EXISTS idx_migr_map_productos_pg ON public.migr_map_productos(pg_id);
  ");
}
ensure_map_table($pg);

function map_set(PDO $db, int $empresaId, int $mysqlId, int $pgId): void {
  $sql = "INSERT INTO migr_map_productos(empresa_id,mysql_id,pg_id)
          VALUES(:e,:m,:p)
          ON CONFLICT (empresa_id,mysql_id) DO UPDATE SET pg_id=EXCLUDED.pg_id";
  $q = $db->prepare($sql);
  $q->execute([":e"=>$empresaId,":m"=>$mysqlId,":p"=>$pgId]);
}
function map_get(PDO $db, int $empresaId, int $mysqlId, array &$cache): ?int {
  if ($mysqlId <= 0) return null;
  $k = $empresaId.':'.$mysqlId;
  if (isset($cache[$k])) return $cache[$k];
  $q = $db->prepare("SELECT pg_id FROM migr_map_productos WHERE empresa_id=:e AND mysql_id=:m LIMIT 1");
  $q->execute([":e"=>$empresaId,":m"=>$mysqlId]);
  $pgId = $q->fetchColumn();
  if ($pgId) { $cache[$k] = (int)$pgId; return (int)$pgId; }
  return null;
}
function map_mysql_pid_to_pg(PDO $db, mysqli $mysql, int $empresaId, int $mysqlPid, array &$cachePid): ?int {
  if ($mysqlPid <= 0) return null;

  // 1) Mapa directo primero
  $k = $empresaId.':'.$mysqlPid;
  if (isset($cachePid[$k])) return $cachePid[$k];

  $q = $db->prepare("SELECT pg_id FROM migr_map_productos WHERE empresa_id=:e AND mysql_id=:m LIMIT 1");
  $q->execute([":e"=>$empresaId, ":m"=>$mysqlPid]);
  $pgId = $q->fetchColumn();
  if ($pgId) { $cachePid[$k] = (int)$pgId; return (int)$pgId; }

  // 2) Fallback: buscar por nombre/descripcion en MySQL → localizar en PG y guardar en mapa
  $rs = $mysql->query("SELECT nombre, descripcion FROM productos WHERE id=".(int)$mysqlPid." LIMIT 1");
  if ($rs && ($r = $rs->fetch_assoc())) {
    $maybe = producto_id_by_nombre_o_desc($db, $empresaId, $r['nombre'] ?? null, $r['descripcion'] ?? null);
    $rs->close();
    if ($maybe) {
      // guardar en mapa para próximas pasadas
      $ins = $db->prepare("INSERT INTO migr_map_productos(empresa_id, mysql_id, pg_id)
                           VALUES(:e,:m,:p)
                           ON CONFLICT (empresa_id, mysql_id) DO UPDATE SET pg_id=EXCLUDED.pg_id");
      $ins->execute([":e"=>$empresaId, ":m"=>$mysqlPid, ":p"=>(int)$maybe]);
      $cachePid[$k] = (int)$maybe;
      return (int)$maybe;
    }
  }
  return null;
}

/* ================== Producto: insert/update ================== */
function producto_id_by_nombre_o_desc(PDO $db, int $empresa_id, ?string $nombre, ?string $descripcion){
  $n = norm_text($nombre); $d = norm_text($descripcion); if ($n==='' && $d==='') return null;
  $sql = "SELECT id FROM productos WHERE empresa_id = :e AND (
           UPPER(regexp_replace(COALESCE(nombre,''),'\\s+',' ','g'))=:n1 OR
           UPPER(regexp_replace(COALESCE(descripcion,''),'\\s+',' ','g'))=:n2 OR
           (:d0<>'' AND (UPPER(regexp_replace(COALESCE(nombre,''),'\\s+',' ','g'))=:d1
                      OR  UPPER(regexp_replace(COALESCE(descripcion,''),'\\s+',' ','g'))=:d2))
         ) LIMIT 1";
  $q=$db->prepare($sql);
  $q->execute([':e'=>$empresa_id,':n1'=>$n,':n2'=>$n,':d0'=>$d,':d1'=>$d,':d2'=>$d]);
  $id=$q->fetchColumn();
  return $id ? (int)$id : null;
}
function generar_codigo_producto(PDO $db,int $empresa_id){
  $pref=str_pad((string)$empresa_id,3,'0',STR_PAD_LEFT);
  $q=$db->prepare("SELECT COALESCE(MAX((regexp_match(codigo_producto,'^[0-9]{3}([0-9]{7})$'))[1]::INT),0)
                   FROM productos WHERE empresa_id=:e AND codigo_producto ~ '^[0-9]{10}$'");
  $q->execute([":e"=>$empresa_id]); $max=(int)$q->fetchColumn(); $next=$max+1; return $pref.str_pad((string)$next,7,'0',STR_PAD_LEFT);
}
function ensure_descripcion_unique_for_insert(PDO $db,string $desc,int $empresa_id){
  $base=trim(mb_substr($desc,0,255)); if($base==='') $base='SIN DESCRIPCION';
  $check=$db->prepare("SELECT 1 FROM productos WHERE UPPER(descripcion)=UPPER(:d) LIMIT 1");
  $check->execute([":d"=>$base]); if(!$check->fetchColumn()) return $base;
  $suf=" (E{$empresa_id})"; $maxLen=255; $try=mb_substr($base,0,$maxLen-mb_strlen($suf)).$suf; $n=2;
  while(true){ $check->execute([":d"=>$try]); if(!$check->fetchColumn()) return $try;
    $suf2=" (E{$empresa_id}-{$n})"; $try=mb_substr($base,0,$maxLen-mb_strlen($suf2)).$suf2; $n++;
    if($n>99){ $rand=mt_rand(100,999); $suf3=" (E{$empresa_id}-{$rand})"; $try=mb_substr($base,0,$maxLen-mb_strlen($suf3)).$suf3; $check->execute([":d"=>$try]); if(!$check->fetchColumn()) return $try; return $base; }
  }
}
function ensure_descripcion_unique(PDO $db, string $desc, int $empresa_id, ?int $excludeId=null){
  $base = trim(mb_substr($desc,0,255));
  if ($base==='') $base = 'SIN DESCRIPCION';
  $sql = "SELECT 1 FROM productos WHERE UPPER(descripcion)=UPPER(:d)".($excludeId? " AND id<>:id" : "")." LIMIT 1";
  $q = $db->prepare($sql);
  $params = [":d"=>$base];
  if ($excludeId) $params[":id"] = (int)$excludeId;
  $q->execute($params);
  if (!$q->fetchColumn()) return $base;
  $suf = " (E{$empresa_id})"; $maxLen=255;
  $try = mb_substr($base,0,$maxLen-mb_strlen($suf)).$suf; $n=2;
  while (true){
    $params[":d"] = $try; $q->execute($params);
    if (!$q->fetchColumn()) return $try;
    $suf2=" (E{$empresa_id}-{$n})";
    $try=mb_substr($base,0,$maxLen-mb_strlen($suf2)).$suf2; $n++;
    if ($n>99){
      $rand=mt_rand(100,999); $suf3=" (E{$empresa_id}-{$rand})";
      $try=mb_substr($base,0,$maxLen-mb_strlen($suf3)).$suf3;
      $params[":d"]=$try; $q->execute($params);
      if (!$q->fetchColumn()) return $try;
      return $base;
    }
  }
}
function upsert_producto_full(PDO $db,array $p){
  $idExist = producto_id_by_nombre_o_desc($db,(int)$p["empresa_id"],$p["nombre"],$p["descripcion"]);
  if($idExist){
    $p["descripcion"] = ensure_descripcion_unique($db,(string)$p["descripcion"],(int)$p["empresa_id"],$idExist);

    $upd=$db->prepare("UPDATE productos SET
      nombre=:nombre,codigo_barras=:codigo_barras,codigo_externo=:codigo_externo,codigo_sunat=:codigo_sunat,
      afecto_igv=:afecto_igv,porcentaje_igv=:porcentaje_igv,
      pcompra_ant=:pcompra_ant,fcompra_ant=:fcompra_ant,doccompra_ant=:doccompra_ant,
      ult_pcompra=:ult_pcompra,ult_fcompra=:ult_fcompra,ult_doccompra=:ult_doccompra,peso=:peso,stock_minimo=:stock_minimo,
      unidad_id=:unidad_id,capacidad=:capacidad,fraccion_id=:fraccion_id,
      pprecio_menor=:pprecio_menor,pprecio_mayor=:pprecio_mayor,pprecio_dist=:pprecio_dist,
      precmenor_und=:precmenor_und,precmenor_fra=:precmenor_fra,precmayor_und=:precmayor_und,precmayor_fra=:precmayor_fra,
      precdist_und=:precdist_und,precdist_fra=:precdist_fra,importe_icbp=:importe_icbp,
      contenido=:contenido,informacion=:informacion,path_foto=:path_foto,descripcion=:descripcion,activo=:activo,
      categoria_id=:categoria_id,marca_id=:marca_id,modelo_id=:modelo_id
      WHERE id=:id");
    $upd->execute([
      "nombre"=>$p["nombre"],"codigo_barras"=>$p["codigo_barras"],"codigo_externo"=>$p["codigo_externo"],"codigo_sunat"=>$p["codigo_sunat"],
      "afecto_igv"=>$p["afecto_igv"],"porcentaje_igv"=>$p["porcentaje_igv"],
      "pcompra_ant"=>$p["pcompra_ant"],"fcompra_ant"=>$p["fcompra_ant"],"doccompra_ant"=>$p["doccompra_ant"],
      "ult_pcompra"=>$p["ult_pcompra"],"ult_fcompra"=>$p["ult_fcompra"],"ult_doccompra"=>$p["ult_doccompra"],"peso"=>$p["peso"],"stock_minimo"=>$p["stock_minimo"],
      "unidad_id"=>$p["unidad_id"],"capacidad"=>$p["capacidad"],"fraccion_id"=>$p["fraccion_id"],
      "pprecio_menor"=>$p["pprecio_menor"],"pprecio_mayor"=>$p["pprecio_mayor"],"pprecio_dist"=>$p["pprecio_dist"],
      "precmenor_und"=>$p["precmenor_und"],"precmenor_fra"=>$p["precmenor_fra"],
      "precmayor_und"=>$p["precmayor_und"],"precmayor_fra"=>$p["precmayor_fra"],
      "precdist_und"=>$p["precdist_und"],"precdist_fra"=>$p["precdist_fra"],"importe_icbp"=>$p["importe_icbp"],
      "contenido"=>$p["contenido"],"informacion"=>$p["informacion"],"path_foto"=>$p["path_foto"],"descripcion"=>$p["descripcion"],"activo"=>$p["activo"],
      "categoria_id"=>$p["categoria_id"],"marca_id"=>$p["marca_id"],"modelo_id"=>$p["modelo_id"],"id"=>$idExist
    ]);

    // Si el código está vacío/nulo, fijarlo a EEE + ID(7)
    $fix = $db->prepare("
      UPDATE productos
         SET codigo_producto = LPAD(empresa_id::text,3,'0') || LPAD(id::text,7,'0')
       WHERE id=:id AND (codigo_producto IS NULL OR codigo_producto='')
    ");
    $fix->execute([":id"=>$idExist]);

    return $idExist;
  }

  // INSERT: permitir NULL en codigo_producto y luego fijarlo por ID
  if (!array_key_exists('codigo_producto',$p) || trim((string)$p['codigo_producto'])==='') {
    $p['codigo_producto'] = null; // se actualiza después del insert
  }

  $ins=$db->prepare("INSERT INTO productos(
    codigo_producto,nombre,codigo_barras,codigo_externo,codigo_sunat,afecto_igv,porcentaje_igv,
    pcompra_ant,fcompra_ant,doccompra_ant,ult_pcompra,ult_fcompra,ult_doccompra,peso,stock_minimo,unidad_id,capacidad,fraccion_id,
    pprecio_menor,pprecio_mayor,pprecio_dist,precmenor_und,precmenor_fra,precmayor_und,precmayor_fra,precdist_und,precdist_fra,
    importe_icbp,contenido,informacion,path_foto,descripcion,usa_serie,offsystem,activo,categoria_id,marca_id,modelo_id,empresa_id,precio_stock
  ) VALUES (
    :codigo_producto,:nombre,:codigo_barras,:codigo_externo,:codigo_sunat,:afecto_igv,:porcentaje_igv,
    :pcompra_ant,:fcompra_ant,:doccompra_ant,:ult_pcompra,:ult_fcompra,:ult_doccompra,:peso,:stock_minimo,:unidad_id,:capacidad,:fraccion_id,
    :pprecio_menor,:pprecio_mayor,:pprecio_dist,:precmenor_und,:precmenor_fra,:precmayor_und,:precmayor_fra,:precdist_und,:precdist_fra,
    :importe_icbp,:contenido,:informacion,:path_foto,:descripcion,:usa_serie,:offsystem,:activo,:categoria_id,:marca_id,:modelo_id,:empresa_id,:precio_stock
  ) RETURNING id");
  $p += ["precio_stock"=>0.0000,"usa_serie"=>0,"offsystem"=>0];
  $ins->execute(prefixify($p));
  $pid = (int)$ins->fetchColumn();

  // Fijar codigo = EEE + ID(7) inmediatamente después del insert (si venía vacío)
  if (empty($p['codigo_producto'])) {
    $updCode = $db->prepare("
      UPDATE productos
         SET codigo_producto = LPAD(empresa_id::text,3,'0') || LPAD(id::text,7,'0')
       WHERE id = :id
    ");
    $updCode->execute([":id"=>$pid]);
  }
  return $pid;
}
/*function upsert_producto_full(PDO $db,array $p){
  $idExist = producto_id_by_nombre_o_desc($db,(int)$p["empresa_id"],$p["nombre"],$p["descripcion"]);
  if($idExist){
    $p["descripcion"] = ensure_descripcion_unique($db,(string)$p["descripcion"],(int)$p["empresa_id"],$idExist);
    $upd=$db->prepare("UPDATE productos SET
      nombre=:nombre,codigo_barras=:codigo_barras,codigo_externo=:codigo_externo,codigo_sunat=:codigo_sunat,
      afecto_igv=:afecto_igv,porcentaje_igv=:porcentaje_igv,
      pcompra_ant=:pcompra_ant,fcompra_ant=:fcompra_ant,doccompra_ant=:doccompra_ant,
      ult_pcompra=:ult_pcompra,ult_fcompra=:ult_fcompra,ult_doccompra=:ult_doccompra,peso=:peso,stock_minimo=:stock_minimo,
      unidad_id=:unidad_id,capacidad=:capacidad,fraccion_id=:fraccion_id,
      pprecio_menor=:pprecio_menor,pprecio_mayor=:pprecio_mayor,pprecio_dist=:pprecio_dist,
      precmenor_und=:precmenor_und,precmenor_fra=:precmenor_fra,precmayor_und=:precmayor_und,precmayor_fra=:precmayor_fra,
      precdist_und=:precdist_und,precdist_fra=:precdist_fra,importe_icbp=:importe_icbp,
      contenido=:contenido,informacion=:informacion,path_foto=:path_foto,descripcion=:descripcion,activo=:activo,
      categoria_id=:categoria_id,marca_id=:marca_id,modelo_id=:modelo_id
      WHERE id=:id");
    $upd->execute([
      "nombre"=>$p["nombre"],"codigo_barras"=>$p["codigo_barras"],"codigo_externo"=>$p["codigo_externo"],"codigo_sunat"=>$p["codigo_sunat"],
      "afecto_igv"=>$p["afecto_igv"],"porcentaje_igv"=>$p["porcentaje_igv"],
      "pcompra_ant"=>$p["pcompra_ant"],"fcompra_ant"=>$p["fcompra_ant"],"doccompra_ant"=>$p["doccompra_ant"],
      "ult_pcompra"=>$p["ult_pcompra"],"ult_fcompra"=>$p["ult_fcompra"],"ult_doccompra"=>$p["ult_doccompra"],"peso"=>$p["peso"],"stock_minimo"=>$p["stock_minimo"],
      "unidad_id"=>$p["unidad_id"],"capacidad"=>$p["capacidad"],"fraccion_id"=>$p["fraccion_id"],
      "pprecio_menor"=>$p["pprecio_menor"],"pprecio_mayor"=>$p["pprecio_mayor"],"pprecio_dist"=>$p["pprecio_dist"],
      "precmenor_und"=>$p["precmenor_und"],"precmenor_fra"=>$p["precmenor_fra"],
      "precmayor_und"=>$p["precmayor_und"],"precmayor_fra"=>$p["precmayor_fra"],
      "precdist_und"=>$p["precdist_und"],"precdist_fra"=>$p["precdist_fra"],"importe_icbp"=>$p["importe_icbp"],
      "contenido"=>$p["contenido"],"informacion"=>$p["informacion"],"path_foto"=>$p["path_foto"],"descripcion"=>$p["descripcion"],"activo"=>$p["activo"],
      "categoria_id"=>$p["categoria_id"],"marca_id"=>$p["marca_id"],"modelo_id"=>$p["modelo_id"],"id"=>$idExist
    ]);
    return $idExist;
  }
  $p["descripcion"]=ensure_descripcion_unique_for_insert($db,(string)$p["descripcion"],(int)$p["empresa_id"]);
  if(empty($p["codigo_producto"])) $p["codigo_producto"]=generar_codigo_producto($db,(int)$p["empresa_id"]);
  $ins=$db->prepare("INSERT INTO productos(
    codigo_producto,nombre,codigo_barras,codigo_externo,codigo_sunat,afecto_igv,porcentaje_igv,
    pcompra_ant,fcompra_ant,doccompra_ant,ult_pcompra,ult_fcompra,ult_doccompra,peso,stock_minimo,unidad_id,capacidad,fraccion_id,
    pprecio_menor,pprecio_mayor,pprecio_dist,precmenor_und,precmenor_fra,precmayor_und,precmayor_fra,precdist_und,precdist_fra,
    importe_icbp,contenido,informacion,path_foto,descripcion,usa_serie,offsystem,activo,categoria_id,marca_id,modelo_id,empresa_id,precio_stock
  ) VALUES (
    :codigo_producto,:nombre,:codigo_barras,:codigo_externo,:codigo_sunat,:afecto_igv,:porcentaje_igv,
    :pcompra_ant,:fcompra_ant,:doccompra_ant,:ult_pcompra,:ult_fcompra,:ult_doccompra,:peso,:stock_minimo,:unidad_id,:capacidad,:fraccion_id,
    :pprecio_menor,:pprecio_mayor,:pprecio_dist,:precmenor_und,:precmenor_fra,:precmayor_und,:precmayor_fra,:precdist_und,:precdist_fra,
    :importe_icbp,:contenido,:informacion,:path_foto,:descripcion,:usa_serie,:offsystem,:activo,:categoria_id,:marca_id,:modelo_id,:empresa_id,:precio_stock
  ) RETURNING id");
  $p += ["precio_stock"=>0.0000,"usa_serie"=>0,"offsystem"=>0];
  $ins->execute(prefixify($p));
  return (int)$ins->fetchColumn();
}*/

/* ================== Helpers extra para resolver FKs ================== */
function id_exists(PDO $db, string $table, int $id): bool {
  if(!$id) return false;
  $sql = "SELECT 1 FROM {$table} WHERE id=:id LIMIT 1";
  $q = $db->prepare($sql); $q->execute([":id"=>$id]);
  return (bool)$q->fetchColumn();
}
function resolve_marca_id(mysqli $mysql, PDO $db, array &$cacheMarca, int $empresaId, ?int $mysqlMarcaId): int {
  if ($mysqlMarcaId && id_exists($db,'marcas',$mysqlMarcaId)) return $mysqlMarcaId;
  if ($mysqlMarcaId) {
    $res = $mysql->query("SELECT nombre, COALESCE(activo,1) activo FROM marcas WHERE id=".(int)$mysqlMarcaId." LIMIT 1");
    if ($res && ($r=$res->fetch_assoc())) {
      $pgId = marca_id($db,$cacheMarca,(string)$r['nombre'],$empresaId, ((int)$r['activo'])===1);
      $res->close();
      return (int)$pgId;
    }
  }
  return (int)marca_id($db,$cacheMarca,'GENERAL',$empresaId,true);
}
function resolve_categoria_id(mysqli $mysql, PDO $db, array &$cacheCat, int $empresaId, ?int $mysqlCatId): int {
  if ($mysqlCatId && id_exists($db,'categorias',$mysqlCatId)) return $mysqlCatId;
  if ($mysqlCatId) {
    $res = $mysql->query("SELECT nombre, COALESCE(activo,1) activo FROM categorias WHERE id=".(int)$mysqlCatId." LIMIT 1");
    if ($res && ($r=$res->fetch_assoc())) {
      $sel = $db->prepare("SELECT id FROM categorias WHERE empresa_id=:e AND UPPER(nombre)=:n LIMIT 1");
      $sel->execute([":e"=>$empresaId,":n"=>mb_strtoupper($r['nombre'])]);
      $id=$sel->fetchColumn();
      if ($id) { $res->close(); return (int)$id; }
      $id = categoria_id($db,$cacheCat,(string)$r['nombre'],$empresaId, ((int)$r['activo'])===1);
      $res->close();
      return (int)$id;
    }
  }
  return 1;
}
function resolve_modelo_id(mysqli $mysql, PDO $db, array &$cacheMod, int $empresaId, ?int $mysqlModId): int {
  if ($mysqlModId && id_exists($db,'modelos',$mysqlModId)) return $mysqlModId;
  if ($mysqlModId) {
    $res = $mysql->query("SELECT nombre, COALESCE(activo,1) activo FROM modelos WHERE id=".(int)$mysqlModId." LIMIT 1");
    if ($res && ($r=$res->fetch_assoc())) {
      $sel = $db->prepare("SELECT id FROM modelos WHERE empresa_id=:e AND UPPER(nombre)=:n LIMIT 1");
      $sel->execute([":e"=>$empresaId,":n"=>mb_strtoupper($r['nombre'])]);
      $id=$sel->fetchColumn();
      if ($id) { $res->close(); return (int)$id; }
      $id = modelo_id($db,$cacheMod,(string)$r['nombre'],$empresaId,null, ((int)$r['activo'])===1);
      $res->close();
      return (int)$id;
    }
  }
  return 1;
}
function resolve_unidad_id(PDO $db, array &$cacheUnidad, ?int $mysqlUnidadId, string $unidadDefaultAbrev): ?int {
  if ($mysqlUnidadId && id_exists($db,'unidadmedida',$mysqlUnidadId)) return $mysqlUnidadId;
  return unidad_id($db,$cacheUnidad,$unidadDefaultAbrev) ?: null;
}

/* ================== Migradores ================== */

function mig_marcas(mysqli $mysql, PDO $db, array &$cacheMarca, int $empresaId) {
  $sql = "SELECT id, nombre, COALESCE(offsystem,0) offsystem, COALESCE(activo,1) activo, COALESCE(empresa_id,{$empresaId}) empresa_id FROM marcas";
  $res = $mysql->query($sql);
  if(!$res) return ["ok"=>false,"error"=>"MySQL: ".$mysql->error];
  $ins=0;$skip=0;$err=[]; $db->beginTransaction();
  try {
    while($r=$res->fetch_assoc()){
      $nombre = trim((string)$r['nombre']); if($nombre===''){ $skip++; continue; }
      marca_id($db,$cacheMarca,$nombre,(int)$r['empresa_id'], ((int)$r['activo'])===1);
      $ins++;
    }
    $db->commit();
  } catch(Throwable $e){ if($db->inTransaction()) $db->rollBack(); $err[]=$e->getMessage(); }
  $res->free();
  return ["insertados"=>$ins,"saltados"=>$skip,"errores"=>$err];
}

function mig_categorias(mysqli $mysql, PDO $db, array &$cacheCat, int $empresaId) {
  $sql = "SELECT id, nombre, COALESCE(offsystem,0) offsystem, COALESCE(activo,1) activo, COALESCE(empresa_id,{$empresaId}) empresa_id FROM categorias";
  $res = $mysql->query($sql);
  if(!$res) return ["ok"=>false,"error"=>"MySQL: ".$mysql->error];
  $ins=0;$skip=0;$err=[]; $db->beginTransaction();
  try {
    while($r=$res->fetch_assoc()){
      $nombre = trim((string)$r['nombre']); if($nombre===''){ $skip++; continue; }
      categoria_id($db,$cacheCat,$nombre,(int)$r['empresa_id'], ((int)$r['activo'])===1);
      $ins++;
    }
    $db->commit();
  } catch(Throwable $e){ if($db->inTransaction()) $db->rollBack(); $err[]=$e->getMessage(); }
  $res->free();
  return ["insertados"=>$ins,"saltados"=>$skip,"errores"=>$err];
}

function mig_modelos(mysqli $mysql, PDO $db, array &$cacheMod, int $empresaId) {
  $sql = "SELECT id, nombre,
                 COALESCE(NULLIF(offsystem,''),0)  offsystem,
                 COALESCE(NULLIF(activo,''),1)     activo,
                 COALESCE(empresa_id,{$empresaId}) empresa_id
          FROM modelos";
  $res = $mysql->query($sql);
  if(!$res) return ["ok"=>false,"error"=>"MySQL: ".$mysql->error];

  $ins=0;$skip=0;$err=[]; $db->beginTransaction();
  try {
    while($r=$res->fetch_assoc()){
      $nombre = trim((string)$r['nombre']);
      if($nombre===''){ $skip++; continue; }
      modelo_id($db,$cacheMod,$nombre,(int)$r['empresa_id'],null, ((int)$r['activo'])===1);
      $ins++;
    }
    $db->commit();
  } catch(Throwable $e){
    if($db->inTransaction()) $db->rollBack();
    $err[]=$e->getMessage();
  }
  $res->free();
  return ["insertados"=>$ins,"saltados"=>$skip,"errores"=>$err];
}

function mig_productos(mysqli $mysql, PDO $db, array &$cacheUnidad,array &$cacheMarca,array &$cacheCat,array &$cacheMod,array &$cacheProd, array $CFG){
  $off=(int)$CFG['offset'];
  $lim = $CFG['limit']!==null ? " LIMIT ".(int)$CFG['limit']." OFFSET ".$off : "";
  $sql = "SELECT
          id,codigo_producto,nombre,descripcion,
          codigo_barras,codigo_externo,codigo_sunat,

          COALESCE(NULLIF(afecto_igv,''),0)       afecto_igv,
          COALESCE(NULLIF(porcentaje_igv,''),0)   porcentaje_igv,

          COALESCE(NULLIF(pcompra_ant,''),0)      pcompra_ant,
          fcompra_ant,
          doccompra_ant,

          COALESCE(NULLIF(ult_pcompra,''),0)      ult_pcompra,
          ult_fcompra,
          ult_doccompra,

          COALESCE(NULLIF(peso,''),0)             peso,
          COALESCE(NULLIF(stock_minimo,''),0)     stock_minimo,

          COALESCE(NULLIF(pprecio_menor,''),0)    pprecio_menor,
          COALESCE(NULLIF(pprecio_mayor,''),0)    pprecio_mayor,
          COALESCE(NULLIF(pprecio_dist,''),0)     pprecio_dist,

          COALESCE(NULLIF(precmenor_und,''),0)    precmenor_und,
          COALESCE(NULLIF(precmenor_fra,''),0)    precmenor_fra,
          COALESCE(NULLIF(precmayor_und,''),0)    precmayor_und,
          COALESCE(NULLIF(precmayor_fra,''),0)    precmayor_fra,
          COALESCE(NULLIF(precdist_und,''),0)     precdist_und,
          COALESCE(NULLIF(precdist_fra,''),0)     precdist_fra,

          COALESCE(NULLIF(importe_icbp,''),0)     importe_icbp,

          contenido,informacion,path_foto,

          COALESCE(NULLIF(usa_serie,''),0)        usa_serie,
          COALESCE(NULLIF(offsystem,''),0)        offsystem,
          COALESCE(NULLIF(activo,''),1)           activo,

          unidad_id,
          COALESCE(NULLIF(capacidad,''),1)        capacidad,
          COALESCE(fraccion_id,unidad_id)         fraccion_id,

          categoria_id, marca_id, modelo_id,
          COALESCE(empresa_id,{$CFG['empresa_id']}) empresa_id,
          COALESCE(NULLIF(precio_stock,''),0)     precio_stock
        FROM productos {$lim}";
  $res = $mysql->query($sql);
  if(!$res) return ["ok"=>false,"error"=>"MySQL: ".$mysql->error];

  $ins=0; $err=[]; $count=0; $chunk=max(1,(int)$CFG['chunk_size']);
  $db->beginTransaction();
  try{
    while($r=$res->fetch_assoc()){
      $nm = norm_text($r['nombre'] ?? '');
      $ds = norm_text($r['descripcion'] ?? '');
      if ($nm==='SERVICIO' || $ds==='SERVICIO'){ continue; }

      $empresa_id=(int)$r['empresa_id'];

      $marca_id_pg     = resolve_marca_id($mysql, $db, $cacheMarca, $empresa_id, $r['marca_id']!==null ? (int)$r['marca_id'] : null);
      $categoria_id_pg = resolve_categoria_id($mysql, $db, $cacheCat,   $empresa_id, $r['categoria_id']!==null ? (int)$r['categoria_id'] : null);
      $modelo_id_pg    = resolve_modelo_id($mysql, $db, $cacheMod,      $empresa_id, $r['modelo_id']!==null ? (int)$r['modelo_id'] : null);

      $unidad_id_pg    = resolve_unidad_id($db,$cacheUnidad, $r['unidad_id']!==null ? (int)$r['unidad_id'] : null, $CFG["kardex"]["unidad_default"]);
      $fraccion_id_pg  = resolve_unidad_id($db,$cacheUnidad, $r['fraccion_id']!==null ? (int)$r['fraccion_id'] : null, $CFG["kardex"]["unidad_default"]);
      if ($fraccion_id_pg===null) $fraccion_id_pg = $unidad_id_pg;

      $payload = [
        "empresa_id"=>$empresa_id,
        "nombre"=>$r['nombre'], "descripcion"=>$r['descripcion'] ?? $r['nombre'],
        "codigo_barras"=>sval_or_null($r['codigo_barras']),
        "codigo_sunat"=>sval_or_null($r['codigo_sunat']),
        "codigo_externo"=>sval_or_null($r['codigo_externo']),
        "afecto_igv"=>(int)$r['afecto_igv'], "porcentaje_igv"=>(float)$r['porcentaje_igv'],
        "peso"=>(float)$r['peso'], "stock_minimo"=>(float)$r['stock_minimo'],
        "pcompra_ant"=>(float)$r['pcompra_ant'], "fcompra_ant"=>sval_or_null($r['fcompra_ant']), "doccompra_ant"=>sval_or_null($r['doccompra_ant']),
        "ult_pcompra"=>(float)$r['ult_pcompra'], "ult_fcompra"=>sval_or_null($r['ult_fcompra'] ?: date('Y-m-d')),
        "ult_doccompra"=>sval_or_null($r['ult_doccompra'] ?: 'INV1-00000001'),
        "unidad_id"=>$unidad_id_pg, "capacidad"=>(float)$r['capacidad'], "fraccion_id"=>$fraccion_id_pg,
        "importe_icbp"=>(float)$r['importe_icbp'],
        "pprecio_menor"=>(float)$r['pprecio_menor'], "pprecio_mayor"=>(float)$r['pprecio_mayor'], "pprecio_dist"=>(float)$r['pprecio_dist'],
        "precmenor_und"=>(float)$r['precmenor_und'], "precmenor_fra"=>(float)$r['precmenor_fra'],
        "precmayor_und"=>(float)$r['precmayor_und'], "precmayor_fra"=>(float)$r['precmayor_fra'],
        "precdist_und"=>(float)$r['precdist_und'], "precdist_fra"=>(float)$r['precdist_fra'],
        "activo"=>(int)$r['activo'],
        "contenido"=>sval_or_null($r['contenido']), "informacion"=>sval_or_null($r['informacion']), "path_foto"=>sval_or_null($r['path_foto']),
        "categoria_id"=>$categoria_id_pg, "marca_id"=>$marca_id_pg, "modelo_id"=>$modelo_id_pg,
        "precio_stock"=>(float)$r['precio_stock'],
        "usa_serie"=> ((int)$r['usa_serie']) ? 1 : 0,
        "offsystem"=> ((int)$r['offsystem']) ? 1 : 0,
      ];
      $payload['afecto_igv'] = (int)!empty($payload['afecto_igv']);
      $payload['usa_serie']  = (int)!empty($payload['usa_serie']);
      $payload['offsystem']  = (int)!empty($payload['offsystem']);
      $payload['activo']     = (int)!empty($payload['activo']);

      try {
        $pgId = upsert_producto_full($db,$payload);
        $mysqlId = (int)$r['id'];
        if ($mysqlId > 0 && $pgId > 0) {
          map_set($db, $empresa_id, $mysqlId, (int)$pgId);
        }
        $ins++;
      } catch (Throwable $e) {
        $err[] = "MyID={$r['id']}: ".$e->getMessage();
      }
      $count++;
      if($count % $chunk === 0){ $db->commit(); $db->beginTransaction(); }
    }
    $db->commit();
  }catch(Throwable $e){ if($db->inTransaction()) $db->rollBack(); $err[]=$e->getMessage(); }
  $res->free();
  return [
    "insertados"=>$ins,
    "errores"=>$err,
    "offset"=>$CFG['offset'],
    "limit"=>$CFG['limit']
  ];
}

function upsert_equivalencia(PDO $db,int $producto_id,int $empresa_id,int $sucursal_id,string $nombre,float $capacidad,string $abrev_precio,string $abrev_univ,float $precio,?string $codigo=null){
  $sel=$db->prepare("SELECT id FROM equivalencias WHERE producto_id=:p AND empresa_id=:e AND sucursal_id=:s AND UPPER(nombre)=UPPER(:n) LIMIT 1");
  $sel->execute([":p"=>$producto_id,":e"=>$empresa_id,":s"=>$sucursal_id,":n"=>$nombre]); $id=$sel->fetchColumn();
  if($id){
    $upd=$db->prepare("UPDATE equivalencias SET capacidad_precio=:cap,abrev_precio=:ap,abrev_universal=:au,precio_venta=:pv,codigo_producto=:cp WHERE id=:id");
    $upd->execute([":cap"=>$capacidad,":ap"=>$abrev_precio,":au"=>$abrev_univ,":pv"=>$precio,":cp"=>$codigo,":id"=>$id]); return (int)$id;
  }else{
    $ins=$db->prepare("INSERT INTO equivalencias(producto_id,nombre,capacidad_precio,abrev_precio,abrev_universal,precio_venta,empresa_id,sucursal_id,codigo_producto)
                       VALUES(:p,:n,:cap,:ap,:au,:pv,:e,:s,:cp) RETURNING id");
    $ins->execute([":p"=>$producto_id,":n"=>$nombre,":cap"=>$capacidad,":ap"=>$abrev_precio,":au"=>$abrev_univ,":pv"=>$precio,":e"=>$empresa_id,":s"=>$sucursal_id,":cp"=>$codigo]);
    return (int)$ins->fetchColumn();
  }
}

function mig_equivalencias(mysqli $mysql, PDO $db, array &$cacheProd, array &$cacheUnidad, array $CFG){
  $empresaId  = (int)$CFG['empresa_id'];
  $sucursalId = (int)$CFG['sucursal_id'];

  $sql = sprintf(
    "SELECT
       id,
       producto_id,
       nombre,
       COALESCE(capacidad_precio, 1)    AS capacidad_precio,
       COALESCE(abrev_precio, 'UND')    AS abrev_precio,
       COALESCE(abrev_universal, 'NIU') AS abrev_universal,
       COALESCE(precio_venta, 0)        AS precio_venta,
       COALESCE(empresa_id,  %d)        AS empresa_id,
       COALESCE(sucursal_id, %d)        AS sucursal_id
     FROM equivalencias",
    $empresaId, $sucursalId
  );

  $res = $mysql->query($sql);
  if (!$res) return ["ok"=>false, "error"=>"MySQL: ".$mysql->error];

  $ins=0; $upd=0; $skip=0; $err=[];
  $noMap=0; $db->beginTransaction();
  $pidCache=[];

  try{
    while($r=$res->fetch_assoc()){
      $mysqlPid = (int)$r['producto_id'];
      $pgPid = map_mysql_pid_to_pg($db, $mysql, (int)$r['empresa_id'], $mysqlPid, $pidCache);
      if(!$pgPid){ $skip++; $noMap++; continue; }

      $id = upsert_equivalencia(
        $db,
        $pgPid,
        (int)$r['empresa_id'],
        (int)$r['sucursal_id'],
        (string)$r['nombre'],
        (float)$r['capacidad_precio'],
        (string)$r['abrev_precio'],
        (string)$r['abrev_universal'],
        (float)$r['precio_venta'],
        null
      );
      if($id) $ins++;
    }
    $db->commit();
  }catch(Throwable $e){
    if($db->inTransaction()) $db->rollBack();
    $err[] = $e->getMessage();
  }
  $res->free();

  return ["insertados"=>$ins,"actualizados"=>$upd,"saltados"=>$skip,"no_map"=>$noMap,"errores"=>$err];
}

function mig_precios_sucursal_desde_productos(mysqli $mysql, PDO $db, array $CFG){
  $empresaId=(int)$CFG['empresa_id']; $sucursalId=(int)$CFG['sucursal_id'];
  $sql="SELECT id, nombre, descripcion,
               COALESCE(precmenor_und,0) det_und, COALESCE(precmenor_fra,0) det_fra,
               COALESCE(precmayor_und,0) may_und, COALESCE(precmayor_fra,0) may_fra,
               COALESCE(precdist_und,0)  dis_und, COALESCE(precdist_fra,0)  dis_fra,
               COALESCE(empresa_id,{$empresaId}) empresa_id
        FROM productos";
  $res=$mysql->query($sql);
  if(!$res) return ["ok"=>false,"error"=>"MySQL: ".$mysql->error];

  $up=$db->prepare("INSERT INTO precios_sucursal(
      empresa_id,sucursal_id,producto_id,
      pprecio_menor,pprecio_mayor,pprecio_dist,
      pminimo_menor,pminimo_mayor,pminimo_dist,
      precmenor_und,precmenor_fra,precmayor_und,precmayor_fra,precdist_und,precdist_fra
    ) VALUES (
      :e,:s,:p,0,0,0,0,0,0,:det_und,:det_fra,:may_und,:may_fra,:dis_und,:dis_fra
    ) ON CONFLICT (empresa_id,sucursal_id,producto_id) DO UPDATE SET
      precmenor_und=EXCLUDED.precmenor_und,precmenor_fra=EXCLUDED.precmenor_fra,
      precmayor_und=EXCLUDED.precmayor_und,precmayor_fra=EXCLUDED.precmayor_fra,
      precdist_und=EXCLUDED.precdist_und,precdist_fra=EXCLUDED.precdist_fra");

  $selByName=$db->prepare("SELECT id FROM productos WHERE empresa_id=:e AND (
    UPPER(regexp_replace(COALESCE(nombre,''),'\s+',' ','g'))=:n OR UPPER(regexp_replace(COALESCE(descripcion,''),'\s+',' ','g'))=:n) LIMIT 1");

  $ins=0;$skip=0;$err=[]; $db->beginTransaction(); $pidCache=[];
  try{
    while($r=$res->fetch_assoc()){
      $empresa_id=(int)$r['empresa_id']; 
      $mysqlId=(int)$r['id'];
      $pgPid = map_get($db,$empresa_id,$mysqlId,$pidCache);
      if(!$pgPid){
        $name=norm_text($r['nombre'] ?: $r['descripcion'] ?: '');
        if($name!==''){
          $selByName->execute([":e"=>$empresa_id,":n"=>$name]); 
          $pgPid=$selByName->fetchColumn();
          if($pgPid){ map_set($db,$empresa_id,$mysqlId,(int)$pgPid); }
        }
      }
      if(!$pgPid){ $skip++; continue; }

      $up->execute([":e"=>$empresa_id,":s"=>$sucursalId,":p"=>$pgPid,
                    ":det_und"=>(float)$r['det_und'],":det_fra"=>(float)$r['det_fra'],
                    ":may_und"=>(float)$r['may_und'],":may_fra"=>(float)$r['may_fra'],
                    ":dis_und"=>(float)$r['dis_und'],":dis_fra"=>(float)$r['dis_fra']]);
      $ins++;
    }
    $db->commit();
  }catch(Throwable $e){ if($db->inTransaction()) $db->rollBack(); $err[]=$e->getMessage(); }
  $res->free();
  return ["insertados"=>$ins,"saltados"=>$skip,"errores"=>$err];
}

function mig_stock_desde_vista(mysqli $mysql, PDO $db, array &$cacheUnidad, array &$cacheProd, array $CFG){
  $K = $CFG['kardex'];
  $empresaId = (int)$CFG['empresa_id'];
  $sucursalId= (int)$CFG['sucursal_id'];
  $almacenId = (int)$CFG['almacen_id'];

  // Detectar columnas existentes en la vista vstock
  $hasEmp  = mysql_column_exists($mysql,'vstock','empresa_id');
  $hasSuc  = mysql_column_exists($mysql,'vstock','sucursal_id');
  $hasAlm  = mysql_column_exists($mysql,'vstock','almacen_id');

  $empExpr = $hasEmp ? "COALESCE(empresa_id, {$empresaId})"   : "{$empresaId}";
  $sucExpr = $hasSuc ? "COALESCE(sucursal_id, {$sucursalId})" : "{$sucursalId}";
  $almExpr = $hasAlm ? "COALESCE(almacen_id, {$almacenId})"   : "{$almacenId}";

  $sql = "
    SELECT
      producto_id,
      COALESCE(StockAct_Und,0)  AS StockAct_Und,
      COALESCE(vencimiento,'-') AS vencimiento,
      COALESCE(lote,'-')        AS lote,
      {$almExpr}                AS almacen_id,
      {$empExpr}                AS empresa_id,
      {$sucExpr}                AS sucursal_id
    FROM vstock
    WHERE nomproducto <> 'SERVICIO' AND COALESCE(StockAct_Und,0) > 0";

  $res = $mysql->query($sql);
  if (!$res) return ["ok"=>false,"error"=>"MySQL: ".$mysql->error];

  $insK=$db->prepare("INSERT INTO kardex(
      nserie, ndocumento, fecha_emision, fecha_traslado, operacion,
      producto_id, cantidad, costo, recogido, unidad_medida,
      vencimiento, lote, transito, razon_social, observacion, anulado,
      docfiscal_id, empresa_id, sucursal_id, almacen_id,
      compra_id, venta_id, guiart_id, ordalm_id, capacidad_precio, iud, precio_salida
    ) VALUES (
      :nserie, :ndoc, :femi, :ftra, :ope,
      :pid, :cant, 0, :cant, :umed,
      :venc, :lote, FALSE, :rs, :obs, FALSE,
      :docf, :emp, :suc, :alm,
      0, 0, 0, 0, 0, 'I', 0
    )");

  $ex=$db->prepare("SELECT 1 FROM kardex
      WHERE empresa_id=:emp AND sucursal_id=:suc AND almacen_id=:alm
        AND nserie=:nserie AND ndocumento=:ndoc
        AND producto_id=:pid AND operacion=:ope AND iud='I'
      LIMIT 1");

  $ins=0; $skip=0; $err=[];
  $pidCache=[]; $motivos=["cant0"=>0,"dup"=>0,"no_map"=>0];

  $db->beginTransaction();
  try{
    while($r = $res->fetch_assoc()){
      $mysqlPid   = (int)$r['producto_id'];
      $cant       = (float)$r['StockAct_Und'];
      $empresa_id = (int)$r['empresa_id'];
      $sucursal_id= (int)$r['sucursal_id'];
      $alm        = (int)$r['almacen_id'];

      if ($cant <= 0){ $skip++; $motivos["cant0"]++; continue; }

      $pgPid = map_mysql_pid_to_pg($db, $mysql, $empresa_id, $mysqlPid, $pidCache);
      if (!$pgPid){ $skip++; $motivos["no_map"]++; continue; }

      $unidad_id = unidad_id($db,$cacheUnidad,$K["unidad_default"]);
      $q = $db->prepare("SELECT COALESCE(NULLIF(abrev,''),abrev_universal) FROM unidadmedida WHERE id=:id");
      $q->execute([":id"=>$unidad_id]);
      $umed = $q->fetchColumn() ?: $K["unidad_default"];

      $ex->execute([
        ":emp"=>$empresa_id, ":suc"=>$sucursal_id, ":alm"=>$alm,
        ":nserie"=>$K["nserie"], ":ndoc"=>$K["ndocumento"],
        ":pid"=>$pgPid, ":ope"=>$K["operacion"]
      ]);
      if ($ex->fetchColumn()){ $skip++; $motivos["dup"]++; continue; }

      $insK->execute([
        ":nserie"=>$K["nserie"], ":ndoc"=>$K["ndocumento"],
        ":femi"=>$K["fecha"], ":ftra"=>$K["fecha"], ":ope"=>$K["operacion"],
        ":pid"=>$pgPid, ":cant"=>$cant, ":umed"=>$umed,
        ":venc"=>$r['vencimiento'], ":lote"=>$r['lote'],
        ":rs"=>$K["razon_social"], ":obs"=>$K["observacion"], ":docf"=>$K["docfiscal_id"],
        ":emp"=>$empresa_id, ":suc"=>$sucursal_id, ":alm"=>$alm
      ]);
      $ins++;
    }
    $db->commit();
  }catch(Throwable $e){
    if($db->inTransaction()) $db->rollBack();
    $err[] = $e->getMessage();
  }
  $res->free();

  return ["insertados"=>$ins,"saltados"=>$skip,"motivos"=>$motivos,"errores"=>$err];
}

function mig_costos_iniciales(PDO $db, array $almacenes, int $empresa_id, string $fecha){
  // Verifica tabla
  $tbl = $db->query("SELECT to_regclass('public.costos')")->fetchColumn();
  if (!$tbl) return ["ok"=>false, "error"=>"Tabla public.costos no existe"];

  // Trae: id producto, costo (ult_pcompra) y fecha real (ult_fcompra o fallback)
  $stmtProd = $db->prepare("
    SELECT
      id AS producto_id,
      COALESCE(ult_pcompra, 0)::numeric(12,4) AS costo,
      COALESCE(ult_fcompra, :f::date)         AS fcompra
    FROM public.productos
    WHERE empresa_id = :e
  ");

  // UPSERT: clave compuesta (almacen_id, producto_id, fecha, compra_id)
  // usamos la fecha por-producto (:fprod)
  $stmtUpsert = $db->prepare("
    WITH up AS (
      INSERT INTO public.costos(
        almacen_id, producto_id, costo, costo_promedio, flete, estiba, fecha, compra_id
      )
      VALUES(:alm, :pid, :costo, :costo, 0.00, 0.00, :fprod, 0)
      ON CONFLICT (almacen_id, producto_id, fecha, compra_id)
      DO UPDATE SET
        costo = EXCLUDED.costo,
        costo_promedio = EXCLUDED.costo_promedio
      RETURNING (xmax = 0) AS inserted
    )
    SELECT inserted FROM up
  ");

  $stmtProd->execute([":e"=>$empresa_id, ":f"=>$fecha]);

  $ins=0;$upd=0;$proc=0;
  $db->beginTransaction();
  try{
    while($row = $stmtProd->fetch(PDO::FETCH_ASSOC)){
      $pid   = (int)$row['producto_id'];
      $costo = (float)$row['costo'];
      $fprod = $row['fcompra']; // fecha por producto (ult_fcompra o fallback)

      foreach($almacenes as $alm){
        $stmtUpsert->execute([
          ":alm"  => (int)$alm,
          ":pid"  => $pid,
          ":costo"=> $costo,
          ":fprod"=> $fprod,
        ]);
        $proc++;
        $flag = $stmtUpsert->fetchColumn();
        if ($flag === 't' || $flag === true) $ins++; else $upd++;
      }
    }
    $db->commit();
  } catch(Throwable $e){
    if($db->inTransaction()) $db->rollBack();
    return ["ok"=>false, "error"=>$e->getMessage()];
  }

  return [
    "ok"=>true,
    "procesados"=>$proc,
    "insertados"=>$ins,
    "actualizados"=>$upd,
    "fallback_fecha"=>$fecha  // usada sólo cuando ult_fcompra es NULL
  ];
}

function mig_ult_costos_productos(mysqli $mysql, PDO $db, array $CFG){
  // Trae crudo y limpiamos en PHP (evita "Incorrect DATE value: ''")
  $sql = "SELECT id AS mysql_id,
                 COALESCE(NULLIF(ult_pcompra,''),0)  AS ult_pcompra,
                 ult_fcompra,
                 NULLIF(ult_doccompra,'')           AS ult_doccompra,
                 COALESCE(empresa_id, {$CFG['empresa_id']}) AS empresa_id,
                 nombre, descripcion
          FROM productos";
  $res = $mysql->query($sql);
  if(!$res) return ["ok"=>false,"error"=>"MySQL: ".$mysql->error];

  // 1) Buscar en el MAPA correcto (NO en productos.mysql_id)
  $selMap = $db->prepare("SELECT pg_id FROM migr_map_productos WHERE empresa_id=:e AND mysql_id=:m LIMIT 1");
  // 2) Fallback por nombre/descr normalizados
  $selByName = $db->prepare("
    SELECT id FROM public.productos
     WHERE empresa_id=:e AND (
       UPPER(regexp_replace(COALESCE(nombre,''),'\\s+',' ','g'))=:n OR
       UPPER(regexp_replace(COALESCE(descripcion,''),'\\s+',' ','g'))=:n
     )
     LIMIT 1
  ");
  // 3) Guardar/actualizar el mapa cuando encontremos por nombre
  $saveMap = $db->prepare("
    INSERT INTO migr_map_productos(empresa_id,mysql_id,pg_id)
    VALUES(:e,:m,:p)
    ON CONFLICT (empresa_id,mysql_id) DO UPDATE SET pg_id=EXCLUDED.pg_id
  ");

  // Actualiza solo si en PG está 0/null (no pisa valores buenos)
  $upd = $db->prepare("UPDATE public.productos
                         SET ult_pcompra=:p, ult_fcompra=:f, ult_doccompra=:d
                       WHERE id=:id AND COALESCE(ult_pcompra,0)=0");

  $ok=0;$skip=0;$err=[]; $db->beginTransaction();
  try{
    while($r=$res->fetch_assoc()){
      $p = (float)$r['ult_pcompra'];
      if ($p <= 0){ $skip++; continue; }

      // Normalizar fecha
      $f = $r['ult_fcompra'];
      if ($f === '' || $f === '0000-00-00' || $f === '0000-00-00 00:00:00' || $f === '0000-00-00 00:00:00.000000') $f = null;

      // 1) Buscar pg_id en mapa
      $selMap->execute([":e"=>(int)$r['empresa_id'], ":m"=>(int)$r['mysql_id']]);
      $pgId = $selMap->fetchColumn();

      // 2) Si no hay en mapa, probar por nombre/descr y guardar en mapa
      if (!$pgId){
        $n = preg_replace('/\s+/u',' ', mb_strtoupper(trim((string)($r['nombre'] ?: $r['descripcion'] ?: ''))));
        if ($n !== ''){
          $selByName->execute([":e"=>(int)$r['empresa_id'], ":n"=>$n]);
          $pgId = $selByName->fetchColumn();
          if ($pgId){
            $saveMap->execute([":e"=>(int)$r['empresa_id'], ":m"=>(int)$r['mysql_id'], ":p"=>(int)$pgId]);
          }
        }
      }
      if(!$pgId){ $skip++; continue; }

      $upd->execute([
        ":p"=>$p,
        ":f"=>$f,
        ":d"=>$r['ult_doccompra'] ?: null,
        ":id"=>(int)$pgId
      ]);
      $ok += $upd->rowCount();
    }
    $db->commit();
  }catch(Throwable $e){
    if($db->inTransaction()) $db->rollBack();
    $err[]=$e->getMessage();
  }
  $res->free();
  return ["actualizados"=>$ok, "saltados"=>$skip, "errores"=>$err];
}

function mig_codbar_productos(mysqli $mysql, PDO $db, array $CFG, bool $overwrite = false){
  // 1) Trae desde MySQL (limpio/seguro: TRIM y NULL si vacío)
  $sql = "SELECT id AS mysql_id,
                 NULLIF(TRIM(codigo_barras),'')         AS codigo_barras,
                 COALESCE(empresa_id, {$CFG['empresa_id']}) AS empresa_id,
                 nombre, descripcion
          FROM productos";
  $res = $mysql->query($sql);
  if(!$res) return ["ok"=>false,"error"=>"MySQL: ".$mysql->error];

  // 2) Reutiliza el mapa MySQL→PG y fallback por nombre/descr
  $selMap = $db->prepare("SELECT pg_id FROM migr_map_productos WHERE empresa_id=:e AND mysql_id=:m LIMIT 1");
  $selByName = $db->prepare("
    SELECT id FROM public.productos
     WHERE empresa_id=:e AND (
       UPPER(regexp_replace(COALESCE(nombre,''),'\\s+',' ','g'))=:n OR
       UPPER(regexp_replace(COALESCE(descripcion,''),'\\s+',' ','g'))=:n
     )
     LIMIT 1
  ");
  $saveMap = $db->prepare("
    INSERT INTO migr_map_productos(empresa_id,mysql_id,pg_id)
    VALUES(:e,:m,:p)
    ON CONFLICT (empresa_id,mysql_id) DO UPDATE SET pg_id=EXCLUDED.pg_id
  ");

  // 3) Updates (dos variantes: condicional vs forzado)
  $upd_if_empty = $db->prepare("
    UPDATE public.productos
       SET codigo_barras=:cb
     WHERE id=:id AND (codigo_barras IS NULL OR codigo_barras='')
  ");
  $upd_force = $db->prepare("
    UPDATE public.productos
       SET codigo_barras=:cb
     WHERE id=:id
  ");

  $ok=0; $skip=0; $err=[];
  $motivos = ["vacio"=>0,"no_map"=>0,"ya_tenia"=>0];

  $db->beginTransaction();
  try{
    while($r=$res->fetch_assoc()){
      $cb = $r['codigo_barras'];
      if ($cb === null || $cb === '') { $skip++; $motivos["vacio"]++; continue; }

      // ⚠️ No tocar ceros a la izquierda; solo TRIM
      $cb = trim((string)$cb);

      // 4) Resuelve pg_id
      $empresa_id = (int)$r['empresa_id'];
      $mysql_id   = (int)$r['mysql_id'];

      $selMap->execute([":e"=>$empresa_id, ":m"=>$mysql_id]);
      $pgId = $selMap->fetchColumn();

      if (!$pgId){
        $n = preg_replace('/\s+/u',' ', mb_strtoupper(trim((string)($r['nombre'] ?: $r['descripcion'] ?: ''))));
        if ($n !== ''){
          $selByName->execute([":e"=>$empresa_id, ":n"=>$n]);
          $pgId = $selByName->fetchColumn();
          if ($pgId){
            $saveMap->execute([":e"=>$empresa_id, ":m"=>$mysql_id, ":p"=>(int)$pgId]);
          }
        }
      }
      if (!$pgId){ $skip++; $motivos["no_map"]++; continue; }

      // 5) Ejecuta update (según modo)
      if ($overwrite){
        $upd_force->execute([":cb"=>$cb, ":id"=>(int)$pgId]);
        $ok += $upd_force->rowCount();
      } else {
        // sólo si estaba vacío en PG
        $upd_if_empty->execute([":cb"=>$cb, ":id"=>(int)$pgId]);
        $rc = $upd_if_empty->rowCount();
        if ($rc > 0) $ok += $rc; else { $skip++; $motivos["ya_tenia"]++; }
      }
    }
    $db->commit();
  }catch(Throwable $e){
    if($db->inTransaction()) $db->rollBack();
    $err[] = $e->getMessage();
  }
  $res->free();

  return [
    "actualizados"=>$ok,
    "saltados"=>$skip,
    "motivos"=>$motivos,
    "overwrite"=>$overwrite?1:0,
    "errores"=>$err
  ];
}

/*function mig_costos_iniciales(PDO $db, array $almacenes, int $empresa_id, string $fecha){
  $tbl = $db->query("SELECT to_regclass('public.costos')")->fetchColumn();
  if (!$tbl) return ["ok"=>false, "error"=>"Tabla public.costos no existe"];
  $stmtProd=$db->prepare("SELECT id AS producto_id, COALESCE(ult_pcompra,0)::numeric(12,4) AS costo FROM productos WHERE empresa_id=:e");
  $stmtUpsert=$db->prepare("
    WITH up AS (
      INSERT INTO costos(almacen_id,producto_id,costo,costo_promedio,flete,estiba,fecha,compra_id)
      VALUES(:alm,:pid,:costo,:costo,0.00,0.00,:fecha,0)
      ON CONFLICT (almacen_id,producto_id,fecha,compra_id)
      DO UPDATE SET costo=EXCLUDED.costo, costo_promedio=EXCLUDED.costo_promedio
      RETURNING (xmax = 0) AS inserted
    ) SELECT inserted FROM up");
  $stmtProd->execute([":e"=>$empresa_id]); $ins=0;$upd=0;$proc=0;
  $db->beginTransaction();
  try{
    while($row=$stmtProd->fetch(PDO::FETCH_ASSOC)){
      foreach($almacenes as $alm){
        $stmtUpsert->execute([":alm"=>(int)$alm,":pid"=>(int)$row['producto_id'],":costo"=>(float)$row['costo'],":fecha"=>$fecha]);
        $proc++; $flag=$stmtUpsert->fetchColumn(); if($flag==='t'||$flag===true) $ins++; else $upd++;
      }
    }
    $db->commit();
  }catch(Throwable $e){ if($db->inTransaction()) $db->rollBack(); return ["ok"=>false,"error"=>$e->getMessage()]; }
  return ["ok"=>true,"procesados"=>$proc,"insertados"=>$ins,"actualizados"=>$upd,"fecha"=>$fecha];
}*/

function mig_clientes(mysqli $mysql, PDO $db, array $CFG){
  $empresaId  = (int)$CFG['empresa_id'];
  $sucursalId = (int)$CFG['sucursal_id'];

  // Paginación al estilo de mig_productos
  $off = (int)$CFG['offset'];
  $lim = $CFG['limit']!==null ? " LIMIT ".$db->quote($CFG['limit'], PDO::PARAM_INT) : "";
  // mysqli no usa prepare con named params para LIMIT/OFFSET; armamos el SQL directo (valores ya saneados)
  $hasLimit = ($CFG['limit'] !== null);
  $limStr   = $hasLimit ? " LIMIT ".(int)$CFG['limit'] : "";
  $offStr   = $hasLimit ? " OFFSET ".$off : "";  // ← solo OFFSET si hay LIMIT

  $sql = "
    SELECT
      id,
      COALESCE(tipo_docidentidad, 0)                     AS tipo_docidentidad,
      COALESCE(documento_identidad, '')                  AS documento_identidad,
      COALESCE(nombre, '')                               AS nombre,
      COALESCE(direccion, '')                            AS direccion,
      COALESCE(ubigeo, NULL)                             AS ubigeo,
      COALESCE(distrito, NULL)                           AS distrito,
      COALESCE(provincia, NULL)                          AS provincia,
      COALESCE(region, NULL)                             AS region,
      COALESCE(contacto, NULL)                           AS contacto,
      COALESCE(email, '-')                               AS email,
      COALESCE(cuenta_bancaria1, NULL)                   AS cuenta_bancaria1,
      COALESCE(cuenta_bancaria2, NULL)                   AS cuenta_bancaria2,
      COALESCE(telefono1, NULL)                          AS telefono1,
      COALESCE(telefono2, NULL)                          AS telefono2,
      COALESCE(linea_credito, 0)                         AS linea_credito,
      COALESCE(importe_maximo, 0)                        AS importe_maximo,
      COALESCE(importe_consumido, 0)                     AS importe_consumido,
      COALESCE(ndias, 0)                                 AS ndias,
      COALESCE(offsystem, 0)                             AS offsystem,
      COALESCE(activo, 1)                                AS activo,
      COALESCE(giro_negocio, NULL)                       AS giro_negocio,
      COALESCE(empresa_id, {$empresaId})                 AS empresa_id,
      COALESCE(sucursal_id, {$sucursalId})               AS sucursal_id,
      COALESCE(zona_id, 1)                               AS zona_id
    FROM clientes
    WHERE COALESCE(UPPER(nombre),'') <> 'CLIENTES VARIOS'
      AND NOT (UPPER(COALESCE(nombre,''))='CLIENTES VARIOS' AND COALESCE(documento_identidad,'')='00000000')
    ORDER BY id
    {$limStr} {$offStr}
  ";

  $res = $mysql->query($sql);
  if(!$res) return ["ok"=>false,"error"=>"MySQL: ".$mysql->error];

  // SELECT/INSERT/UPDATE en PG
  $sel = $db->prepare("
    SELECT id FROM public.clientes
     WHERE empresa_id=:e AND sucursal_id=:s
       AND (
         (COALESCE(documento_identidad,'')<>'' AND documento_identidad=:doc)
         OR ((COALESCE(documento_identidad,'')='') AND UPPER(nombre)=UPPER(:nom))
       )
     LIMIT 1
  ");
  $ins = $db->prepare("
    INSERT INTO public.clientes(
      tipo_docidentidad, documento_identidad, nombre, direccion, ubigeo, distrito, provincia, region,
      contacto, email, cuenta_bancaria1, cuenta_bancaria2, telefono1, telefono2, linea_credito,
      importe_maximo, importe_consumido, ndias, offsystem, activo, giro_negocio,
      empresa_id, sucursal_id, zona_id, imprime_precios
    ) VALUES (
      :tipo_doc, :doc, :nom, :dir, :ubi, :dis, :pro, :reg,
      :con, :ema, :cta1, :cta2, :tel1, :tel2, :lin,
      :imax, :icons, :nd, :off, :act, :giro,
      :e, :s, :zona, TRUE
    )
  ");
  $upd = $db->prepare("
    UPDATE public.clientes SET
      tipo_docidentidad=:tipo_doc, direccion=:dir, ubigeo=:ubi, distrito=:dis, provincia=:pro, region=:reg,
      contacto=:con, email=:ema, cuenta_bancaria1=:cta1, cuenta_bancaria2=:cta2,
      telefono1=:tel1, telefono2=:tel2, linea_credito=:lin,
      importe_maximo=:imax, importe_consumido=:icons, ndias=:nd,
      offsystem=:off, activo=:act, giro_negocio=:giro, zona_id=:zona
    WHERE id=:id
  ");

  $insertados=0; $actualizados=0; $errores=[];
  $chunk = max(1,(int)$CFG['chunk_size']); $i=0;

  $db->beginTransaction();
  try{
    while($r = $res->fetch_assoc()){
      try{
        $doc = trim((string)$r['documento_identidad']);
        $nom = trim((string)$r['nombre']);
        $sel->execute([":e"=>(int)$r['empresa_id'],":s"=>(int)$r['sucursal_id'],":doc"=>$doc,":nom"=>$nom]);
        $id = $sel->fetchColumn();

        $params = [
          ":tipo_doc"=>(int)$r['tipo_docidentidad'],
          ":doc"=>$doc,
          ":nom"=>$nom,
          ":dir"=>(string)$r['direccion'],
          ":ubi"=>$r['ubigeo'] !== '' ? (string)$r['ubigeo'] : null,
          ":dis"=>$r['distrito'] !== '' ? (string)$r['distrito'] : null,
          ":pro"=>$r['provincia'] !== '' ? (string)$r['provincia'] : null,
          ":reg"=>$r['region'] !== '' ? (string)$r['region'] : null,
          ":con"=>$r['contacto'] !== '' ? (string)$r['contacto'] : null,
          ":ema"=> ($r['email'] === '' ? '-' : (string)$r['email']),
          ":cta1"=>$r['cuenta_bancaria1'] !== '' ? (string)$r['cuenta_bancaria1'] : null,
          ":cta2"=>$r['cuenta_bancaria2'] !== '' ? (string)$r['cuenta_bancaria2'] : null,
          ":tel1"=>$r['telefono1'] !== '' ? (string)$r['telefono1'] : null,
          ":tel2"=>$r['telefono2'] !== '' ? (string)$r['telefono2'] : null,
          ":lin"=>(int)!empty($r['linea_credito']),
          ":imax"=>(float)$r['importe_maximo'],
          ":icons"=>(float)$r['importe_consumido'],
          ":nd"=>(int)$r['ndias'],
          ":off"=>(int)!empty($r['offsystem']),
          ":act"=>(int)!empty($r['activo']),
          ":giro"=> $r['giro_negocio']!==null && $r['giro_negocio']!=='' ? (int)$r['giro_negocio'] : null,
          ":e"=>(int)$r['empresa_id'],
          ":s"=>(int)$r['sucursal_id'],
          ":zona"=>(int)$r['zona_id']
        ];

        if ($id){
          $upd->execute($params + [":id"=>(int)$id]);
          $actualizados++;
        } else {
          $ins->execute($params);
          $insertados++;
        }
      } catch(Throwable $e){
        $errores[] = "Cliente MySQL id={$r['id']}: ".$e->getMessage();
      }

      $i++;
      if ($i % $chunk === 0){ $db->commit(); $db->beginTransaction(); }
    }
    $db->commit();
  } catch(Throwable $e){
    if ($db->inTransaction()) $db->rollBack();
    $errores[] = $e->getMessage();
  }
  $res->free();

  return [
    "insertados"=>$insertados,
    "actualizados"=>$actualizados,
    "errores"=>$errores,
    "offset"=>$CFG['offset'],
    "limit"=>$CFG['limit']
  ];
}

function mig_proveedores(mysqli $mysql, PDO $db, array $CFG){
  $empresaId  = (int)$CFG['empresa_id'];

  $off = (int)$CFG['offset'];
  $hasLimit = ($CFG['limit'] !== null);
  $limStr   = $hasLimit ? " LIMIT ".(int)$CFG['limit'] : "";
  $offStr   = $hasLimit ? " OFFSET ".$off : "";

  $sql = "
    SELECT
      id,
      COALESCE(tipo_docidentidad, 0)                     AS tipo_docidentidad,
      COALESCE(documento_identidad, '')                  AS documento_identidad,
      COALESCE(nombre, '')                               AS nombre,
      COALESCE(direccion, '')                            AS direccion,
      COALESCE(ubigeo, NULL)                             AS ubigeo,
      COALESCE(distrito, NULL)                           AS distrito,
      COALESCE(provincia, NULL)                          AS provincia,
      COALESCE(region, NULL)                             AS region,
      COALESCE(contacto, NULL)                           AS contacto,
      COALESCE(email, NULL)                              AS email,
      COALESCE(cuenta_bancaria1, NULL)                   AS cuenta_bancaria1,
      COALESCE(cuenta_bancaria2, NULL)                   AS cuenta_bancaria2,
      COALESCE(telefono1, NULL)                          AS telefono1,
      COALESCE(telefono2, NULL)                          AS telefono2,
      COALESCE(offsystem, 0)                             AS offsystem,
      COALESCE(activo, 1)                                AS activo,
      COALESCE(empresa_id, {$empresaId})                 AS empresa_id
    FROM proveedores
    WHERE COALESCE(UPPER(nombre),'') <> 'AJUSTE'
    ORDER BY id
    {$limStr} {$offStr}
  ";

  $res = $mysql->query($sql);
  if(!$res) return ["ok"=>false,"error"=>"MySQL: ".$mysql->error];

  $sel = $db->prepare("
    SELECT id FROM public.proveedores
     WHERE empresa_id=:e
       AND (
         (COALESCE(documento_identidad,'')<>'' AND documento_identidad=:doc)
         OR ((COALESCE(documento_identidad,'')='') AND UPPER(nombre)=UPPER(:nom))
       )
     LIMIT 1
  ");
  $ins = $db->prepare("
    INSERT INTO public.proveedores(
      tipo_docidentidad, documento_identidad, nombre, direccion, ubigeo, distrito,
      provincia, region, contacto, email, cuenta_bancaria1, cuenta_bancaria2,
      telefono1, telefono2, offsystem, activo, empresa_id
    ) VALUES (
      :tipo_doc, :doc, :nom, :dir, :ubi, :dis,
      :pro, :reg, :con, :ema, :cta1, :cta2,
      :tel1, :tel2, :off, :act, :e
    )
  ");
  $upd = $db->prepare("
    UPDATE public.proveedores SET
      tipo_docidentidad=:tipo_doc, direccion=:dir, ubigeo=:ubi, distrito=:dis,
      provincia=:pro, region=:reg, contacto=:con, email=:ema,
      cuenta_bancaria1=:cta1, cuenta_bancaria2=:cta2, telefono1=:tel1, telefono2=:tel2,
      offsystem=:off, activo=:act
    WHERE id=:id
  ");

  $insertados=0; $actualizados=0; $errores=[];
  $chunk = max(1,(int)$CFG['chunk_size']); $i=0;

  $db->beginTransaction();
  try{
    while($r = $res->fetch_assoc()){
      try{
        $doc = trim((string)$r['documento_identidad']);
        $nom = trim((string)$r['nombre']);
        $sel->execute([":e"=>(int)$r['empresa_id'],":doc"=>$doc,":nom"=>$nom]);
        $id = $sel->fetchColumn();

        $params = [
          ":tipo_doc"=>(int)$r['tipo_docidentidad'],
          ":doc"=>$doc,
          ":nom"=>$nom,
          ":dir"=>(string)$r['direccion'],
          ":ubi"=>$r['ubigeo'] !== '' ? (string)$r['ubigeo'] : null,
          ":dis"=>$r['distrito'] !== '' ? (string)$r['distrito'] : null,
          ":pro"=>$r['provincia'] !== '' ? (string)$r['provincia'] : null,
          ":reg"=>$r['region'] !== '' ? (string)$r['region'] : null,
          ":con"=>$r['contacto'] !== '' ? (string)$r['contacto'] : null,
          ":ema"=>$r['email'] !== '' ? (string)$r['email'] : null,
          ":cta1"=>$r['cuenta_bancaria1'] !== '' ? (string)$r['cuenta_bancaria1'] : null,
          ":cta2"=>$r['cuenta_bancaria2'] !== '' ? (string)$r['cuenta_bancaria2'] : null,
          ":tel1"=>$r['telefono1'] !== '' ? (string)$r['telefono1'] : null,
          ":tel2"=>$r['telefono2'] !== '' ? (string)$r['telefono2'] : null,
          ":off"=>(int)!empty($r['offsystem']),
          ":act"=>(int)!empty($r['activo']),
          ":e"=>(int)$r['empresa_id']
        ];

        if ($id){
          $upd->execute($params + [":id"=>(int)$id]);
          $actualizados++;
        } else {
          $ins->execute($params);
          $insertados++;
        }
      } catch(Throwable $e){
        $errores[] = "Proveedor MySQL id={$r['id']}: ".$e->getMessage();
      }

      $i++;
      if ($i % $chunk === 0){ $db->commit(); $db->beginTransaction(); }
    }
    $db->commit();
  } catch(Throwable $e){
    if ($db->inTransaction()) $db->rollBack();
    $errores[] = $e->getMessage();
  }
  $res->free();

  return [
    "insertados"=>$insertados,
    "actualizados"=>$actualizados,
    "errores"=>$errores,
    "offset"=>$CFG['offset'],
    "limit"=>$CFG['limit']
  ];
}

/* ================== Dispatcher ================== */
$ACTION = $RAW['action'] ?? null;
$resumen=[];
try{
    if (!$ACTION) {
      if (run_section($ONLY,'marcas'))            $resumen['marcas']            = mig_marcas($mysql,$pg,$cacheMarca,$CFG['empresa_id']);
      if (run_section($ONLY,'categorias'))        $resumen['categorias']        = mig_categorias($mysql,$pg,$cacheCat,$CFG['empresa_id']);
      if (run_section($ONLY,'modelos'))           $resumen['modelos']           = mig_modelos($mysql,$pg,$cacheMod,$CFG['empresa_id']);
      if (run_section($ONLY,'productos'))         $resumen['productos']         = mig_productos($mysql,$pg,$cacheUnidad,$cacheMarca,$cacheCat,$cacheMod,$cacheProd,$CFG);
      if (run_section($ONLY,'equivalencias'))     $resumen['equivalencias']     = mig_equivalencias($mysql,$pg,$cacheProd,$cacheUnidad,$CFG);
      if (run_section($ONLY,'precios_sucursal'))  $resumen['precios_sucursal']  = mig_precios_sucursal_desde_productos($mysql,$pg,$CFG);
      if (run_section($ONLY,'stock'))             $resumen['stock']             = mig_stock_desde_vista($mysql,$pg,$cacheUnidad,$cacheProd,$CFG);
      if (run_section($ONLY,'costos_iniciales')){
        $almacenes = isset($RAW['almacenes']) && is_array($RAW['almacenes']) ? array_map('intval',$RAW['almacenes']) : [$CFG['almacen_id']];
        $resumen['costos_iniciales'] = mig_costos_iniciales($pg, $almacenes, $CFG['empresa_id'], $CFG['kardex']['fecha']);
      }
      if (run_section($ONLY,'ult_costos'))        $resumen['ult_costos']        = mig_ult_costos_productos($mysql,$pg,$CFG);
      if (run_section($ONLY,'cod_barras'))        $resumen['cod_barras']        = mig_codbar_productos($mysql,$pg,$CFG, !empty($RAW['overwrite']));
      if (run_section($ONLY,'clientes'))          $resumen['clientes']          = mig_clientes($mysql,$pg,$CFG);
      if (run_section($ONLY,'proveedores'))       $resumen['proveedores']       = mig_proveedores($mysql,$pg,$CFG);
    }

    out_json(["ok"=>true,"resumen"=>$resumen]);
}catch(Throwable $e){
  while (ob_get_level() > 0) { @ob_end_clean(); }
  out_json(["ok"=>false,"error"=>$e->getMessage()],500);
}