unit frm_PreparacionPedidoEditor;

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes,
  Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.ExtCtrls, Vcl.StdCtrls,
  Vcl.Grids, Vcl.DBGrids, Vcl.ComCtrls, Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Intf,
  FireDAC.Stan.Option, FireDAC.Stan.Param, FireDAC.Stan.Error, FireDAC.DatS,
  FireDAC.Phys.Intf, FireDAC.DApt.Intf, FireDAC.Stan.Async, FireDAC.DApt,
  System.Generics.Collections, System.Math, uGS1BarcodeParser, uValidacionFiscal, uEdicomDesadvExporter, uEdicomWsClient;

type
  TfrmPreparacionPedidoEditor = class(TForm)
    pnlHeader: TPanel;
    lblPedidoTitle: TLabel;
    lblAlbaranTitle: TLabel;
    lblCliente: TLabel;
    lblDestino: TLabel;
    lblEstado: TLabel;
    pnlCenter: TPanel;
    pnlLinesHeader: TPanel;
    lblLinesTitle: TLabel;
    dbgLines: TDBGrid;
    pnlBottom: TPanel;
    pnlScanControls: TPanel;
    lblEdiNumeroSSCC: TLabel;
    lblScanPrompt: TLabel;
    lblPaletInfo: TLabel;
    edtEdiNumeroSSCC: TEdit;
    btnEdiAddPalet: TButton;
    btnEdiScanEnvase: TButton;
    btnEdiDeleteBulto: TButton;
    btnEdiDeleteAllBultos: TButton;
    btnEdiRefreshTree: TButton;
    btnEdiExportDesadv: TButton;
    btnSolicitarRecogida: TButton;
    btnFinalizar: TButton;
    btnCerrar: TButton;
    edtBarcode: TEdit;
    chkSinPaletizar: TCheckBox;
    tvEdiBultos: TTreeView;
    dsLines: TDataSource;

    procedure FormShow(Sender: TObject);
    procedure FormClose(Sender: TObject; var Action: TCloseAction);
    procedure btnEdiAddPaletClick(Sender: TObject);
    procedure btnEdiScanEnvaseClick(Sender: TObject);
    procedure btnEdiDeleteBultoClick(Sender: TObject);
    procedure btnEdiDeleteAllBultosClick(Sender: TObject);
    procedure btnEdiRefreshTreeClick(Sender: TObject);
    procedure btnEdiExportDesadvClick(Sender: TObject);
    procedure btnSolicitarRecogidaClick(Sender: TObject);
    procedure btnFinalizarClick(Sender: TObject);
    procedure btnCerrarClick(Sender: TObject);
    procedure chkSinPaletizarClick(Sender: TObject);
    procedure edtBarcodeKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
    procedure tvEdiBultosDblClick(Sender: TObject);
  private
    FPedidoId: string;
    FAlbaranId: Integer;
    FPaletActual: Integer;
    FmtLines: TFDMemTable;

    function ObtenerOCrearAlbaranPedido: Integer;
    procedure SetupMemTables;
    procedure LoadOrderData;
    procedure CargarArbolBultosEdi;
    function GenerarPaletSSCC: string;
    procedure ProcessBarcodeScan(const ABarcode: string);
    procedure RecalculateLineStatus;
    procedure CheckUpdateStateInPreparation;
    procedure GenerarAlbaranesInterempresas(const AAlbaranId: Integer);
  public
    property PedidoId: string read FPedidoId write FPedidoId;
  end;

var
  frmPreparacionPedidoEditor: TfrmPreparacionPedidoEditor;

implementation

uses
  dmg_Main, uAppTheme, uDbErrorHandler, frm_EnviosRecogida, uAlbaranPrintService, frm_SelectLote;

{$R *.dfm}

procedure TfrmPreparacionPedidoEditor.FormShow(Sender: TObject);
begin
  TAppTheme.ApplyToForm(Self);
  FPaletActual := 0;
  lblPaletInfo.Caption := 'Sin Palet';
  lblPaletInfo.Font.Color := clGray;

  SetupMemTables;
  if FPedidoId <> '' then
  begin
    FAlbaranId := ObtenerOCrearAlbaranPedido;
    LoadOrderData;
    CargarArbolBultosEdi;
  end;

  if Assigned(tvEdiBultos) then
    tvEdiBultos.OnDblClick := tvEdiBultosDblClick;

  if Assigned(edtBarcode) then
    edtBarcode.SetFocus;
end;

procedure TfrmPreparacionPedidoEditor.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  CheckUpdateStateInPreparation;
end;

function TfrmPreparacionPedidoEditor.ObtenerOCrearAlbaranPedido: Integer;
var
  Qry, QryLineas: TFDQuery;
  LAlbId, LNextNum, LPos: Integer;
  LSerie, FEmpId, FCliId, FDestId: string;
  LTotalBase, LTotalIva, LTotalDoc: Double;
  LCant, LBultos, LCantBulto, LPrecio, LTipoIva, LBase, LIva, LTotal: Double;
  LCountLineas: Integer;
  LHasSeriesRow: Boolean;
begin
  Result := 0;
  if StrToIntDef(FPedidoId, 0) <= 0 then Exit;

  LTotalBase := 0;
  LTotalIva  := 0;
  LTotalDoc  := 0;

  Qry := TFDQuery.Create(nil);
  QryLineas := TFDQuery.Create(nil);
  try
    Qry.Connection := dmgMain.dbConn;
    QryLineas.Connection := dmgMain.dbConn;

    // 1. Buscar si ya existe albarán vinculado en ge_envios_recogidas o ge_albaranes
    Qry.SQL.Text :=
      'SELECT r.albaran_id FROM ge_envios_recogidas r WHERE r.pedido_id = :ped AND r.albaran_id IS NOT NULL LIMIT 1';
    Qry.ParamByName('ped').AsString := FPedidoId;
    Qry.Open;

    if not Qry.IsEmpty and not Qry.FieldByName('albaran_id').IsNull then
      LAlbId := Qry.FieldByName('albaran_id').AsInteger
    else
    begin
      Qry.Close;
      Qry.SQL.Text := 'SELECT id FROM ge_albaranes WHERE id_pedido = :ped AND (anulado = 0 OR anulado IS NULL) LIMIT 1';
      Qry.ParamByName('ped').AsString := FPedidoId;
      Qry.Open;
      if not Qry.IsEmpty then
        LAlbId := Qry.FieldByName('id').AsInteger
      else
        LAlbId := 0;
    end;
    Qry.Close;

    // Si ya existe albarán, verificar si tiene líneas
    if LAlbId > 0 then
    begin
      Qry.SQL.Text := 'SELECT COUNT(*) AS total_lineas FROM ge_albaranes_lineas WHERE albaran_id = :alb';
      Qry.ParamByName('alb').AsInteger := LAlbId;
      Qry.Open;
      LCountLineas := Qry.FieldByName('total_lineas').AsInteger;
      Qry.Close;

      // Si ya tiene líneas válidas, devolver el ID directamente
      if LCountLineas > 0 then
      begin
        Result := LAlbId;
        Exit;
      end;
    end;

    // Obtener datos del pedido para empresa, cliente y destino
    Qry.SQL.Text :=
      'SELECT p.empresa_id, p.cliente_id, p.clientedestino_id, p.numero_pedido, p.observaciones ' +
      'FROM ge_pedidos p WHERE p.id = :ped';
    Qry.ParamByName('ped').AsString := FPedidoId;
    Qry.Open;
    if Qry.IsEmpty then Exit;

    FEmpId  := Qry.FieldByName('empresa_id').AsString;
    FCliId  := Qry.FieldByName('cliente_id').AsString;
    FDestId := Qry.FieldByName('clientedestino_id').AsString;
    Qry.Close;

    // 2. Determinar la serie y siguiente número oficial de la empresa
    LSerie := '';
    LNextNum := 0;
    LHasSeriesRow := False;

    if dmgMain.GetSerieYNumeroDocumento(FEmpId, 'ALBARAN', Date, '', LSerie, LNextNum) and (LSerie <> '') then
    begin
      LHasSeriesRow := True;
    end
    else
    begin
      Qry.SQL.Text :=
        'SELECT serie, sig_numero_albaran FROM ge_series ' +
        'WHERE empresa_id = :emp AND activo = 1 ' +
        'ORDER BY por_defecto DESC, ' +
        '         (CASE WHEN (CURDATE() >= fecha_inicio OR fecha_inicio IS NULL) AND (CURDATE() <= fecha_fin OR fecha_fin IS NULL) THEN 1 ELSE 0 END) DESC, ' +
        '         serie ASC LIMIT 1';
      Qry.ParamByName('emp').AsString := FEmpId;
      Qry.Open;
      if not Qry.IsEmpty and (Trim(Qry.FieldByName('serie').AsString) <> '') then
      begin
        LSerie := Trim(Qry.FieldByName('serie').AsString);
        LNextNum := Qry.FieldByName('sig_numero_albaran').AsInteger;
        if LNextNum <= 0 then LNextNum := 1;
        LHasSeriesRow := True;
      end
      else
      begin
        LSerie := FormatDateTime('yy', Date);
        Qry.Close;
        Qry.SQL.Text := 'SELECT COALESCE(MAX(numero), 0) + 1 AS next_num FROM ge_albaranes WHERE empresa_id = :emp AND serie = :ser';
        Qry.ParamByName('emp').AsString := FEmpId;
        Qry.ParamByName('ser').AsString := LSerie;
        Qry.Open;
        LNextNum := Qry.FieldByName('next_num').AsInteger;
      end;
      Qry.Close;
    end;

    // Si no existía albarán, crear la cabecera
    if LAlbId <= 0 then
    begin
      Qry.SQL.Text :=
        'INSERT INTO ge_albaranes (' +
        '  empresa_id, cliente_id, clientedestino_id, id_pedido, serie, numero, fecha, facturado, id_forma_pago, ' +
        '  observaciones1, referencia, edi_referencia_pedido_cliente, id_user_creator, id_user_update' +
        ') VALUES (' +
        '  :emp, :cli, :dest, :ped, :ser, :num, NOW(), 0, (SELECT id_forma_pago FROM ge_clientes WHERE id = :cli), ' +
        '  (SELECT COALESCE(observaciones, '''') FROM ge_pedidos WHERE id = :ped), ' +
        '  CONCAT(''Pedido '', (SELECT COALESCE(numero_pedido, '''') FROM ge_pedidos WHERE id = :ped)), ' +
        '  (SELECT COALESCE(numero_pedido, '''') FROM ge_pedidos WHERE id = :ped), ' +
        '  :user, :user' +
        ')';
      Qry.ParamByName('emp').AsString := FEmpId;
      Qry.ParamByName('cli').AsString := FCliId;
      if FDestId <> '' then
        Qry.ParamByName('dest').AsString := FDestId
      else
      begin
        Qry.ParamByName('dest').DataType := ftInteger;
        Qry.ParamByName('dest').Clear;
      end;
      Qry.ParamByName('ped').AsString := FPedidoId;
      Qry.ParamByName('ser').AsString := LSerie;
      Qry.ParamByName('num').AsInteger := LNextNum;
      Qry.ParamByName('user').AsString := dmgMain.CurrentUserId;
      Qry.ExecSQL;

      Qry.Close;
      Qry.SQL.Text := 'SELECT LAST_INSERT_ID() AS new_id';
      Qry.Open;
      LAlbId := Qry.FieldByName('new_id').AsInteger;
      Qry.Close;

      if LHasSeriesRow then
      begin
        Qry.SQL.Text := 'UPDATE ge_series SET sig_numero_albaran = :num + 1 WHERE empresa_id = :emp AND serie = :ser AND activo = 1';
        Qry.ParamByName('num').AsInteger := LNextNum;
        Qry.ParamByName('emp').AsString  := FEmpId;
        Qry.ParamByName('ser').AsString  := LSerie;
        Qry.ExecSQL;
      end;
    end
    else
    begin
      // Si existía un albarán pero estaba vacío o con serie incorrecta 'A', actualizar su serie a la oficial
      Qry.SQL.Text :=
        'UPDATE ge_albaranes SET serie = :ser, numero = :num, updated_at = NOW(), id_user_update = :user ' +
        'WHERE id = :id AND (serie = ''A'' OR serie IS NULL OR TRIM(serie) = '''')';
      Qry.ParamByName('ser').AsString  := LSerie;
      Qry.ParamByName('num').AsInteger := LNextNum;
      Qry.ParamByName('user').AsString := dmgMain.CurrentUserId;
      Qry.ParamByName('id').AsInteger  := LAlbId;
      Qry.ExecSQL;
      if Qry.RowsAffected > 0 then
      begin
        if LHasSeriesRow then
        begin
          Qry.Close;
          Qry.SQL.Text := 'UPDATE ge_series SET sig_numero_albaran = :num + 1 WHERE empresa_id = :emp AND serie = :ser AND activo = 1';
          Qry.ParamByName('num').AsInteger := LNextNum;
          Qry.ParamByName('emp').AsString  := FEmpId;
          Qry.ParamByName('ser').AsString  := LSerie;
          Qry.ExecSQL;
        end;
      end;
    end;

    Result := LAlbId;
    if LAlbId <= 0 then Exit;

    // 3. Insertar líneas en ge_albaranes_lineas desde ge_pedidos_lineas calculando IVA y bases
    QryLineas.SQL.Text :=
      'SELECT pl.id, pl.id_articulo, pl.descripcion, pl.cantidad, COALESCE(pl.bultos, 0) AS bultos, pl.precio, ' +
      '       COALESCE(ti.tipo, 10.0) AS tipo_iva, ' +
      '       CAST(a.id AS CHAR) AS art_cod, a.ean AS art_ean ' +
      'FROM ge_pedidos_lineas pl ' +
      'LEFT JOIN ge_articulos a ON pl.id_articulo = a.id ' +
      'LEFT JOIN ge_tipos_iva ti ON a.id_tipo_iva = ti.id ' +
      'WHERE pl.id_pedido = :ped ORDER BY pl.id ASC';
    QryLineas.ParamByName('ped').AsString := FPedidoId;
    QryLineas.Open;

    LPos := 0;
    while not QryLineas.Eof do
    begin
      Inc(LPos);
      LCant    := QryLineas.FieldByName('cantidad').AsFloat;
      LBultos  := QryLineas.FieldByName('bultos').AsFloat;
      LPrecio  := QryLineas.FieldByName('precio').AsFloat;
      LTipoIva := QryLineas.FieldByName('tipo_iva').AsFloat;

      if (LBultos > 0) and (LCant > 0) then
        LCantBulto := LCant / LBultos
      else
        LCantBulto := 0;

      LBase  := LCant * LPrecio;
      LIva   := LBase * (LTipoIva / 100.0);
      LTotal := LBase + LIva;

      LTotalBase := LTotalBase + LBase;
      LTotalIva  := LTotalIva + LIva;
      LTotalDoc  := LTotalDoc + LTotal;

      Qry.Close;
      Qry.SQL.Text :=
        'INSERT INTO ge_albaranes_lineas (' +
        '  albaran_id, articulo_id, articulo_codigo, posicion, descripcion, bultos, cantidad_bulto, ' +
        '  cantidad, precio, tipo_iva, base, iva, total, id_linea_pedido, edi_ean_enviado, id_user_creator, id_user_update' +
        ') VALUES (' +
        '  :alb, :art, :cod, :pos, :desc, :bultos, :cant_bulto, ' +
        '  :cant, :precio, :iva_tipo, :base, :iva_cant, :total, :id_ped_linea, :ean, :user, :user' +
        ')';
      Qry.ParamByName('alb').AsInteger        := LAlbId;
      Qry.ParamByName('art').AsInteger        := QryLineas.FieldByName('id_articulo').AsInteger;
      Qry.ParamByName('cod').AsString         := QryLineas.FieldByName('art_cod').AsString;
      Qry.ParamByName('pos').AsInteger        := LPos;
      Qry.ParamByName('desc').AsString        := QryLineas.FieldByName('descripcion').AsString;
      Qry.ParamByName('bultos').AsFloat       := LBultos;
      Qry.ParamByName('cant_bulto').AsFloat   := LCantBulto;
      Qry.ParamByName('cant').AsFloat         := LCant;
      Qry.ParamByName('precio').AsFloat       := LPrecio;
      Qry.ParamByName('iva_tipo').AsFloat     := LTipoIva;
      Qry.ParamByName('base').AsFloat         := LBase;
      Qry.ParamByName('iva_cant').AsFloat     := LIva;
      Qry.ParamByName('total').AsFloat        := LTotal;
      Qry.ParamByName('id_ped_linea').AsInteger := QryLineas.FieldByName('id').AsInteger;
      Qry.ParamByName('ean').AsString         := QryLineas.FieldByName('art_ean').AsString;
      Qry.ParamByName('user').AsString        := dmgMain.CurrentUserId;
      Qry.ExecSQL;

      QryLineas.Next;
    end;

    // Actualizar totales calculados en la cabecera del albarán
    Qry.Close;
    Qry.SQL.Text :=
      'UPDATE ge_albaranes SET base_imponible = :base, iva = :iva, total = :tot WHERE id = :id';
    Qry.ParamByName('base').AsFloat := LTotalBase;
    Qry.ParamByName('iva').AsFloat  := LTotalIva;
    Qry.ParamByName('tot').AsFloat  := LTotalDoc;
    Qry.ParamByName('id').AsInteger := LAlbId;
    Qry.ExecSQL;

  finally
    Qry.Free;
    QryLineas.Free;
  end;
end;

procedure TfrmPreparacionPedidoEditor.SetupMemTables;
begin
  if not Assigned(FmtLines) then
  begin
    FmtLines := TFDMemTable.Create(Self);
    FmtLines.FieldDefs.Add('id_linea', ftInteger);
    FmtLines.FieldDefs.Add('id_articulo', ftInteger);
    FmtLines.FieldDefs.Add('ean_producto', ftString, 20);
    FmtLines.FieldDefs.Add('descripcion', ftString, 250);
    FmtLines.FieldDefs.Add('cantidad', ftFloat);
    FmtLines.FieldDefs.Add('bultos', ftFloat);
    FmtLines.FieldDefs.Add('unidades_preparadas', ftFloat);
    FmtLines.FieldDefs.Add('estado_linea', ftString, 30);
    FmtLines.CreateDataSet;
  end;
  dsLines.DataSet := FmtLines;

  // Configurar columnas del dbgLines
  dbgLines.Columns.Clear;
  with dbgLines.Columns.Add do
  begin
    FieldName := 'id_articulo';
    Title.Caption := 'Ref. Art.';
    Width := 75;
  end;
  with dbgLines.Columns.Add do
  begin
    FieldName := 'ean_producto';
    Title.Caption := 'EAN / Código';
    Width := 130;
  end;
  with dbgLines.Columns.Add do
  begin
    FieldName := 'descripcion';
    Title.Caption := 'Descripción del Producto';
    Width := 300;
  end;
  with dbgLines.Columns.Add do
  begin
    FieldName := 'cantidad';
    Title.Caption := 'Cant. Solicitada';
    Width := 110;
    Alignment := taRightJustify;
  end;
  with dbgLines.Columns.Add do
  begin
    FieldName := 'bultos';
    Title.Caption := 'Bultos';
    Width := 70;
    Alignment := taRightJustify;
  end;
  with dbgLines.Columns.Add do
  begin
    FieldName := 'unidades_preparadas';
    Title.Caption := 'Unidades Preparadas';
    Width := 140;
    Alignment := taRightJustify;
  end;
  with dbgLines.Columns.Add do
  begin
    FieldName := 'estado_linea';
    Title.Caption := 'Estado Línea';
    Width := 120;
    Alignment := taCenter;
  end;
end;

procedure TfrmPreparacionPedidoEditor.LoadOrderData;
var
  QryMaster, QryLines: TFDQuery;
begin
  QryMaster := TFDQuery.Create(nil);
  QryLines := TFDQuery.Create(nil);
  try
    QryMaster.Connection := dmgMain.dbConn;
    QryMaster.SQL.Text :=
      'SELECT p.id, p.numero_pedido, p.fecha_pedido, p.estado, ' +
      '       a.serie AS albaran_serie, a.numero AS albaran_numero, a.edi_numero_sscc, ' +
      '       c.nombre_fiscal AS cliente_nombre, ' +
      '       COALESCE(d.descripcion, c.nombre_fiscal) AS destino_nombre ' +
      'FROM ge_pedidos p ' +
      'LEFT JOIN ge_clientes c ON p.cliente_id = c.id ' +
      'LEFT JOIN ge_clientes_destinos d ON p.clientedestino_id = d.id ' +
      'LEFT JOIN ge_albaranes a ON a.id = :albaran_id ' +
      'WHERE p.id = :id';
    QryMaster.ParamByName('id').AsString := FPedidoId;
    QryMaster.ParamByName('albaran_id').AsInteger := FAlbaranId;
    QryMaster.Open;

    if not QryMaster.IsEmpty then
    begin
      lblPedidoTitle.Caption := 'Pedido Nº: ' + QryMaster.FieldByName('numero_pedido').AsString;

      var LAlbNum := Trim(QryMaster.FieldByName('albaran_numero').AsString);
      var LAlbSer := Trim(QryMaster.FieldByName('albaran_serie').AsString);
      if LAlbNum <> '' then
      begin
        if LAlbSer <> '' then
          lblAlbaranTitle.Caption := 'Albarán Nº: ' + LAlbSer + '-' + LAlbNum
        else
          lblAlbaranTitle.Caption := 'Albarán Nº: ' + LAlbNum;
      end
      else
        lblAlbaranTitle.Caption := 'Albarán Nº: (Pendiente)';

      lblCliente.Caption := 'Cliente: ' + QryMaster.FieldByName('cliente_nombre').AsString;
      lblDestino.Caption := 'Destino: ' + QryMaster.FieldByName('destino_nombre').AsString;
      lblEstado.Caption := 'Estado: ' + QryMaster.FieldByName('estado').AsString;
      edtEdiNumeroSSCC.Text := QryMaster.FieldByName('edi_numero_sscc').AsString;
    end;

    // Cargar líneas del pedido
    QryLines.Connection := dmgMain.dbConn;
    QryLines.SQL.Text :=
      'SELECT pl.id, pl.id_articulo, pl.ean_producto, pl.descripcion, pl.cantidad, pl.bultos, ' +
      '       a.ean AS art_ean ' +
      'FROM ge_pedidos_lineas pl ' +
      'LEFT JOIN ge_articulos a ON pl.id_articulo = a.id ' +
      'WHERE pl.id_pedido = :id ' +
      'ORDER BY pl.id ASC';
    QryLines.ParamByName('id').AsString := FPedidoId;
    QryLines.Open;

    FmtLines.EmptyDataSet;
    while not QryLines.Eof do
    begin
      FmtLines.Append;
      FmtLines.FieldByName('id_linea').AsInteger := QryLines.FieldByName('id').AsInteger;
      FmtLines.FieldByName('id_articulo').AsInteger := QryLines.FieldByName('id_articulo').AsInteger;

      var LEanStr := Trim(QryLines.FieldByName('ean_producto').AsString);
      if LEanStr = '' then
        LEanStr := Trim(QryLines.FieldByName('art_ean').AsString);

      FmtLines.FieldByName('ean_producto').AsString := LEanStr;
      FmtLines.FieldByName('descripcion').AsString := QryLines.FieldByName('descripcion').AsString;
      FmtLines.FieldByName('cantidad').AsFloat := QryLines.FieldByName('cantidad').AsFloat;
      FmtLines.FieldByName('bultos').AsFloat := QryLines.FieldByName('bultos').AsFloat;
      FmtLines.FieldByName('unidades_preparadas').AsFloat := 0.0;
      FmtLines.FieldByName('estado_linea').AsString := 'PENDIENTE';
      FmtLines.Post;

      QryLines.Next;
    end;
  finally
    QryMaster.Free;
    QryLines.Free;
  end;
end;

procedure TfrmPreparacionPedidoEditor.CargarArbolBultosEdi;
var
  QryBultos, QryContenido: TFDQuery;
  NodePalet, NodeEnvase, NodeContenido, ParentNode: TTreeNode;
  LBultoMap: TDictionary<Integer, TTreeNode>;
  LTotalPrepMap: TDictionary<Integer, Double>;
  LBultoId, LPadreId, LNivel, LNumBulto, LArtId: Integer;
  LSscc, LEan, LLote, LCadStr, LText: string;
  LCant, LCurrPrep: Double;
begin
  if FAlbaranId <= 0 then Exit;

  tvEdiBultos.Items.BeginUpdate;
  LBultoMap := TDictionary<Integer, TTreeNode>.Create;
  LTotalPrepMap := TDictionary<Integer, Double>.Create;
  try
    tvEdiBultos.Items.Clear;

    QryBultos := TFDQuery.Create(nil);
    QryContenido := TFDQuery.Create(nil);
    try
      QryBultos.Connection := dmgMain.dbConn;
      QryContenido.Connection := dmgMain.dbConn;

      // 1. Cargar bultos (palets/cajas) del albarán desde ge_albaranes_bultos
      QryBultos.SQL.Text :=
        'SELECT id, albaran_id, nivel, bulto_padre_id, sscc, tipo_embalaje, numero_bulto ' +
        'FROM ge_albaranes_bultos ' +
        'WHERE albaran_id = :alb ' +
        'ORDER BY nivel ASC, id ASC';
      QryBultos.ParamByName('alb').AsInteger := FAlbaranId;
      QryBultos.Open;

      while not QryBultos.Eof do
      begin
        LBultoId := QryBultos.FieldByName('id').AsInteger;
        LNivel := QryBultos.FieldByName('nivel').AsInteger;
        LNumBulto := QryBultos.FieldByName('numero_bulto').AsInteger;
        LSscc := QryBultos.FieldByName('sscc').AsString;

        ParentNode := nil;
        if not QryBultos.FieldByName('bulto_padre_id').IsNull then
        begin
          LPadreId := QryBultos.FieldByName('bulto_padre_id').AsInteger;
          LBultoMap.TryGetValue(LPadreId, ParentNode);
        end;

        if LNivel = 2 then
        begin
          LText := Format('📦 Palet #%d — SSCC: %s (Tipo: %s)', [
            LNumBulto, LSscc, QryBultos.FieldByName('tipo_embalaje').AsString
          ]);
          NodePalet := tvEdiBultos.Items.AddChild(nil, LText);
          NodePalet.Data := TObject(IntPtr(LBultoId));
          LBultoMap.AddOrSetValue(LBultoId, NodePalet);
          NodePalet.Expand(True);
        end;

        if LNivel = 3 then
        begin
          LText := Format('📥 Envase/Caja #%d — Tipo: %s', [
            LNumBulto, QryBultos.FieldByName('tipo_embalaje').AsString
          ]);
          NodeEnvase := tvEdiBultos.Items.AddChild(ParentNode, LText);
          NodeEnvase.Data := TObject(IntPtr(LBultoId));
          LBultoMap.AddOrSetValue(LBultoId, NodeEnvase);
        end;

        QryBultos.Next;
      end;

      // 2. Cargar contenido de líneas escaneadas desde ge_albaranes_lineas_bultos
      QryContenido.SQL.Text :=
        'SELECT lb.id AS lb_id, lb.bulto_id, lb.linea_albaran_id, lb.cantidad, lb.lote, lb.lote_interno, lb.fecha_caducidad, ' +
        '       al.articulo_id, al.descripcion AS art_desc, ' +
        '       COALESCE(art.ean, al.edi_ean_enviado, '''') AS ean ' +
        'FROM ge_albaranes_lineas_bultos lb ' +
        'INNER JOIN ge_albaranes_lineas al ON lb.linea_albaran_id = al.id ' +
        'LEFT JOIN ge_articulos art ON al.articulo_id = art.id ' +
        'WHERE al.albaran_id = :alb ' +
        'ORDER BY lb.id ASC';
      QryContenido.ParamByName('alb').AsInteger := FAlbaranId;
      QryContenido.Open;

      while not QryContenido.Eof do
      begin
        LBultoId := QryContenido.FieldByName('bulto_id').AsInteger;
        LArtId := QryContenido.FieldByName('articulo_id').AsInteger;
        LCant := QryContenido.FieldByName('cantidad').AsFloat;

        // Sumar unidades preparadas por artículo
        LCurrPrep := 0;
        LTotalPrepMap.TryGetValue(LArtId, LCurrPrep);
        LTotalPrepMap.AddOrSetValue(LArtId, LCurrPrep + LCant);

        if LBultoMap.TryGetValue(LBultoId, ParentNode) then
        begin
          LEan := QryContenido.FieldByName('ean').AsString;
          LLote := QryContenido.FieldByName('lote').AsString;
          var LLoteInt := Trim(QryContenido.FieldByName('lote_interno').AsString);
          var LLoteDisplay := LLote;
          if (LLoteInt <> '') and not SameText(LLoteInt, LLote) then
          begin
            if LLoteDisplay <> '' then
              LLoteDisplay := Format('%s (Int: %s)', [LLote, LLoteInt])
            else
              LLoteDisplay := Format('Int: %s', [LLoteInt]);
          end;

          LCadStr := '—';
          if not QryContenido.FieldByName('fecha_caducidad').IsNull then
            LCadStr := FormatDateTime('dd/mm/yyyy', QryContenido.FieldByName('fecha_caducidad').AsDateTime);

          LText := Format('🏷️ Art. #%d (%s) | EAN: %s | Lote: %s | Cad: %s | Cant: %s uds', [
            LArtId,
            QryContenido.FieldByName('art_desc').AsString,
            LEan, LLoteDisplay, LCadStr,
            FormatFloat('0.##', LCant)
          ]);
          NodeContenido := tvEdiBultos.Items.AddChild(ParentNode, LText);
          NodeContenido.Data := TObject(IntPtr(QryContenido.FieldByName('lb_id').AsInteger));
        end;

        QryContenido.Next;
      end;

      // 3. Actualizar MemTable FmtLines con las unidades preparadas leídas de la BD
      FmtLines.First;
      while not FmtLines.Eof do
      begin
        LArtId := FmtLines.FieldByName('id_articulo').AsInteger;
        LCant := 0;
        LTotalPrepMap.TryGetValue(LArtId, LCant);
        FmtLines.Edit;
        FmtLines.FieldByName('unidades_preparadas').AsFloat := LCant;
        RecalculateLineStatus;
        FmtLines.Post;
        FmtLines.Next;
      end;

      // 4. Actualizar estado visual de paletización
      QryBultos.Close;
      QryBultos.SQL.Text := 'SELECT COUNT(*) AS total_palets FROM ge_albaranes_bultos WHERE albaran_id = :alb AND nivel = 2';
      QryBultos.ParamByName('alb').AsInteger := FAlbaranId;
      QryBultos.Open;
      var LTotalPalets := QryBultos.FieldByName('total_palets').AsInteger;

      if chkSinPaletizar.Checked then
      begin
        FPaletActual := 0;
        lblPaletInfo.Caption := 'Sin Palet';
        lblPaletInfo.Font.Color := clGray;
      end
      else if LTotalPalets > 0 then
      begin
        FPaletActual := LTotalPalets;
        lblPaletInfo.Caption := Format('Palet Nº %d', [FPaletActual]);
        lblPaletInfo.Font.Color := clGreen;
      end
      else
      begin
        FPaletActual := 0;
        lblPaletInfo.Caption := 'Sin Palet';
        lblPaletInfo.Font.Color := clGray;
      end;

    finally
      QryBultos.Free;
      QryContenido.Free;
    end;
  finally
    LBultoMap.Free;
    LTotalPrepMap.Free;
    tvEdiBultos.Items.EndUpdate;
  end;
end;

function TfrmPreparacionPedidoEditor.GenerarPaletSSCC: string;
var
  Qry: TFDQuery;
  LGlnEmp, LPrefix9, LSSCC18: string;
  LNextSscc: Int64;
  LNextBultoNum: Integer;
begin
  Result := '';
  if FAlbaranId <= 0 then Exit;

  Qry := TFDQuery.Create(nil);
  try
    Qry.Connection := dmgMain.dbConn;
    Qry.SQL.Text := 'SELECT gln FROM ge_empresas WHERE id = :emp';
    Qry.ParamByName('emp').AsString := dmgMain.CurrentCompanyId;
    Qry.Open;
    LGlnEmp := Trim(Qry.FieldByName('gln').AsString);
    Qry.Close;

    if Length(LGlnEmp) >= 9 then
      LPrefix9 := Copy(LGlnEmp, 1, 9)
    else
      LPrefix9 := '843700368';

    Qry.SQL.Text := 'SELECT sscc FROM ge_contadores LIMIT 1';
    Qry.Open;

    if Qry.IsEmpty then
    begin
      LNextSscc := 1;
      Qry.Close;
      Qry.SQL.Text := 'INSERT INTO ge_contadores (id, sscc) VALUES (1, 2)';
      Qry.ExecSQL;
    end
    else
    begin
      LNextSscc := Qry.FieldByName('sscc').AsLargeInt;
      Qry.Close;
      Qry.SQL.Text := 'UPDATE ge_contadores SET sscc = sscc + 1';
      Qry.ExecSQL;
    end;

    LSSCC18 := TValidacionFiscal.GenerarSSCC18(LPrefix9, LNextSscc, 3);
    Result := LSSCC18;

    // Calcular siguiente numero_bulto para ge_albaranes_bultos
    Qry.SQL.Text := 'SELECT COALESCE(MAX(numero_bulto), 0) + 1 AS next_num FROM ge_albaranes_bultos WHERE albaran_id = :alb';
    Qry.ParamByName('alb').AsInteger := FAlbaranId;
    Qry.Open;
    LNextBultoNum := Qry.FieldByName('next_num').AsInteger;
    Qry.Close;

    // Insertar el palet (nivel 2) en ge_albaranes_bultos
    Qry.SQL.Text :=
      'INSERT INTO ge_albaranes_bultos (albaran_id, numero_bulto, bulto_padre_id, nivel, tipo_embalaje, sscc, id_user_creator) ' +
      'VALUES (:alb, :num, NULL, 2, ''PE'', :sscc, :user)';
    Qry.ParamByName('alb').AsInteger := FAlbaranId;
    Qry.ParamByName('num').AsInteger := LNextBultoNum;
    Qry.ParamByName('sscc').AsString := LSSCC18;
    Qry.ParamByName('user').AsString := dmgMain.CurrentUserId;
    Qry.ExecSQL;

    // Actualizar campo edi_numero_sscc en ge_albaranes si estaba vacío
    Qry.SQL.Text := 'UPDATE ge_albaranes SET edi_numero_sscc = :sscc WHERE id = :alb AND (edi_numero_sscc IS NULL OR edi_numero_sscc = '''')';
    Qry.ParamByName('sscc').AsString := LSSCC18;
    Qry.ParamByName('alb').AsInteger := FAlbaranId;
    Qry.ExecSQL;
  finally
    Qry.Free;
  end;
end;

procedure TfrmPreparacionPedidoEditor.btnEdiAddPaletClick(Sender: TObject);
var
  LSSCC: string;
begin
  if chkSinPaletizar.Checked then
  begin
    chkSinPaletizar.Checked := False;
    chkSinPaletizarClick(Sender);
  end;

  LSSCC := GenerarPaletSSCC;
  if LSSCC <> '' then
  begin
    edtEdiNumeroSSCC.Text := LSSCC;
    Inc(FPaletActual);
    lblPaletInfo.Caption := Format('Palet Nº %d', [FPaletActual]);
    CargarArbolBultosEdi;

    // Seleccionar el palet recién creado en el TreeView tvEdiBultos
    if tvEdiBultos.Items.Count > 0 then
    begin
      for var i := tvEdiBultos.Items.Count - 1 downto 0 do
      begin
        if tvEdiBultos.Items[i].Level = 0 then
        begin
          tvEdiBultos.Selected := tvEdiBultos.Items[i];
          Break;
        end;
      end;
    end;

    ShowMessage('✅ Generado el Palet SSCC ' + LSSCC + ' correctamente.');
  end;
end;

procedure TfrmPreparacionPedidoEditor.btnEdiScanEnvaseClick(Sender: TObject);
begin
  if Assigned(edtBarcode) then
    edtBarcode.SetFocus;
end;

procedure TfrmPreparacionPedidoEditor.btnEdiDeleteBultoClick(Sender: TObject);
var
  LNode: TTreeNode;
  LId: Integer;
  Qry: TFDQuery;
begin
  LNode := tvEdiBultos.Selected;
  if not Assigned(LNode) then
  begin
    ShowMessage('Seleccione un elemento del árbol para eliminar.');
    Exit;
  end;

  LId := Integer(IntPtr(LNode.Data));
  if LId <= 0 then Exit;

  if MessageDlg('¿Desea eliminar el elemento seleccionado del árbol de paletizado?', mtConfirmation, [mbYes, mbNo], 0) = mrYes then
  begin
    Qry := TFDQuery.Create(nil);
    try
      Qry.Connection := dmgMain.dbConn;
      if LNode.Level = 2 then // Nivel contenido (ge_albaranes_lineas_bultos)
      begin
        Qry.SQL.Text := 'DELETE FROM ge_albaranes_lineas_bultos WHERE id = :id';
        Qry.ParamByName('id').AsInteger := LId;
        Qry.ExecSQL;
      end
      else // Nivel Palet o Caja (ge_albaranes_bultos)
      begin
        Qry.SQL.Text := 'DELETE FROM ge_albaranes_bultos WHERE id = :id';
        Qry.ParamByName('id').AsInteger := LId;
        Qry.ExecSQL;
      end;
    finally
      Qry.Free;
    end;

    CargarArbolBultosEdi;
  end;
end;

procedure TfrmPreparacionPedidoEditor.btnEdiDeleteAllBultosClick(Sender: TObject);
var
  Qry: TFDQuery;
begin
  if FAlbaranId <= 0 then Exit;

  if MessageDlg('⚠️ ¿Está seguro de que desea eliminar TODOS los bultos y palets escaneados para este pedido?' + sLineBreak + sLineBreak +
                 'Esta acción vaciará por completo el árbol de preparación de este albarán.',
                 mtConfirmation, [mbYes, mbNo], 0) = mrYes then
  begin
    Qry := TFDQuery.Create(nil);
    try
      Qry.Connection := dmgMain.dbConn;
      Qry.SQL.Text :=
        'DELETE lb FROM ge_albaranes_lineas_bultos lb ' +
        'INNER JOIN ge_albaranes_lineas al ON lb.linea_albaran_id = al.id ' +
        'WHERE al.albaran_id = :alb';
      Qry.ParamByName('alb').AsInteger := FAlbaranId;
      Qry.ExecSQL;

      Qry.SQL.Text := 'DELETE FROM ge_albaranes_bultos WHERE albaran_id = :alb';
      Qry.ParamByName('alb').AsInteger := FAlbaranId;
      Qry.ExecSQL;
    finally
      Qry.Free;
    end;

    FPaletActual := 1;
    lblPaletInfo.Caption := Format('Palet Nº %d', [FPaletActual]);
    CargarArbolBultosEdi;
    ShowMessage('✅ Se han eliminado todos los bultos escaneados con éxito.');
  end;
end;

procedure TfrmPreparacionPedidoEditor.btnEdiRefreshTreeClick(Sender: TObject);
begin
  CargarArbolBultosEdi;
end;

procedure TfrmPreparacionPedidoEditor.btnEdiExportDesadvClick(Sender: TObject);
var
  LErrMsg, LXmlContent, LResponse: string;
  LClient: TEdicomWsClient;
begin
  if FAlbaranId <= 0 then
  begin
    ShowMessage('No existe un albarán válido para exportar/enviar DESADV EDI.');
    Exit;
  end;

  // 1. Validar Datos Críticos
  if not ValidarAlbaranEdicom(IntToStr(FAlbaranId), dmgMain.dbConn, LErrMsg) then
  begin
    MessageDlg(LErrMsg, mtWarning, [mbOK], 0);
    Exit;
  end;

  // 2. Generar XML DESADV (EANCOM D96.A / EDIFACT)
  LXmlContent := GenerarXmlDesadv(IntToStr(FAlbaranId), dmgMain.dbConn);

  // 3. Enviar mediante WebService REST API a EDICOM iPaaS
  LClient := TEdicomWsClient.Create(dmgMain.dbConn, dmgMain.CurrentCompanyId);
  try
    Screen.Cursor := crHourGlass;
    try
      if LClient.PublicarAlbaran(IntToStr(FAlbaranId), LXmlContent, LResponse) then
      begin
        MessageDlg('✅ Albarán logístico enviado con ÉXITO a EDICOM iPaaS (DESADV WebService).' + sLineBreak + sLineBreak +
                   'Respuesta del Servidor:' + sLineBreak + Copy(LResponse, 1, 300),
                   mtInformation, [mbOK], 0);
      end
      else
      begin
        MessageDlg('❌ Error al enviar el albarán a EDICOM por WebService.' + sLineBreak + sLineBreak +
                   'Respuesta del Servidor:' + sLineBreak + Copy(LResponse, 1, 400),
                   mtError, [mbOK], 0);
      end;
    finally
      Screen.Cursor := crDefault;
    end;
  finally
    LClient.Free;
  end;

  // Opción para guardar una copia local en archivo XML
  if MessageDlg('¿Desea guardar también una copia local en archivo del XML DESADV generado?', mtConfirmation, [mbYes, mbNo], 0) = mrYes then
  begin
    ExportarAlbaranXmlEdicom(IntToStr(FAlbaranId), dmgMain.dbConn);
  end;
end;

procedure TfrmPreparacionPedidoEditor.btnSolicitarRecogidaClick(Sender: TObject);
var
  LIdPed: Integer;
begin
  LIdPed := StrToIntDef(FPedidoId, 0);
  if LIdPed <= 0 then Exit;

  FAlbaranId := ObtenerOCrearAlbaranPedido;

  if not Assigned(frmEnviosRecogida) then
    Application.CreateForm(TfrmEnviosRecogida, frmEnviosRecogida);

  frmEnviosRecogida.PedidoId := LIdPed;
  if frmEnviosRecogida.ShowModal = mrOk then
  begin
    LoadOrderData;
    ShowMessage('Solicitud de envío/recogida registrada correctamente.');
  end;
end;

procedure TfrmPreparacionPedidoEditor.chkSinPaletizarClick(Sender: TObject);
begin
  if chkSinPaletizar.Checked then
  begin
    FPaletActual := 0;
    lblPaletInfo.Caption := 'Sin Palet';
    lblPaletInfo.Font.Color := clGray;
    btnEdiAddPalet.Enabled := False;
  end
  else
  begin
    var Qry := TFDQuery.Create(nil);
    try
      Qry.Connection := dmgMain.dbConn;
      Qry.SQL.Text := 'SELECT COUNT(*) AS total_palets FROM ge_albaranes_bultos WHERE albaran_id = :alb AND nivel = 2';
      Qry.ParamByName('alb').AsInteger := FAlbaranId;
      Qry.Open;
      var LTotalPalets := Qry.FieldByName('total_palets').AsInteger;
      if LTotalPalets > 0 then
      begin
        FPaletActual := LTotalPalets;
        lblPaletInfo.Caption := Format('Palet Nº %d', [FPaletActual]);
        lblPaletInfo.Font.Color := clGreen;
      end
      else
      begin
        FPaletActual := 0;
        lblPaletInfo.Caption := 'Sin Palet';
        lblPaletInfo.Font.Color := clGray;
      end;
    finally
      Qry.Free;
    end;
    btnEdiAddPalet.Enabled := True;
  end;
  if Assigned(edtBarcode) then
    edtBarcode.SetFocus;
end;

procedure TfrmPreparacionPedidoEditor.edtBarcodeKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
begin
  if Key = VK_RETURN then
  begin
    Key := 0;
    ProcessBarcodeScan(edtBarcode.Text);
    edtBarcode.Text := '';
    edtBarcode.SetFocus;
  end;
end;

procedure TfrmPreparacionPedidoEditor.ProcessBarcodeScan(const ABarcode: string);
var
  LScanned: TGS1BarcodeData;
  LMatchedArtId, LLineaAlbId: Integer;
  LUnitsToAdd, LReq, LPrep: Double;
  LEanTarget, LDun14, LBaseEan, LArtDesc: string;
  QryLookup: TFDQuery;
  LFoundInOrder: Boolean;
  LPaletBultoId, LBoxBultoId, LNextNum: Integer;
begin
  if (Trim(ABarcode) = '') or (FAlbaranId <= 0) then Exit;

  // 1. Parsear código GS1-128
  LScanned := TGS1BarcodeParser.Parse(ABarcode);
  LMatchedArtId := 0;
  LUnitsToAdd := LScanned.Cantidad;
  LDun14 := LScanned.GTIN;
  LEanTarget := LScanned.EAN13;
  LBaseEan := '';
  LFoundInOrder := False;

  QryLookup := TFDQuery.Create(nil);
  try
    QryLookup.Connection := dmgMain.dbConn;

    // 2. Buscar el DUN-14 escaneado en ge_articulos_ean por el campo ean
    QryLookup.SQL.Text := 'SELECT articulo_id, unidades FROM ge_articulos_ean WHERE ean = :ean LIMIT 1';
    QryLookup.ParamByName('ean').AsString := LDun14;
    QryLookup.Open;

    if not QryLookup.IsEmpty then
    begin
      LMatchedArtId := QryLookup.FieldByName('articulo_id').AsInteger;
      if not QryLookup.FieldByName('unidades').IsNull and (QryLookup.FieldByName('unidades').AsFloat > 0) then
        LUnitsToAdd := QryLookup.FieldByName('unidades').AsFloat
      else
        LUnitsToAdd := 1.0;
    end
    else
    begin
      // Si no existe en ge_articulos_ean, verificar si es EAN13 o GTIN iniciado en 0
      if (Length(LDun14) = 14) and (LDun14[1] = '0') then
        LEanTarget := Copy(LDun14, 2, 13)
      else
        LEanTarget := LDun14;

      // Buscar por ean en ge_articulos
      QryLookup.Close;
      QryLookup.SQL.Text := 'SELECT id FROM ge_articulos WHERE ean = :ean OR ean = :gtin LIMIT 1';
      QryLookup.ParamByName('ean').AsString := LEanTarget;
      QryLookup.ParamByName('gtin').AsString := LDun14;
      QryLookup.Open;

      if not QryLookup.IsEmpty then
        LMatchedArtId := QryLookup.FieldByName('id').AsInteger;
    end;

    // 2.1 Si no existe en las tablas de artículos propias, buscar en ge_clientes_articulos (EAN-13 o DUN-14 del cliente)
    if LMatchedArtId <= 0 then
    begin
      QryLookup.Close;
      QryLookup.SQL.Text :=
        'SELECT ca.id_articulo, ca.dun14, ca.ean13 FROM ge_clientes_articulos ca ' +
        'WHERE (ca.ean13 = :ean OR ca.dun14 = :ean OR ca.dun14 = :gtin OR ca.ean13 = :gtin) ' +
        '  AND ca.activo = 1 ' +
        'ORDER BY (CASE WHEN ca.id_cliente = (SELECT cliente_id FROM ge_pedidos WHERE id = :ped) THEN 0 ELSE 1 END) ASC ' +
        'LIMIT 1';
      QryLookup.ParamByName('ean').AsString := LEanTarget;
      QryLookup.ParamByName('gtin').AsString := LDun14;
      QryLookup.ParamByName('ped').AsString := FPedidoId;
      QryLookup.Open;

      if not QryLookup.IsEmpty then
      begin
        LMatchedArtId := QryLookup.FieldByName('id_articulo').AsInteger;
        // Si no se leyó cantidad del código de barras y es un DUN-14 de caja de cliente
        if (LUnitsToAdd <= 1.0) and ((LDun14 = QryLookup.FieldByName('dun14').AsString) or (LEanTarget = QryLookup.FieldByName('dun14').AsString)) then
        begin
          var QryUnits := TFDQuery.Create(nil);
          try
            QryUnits.Connection := dmgMain.dbConn;
            QryUnits.SQL.Text := 'SELECT cantidad_bulto FROM ge_albaranes_lineas WHERE albaran_id = :alb AND articulo_id = :art LIMIT 1';
            QryUnits.ParamByName('alb').AsInteger := FAlbaranId;
            QryUnits.ParamByName('art').AsInteger := LMatchedArtId;
            QryUnits.Open;
            if not QryUnits.IsEmpty and (QryUnits.FieldByName('cantidad_bulto').AsFloat > 1.0) then
              LUnitsToAdd := QryUnits.FieldByName('cantidad_bulto').AsFloat
            else
            begin
              QryUnits.Close;
              QryUnits.SQL.Text := 'SELECT unidades FROM ge_articulos_ean WHERE articulo_id = :art AND unidades > 1 ORDER BY id DESC LIMIT 1';
              QryUnits.ParamByName('art').AsInteger := LMatchedArtId;
              QryUnits.Open;
              if not QryUnits.IsEmpty and (QryUnits.FieldByName('unidades').AsFloat > 1.0) then
                LUnitsToAdd := QryUnits.FieldByName('unidades').AsFloat;
            end;
          finally
            QryUnits.Free;
          end;
        end;
      end;
    end;

    // Si obtuvimos un articulo_id, buscar su EAN base en ge_articulos
    if LMatchedArtId > 0 then
    begin
      QryLookup.Close;
      QryLookup.SQL.Text := 'SELECT ean FROM ge_articulos WHERE id = :id LIMIT 1';
      QryLookup.ParamByName('id').AsInteger := LMatchedArtId;
      QryLookup.Open;

      if not QryLookup.IsEmpty then
        LBaseEan := QryLookup.FieldByName('ean').AsString;
    end;

    // 3. Comprobar que dicho artículo / EAN esté en las líneas del pedido
    if LMatchedArtId > 0 then
      LFoundInOrder := FmtLines.Locate('id_articulo', LMatchedArtId, []);

    if not LFoundInOrder and (LBaseEan <> '') then
      LFoundInOrder := FmtLines.Locate('ean_producto', LBaseEan, [loCaseInsensitive]);

    if not LFoundInOrder and (LEanTarget <> '') then
      LFoundInOrder := FmtLines.Locate('ean_producto', LEanTarget, [loCaseInsensitive]);

    if not LFoundInOrder and (LDun14 <> '') then
      LFoundInOrder := FmtLines.Locate('ean_producto', LDun14, [loCaseInsensitive]);

    if not LFoundInOrder then
    begin
      var LCadStrMsg: string := 'No informada';
      if LScanned.HasCaducidad and (LScanned.FechaCaducidad > 0) then
        LCadStrMsg := FormatDateTime('dd/mm/yyyy', LScanned.FechaCaducidad);

      var LArtMatchMsg: string := 'No encontrado en catálogo';
      if LMatchedArtId > 0 then
      begin
        QryLookup.Close;
        QryLookup.SQL.Text := 'SELECT descripcion FROM ge_articulos WHERE id = :id LIMIT 1';
        QryLookup.ParamByName('id').AsInteger := LMatchedArtId;
        QryLookup.Open;
        if not QryLookup.IsEmpty then
          LArtDesc := QryLookup.FieldByName('descripcion').AsString;

        if LArtDesc <> '' then
          LArtMatchMsg := Format('Art. #%d - %s (EAN Base: %s)', [LMatchedArtId, LArtDesc, LBaseEan])
        else
          LArtMatchMsg := Format('Art. #%d (EAN Base: %s)', [LMatchedArtId, LBaseEan]);
      end;

      var LMsgDetails := Format(
        '⚠️ ALERTA: El código escaneado no corresponde a ningún artículo de este pedido.' + sLineBreak + sLineBreak +
        'INFORMACIÓN EXTRAÍDA DEL CÓDIGO GS1-128:' + sLineBreak +
        '• Código Bruto Escaneado: %s' + sLineBreak +
        '• DUN-14 / GTIN (IA 01): %s' + sLineBreak +
        '• Artículo / EAN Base Resuelto: %s' + sLineBreak +
        '• Lote (IA 10): %s' + sLineBreak +
        '• Fecha Caducidad (IA 15/17): %s' + sLineBreak +
        '• Unidades Contenido: %.2f uds.',
        [ABarcode,
         LDun14,
         LArtMatchMsg,
         LScanned.Lote,
         LCadStrMsg,
         LUnitsToAdd]);

      MessageDlg(LMsgDetails, mtWarning, [mbOK], 0);
      Exit;
    end;

    LArtDesc := FmtLines.FieldByName('descripcion').AsString;
    LReq := FmtLines.FieldByName('cantidad').AsFloat;
    LPrep := FmtLines.FieldByName('unidades_preparadas').AsFloat;
    var LPend := LReq - LPrep;

    // Si ya se han preparado todas las unidades solicitadas del artículo
    if LPend <= 0.001 then
    begin
      MessageDlg(Format('❌ ALERTA DE EXCESO DE UNIDADES DETECTADO:' + sLineBreak + sLineBreak +
                        'Artículo: %s' + sLineBreak +
                        'Cantidad Solicitada: %.2f uds.' + sLineBreak +
                        'Unidades Ya Preparadas: %.2f uds.' + sLineBreak +
                        'Ya se han preparado todas las unidades solicitadas de este artículo.' + sLineBreak + sLineBreak +
                        'No se permite registrar escaneos en exceso.',
                        [LArtDesc, LReq, LPrep]), mtError, [mbOK], 0);
      Exit;
    end;

    // Si se introduce un código EAN de producto individual sin código de lote,
    // preguntar el número de unidades que tienen de ese mismo código para no tener que escanearlo repetidas veces (ej: 20 veces)
    var LCleanLoteCode := TGS1BarcodeParser.SanitizarLote(LScanned.Lote);
    if LCleanLoteCode = '' then
    begin
      var LEanMostrado := LBaseEan;
      if LEanMostrado = '' then LEanMostrado := LEanTarget;
      if LEanMostrado = '' then LEanMostrado := LDun14;
      if LEanMostrado = '' then LEanMostrado := ABarcode;

      var LDefaultUds := LPend;
      var LInputVal := FormatFloat('0.##', LDefaultUds);
      var LPrompt := Format(
        '🏷️ PRODUCTO INDIVIDUAL (EAN: %s):' + sLineBreak + sLineBreak +
        'Artículo: %s' + sLineBreak +
        '• Cantidad solicitada en pedido: %s uds.' + sLineBreak +
        '• Unidades ya preparadas: %s uds.' + sLineBreak +
        '• Unidades pendientes: %s uds.' + sLineBreak + sLineBreak +
        'Indique el número de unidades que dispone de este código para registrar:',
        [LEanMostrado,
         LArtDesc,
         FormatFloat('0.##', LReq),
         FormatFloat('0.##', LPrep),
         FormatFloat('0.##', LPend)]);

      if not InputQuery('Unidades de Producto Individual', LPrompt, LInputVal) then
        Exit;

      var LParsedUds: Double := StrToFloatDef(Trim(StringReplace(LInputVal, ',', '.', [rfReplaceAll])), 0.0);
      if LParsedUds <= 0 then
      begin
        MessageDlg('Las unidades deben ser un número positivo mayor que cero.', mtWarning, [mbOK], 0);
        Exit;
      end;

      LUnitsToAdd := LParsedUds;
    end
    else
    begin
      // Para cajas/bultos con lote: permitir modificar las unidades de la caja cuando no coincide exactamente con lo pendiente
      // o cuando el pedido no es múltiplo exacto de las unidades por caja (ej: caja de 6 unidades para preparar 3 uds)
      var LUnidadesCajaLeidas := LUnitsToAdd;
      if (LUnitsToAdd > LPend + 0.001) or ((LUnitsToAdd > 1.0) and (Frac(LReq / LUnitsToAdd) > 0.001)) then
      begin
        var LDefaultUds := LUnidadesCajaLeidas;
        if LDefaultUds > LPend then
          LDefaultUds := LPend;

        var LInputVal := FormatFloat('0.##', LDefaultUds);
        var LPrompt := Format(
          '📦 UNIDADES DE LA CAJA ESCANEADA:' + sLineBreak + sLineBreak +
          'Artículo: %s' + sLineBreak +
          '• Unidades estándar de la caja leídas: %s uds.' + sLineBreak +
          '• Pedido: %s solicitadas (Ya preparadas: %s | Pendientes: %s)' + sLineBreak + sLineBreak +
          'Indique las unidades que contiene esta caja para este pedido:',
          [LArtDesc,
           FormatFloat('0.##', LUnidadesCajaLeidas),
           FormatFloat('0.##', LReq),
           FormatFloat('0.##', LPrep),
           FormatFloat('0.##', LPend)]);

        if not InputQuery('Unidades de la Caja', LPrompt, LInputVal) then
          Exit;

        var LParsedUds: Double := StrToFloatDef(Trim(StringReplace(LInputVal, ',', '.', [rfReplaceAll])), 0.0);
        if LParsedUds <= 0 then
        begin
          MessageDlg('Las unidades deben ser un número positivo mayor que cero.', mtWarning, [mbOK], 0);
          Exit;
        end;

        LUnitsToAdd := LParsedUds;
      end;
    end;

    // Control preventivo de excesos tras la lectura o modificación
    if (LPrep + LUnitsToAdd) > (LReq + 0.001) then
    begin
      MessageDlg(Format('❌ ALERTA DE EXCESO DE UNIDADES DETECTADO:' + sLineBreak + sLineBreak +
                        'Artículo: %s' + sLineBreak +
                        'Cantidad Solicitada: %.2f uds.' + sLineBreak +
                        'Unidades Ya Preparadas: %.2f uds.' + sLineBreak +
                        'Las unidades indicadas (+%.2f uds.) SUPERARÍAN la cantidad del pedido (total: %.2f uds.).' + sLineBreak + sLineBreak +
                        'No se permite registrar escaneos en exceso.',
                        [LArtDesc, LReq, LPrep, LUnitsToAdd, LPrep + LUnitsToAdd]), mtError, [mbOK], 0);
      Exit;
    end;

    // Comprobar si el lote escaneado existe en el sistema o es un lote proporcionado por el cliente
    var LInternalLoteCode := '';
    var LInternalCaducidad: TDateTime := 0;
    var LHasInternalCad := False;

    if (LCleanLoteCode <> '') and (LCleanLoteCode <> '0') and (not SameText(LCleanLoteCode, 'NULL')) then
    begin
      QryLookup.Close;
      QryLookup.SQL.Text :=
        'SELECT id FROM ge_lotes WHERE (codigo = :lote OR codigo_antiguo = :lote) LIMIT 1';
      QryLookup.ParamByName('lote').AsString := LCleanLoteCode;
      QryLookup.Open;

      if not QryLookup.IsEmpty then
      begin
        // El lote existe en ge_lotes: es un lote interno/propio ya registrado
        LInternalLoteCode := LCleanLoteCode;
      end
      else
      begin
        // El lote NO existe en el sistema (es un lote proporcionado por el cliente en su etiqueta)
        // Preguntar al operario a qué lote propio de los disponibles pertenece realmente el producto
        var FrmSelLote := TfrmSelectLote.Create(Self);
        try
          FrmSelLote.ArticuloId := LMatchedArtId;
          FrmSelLote.Caption := Format('Seleccionar Lote Propio - %s (Lote Cliente: %s)', [LArtDesc, LCleanLoteCode]);
          if FrmSelLote.ShowModal = mrOk then
          begin
            LInternalLoteCode := Trim(FrmSelLote.SelectedLoteCode);
            if FrmSelLote.HasFechaCaducidad then
            begin
              LInternalCaducidad := FrmSelLote.SelectedFechaCaducidad;
              LHasInternalCad := True;
            end;
          end
          else
          begin
            // El operario canceló la selección de lote propio
            Exit;
          end;
        finally
          FrmSelLote.Free;
        end;
      end;
    end;

    // 3. Buscar Palet (nivel 2) en ge_albaranes_bultos si no está marcado 'Sin Palet'
    LPaletBultoId := 0;

    if not chkSinPaletizar.Checked then
    begin
      // A. Prioridad 1: si hay un nodo seleccionado en el TreeView, comprobar si es un palet o una caja de un palet
      if Assigned(tvEdiBultos.Selected) then
      begin
        var LSelId := NativeInt(tvEdiBultos.Selected.Data);
        if LSelId > 0 then
        begin
          QryLookup.Close;
          QryLookup.SQL.Text := 'SELECT id, nivel, bulto_padre_id FROM ge_albaranes_bultos WHERE id = :id';
          QryLookup.ParamByName('id').AsInteger := LSelId;
          QryLookup.Open;
          if not QryLookup.IsEmpty then
          begin
            if QryLookup.FieldByName('nivel').AsInteger = 2 then
              LPaletBultoId := LSelId
            else if not QryLookup.FieldByName('bulto_padre_id').IsNull then
              LPaletBultoId := QryLookup.FieldByName('bulto_padre_id').AsInteger;
          end;
        end;
      end;

      // B. Prioridad 2: si no hay nodo seleccionado o no pertenece a un palet, usar el último palet activo de este albarán
      if LPaletBultoId <= 0 then
      begin
        QryLookup.Close;
        QryLookup.SQL.Text :=
          'SELECT id FROM ge_albaranes_bultos WHERE albaran_id = :alb AND nivel = 2 ORDER BY id DESC LIMIT 1';
        QryLookup.ParamByName('alb').AsInteger := FAlbaranId;
        QryLookup.Open;
        if not QryLookup.IsEmpty then
          LPaletBultoId := QryLookup.FieldByName('id').AsInteger;
      end;

      // NOTA: NO se crea palet automáticamente. A no ser que se añada el palet
      // manualmente mediante "+ Añadir Palet", las cajas se crean sueltas (sin palet padre).
    end;

    // 4. Crear Caja / Envase (nivel 3) en ge_albaranes_bultos
    QryLookup.Close;
    QryLookup.SQL.Text := 'SELECT COALESCE(MAX(numero_bulto), 0) + 1 AS next_num FROM ge_albaranes_bultos WHERE albaran_id = :alb';
    QryLookup.ParamByName('alb').AsInteger := FAlbaranId;
    QryLookup.Open;
    LNextNum := QryLookup.FieldByName('next_num').AsInteger;

    QryLookup.Close;
    QryLookup.SQL.Text :=
      'INSERT INTO ge_albaranes_bultos (albaran_id, numero_bulto, bulto_padre_id, nivel, tipo_embalaje, sscc, id_user_creator) ' +
      'VALUES (:alb, :num, :padre, 3, ''CT'', '''', :user)';
    QryLookup.ParamByName('alb').AsInteger := FAlbaranId;
    QryLookup.ParamByName('num').AsInteger := LNextNum;
    if LPaletBultoId > 0 then
      QryLookup.ParamByName('padre').AsInteger := LPaletBultoId
    else
    begin
      QryLookup.ParamByName('padre').DataType := ftInteger;
      QryLookup.ParamByName('padre').Clear;
    end;
    QryLookup.ParamByName('user').AsString := dmgMain.CurrentUserId;
    QryLookup.ExecSQL;

    QryLookup.Close;
    QryLookup.SQL.Text := 'SELECT LAST_INSERT_ID() AS new_id';
    QryLookup.Open;
    LBoxBultoId := QryLookup.FieldByName('new_id').AsInteger;

    // 5. Obtener linea_albaran_id en ge_albaranes_lineas
    LLineaAlbId := 0;
    QryLookup.Close;
    QryLookup.SQL.Text := 'SELECT id FROM ge_albaranes_lineas WHERE albaran_id = :alb AND articulo_id = :art LIMIT 1';
    QryLookup.ParamByName('alb').AsInteger := FAlbaranId;
    QryLookup.ParamByName('art').AsInteger := LMatchedArtId;
    QryLookup.Open;
    if not QryLookup.IsEmpty then
      LLineaAlbId := QryLookup.FieldByName('id').AsInteger;

    // 6. Insertar en ge_albaranes_lineas_bultos
    if (LBoxBultoId > 0) and (LLineaAlbId > 0) then
    begin
      var LCleanLoteToSave := TGS1BarcodeParser.SanitizarLote(LScanned.Lote);
      if (LCleanLoteToSave = '0') or SameText(LCleanLoteToSave, 'NULL') then
        LCleanLoteToSave := '';

      QryLookup.Close;
      QryLookup.SQL.Text :=
        'INSERT INTO ge_albaranes_lineas_bultos (bulto_id, linea_albaran_id, cantidad, lote, lote_interno, fecha_caducidad) ' +
        'VALUES (:bulto, :lin, :cant, :lote, :lote_interno, :fcad)';
      QryLookup.ParamByName('bulto').AsInteger := LBoxBultoId;
      QryLookup.ParamByName('lin').AsInteger := LLineaAlbId;
      QryLookup.ParamByName('cant').AsFloat := LUnitsToAdd;
      if LCleanLoteToSave <> '' then
        QryLookup.ParamByName('lote').AsString := LCleanLoteToSave
      else
      begin
        QryLookup.ParamByName('lote').Clear;
        QryLookup.ParamByName('lote').DataType := ftString;
      end;

      if LInternalLoteCode <> '' then
        QryLookup.ParamByName('lote_interno').AsString := LInternalLoteCode
      else
      begin
        QryLookup.ParamByName('lote_interno').Clear;
        QryLookup.ParamByName('lote_interno').DataType := ftString;
      end;
      
      QryLookup.ParamByName('fcad').DataType := ftDate;
      if LScanned.HasCaducidad and (LScanned.FechaCaducidad > 0) then
        QryLookup.ParamByName('fcad').AsDate := LScanned.FechaCaducidad
      else if LHasInternalCad and (LInternalCaducidad > 0) then
        QryLookup.ParamByName('fcad').AsDate := LInternalCaducidad
      else
        QryLookup.ParamByName('fcad').Clear;

      QryLookup.ExecSQL;
    end;

    // 7. Refrescar el árbol de bultos
    CargarArbolBultosEdi;
    CheckUpdateStateInPreparation;

  finally
    QryLookup.Free;
  end;
end;

procedure TfrmPreparacionPedidoEditor.RecalculateLineStatus;
var
  LReq, LPrep: Double;
begin
  LReq := FmtLines.FieldByName('cantidad').AsFloat;
  LPrep := FmtLines.FieldByName('unidades_preparadas').AsFloat;

  if Abs(LPrep - LReq) < 0.001 then
    FmtLines.FieldByName('estado_linea').AsString := '✅ COMPLETO'
  else if LPrep > LReq then
    FmtLines.FieldByName('estado_linea').AsString := '⚠️ EXCESO'
  else if LPrep > 0 then
    FmtLines.FieldByName('estado_linea').AsString := '⏳ INCOMPLETO'
  else
    FmtLines.FieldByName('estado_linea').AsString := 'PENDIENTE';
end;

procedure TfrmPreparacionPedidoEditor.CheckUpdateStateInPreparation;
var
  LAnyPrep, LAllComplete: Boolean;
  LReq, LPrep: Double;
begin
  if StrToIntDef(FPedidoId, 0) <= 0 then Exit;

  LAnyPrep := False;
  LAllComplete := True;

  FmtLines.First;
  while not FmtLines.Eof do
  begin
    LReq := FmtLines.FieldByName('cantidad').AsFloat;
    LPrep := FmtLines.FieldByName('unidades_preparadas').AsFloat;

    if LPrep > 0 then
      LAnyPrep := True;

    if Abs(LPrep - LReq) >= 0.001 then
      LAllComplete := False;

    FmtLines.Next;
  end;

  if LAnyPrep and not LAllComplete then
  begin
    try
      dmgMain.qryExec.Close;
      dmgMain.qryExec.SQL.Text :=
        'UPDATE ge_pedidos SET estado = ''EN PREPARACION'', id_user_update = :user WHERE id = :id AND estado <> ''PREPARADO''';
      dmgMain.qryExec.ParamByName('id').AsString := FPedidoId;
      dmgMain.qryExec.ParamByName('user').AsString := dmgMain.CurrentUserId;
      dmgMain.qryExec.ExecSQL;
      lblEstado.Caption := 'Estado: EN PREPARACION';
    except
    end;
  end;
end;

procedure TfrmPreparacionPedidoEditor.btnFinalizarClick(Sender: TObject);
var
  LIncompleteList, LExcessList: TStringList;
  LReq, LPrep: Double;
  LDesc, LMsg: string;
  QryTemp, QryRead, QryGroup, QryFindStock, QryFifo, QryScanned: TFDQuery;
  LLineId, LBultosCnt, LArtId, LArtStockId, LStockId, LSId, LPos: Integer;
  LPrecio, LTipoIva, LTotalCant, LBase, LIvaCant, LTotal, LCantDeducir, LPend, LCurUnits, LTomar: Double;
  LLotesStr, LLoteCode: string;
begin
  LIncompleteList := TStringList.Create;
  LExcessList := TStringList.Create;
  try
    FmtLines.First;
    while not FmtLines.Eof do
    begin
      LReq := FmtLines.FieldByName('cantidad').AsFloat;
      LPrep := FmtLines.FieldByName('unidades_preparadas').AsFloat;
      LDesc := FmtLines.FieldByName('descripcion').AsString;

      if (LReq - LPrep) > 0.001 then
        LIncompleteList.Add(Format('• %s: Faltan %.2f de %.2f uds.', [LDesc, (LReq - LPrep), LReq]))
      else if (LPrep - LReq) > 0.001 then
        LExcessList.Add(Format('• %s: Sobran %.2f de %.2f uds.', [LDesc, (LPrep - LReq), LReq]));

      FmtLines.Next;
    end;

    if (LIncompleteList.Count > 0) or (LExcessList.Count > 0) then
    begin
      LMsg := '❌ IMPOSIBLE FINALIZAR LA PREPARACIÓN' + sLineBreak + sLineBreak;
      if LIncompleteList.Count > 0 then
        LMsg := LMsg + 'Líneas Incompletas / Pendientes:' + sLineBreak + LIncompleteList.Text + sLineBreak;
      if LExcessList.Count > 0 then
        LMsg := LMsg + 'Líneas con Exceso de unidades:' + sLineBreak + LExcessList.Text + sLineBreak;

      LMsg := LMsg + 'Todas las líneas del pedido deben estar 100% completas y sin excesos para poder pasar a estado PREPARADO.';

      MessageDlg(LMsg, mtError, [mbOK], 0);
      Exit;
    end;
  finally
    LIncompleteList.Free;
    LExcessList.Free;
  end;

  if MessageDlg('¿Confirma que ha finalizado la preparación física del pedido?', mtConfirmation, [mbYes, mbNo], 0) <> mrYes then
    Exit;

  // Actualizar estado del pedido a PREPARADO en la base de datos
  try
    // 1. Actualizar de forma inmediata el estado del pedido a PREPARADO
    dmgMain.qryExec.Close;
    dmgMain.qryExec.SQL.Text :=
      'UPDATE ge_pedidos SET estado = ''PREPARADO'', id_user_update = :user WHERE id = :id';
    dmgMain.qryExec.ParamByName('id').AsString := FPedidoId;
    dmgMain.qryExec.ParamByName('user').AsString := dmgMain.CurrentUserId;
    dmgMain.qryExec.ExecSQL;

    lblEstado.Caption := 'Estado: PREPARADO';

    // 2. Transacción para las operaciones de albarán, stock y venta interempresas
    dmgMain.dbConn.StartTransaction;
    try
      // Actualizar edi_numero_sscc en el albarán si existe
      if (FAlbaranId > 0) and (Trim(edtEdiNumeroSSCC.Text) <> '') then
      begin
        dmgMain.qryExec.Close;
        dmgMain.qryExec.SQL.Text :=
          'UPDATE ge_albaranes SET edi_numero_sscc = :sscc WHERE id = :alb';
        dmgMain.qryExec.ParamByName('sscc').AsString := Trim(edtEdiNumeroSSCC.Text);
        dmgMain.qryExec.ParamByName('alb').AsInteger := FAlbaranId;
        dmgMain.qryExec.ExecSQL;
      end;

      if FAlbaranId > 0 then
      begin
        QryTemp := TFDQuery.Create(nil);
        QryRead := TFDQuery.Create(nil);
        QryGroup := TFDQuery.Create(nil);
        QryFindStock := TFDQuery.Create(nil);
        QryFifo := TFDQuery.Create(nil);
        try
          QryTemp.Connection := dmgMain.dbConn;
          QryRead.Connection := dmgMain.dbConn;
          QryGroup.Connection := dmgMain.dbConn;
          QryFindStock.Connection := dmgMain.dbConn;
          QryFifo.Connection := dmgMain.dbConn;

          // 0. Liberar reservas originales del albarán ANTES de actualizar las líneas
          // Esto es necesario porque durante la preparación se pueden escanear lotes diferentes
          // a los que se reservaron originalmente en el procesamiento del pedido.
          QryRead.Close;
          QryRead.SQL.Text :=
            'SELECT al.articulo_id, al.cantidad, COALESCE(al.lotes, '''') AS lote_original, ' +
            '       COALESCE(NULLIF(a.id_maestro, 0), al.articulo_id) AS id_articulo_stock ' +
            'FROM ge_albaranes_lineas al ' +
            'LEFT JOIN ge_articulos a ON a.id = al.articulo_id ' +
            'WHERE al.albaran_id = :alb AND al.cantidad > 0';
          QryRead.ParamByName('alb').AsInteger := FAlbaranId;
          QryRead.Open;

          while not QryRead.Eof do
          begin
            LArtId     := QryRead.FieldByName('articulo_id').AsInteger;
            LArtStockId:= QryRead.FieldByName('id_articulo_stock').AsInteger;
            LTotalCant := QryRead.FieldByName('cantidad').AsFloat;
            LLoteCode  := Trim(QryRead.FieldByName('lote_original').AsString);
            LPend      := LTotalCant;

            // A. Intentar liberar primero del lote asignado originalmente
            if (LLoteCode <> '') and (LPend > 0) then
            begin
              QryFindStock.Close;
              QryFindStock.SQL.Text :=
                'SELECT se.id, COALESCE(se.cantidad_reservada, 0) AS reservada FROM ge_stock_envasado se ' +
                'INNER JOIN ge_lotes l ON se.id_lote = l.id ' +
                'WHERE se.id_articulo = :art AND l.codigo = :lote LIMIT 1';
              QryFindStock.ParamByName('art').AsInteger := LArtStockId;
              QryFindStock.ParamByName('lote').AsString := LLoteCode;
              QryFindStock.Open;
              if not QryFindStock.IsEmpty then
              begin
                LStockId := QryFindStock.FieldByName('id').AsInteger;
                LCurUnits := QryFindStock.FieldByName('reservada').AsFloat;
                LTomar := Min(LPend, LCurUnits);

                if LTomar > 0 then
                begin
                  dmgMain.qryExec.Close;
                  dmgMain.qryExec.SQL.Text :=
                    'UPDATE ge_stock_envasado ' +
                    'SET cantidad_reservada = GREATEST(0, COALESCE(cantidad_reservada, 0) - :cant), ' +
                    '    id_user_update = :user ' +
                    'WHERE id = :sid';
                  dmgMain.qryExec.ParamByName('cant').AsFloat  := LTomar;
                  dmgMain.qryExec.ParamByName('user').AsString := dmgMain.CurrentUserId;
                  dmgMain.qryExec.ParamByName('sid').AsInteger := LStockId;
                  dmgMain.qryExec.ExecSQL;

                  LPend := LPend - LTomar;
                end;
              end;
            end;

            // B. Si aún quedan reservas por liberar, usar FIFO en otros lotes del mismo artículo
            if LPend > 0 then
            begin
              QryFifo.Close;
              QryFifo.SQL.Text :=
                'SELECT id, COALESCE(cantidad_reservada, 0) AS reservada FROM ge_stock_envasado ' +
                'WHERE id_articulo = :art AND COALESCE(cantidad_reservada, 0) > 0 ORDER BY id ASC';
              QryFifo.ParamByName('art').AsInteger := LArtStockId;
              QryFifo.Open;

              while not QryFifo.Eof and (LPend > 0) do
              begin
                LSId := QryFifo.FieldByName('id').AsInteger;
                LCurUnits := QryFifo.FieldByName('reservada').AsFloat;
                LTomar := Min(LPend, LCurUnits);

                dmgMain.qryExec.Close;
                dmgMain.qryExec.SQL.Text :=
                  'UPDATE ge_stock_envasado ' +
                  'SET cantidad_reservada = GREATEST(0, COALESCE(cantidad_reservada, 0) - :cant), ' +
                  '    id_user_update = :user ' +
                  'WHERE id = :sid';
                dmgMain.qryExec.ParamByName('cant').AsFloat  := LTomar;
                dmgMain.qryExec.ParamByName('user').AsString := dmgMain.CurrentUserId;
                dmgMain.qryExec.ParamByName('sid').AsInteger := LSId;
                dmgMain.qryExec.ExecSQL;

                LPend := LPend - LTomar;
                QryFifo.Next;
              end;
            end;

            QryRead.Next;
          end;

          // 1. Actualizar ge_albaranes_lineas asignando 1 línea por (articulo_id, lote, fecha_caducidad) escaneados
          // manteniendo ge_albaranes_lineas_bultos INTACTO para evitar borrado por FK CASCADE
          QryScanned := TFDQuery.Create(nil);
          try
            QryScanned.Connection := dmgMain.dbConn;
            QryScanned.SQL.Text :=
              'SELECT al.articulo_id, al.descripcion, al.precio, COALESCE(al.tipo_iva, 0) AS tipo_iva, ' +
              '       COALESCE(al.edi_ean_enviado, '''') AS art_ean, ' +
              '       COALESCE(NULLIF(lb.lote, ''0''), '''') AS lote, lb.fecha_caducidad, ' +
              '       SUM(lb.cantidad) AS total_cant, ' +
              '       COUNT(DISTINCT lb.bulto_id) AS bultos_cnt ' +
              'FROM ge_albaranes_lineas_bultos lb ' +
              'INNER JOIN ge_albaranes_lineas al ON lb.linea_albaran_id = al.id ' +
              'WHERE al.albaran_id = :alb ' +
              'GROUP BY al.articulo_id, al.descripcion, al.precio, COALESCE(al.tipo_iva, 0), COALESCE(al.edi_ean_enviado, ''''), COALESCE(NULLIF(lb.lote, ''0''), ''''), lb.fecha_caducidad ' +
              'ORDER BY al.articulo_id ASC, lb.lote ASC';
            QryScanned.ParamByName('alb').AsInteger := FAlbaranId;
            QryScanned.Open;

            if not QryScanned.IsEmpty then
            begin
              var LKeptLineIds := TList<Integer>.Create;
              try
                LPos := 0;
                while not QryScanned.Eof do
                begin
                  Inc(LPos);
                  LArtId     := QryScanned.FieldByName('articulo_id').AsInteger;
                  LTotalCant := QryScanned.FieldByName('total_cant').AsFloat;
                  LBultosCnt := QryScanned.FieldByName('bultos_cnt').AsInteger;
                  LPrecio    := QryScanned.FieldByName('precio').AsFloat;
                  LTipoIva   := QryScanned.FieldByName('tipo_iva').AsFloat;
                  LLoteCode  := Trim(QryScanned.FieldByName('lote').AsString);
                  if (LLoteCode = '0') or SameText(LLoteCode, 'NULL') then
                    LLoteCode := '';

                  LBase      := LTotalCant * LPrecio;
                  LIvaCant   := LBase * (LTipoIva / 100.0);
                  LTotal     := LBase + LIvaCant;

                  LLineId := 0;
                  var LExcludeClause := '';
                  if LKeptLineIds.Count > 0 then
                  begin
                    var LIdsStr := '';
                    for var LId in LKeptLineIds do
                    begin
                      if LIdsStr <> '' then LIdsStr := LIdsStr + ',';
                      LIdsStr := LIdsStr + IntToStr(LId);
                    end;
                    LExcludeClause := ' AND id NOT IN (' + LIdsStr + ')';
                  end;

                  // Prioridad 1: Buscar línea del mismo artículo con el mismo lote que aún no haya sido reutilizada
                  QryTemp.Close;
                  QryTemp.SQL.Text :=
                    'SELECT id FROM ge_albaranes_lineas ' +
                    'WHERE albaran_id = :alb AND articulo_id = :art' + LExcludeClause + ' AND lotes = :lote LIMIT 1';
                  QryTemp.ParamByName('alb').AsInteger := FAlbaranId;
                  QryTemp.ParamByName('art').AsInteger := LArtId;
                  QryTemp.ParamByName('lote').AsString := LLoteCode;
                  QryTemp.Open;

                  if not QryTemp.IsEmpty then
                    LLineId := QryTemp.FieldByName('id').AsInteger
                  else
                  begin
                    // Prioridad 2: Reutilizar cualquier línea existente del mismo artículo que aún no haya sido asignada
                    QryTemp.Close;
                    QryTemp.SQL.Text :=
                      'SELECT id FROM ge_albaranes_lineas ' +
                      'WHERE albaran_id = :alb AND articulo_id = :art' + LExcludeClause + ' ORDER BY id ASC LIMIT 1';
                    QryTemp.ParamByName('alb').AsInteger := FAlbaranId;
                    QryTemp.ParamByName('art').AsInteger := LArtId;
                    QryTemp.Open;

                    if not QryTemp.IsEmpty then
                      LLineId := QryTemp.FieldByName('id').AsInteger;
                  end;

                  if LLineId > 0 then
                  begin
                    dmgMain.qryExec.Close;
                    dmgMain.qryExec.SQL.Text :=
                      'UPDATE ge_albaranes_lineas ' +
                      'SET cantidad = :cant, bultos = :bultos, posicion = :pos, lotes = :lote, fecha_caducidad = :fcad, ' +
                      '    precio = :precio, tipo_iva = :iva_tipo, base = :base, iva = :iva, total = :tot, id_user_update = :user ' +
                      'WHERE id = :lid';
                    dmgMain.qryExec.ParamByName('cant').AsFloat     := LTotalCant;
                    dmgMain.qryExec.ParamByName('bultos').AsInteger := LBultosCnt;
                    dmgMain.qryExec.ParamByName('pos').AsInteger    := LPos;
                    if (LLoteCode <> '') and (LLoteCode <> '0') and (not SameText(LLoteCode, 'NULL')) then
                      dmgMain.qryExec.ParamByName('lote').AsString  := LLoteCode
                    else
                    begin
                      dmgMain.qryExec.ParamByName('lote').Clear;
                      dmgMain.qryExec.ParamByName('lote').DataType := ftString;
                    end;

                    if not QryScanned.FieldByName('fecha_caducidad').IsNull then
                      dmgMain.qryExec.ParamByName('fcad').AsDateTime := QryScanned.FieldByName('fecha_caducidad').AsDateTime
                    else
                    begin
                      dmgMain.qryExec.ParamByName('fcad').Clear;
                      dmgMain.qryExec.ParamByName('fcad').DataType := ftDate;
                    end;

                    dmgMain.qryExec.ParamByName('precio').AsFloat   := LPrecio;
                    dmgMain.qryExec.ParamByName('iva_tipo').AsFloat := LTipoIva;
                    dmgMain.qryExec.ParamByName('base').AsFloat     := LBase;
                    dmgMain.qryExec.ParamByName('iva').AsFloat      := LIvaCant;
                    dmgMain.qryExec.ParamByName('tot').AsFloat      := LTotal;
                    dmgMain.qryExec.ParamByName('user').AsString   := dmgMain.CurrentUserId;
                    dmgMain.qryExec.ParamByName('lid').AsInteger   := LLineId;
                    dmgMain.qryExec.ExecSQL;
                  end
                  else
                  begin
                    // Insertar nueva línea si es un lote adicional para el mismo artículo
                    dmgMain.qryExec.Close;
                    dmgMain.qryExec.SQL.Text :=
                      'INSERT INTO ge_albaranes_lineas (albaran_id, articulo_id, articulo_codigo, posicion, descripcion, bultos, cantidad, precio, tipo_iva, base, iva, total, lotes, fecha_caducidad, edi_ean_enviado, id_user_creator, id_user_update) ' +
                      'VALUES (:alb, :art, :cod, :pos, :desc, :bultos, :cant, :precio, :iva_tipo, :base, :iva, :tot, :lote, :fcad, :ean, :user, :user)';
                    dmgMain.qryExec.ParamByName('alb').AsInteger       := FAlbaranId;
                    dmgMain.qryExec.ParamByName('art').AsInteger       := LArtId;
                    dmgMain.qryExec.ParamByName('cod').AsString        := IntToStr(LArtId);
                    dmgMain.qryExec.ParamByName('pos').AsInteger       := LPos;
                    dmgMain.qryExec.ParamByName('desc').AsString       := QryScanned.FieldByName('descripcion').AsString;
                    dmgMain.qryExec.ParamByName('bultos').AsInteger    := LBultosCnt;
                    dmgMain.qryExec.ParamByName('cant').AsFloat        := LTotalCant;
                    dmgMain.qryExec.ParamByName('precio').AsFloat      := LPrecio;
                    dmgMain.qryExec.ParamByName('iva_tipo').AsFloat    := LTipoIva;
                    dmgMain.qryExec.ParamByName('base').AsFloat        := LBase;
                    dmgMain.qryExec.ParamByName('iva').AsFloat         := LIvaCant;
                    dmgMain.qryExec.ParamByName('tot').AsFloat         := LTotal;
                    if (LLoteCode <> '') and (LLoteCode <> '0') and (not SameText(LLoteCode, 'NULL')) then
                      dmgMain.qryExec.ParamByName('lote').AsString     := LLoteCode
                    else
                    begin
                      dmgMain.qryExec.ParamByName('lote').Clear;
                      dmgMain.qryExec.ParamByName('lote').DataType := ftString;
                    end;

                    if not QryScanned.FieldByName('fecha_caducidad').IsNull then
                      dmgMain.qryExec.ParamByName('fcad').AsDateTime   := QryScanned.FieldByName('fecha_caducidad').AsDateTime
                    else
                    begin
                      dmgMain.qryExec.ParamByName('fcad').Clear;
                      dmgMain.qryExec.ParamByName('fcad').DataType := ftDate;
                    end;

                    dmgMain.qryExec.ParamByName('ean').AsString        := QryScanned.FieldByName('art_ean').AsString;
                    dmgMain.qryExec.ParamByName('user').AsString       := dmgMain.CurrentUserId;
                    dmgMain.qryExec.ExecSQL;

                    dmgMain.qryExec.Close;
                    dmgMain.qryExec.SQL.Text := 'SELECT LAST_INSERT_ID() AS id';
                    dmgMain.qryExec.Open;
                    LLineId := dmgMain.qryExec.FieldByName('id').AsInteger;
                  end;

                  LKeptLineIds.Add(LLineId);

                  // Re-vincular las lecturas de bultos a esta línea exacta (por lote y caducidad)
                  dmgMain.qryExec.Close;
                  if not QryScanned.FieldByName('fecha_caducidad').IsNull then
                  begin
                    dmgMain.qryExec.SQL.Text :=
                      'UPDATE ge_albaranes_lineas_bultos lb ' +
                      'INNER JOIN ge_albaranes_lineas al ON lb.linea_albaran_id = al.id ' +
                      'SET lb.linea_albaran_id = :new_lid ' +
                      'WHERE al.albaran_id = :alb AND al.articulo_id = :art ' +
                      '  AND (lb.lote = :lote OR (lb.lote IS NULL AND :lote = '''') OR (lb.lote = ''0'' AND :lote = '''')) ' +
                      '  AND lb.fecha_caducidad = :fcad';
                    dmgMain.qryExec.ParamByName('fcad').AsDateTime := QryScanned.FieldByName('fecha_caducidad').AsDateTime;
                  end
                  else
                  begin
                    dmgMain.qryExec.SQL.Text :=
                      'UPDATE ge_albaranes_lineas_bultos lb ' +
                      'INNER JOIN ge_albaranes_lineas al ON lb.linea_albaran_id = al.id ' +
                      'SET lb.linea_albaran_id = :new_lid ' +
                      'WHERE al.albaran_id = :alb AND al.articulo_id = :art ' +
                      '  AND (lb.lote = :lote OR (lb.lote IS NULL AND :lote = '''') OR (lb.lote = ''0'' AND :lote = '''')) ' +
                      '  AND lb.fecha_caducidad IS NULL';
                  end;
                  dmgMain.qryExec.ParamByName('new_lid').AsInteger := LLineId;
                  dmgMain.qryExec.ParamByName('alb').AsInteger     := FAlbaranId;
                  dmgMain.qryExec.ParamByName('art').AsInteger     := LArtId;
                  dmgMain.qryExec.ParamByName('lote').AsString     := LLoteCode;
                  dmgMain.qryExec.ExecSQL;

                  QryScanned.Next;
                end;

                // Eliminar cualquier línea antigua/huérfana del albarán que no haya sido asignada a los bultos escaneados
                if LKeptLineIds.Count > 0 then
                begin
                  var LKeptStr := '';
                  for var LId in LKeptLineIds do
                  begin
                    if LKeptStr <> '' then LKeptStr := LKeptStr + ',';
                    LKeptStr := LKeptStr + IntToStr(LId);
                  end;

                  dmgMain.qryExec.Close;
                  dmgMain.qryExec.SQL.Text :=
                    'DELETE FROM ge_albaranes_lineas WHERE albaran_id = :alb AND id NOT IN (' + LKeptStr + ')';
                  dmgMain.qryExec.ParamByName('alb').AsInteger := FAlbaranId;
                  dmgMain.qryExec.ExecSQL;
                end;
              finally
                LKeptLineIds.Free;
              end;

              // Recalcular cabecera de ge_albaranes
              dmgMain.qryExec.Close;
              dmgMain.qryExec.SQL.Text :=
                'UPDATE ge_albaranes SET ' +
                '  base_imponible = (SELECT COALESCE(SUM(base), 0) FROM ge_albaranes_lineas WHERE albaran_id = :alb), ' +
                '  iva = (SELECT COALESCE(SUM(iva), 0) FROM ge_albaranes_lineas WHERE albaran_id = :alb), ' +
                '  total = (SELECT COALESCE(SUM(total), 0) FROM ge_albaranes_lineas WHERE albaran_id = :alb) ' +
                'WHERE id = :alb';
              dmgMain.qryExec.ParamByName('alb').AsInteger := FAlbaranId;
              dmgMain.qryExec.ExecSQL;
            end;
          finally
            QryScanned.Free;
          end;

          // 2. Descontar stock de envasados (ge_stock_envasado) manteniendo estricta trazabilidad por lote escaneado
          QryGroup.Close;
          QryGroup.SQL.Text :=
            'SELECT al.articulo_id, COALESCE(NULLIF(lb.lote_interno, ''''), NULLIF(lb.lote, ''0''), '''') AS lote, SUM(lb.cantidad) AS cant_escaneada, ' +
            '       COALESCE(NULLIF(a.id_maestro, 0), al.articulo_id) AS id_articulo_stock ' +
            'FROM ge_albaranes_lineas_bultos lb ' +
            'INNER JOIN ge_albaranes_lineas al ON lb.linea_albaran_id = al.id ' +
            'LEFT JOIN ge_articulos a ON a.id = al.articulo_id ' +
            'WHERE al.albaran_id = :alb ' +
            'GROUP BY al.articulo_id, COALESCE(NULLIF(lb.lote_interno, ''''), NULLIF(lb.lote, ''0''), ''''), COALESCE(NULLIF(a.id_maestro, 0), al.articulo_id)';
          QryGroup.ParamByName('alb').AsInteger := FAlbaranId;
          QryGroup.Open;

          while not QryGroup.Eof do
          begin
            LArtId       := QryGroup.FieldByName('articulo_id').AsInteger;
            LArtStockId  := QryGroup.FieldByName('id_articulo_stock').AsInteger;
            LLoteCode    := Trim(QryGroup.FieldByName('lote').AsString);
            LCantDeducir := QryGroup.FieldByName('cant_escaneada').AsFloat;

            // NUNCA procesar ni crear lotes vacíos, con valor '0' o inválidos
            if (LCantDeducir > 0) and (LLoteCode <> '') and (LLoteCode <> '0') and (not SameText(LLoteCode, 'NULL')) then
            begin
              LStockId := 0;
              QryFindStock.Close;
              QryFindStock.SQL.Text :=
                'SELECT se.id FROM ge_stock_envasado se ' +
                'INNER JOIN ge_lotes l ON se.id_lote = l.id ' +
                'WHERE se.id_articulo = :art AND (l.codigo = :lote OR l.codigo_antiguo = :lote) LIMIT 1';
              QryFindStock.ParamByName('art').AsInteger := LArtStockId;
              QryFindStock.ParamByName('lote').AsString := LLoteCode;
              QryFindStock.Open;
              if not QryFindStock.IsEmpty then
                LStockId := QryFindStock.FieldByName('id').AsInteger;

              if LStockId > 0 then
              begin
                // Descontar directamente del lote escaneado permitiendo valores negativos si hay descuadre real
                // manteniendo así la trazabilidad real con el cliente (DESADV) e indicando necesidad de ajuste de inventario
                dmgMain.qryExec.Close;
                dmgMain.qryExec.SQL.Text :=
                  'UPDATE ge_stock_envasado ' +
                  'SET cantidad_unidades = COALESCE(cantidad_unidades, 0) - :cant, ' +
                  '    id_user_update = :user ' +
                  'WHERE id = :sid';
                dmgMain.qryExec.ParamByName('cant').AsFloat  := LCantDeducir;
                dmgMain.qryExec.ParamByName('user').AsString := dmgMain.CurrentUserId;
                dmgMain.qryExec.ParamByName('sid').AsInteger := LStockId;
                dmgMain.qryExec.ExecSQL;
              end;
            end;

            QryGroup.Next;
          end;
          // 3. Generar albaranes de venta interempresas si los artículos provienen de almacenes compartidos de otras empresas
          GenerarAlbaranesInterempresas(FAlbaranId);
        finally
          QryTemp.Free;
          QryRead.Free;
          QryGroup.Free;
          QryFindStock.Free;
          QryFifo.Free;
        end;
      end;

      dmgMain.dbConn.Commit;
      ShowMessage('✅ Pedido completado y marcado como PREPARADO con éxito.');

      // Preguntar si desea imprimir el albarán del cliente
      if FAlbaranId > 0 then
      begin
        if MessageDlg('¿Desea imprimir el albarán del cliente?', mtConfirmation, [mbYes, mbNo], 0) = mrYes then
        begin
          TAlbaranPrintService.ImprimirAlbaran(Self, IntToStr(FAlbaranId));
        end;
      end;

      ModalResult := mrOk;
    except
      on E: Exception do
      begin
        dmgMain.dbConn.Rollback;
        raise;
      end;
    end;
  except
    on E: Exception do
      TDbErrorHandler.HandleException(E, 'Error al cambiar el estado a PREPARADO');
  end;
end;

procedure TfrmPreparacionPedidoEditor.GenerarAlbaranesInterempresas(const AAlbaranId: Integer);
var
  QryAlb, QryItems, QryEmp, QryCli, QryNum: TFDQuery;
  LEmpresaAlbaran, LEmpVendId, LClienteId, LNextNum, LNewAlbId, LArtId, LPos: Integer;
  LSerie, LAlbRefStr, LNombreComp, LCifComp, LObs, LLote: string;
  LCant, LPrecio, LTipoIva, LBase, LTotal, LTotBase, LTotIva, LTotDoc, LIvaCant: Double;
  LHasSeriesRow: Boolean;
begin
  if AAlbaranId <= 0 then Exit;

  QryAlb := TFDQuery.Create(nil);
  QryItems := TFDQuery.Create(nil);
  QryEmp := TFDQuery.Create(nil);
  QryCli := TFDQuery.Create(nil);
  QryNum := TFDQuery.Create(nil);
  try
    QryAlb.Connection := dmgMain.dbConn;
    QryItems.Connection := dmgMain.dbConn;
    QryEmp.Connection := dmgMain.dbConn;
    QryCli.Connection := dmgMain.dbConn;
    QryNum.Connection := dmgMain.dbConn;

    // 1. Obtener la empresa del albarán original
    QryAlb.SQL.Text := 'SELECT empresa_id, serie, numero FROM ge_albaranes WHERE id = :alb';
    QryAlb.ParamByName('alb').AsInteger := AAlbaranId;
    QryAlb.Open;
    if QryAlb.IsEmpty then Exit;

    LEmpresaAlbaran := QryAlb.FieldByName('empresa_id').AsInteger;
    LAlbRefStr := QryAlb.FieldByName('serie').AsString + '-' + QryAlb.FieldByName('numero').AsString;

    // 2. Obtener artículos y lotes preparados y determinar la empresa propietaria del almacén de origen
    QryItems.SQL.Text :=
      'SELECT al.articulo_id, al.descripcion, al.precio, COALESCE(al.tipo_iva, 0) AS tipo_iva, ' +
      '       COALESCE(lb.lote, '''') AS lote, lb.fecha_caducidad, SUM(lb.cantidad) AS cantidad_total, ' +
      '       COALESCE(alm.id_empresa, 0) AS empresa_propietaria ' +
      'FROM ge_albaranes_lineas_bultos lb ' +
      'INNER JOIN ge_albaranes_lineas al ON lb.linea_albaran_id = al.id ' +
      'LEFT JOIN ge_articulos a ON a.id = al.articulo_id ' +
      'LEFT JOIN ge_stock_envasado se ON se.id_articulo = COALESCE(NULLIF(a.id_maestro, 0), al.articulo_id) ' +
      '     AND (se.id_lote IN (SELECT id FROM ge_lotes WHERE codigo = COALESCE(NULLIF(lb.lote_interno, ''''), lb.lote)) OR lb.lote IS NULL OR lb.lote = '''') ' +
      'LEFT JOIN ge_almacenes alm ON se.id_almacen = alm.id ' +
      'WHERE al.albaran_id = :alb ' +
      'GROUP BY al.articulo_id, al.descripcion, al.precio, al.tipo_iva, COALESCE(lb.lote, ''''), lb.fecha_caducidad, COALESCE(alm.id_empresa, 0)';
    QryItems.ParamByName('alb').AsInteger := AAlbaranId;
    QryItems.Open;

    if QryItems.IsEmpty then Exit;

    // Obtener datos de la empresa compradora
    QryEmp.SQL.Text := 'SELECT nombre, cif_nif, direccion, cp, poblacion FROM ge_empresas WHERE id = :id';
    QryEmp.ParamByName('id').AsInteger := LEmpresaAlbaran;
    QryEmp.Open;
    if not QryEmp.IsEmpty then
    begin
      LNombreComp := QryEmp.FieldByName('nombre').AsString;
      LCifComp    := Trim(QryEmp.FieldByName('cif_nif').AsString);
    end
    else
    begin
      LNombreComp := 'Empresa ID ' + IntToStr(LEmpresaAlbaran);
      LCifComp    := '';
    end;

    // Recopilar las empresas vendedoras distintas en una lista para evitar bucles infinitos
    var LEmpVendList: TList<Integer>;
    LEmpVendList := TList<Integer>.Create;
    try
      QryItems.First;
      while not QryItems.Eof do
      begin
        LEmpVendId := QryItems.FieldByName('empresa_propietaria').AsInteger;
        if (LEmpVendId > 0) and (LEmpVendId <> LEmpresaAlbaran) and (not LEmpVendList.Contains(LEmpVendId)) then
          LEmpVendList.Add(LEmpVendId);
        QryItems.Next;
      end;

      // Para cada empresa vendedora, crear UN ÚNICO albarán con todas sus líneas
      for var iEmpIdx := 0 to LEmpVendList.Count - 1 do
      begin
        LEmpVendId := LEmpVendList[iEmpIdx];

        // A. Buscar o crear el cliente para la empresa compradora en la tabla global ge_clientes
        LClienteId := 0;
        if LCifComp <> '' then
        begin
          QryCli.Close;
          QryCli.SQL.Text := 'SELECT id FROM ge_clientes WHERE nif = :cif LIMIT 1';
          QryCli.ParamByName('cif').AsString := LCifComp;
          QryCli.Open;
          if not QryCli.IsEmpty then
            LClienteId := QryCli.FieldByName('id').AsInteger;
        end;

        if LClienteId = 0 then
        begin
          QryCli.Close;
          QryCli.SQL.Text := 'SELECT id FROM ge_clientes WHERE UPPER(nombre_fiscal) LIKE UPPER(:nom) LIMIT 1';
          QryCli.ParamByName('nom').AsString := '%' + LNombreComp + '%';
          QryCli.Open;
          if not QryCli.IsEmpty then
            LClienteId := QryCli.FieldByName('id').AsInteger;
        end;

        if LClienteId = 0 then
        begin
          dmgMain.qryExec.Close;
          dmgMain.qryExec.SQL.Text :=
            'INSERT INTO ge_clientes (nombre_fiscal, nif, domicilio, codigo_postal, poblacion, activo, id_user_creator, id_user_update) ' +
            'VALUES (:nom, :nif, :dom, :cp, :pob, 1, :user, :user)';
          dmgMain.qryExec.ParamByName('nom').AsString  := LNombreComp;
          dmgMain.qryExec.ParamByName('nif').AsString  := LCifComp;
          if not QryEmp.IsEmpty then
          begin
            dmgMain.qryExec.ParamByName('dom').AsString := QryEmp.FieldByName('direccion').AsString;
            dmgMain.qryExec.ParamByName('cp').AsString  := QryEmp.FieldByName('cp').AsString;
            dmgMain.qryExec.ParamByName('pob').AsString := QryEmp.FieldByName('poblacion').AsString;
          end
          else
          begin
            dmgMain.qryExec.ParamByName('dom').AsString := 'Venta Interempresas';
            dmgMain.qryExec.ParamByName('cp').AsString  := '';
            dmgMain.qryExec.ParamByName('pob').AsString := '';
          end;
          dmgMain.qryExec.ParamByName('user').AsString := dmgMain.CurrentUserId;
          dmgMain.qryExec.ExecSQL;

          dmgMain.qryExec.Close;
          dmgMain.qryExec.SQL.Text := 'SELECT LAST_INSERT_ID() AS id';
          dmgMain.qryExec.Open;
          LClienteId := dmgMain.qryExec.FieldByName('id').AsInteger;
        end;

        // B. Obtener la serie por defecto y siguiente número de albarán para la empresa vendedora desde ge_series
        LSerie := 'A';
        LNextNum := 0;
        LHasSeriesRow := False;

        QryNum.Close;
        QryNum.SQL.Text :=
          'SELECT serie, sig_numero_albaran FROM ge_series ' +
          'WHERE empresa_id = :emp AND activo = 1 ORDER BY por_defecto DESC, serie ASC LIMIT 1';
        QryNum.ParamByName('emp').AsInteger := LEmpVendId;
        QryNum.Open;

        if not QryNum.IsEmpty and (Trim(QryNum.FieldByName('serie').AsString) <> '') then
        begin
          LSerie := Trim(QryNum.FieldByName('serie').AsString);
          LNextNum := QryNum.FieldByName('sig_numero_albaran').AsInteger;
          if LNextNum <= 0 then LNextNum := 1;
          LHasSeriesRow := True;
        end
        else
        begin
          QryNum.Close;
          QryNum.SQL.Text := 'SELECT COALESCE(MAX(numero), 0) + 1 AS next_num FROM ge_albaranes WHERE empresa_id = :emp AND serie = :ser';
          QryNum.ParamByName('emp').AsInteger := LEmpVendId;
          QryNum.ParamByName('ser').AsString := LSerie;
          QryNum.Open;
          LNextNum := QryNum.FieldByName('next_num').AsInteger;
        end;

        LObs := 'Albarán de venta interempresas generado automáticamente por traspaso desde almacén compartido para el albarán original ' + LAlbRefStr;

        // Verificar si ya se ha generado previamente un albarán interempresas para esta empresa vendedora y este albarán original
        QryNum.Close;
        QryNum.SQL.Text :=
          'SELECT id FROM ge_albaranes WHERE empresa_id = :emp AND observaciones1 = :obs LIMIT 1';
        QryNum.ParamByName('emp').AsInteger := LEmpVendId;
        QryNum.ParamByName('obs').AsString := LObs;
        QryNum.Open;

        if not QryNum.IsEmpty then
          Continue; // Ya fue generado previamente, omitir creación duplicada

        // C. Crear UN ÚNICO albarán de venta interempresas para esta empresa vendedora
        dmgMain.qryExec.Close;
        dmgMain.qryExec.SQL.Text :=
          'INSERT INTO ge_albaranes (empresa_id, cliente_id, serie, numero, fecha, facturado, observaciones1, id_forma_pago, id_user_creator, id_user_update) ' +
          'VALUES (:emp, :cli, :ser, :num, CURDATE(), 0, :obs, (SELECT id_forma_pago FROM ge_clientes WHERE id = :cli), :user, :user)';
        dmgMain.qryExec.ParamByName('emp').AsInteger  := LEmpVendId;
        dmgMain.qryExec.ParamByName('cli').AsInteger  := LClienteId;
        dmgMain.qryExec.ParamByName('ser').AsString   := LSerie;
        dmgMain.qryExec.ParamByName('num').AsInteger  := LNextNum;
        dmgMain.qryExec.ParamByName('obs').AsString   := LObs;
        dmgMain.qryExec.ParamByName('user').AsString  := dmgMain.CurrentUserId;
        dmgMain.qryExec.ExecSQL;

        dmgMain.qryExec.Close;
        dmgMain.qryExec.SQL.Text := 'SELECT LAST_INSERT_ID() AS id';
        dmgMain.qryExec.Open;
        LNewAlbId := dmgMain.qryExec.FieldByName('id').AsInteger;

        if LHasSeriesRow then
        begin
          dmgMain.qryExec.Close;
          dmgMain.qryExec.SQL.Text :=
            'UPDATE ge_series SET sig_numero_albaran = :nextnum + 1 WHERE empresa_id = :emp AND serie = :ser AND activo = 1';
          dmgMain.qryExec.ParamByName('nextnum').AsInteger := LNextNum;
          dmgMain.qryExec.ParamByName('emp').AsInteger     := LEmpVendId;
          dmgMain.qryExec.ParamByName('ser').AsString      := LSerie;
          dmgMain.qryExec.ExecSQL;
        end;

        // D. Insertar TODAS las líneas pertenecientes a esta empresa vendedora bajo este ÚNICO albarán
        LPos := 0;
        LTotBase := 0;
        LTotIva := 0;
        LTotDoc := 0;

        QryItems.First;
        while not QryItems.Eof do
        begin
          if QryItems.FieldByName('empresa_propietaria').AsInteger = LEmpVendId then
          begin
            Inc(LPos);
            LArtId    := QryItems.FieldByName('articulo_id').AsInteger;
            LCant     := QryItems.FieldByName('cantidad_total').AsFloat;
            LPrecio   := QryItems.FieldByName('precio').AsFloat;
            LTipoIva  := QryItems.FieldByName('tipo_iva').AsFloat;
            LLote     := Trim(QryItems.FieldByName('lote').AsString);
            LBase     := LCant * LPrecio;
            LTotal    := LBase * (1.0 + (LTipoIva / 100.0));
            LIvaCant  := LTotal - LBase;

            LTotBase := LTotBase + LBase;
            LTotIva  := LTotIva + LIvaCant;
            LTotDoc  := LTotDoc + LTotal;

            dmgMain.qryExec.Close;
            dmgMain.qryExec.SQL.Text :=
              'INSERT INTO ge_albaranes_lineas (albaran_id, articulo_id, articulo_codigo, posicion, descripcion, cantidad, precio, tipo_iva, base, iva, total, lotes, fecha_caducidad, id_user_creator, id_user_update) ' +
              'VALUES (:alb, :art, :cod, :pos, :desc, :cant, :precio, :iva_tipo, :base, :iva, :tot, :lote, :fcad, :user, :user)';
            dmgMain.qryExec.ParamByName('alb').AsInteger        := LNewAlbId;
            dmgMain.qryExec.ParamByName('art').AsInteger        := LArtId;
            dmgMain.qryExec.ParamByName('cod').AsString      := IntToStr(LArtId);
            dmgMain.qryExec.ParamByName('pos').AsInteger        := LPos;
            dmgMain.qryExec.ParamByName('desc').AsString        := QryItems.FieldByName('descripcion').AsString;
            dmgMain.qryExec.ParamByName('cant').AsFloat         := LCant;
            dmgMain.qryExec.ParamByName('precio').AsFloat       := LPrecio;
            dmgMain.qryExec.ParamByName('iva_tipo').AsFloat     := LTipoIva;
            dmgMain.qryExec.ParamByName('base').AsFloat         := LBase;
            dmgMain.qryExec.ParamByName('iva').AsFloat          := LIvaCant;
            dmgMain.qryExec.ParamByName('tot').AsFloat          := LTotal;
            if LLote <> '' then
              dmgMain.qryExec.ParamByName('lote').AsString      := LLote
            else
            begin
              dmgMain.qryExec.ParamByName('lote').Clear;
              dmgMain.qryExec.ParamByName('lote').DataType := ftString;
            end;

            if not QryItems.FieldByName('fecha_caducidad').IsNull then
              dmgMain.qryExec.ParamByName('fcad').AsDateTime    := QryItems.FieldByName('fecha_caducidad').AsDateTime
            else
            begin
              dmgMain.qryExec.ParamByName('fcad').Clear;
              dmgMain.qryExec.ParamByName('fcad').DataType := ftDate;
            end;

            dmgMain.qryExec.ParamByName('user').AsString       := dmgMain.CurrentUserId;
            dmgMain.qryExec.ExecSQL;
          end;

          QryItems.Next;
        end;

        // E. Recalcular cabecera del albarán interempresas
        dmgMain.qryExec.Close;
        dmgMain.qryExec.SQL.Text :=
          'UPDATE ge_albaranes SET base_imponible = :base, iva = :iva, total = :tot WHERE id = :alb';
        dmgMain.qryExec.ParamByName('base').AsFloat := LTotBase;
        dmgMain.qryExec.ParamByName('iva').AsFloat  := LTotIva;
        dmgMain.qryExec.ParamByName('tot').AsFloat  := LTotDoc;
        dmgMain.qryExec.ParamByName('alb').AsInteger := LNewAlbId;
        dmgMain.qryExec.ExecSQL;
      end;
    finally
      LEmpVendList.Free;
    end;

  finally
    QryAlb.Free;
    QryItems.Free;
    QryEmp.Free;
    QryCli.Free;
    QryNum.Free;
  end;
end;

procedure TfrmPreparacionPedidoEditor.btnCerrarClick(Sender: TObject);
begin
  Close;
end;

procedure TfrmPreparacionPedidoEditor.tvEdiBultosDblClick(Sender: TObject);
var
  LNode: TTreeNode;
  LLbId, LArtId: Integer;
  LQry: TFDQuery;
  LActualCant, LReq, LOtrasCajasCant, LMaxPermitido: Double;
  LArtDesc, LInputVal, LPrompt: string;
begin
  LNode := tvEdiBultos.Selected;
  if not Assigned(LNode) or (LNode.Level <> 2) then Exit;

  LLbId := Integer(IntPtr(LNode.Data));
  if LLbId <= 0 then Exit;

  LQry := TFDQuery.Create(nil);
  try
    LQry.Connection := dmgMain.dbConn;
    LQry.SQL.Text :=
      'SELECT lb.cantidad, al.articulo_id, al.descripcion, pl.cantidad AS cant_solicitada ' +
      'FROM ge_albaranes_lineas_bultos lb ' +
      'INNER JOIN ge_albaranes_lineas al ON lb.linea_albaran_id = al.id ' +
      'LEFT JOIN ge_pedidos_lineas pl ON pl.id_pedido = :ped AND pl.id_articulo = al.articulo_id ' +
      'WHERE lb.id = :id LIMIT 1';
    LQry.ParamByName('ped').AsString := FPedidoId;
    LQry.ParamByName('id').AsInteger := LLbId;
    LQry.Open;

    if LQry.IsEmpty then Exit;

    LActualCant := LQry.FieldByName('cantidad').AsFloat;
    LArtId := LQry.FieldByName('articulo_id').AsInteger;
    LArtDesc := LQry.FieldByName('descripcion').AsString;
    LReq := LQry.FieldByName('cant_solicitada').AsFloat;

    // Calcular cuántas unidades llevan las OTRAS cajas de este mismo artículo
    LQry.Close;
    LQry.SQL.Text :=
      'SELECT COALESCE(SUM(lb.cantidad), 0) AS total_otras ' +
      'FROM ge_albaranes_lineas_bultos lb ' +
      'INNER JOIN ge_albaranes_lineas al ON lb.linea_albaran_id = al.id ' +
      'WHERE al.albaran_id = :alb AND al.articulo_id = :art AND lb.id <> :id';
    LQry.ParamByName('alb').AsInteger := FAlbaranId;
    LQry.ParamByName('art').AsInteger := LArtId;
    LQry.ParamByName('id').AsInteger := LLbId;
    LQry.Open;
    LOtrasCajasCant := LQry.FieldByName('total_otras').AsFloat;

    LMaxPermitido := LReq - LOtrasCajasCant;
    if LMaxPermitido < 0 then LMaxPermitido := 0;

    LInputVal := FormatFloat('0.##', LActualCant);
    LPrompt := Format(
      'Artículo: %s' + sLineBreak +
      '• Unidades actuales en esta caja: %s uds.' + sLineBreak +
      '• Pedido total solicitado: %s uds. (Otras cajas: %s uds.)' + sLineBreak +
      '• Máximo permitido en esta caja: %s uds.' + sLineBreak + sLineBreak +
      'Indique las nuevas unidades para esta caja:',
      [LArtDesc,
       FormatFloat('0.##', LActualCant),
       FormatFloat('0.##', LReq),
       FormatFloat('0.##', LOtrasCajasCant),
       FormatFloat('0.##', LMaxPermitido)]);

    if not InputQuery('Modificar Unidades del Bulto', LPrompt, LInputVal) then Exit;

    var LNuevaCant: Double := StrToFloatDef(Trim(StringReplace(LInputVal, ',', '.', [rfReplaceAll])), 0.0);
    if LNuevaCant <= 0 then
    begin
      MessageDlg('Las unidades deben ser un número positivo mayor que cero.', mtWarning, [mbOK], 0);
      Exit;
    end;

    if (LOtrasCajasCant + LNuevaCant) > (LReq + 0.001) then
    begin
      MessageDlg(Format('❌ ALERTA DE EXCESO DE UNIDADES DETECTADO:' + sLineBreak + sLineBreak +
                        'Artículo: %s' + sLineBreak +
                        'Cantidad Solicitada: %.2f uds.' + sLineBreak +
                        'Otras Cajas: %.2f uds.' + sLineBreak +
                        'Poner %.2f uds. en esta caja superaría el pedido (total: %.2f uds.).' + sLineBreak + sLineBreak +
                        'No se permite registrar escaneos en exceso.',
                        [LArtDesc, LReq, LOtrasCajasCant, LNuevaCant, LOtrasCajasCant + LNuevaCant]), mtError, [mbOK], 0);
      Exit;
    end;

    LQry.Close;
    LQry.SQL.Text := 'UPDATE ge_albaranes_lineas_bultos SET cantidad = :cant WHERE id = :id';
    LQry.ParamByName('cant').AsFloat := LNuevaCant;
    LQry.ParamByName('id').AsInteger := LLbId;
    LQry.ExecSQL;

    CargarArbolBultosEdi;
  finally
    LQry.Free;
  end;
end;

end.
