unit frm_TipsaRecogida;

{
  frm_TipsaRecogida.pas
  Formulario modal: Solicitar Recogida TIPSA desde un Pedido de Cliente.

  Flujo completo:
    1. Carga los datos del pedido y del destino de recogida.
    2. Verifica la disponibilidad de stock en ge_stock_envasado por artículo.
    3. Permite configurar la fecha/hora de recogida, bultos, persona de contacto,
       tipo de servicio y observaciones.
    4. Al confirmar:
       a) Llama a GrabaRecogida10 en el API TIPSA.
       b) Genera automáticamente el Albarán de Venta (ge_albaranes + ge_albaranes_lineas).
       c) Descuenta el stock consumido de ge_stock_envasado (FIFO por lote).
       d) Guarda el registro en ge_tipsa_recogidas vinculado al pedido y al albarán.
       e) Abre el Documento de Carga (informe imprimible con los datos del envío).
       f) Actualiza el estado del pedido a 'EN PREPARACION'.
}

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants,
  System.Classes, Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs,
  Vcl.StdCtrls, Vcl.ExtCtrls, Vcl.Grids, Vcl.ComCtrls, Vcl.Buttons,
  Vcl.Samples.Spin, Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, FireDAC.Stan.Intf,
  FireDAC.Stan.Option, FireDAC.Stan.Error, FireDAC.DatS, FireDAC.Phys.Intf,
  FireDAC.DApt.Intf, FireDAC.DApt, FireDAC.Comp.DataSet, System.UITypes, uAppTheme;

type
  // Registro de línea de stock para cada artículo del pedido
  TLineaStock = record
    IdArticulo   : Integer;
    Descripcion  : string;
    CantPedida   : Double;
    StockTotal   : Double;      // Suma de todas las unidades disponibles
    StockOK      : Boolean;     // CantPedida <= StockTotal
  end;

  TfrmTipsaRecogida = class(TForm)
    // ── Cabecera del pedido (informativa) ───────────────────────────────────
    pnlHeader: TPanel;
    lblTitulo: TLabel;
    lblPedidoNum: TLabel;
    edtPedidoNum: TEdit;
    lblCliente: TLabel;
    edtCliente: TEdit;
    lblDestino: TLabel;
    edtDestino: TEdit;
    lblFechaPedido: TLabel;
    edtFechaPedido: TEdit;

    // ── Verificación de Stock ────────────────────────────────────────────────
    pnlStock: TPanel;
    lblStockTitle: TLabel;
    sgStock: TStringGrid;
    btnVerificarStock: TButton;
    lblStockStatus: TLabel;

    // ── Datos de la Recogida ─────────────────────────────────────────────────
    pnlRecogida: TPanel;
    lblRecogidaTitle: TLabel;

    lblFechaRec: TLabel;
    dtpFechaRec: TDateTimePicker;
    lblHoraIni: TLabel;
    dtpHoraIni: TDateTimePicker;
    lblHoraFin: TLabel;
    dtpHoraFin: TDateTimePicker;
    lblBultos: TLabel;
    spnBultos: TSpinEdit;
    lblTipoServ: TLabel;
    cbTipoServ: TComboBox;
    lblContacto: TLabel;
    edtContacto: TEdit;
    lblObs: TLabel;
    memObs: TMemo;

    // ── Dirección de recogida (origen para TIPSA — desde el destino del pedido)
    pnlDirRecogida: TPanel;
    lblDirRecTitle: TLabel;
    lblNomRec: TLabel;
    edtNomRec: TEdit;
    lblDirRec: TLabel;
    edtDirRec: TEdit;
    lblNumRec: TLabel;
    edtNumRec: TEdit;
    lblCPRec: TLabel;
    edtCPRec: TEdit;
    lblTlfRec: TLabel;
    edtTlfRec: TEdit;

    // ── Botones de acción ────────────────────────────────────────────────────
    pnlButtons: TPanel;
    btnConfirmar: TButton;
    btnCancelar: TButton;
    lblInfoResult: TLabel;

    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure FormShow(Sender: TObject);
    procedure btnVerificarStockClick(Sender: TObject);
    procedure btnConfirmarClick(Sender: TObject);
    procedure btnCancelarClick(Sender: TObject);

  private
    FPedidoId      : Integer;
    FEmpresaId     : Integer;
    FClienteId     : Integer;
    FDestinoId     : Integer;
    FRecogidaId    : Integer;   // ID en ge_tipsa_recogidas tras confirmar
    FAlbaranId     : Integer;   // ID del albarán generado
    FStockVerificado: Boolean;
    FLineasStock   : TArray<TLineaStock>;

    // Carga datos del pedido y del destino de recogida
    procedure LoadPedidoData;
    // Carga datos del remitente (empresa) como destino de la recogida en TIPSA
    procedure LoadConfigTipsa(out ACodAge, ACodCli, APassword: string;
                              out AEntorno: Boolean;
                              out ANomDes, ADirDes, ANomDes2, ACPDes,
                                  APobDes, ATlfDes: string;
                              out AServDefecto: string);
    // Verifica stock disponible para todas las líneas del pedido
    function VerificarStock: Boolean;
    // Genera el albarán de venta a partir del pedido
    function GenerarAlbaran(const ANumAlbaran: string): Integer;
    // Descuenta stock FIFO para un artículo
    procedure DescontarStock(AIdArticulo: Integer; ACantidad: Double);
    // Genera e imprime el documento de carga
    procedure MostrarDocumentoCarga(const ACodRecogida: string);
    // Rellena el combo de tipos de servicio
    procedure PopularServicioCombo;
  public
    property PedidoId: Integer read FPedidoId write FPedidoId;
    property RecogidaId: Integer read FRecogidaId;
    property AlbaranId: Integer read FAlbaranId;
  end;

var
  frmTipsaRecogida: TfrmTipsaRecogida;

implementation

uses
  System.Math, Winapi.ShellAPI, dmg_Main, uDbErrorHandler,
  uTipsaClient, uTipsaTypes, Vcl.Mask, uPdfRotateService;

{$R *.dfm}

// ---------------------------------------------------------------------------
procedure TfrmTipsaRecogida.FormCreate(Sender: TObject);
begin
  FPedidoId       := 0;
  FEmpresaId      := 0;
  FClienteId      := 0;
  FDestinoId      := 0;
  FRecogidaId     := 0;
  FAlbaranId      := 0;
  FStockVerificado := False;
  SetLength(FLineasStock, 0);
end;

// ---------------------------------------------------------------------------
procedure TfrmTipsaRecogida.FormDestroy(Sender: TObject);
begin
  if frmTipsaRecogida = Self then
    frmTipsaRecogida := nil;
end;

// ---------------------------------------------------------------------------
procedure TfrmTipsaRecogida.PopularServicioCombo;
begin
  cbTipoServ.Items.Clear;
  cbTipoServ.Items.Add('48 - ECONOMY');
  cbTipoServ.Items.Add('19 - EXPRESS 19H');
  cbTipoServ.Items.Add('10 - 10 HORAS');
  cbTipoServ.Items.Add('14 - 14 HORAS');
  cbTipoServ.Items.Add('90 - INT-EXPRESS');
  cbTipoServ.Items.Add('91 - INT-ECONOMY');
  cbTipoServ.ItemIndex := 0;
end;

// ---------------------------------------------------------------------------
procedure TfrmTipsaRecogida.FormShow(Sender: TObject);
begin
  TAppTheme.ApplyToForm(Self);
  PopularServicioCombo;

  // Fecha de recogida por defecto: mañana
  dtpFechaRec.Date := Date + 1;
  // Franja horaria por defecto: 09:00 - 18:00
  dtpHoraIni.Time := EncodeTime(9, 0, 0, 0);
  dtpHoraFin.Time := EncodeTime(18, 0, 0, 0);

  spnBultos.Value := 1;
  btnConfirmar.Enabled := False;
  lblStockStatus.Caption := '';
  lblInfoResult.Caption := '';

  // Configurar cabeceras del grid de stock
  sgStock.ColCount := 4;
  sgStock.RowCount := 2;
  sgStock.FixedRows := 1;
  sgStock.FixedCols := 0;
  sgStock.Cells[0, 0] := 'Artículo';
  sgStock.Cells[1, 0] := 'Cant. Pedida';
  sgStock.Cells[2, 0] := 'Stock Disponible';
  sgStock.Cells[3, 0] := 'Estado';
  sgStock.ColWidths[0] := 280;
  sgStock.ColWidths[1] := 100;
  sgStock.ColWidths[2] := 130;
  sgStock.ColWidths[3] := 90;

  if FPedidoId > 0 then
    LoadPedidoData;
end;

// ---------------------------------------------------------------------------
// Carga datos del pedido, cliente y destino de recogida
// ---------------------------------------------------------------------------
procedure TfrmTipsaRecogida.LoadPedidoData;
var
  Qry: TFDQuery;
begin
  Qry := TFDQuery.Create(nil);
  try
    Qry.Connection := dmgMain.dbConn;

    // Datos del pedido
    Qry.SQL.Text :=
      'SELECT p.numero_pedido, p.empresa_id, p.cliente_id, p.clientedestino_id, ' +
      '       p.fecha_pedido, c.nombre_fiscal, ' +
      '       d.descripcion AS dest_desc, d.direccion, d.cp, d.provincia, ' +
      '       c.telefono AS tlf_cli ' +
      'FROM ge_pedidos p ' +
      'LEFT JOIN ge_clientes c ON c.id = p.cliente_id ' +
      'LEFT JOIN ge_clientes_destinos d ON d.id = p.clientedestino_id ' +
      'WHERE p.id = :id';
    Qry.ParamByName('id').AsInteger := FPedidoId;
    Qry.Open;

    if Qry.IsEmpty then Exit;

    FEmpresaId := Qry.FieldByName('empresa_id').AsInteger;
    FClienteId := Qry.FieldByName('cliente_id').AsInteger;
    FDestinoId := Qry.FieldByName('clientedestino_id').AsInteger;

    edtPedidoNum.Text   := Qry.FieldByName('numero_pedido').AsString;
    edtCliente.Text     := Qry.FieldByName('nombre_fiscal').AsString;
    edtDestino.Text     := Qry.FieldByName('dest_desc').AsString;
    edtFechaPedido.Text := FormatDateTime('dd/mm/yyyy', Qry.FieldByName('fecha_pedido').AsDateTime);

    // Pre-rellenar dirección de recogida (origen en TIPSA = destino del pedido)
    edtNomRec.Text := Qry.FieldByName('nombre_fiscal').AsString;
    edtDirRec.Text := Qry.FieldByName('direccion').AsString;
    edtCPRec.Text  := Qry.FieldByName('cp').AsString;
    edtTlfRec.Text := Qry.FieldByName('tlf_cli').AsString;

    // Persona de contacto: nombre del cliente por defecto
    edtContacto.Text := Qry.FieldByName('nombre_fiscal').AsString;

    Qry.Close;

    // Cargar tipo de servicio por defecto desde ge_tipsa_config
    Qry.SQL.Text :=
      'SELECT serv_defecto FROM ge_tipsa_config WHERE empresa_id = :emp AND activo = 1 LIMIT 1';
    Qry.ParamByName('emp').AsInteger := FEmpresaId;
    Qry.Open;
    if not Qry.IsEmpty then
    begin
      var LServDef := Qry.FieldByName('serv_defecto').AsString;
      var i: Integer;
      for i := 0 to cbTipoServ.Items.Count - 1 do
        if cbTipoServ.Items[i].StartsWith(LServDef) then
        begin
          cbTipoServ.ItemIndex := i;
          Break;
        end;
    end;

  finally
    Qry.Free;
  end;
end;

// ---------------------------------------------------------------------------
// Verificar stock disponible para todas las líneas del pedido
// ---------------------------------------------------------------------------
function TfrmTipsaRecogida.VerificarStock: Boolean;
var
  Qry  : TFDQuery;
  i    : Integer;
  LLine: TLineaStock;
  LRow : Integer;
begin
  Result := False;
  SetLength(FLineasStock, 0);

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

    // Obtener líneas del pedido con stock disponible (unidades - reservadas)
    Qry.SQL.Text :=
      'SELECT pl.id_articulo, pl.descripcion, pl.cantidad, ' +
      '       COALESCE(SUM(GREATEST(0, se.cantidad_unidades - COALESCE(se.cantidad_reservada, 0))), 0) AS stock_total ' +
      'FROM ge_pedidos_lineas pl ' +
      'LEFT JOIN ge_articulos a ON a.id = pl.id_articulo ' +
      'LEFT JOIN ge_stock_envasado se ON se.id_articulo = COALESCE(NULLIF(a.id_maestro, 0), pl.id_articulo) ' +
      'WHERE pl.id_pedido = :id ' +
      'GROUP BY pl.id_articulo, pl.descripcion, pl.cantidad';
    Qry.ParamByName('id').AsInteger := FPedidoId;
    Qry.Open;

    LRow := 1;
    sgStock.RowCount := Max(2, Qry.RecordCount + 1);
    i := 0;

    while not Qry.Eof do
    begin
      LLine.IdArticulo  := Qry.FieldByName('id_articulo').AsInteger;
      LLine.Descripcion := Qry.FieldByName('descripcion').AsString;
      LLine.CantPedida  := Qry.FieldByName('cantidad').AsFloat;
      LLine.StockTotal  := Qry.FieldByName('stock_total').AsFloat;
      LLine.StockOK     := LLine.StockTotal >= LLine.CantPedida;

      SetLength(FLineasStock, i + 1);
      FLineasStock[i] := LLine;

      // Rellenar el grid
      sgStock.Cells[0, LRow] := LLine.Descripcion;
      sgStock.Cells[1, LRow] := FormatFloat('#,##0.##', LLine.CantPedida);
      sgStock.Cells[2, LRow] := FormatFloat('#,##0.##', LLine.StockTotal);
      if LLine.StockOK then
        sgStock.Cells[3, LRow] := '✅ OK'
      else
        sgStock.Cells[3, LRow] := '❌ FALTA';

      Inc(i);
      Inc(LRow);
      Qry.Next;
    end;

    // Verificar que TODOS los artículos tienen stock suficiente
    Result := True;
    for i := 0 to Length(FLineasStock) - 1 do
      if not FLineasStock[i].StockOK then
      begin
        Result := False;
        Break;
      end;

    FStockVerificado := True;
  finally
    Qry.Free;
  end;
end;

// ---------------------------------------------------------------------------
procedure TfrmTipsaRecogida.btnVerificarStockClick(Sender: TObject);
var
  LStockOK: Boolean;
begin
  lblStockStatus.Caption := '⏳ Verificando stock...';
  lblStockStatus.Font.Color := clGray;
  Application.ProcessMessages;

  try
    LStockOK := VerificarStock;

    if LStockOK then
    begin
      lblStockStatus.Caption := '✅ Stock disponible para todos los artículos.';
      lblStockStatus.Font.Color := clGreen;
      btnConfirmar.Enabled := True;
    end
    else
    begin
      lblStockStatus.Caption := '❌ Stock insuficiente en algún artículo. No se puede confirmar la recogida.';
      lblStockStatus.Font.Color := clRed;
      btnConfirmar.Enabled := False;
    end;
  except
    on E: Exception do
    begin
      lblStockStatus.Caption := '❌ Error al verificar stock: ' + E.Message;
      lblStockStatus.Font.Color := clRed;
    end;
  end;
end;

// ---------------------------------------------------------------------------
// Carga configuración TIPSA de la empresa activa
// ---------------------------------------------------------------------------
procedure TfrmTipsaRecogida.LoadConfigTipsa(out ACodAge, ACodCli, APassword: string;
  out AEntorno: Boolean;
  out ANomDes, ADirDes, ANomDes2, ACPDes, APobDes, ATlfDes: string;
  out AServDefecto: string);
var
  Qry: TFDQuery;
begin
  ACodAge := ''; ACodCli := ''; APassword := '';
  AEntorno := False;
  ANomDes := ''; ADirDes := ''; ANomDes2 := '';
  ACPDes := ''; APobDes := ''; ATlfDes := '';
  AServDefecto := '48';

  Qry := TFDQuery.Create(nil);
  try
    Qry.Connection := dmgMain.dbConn;
    Qry.SQL.Text :=
      'SELECT tc.cod_agencia, tc.cod_cliente, tc.password, tc.entorno, ' +
      '       tc.nom_remitente, tc.dir_remitente, tc.pob_remitente, ' +
      '       tc.cp_remitente, tc.tlf_remitente, tc.serv_defecto ' +
      'FROM ge_tipsa_config tc ' +
      'WHERE tc.empresa_id = :emp AND tc.activo = 1 LIMIT 1';
    Qry.ParamByName('emp').AsInteger := FEmpresaId;
    Qry.Open;

    ACodAge      := Qry.FieldByName('cod_agencia').AsString;
    ACodCli      := Qry.FieldByName('cod_cliente').AsString;
    APassword    := Qry.FieldByName('password').AsString;
    AEntorno     := Qry.FieldByName('entorno').AsInteger <> 0;
    ANomDes      := Qry.FieldByName('nom_remitente').AsString;
    ADirDes      := Qry.FieldByName('dir_remitente').AsString;
    APobDes      := Qry.FieldByName('pob_remitente').AsString;
    ACPDes       := Qry.FieldByName('cp_remitente').AsString;
    ATlfDes      := Qry.FieldByName('tlf_remitente').AsString;
    AServDefecto := Qry.FieldByName('serv_defecto').AsString;
  finally
    Qry.Free;
  end;
end;

// ---------------------------------------------------------------------------
// Genera el Albarán de Venta desde las líneas del pedido desglosando por lote
// Devuelve el ID del albarán generado, o 0 si falla
// ---------------------------------------------------------------------------
function TfrmTipsaRecogida.GenerarAlbaran(const ANumAlbaran: string): Integer;
var
  Qry, QryLines, QryStock: TFDQuery;
  LAlbId: Integer;
  LSerie: string;
  LNumero: Integer;
  LPosicion: Integer;
  LArtId, LArtStockId: Integer;
  LDesc: string;
  LPrecio: Double;
  LTipoIva: Double;
  LCantTotalReq: Double;
  LCantPendiente: Double;
  LBultosReq, LCantBulto, LBultosLinea: Double;
  LStockId: Integer;
  LDisp: Double;
  LTomar: Double;
  LLoteCode: string;
  LFabFecha: TDateTime;
  LDiasVida: Integer;
  LFechaCaducidad: TDateTime;
  LBaseLinea, LTotalLinea, LIvaLinea: Double;
  LTotalBase, LTotalIva, LTotalDoc: Double;
  LHasCaducidad: Boolean;
begin
  Result := 0;
  LTotalBase := 0;
  LTotalIva := 0;
  LTotalDoc := 0;

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

    // Obtener primera serie activa de la empresa
    Qry.SQL.Text :=
      'SELECT serie, sig_numero_albaran AS prox_num ' +
      'FROM ge_series WHERE empresa_id = :emp AND activo = 1 ' +
      'ORDER BY serie LIMIT 1';
    Qry.ParamByName('emp').AsInteger := FEmpresaId;
    Qry.Open;

    if Qry.IsEmpty then
    begin
      LSerie  := 'A';
      LNumero := 1;
    end
    else
    begin
      LSerie  := Qry.FieldByName('serie').AsString;
      LNumero := Qry.FieldByName('prox_num').AsInteger;
    end;
    Qry.Close;

    // Insertar primero la cabecera del albarán con totales a 0
    dmgMain.qryExec.Close;
    dmgMain.qryExec.SQL.Text :=
      'INSERT INTO ge_albaranes ' +
      '(empresa_id, cliente_id, clientedestino_id, serie, numero, fecha, ' +
      ' base_imponible, iva, total, id_pedido, id_user_creator, id_user_update) ' +
      'VALUES (:emp, :cli, :dest, :ser, :num, NOW(), 0, 0, 0, :ped, :user, :user)';
    dmgMain.qryExec.ParamByName('emp').AsInteger  := FEmpresaId;
    dmgMain.qryExec.ParamByName('cli').AsInteger  := FClienteId;
    dmgMain.qryExec.ParamByName('dest').AsInteger := FDestinoId;
    dmgMain.qryExec.ParamByName('ser').AsString   := LSerie;
    dmgMain.qryExec.ParamByName('num').AsInteger  := LNumero;
    dmgMain.qryExec.ParamByName('ped').AsInteger  := FPedidoId;
    dmgMain.qryExec.ParamByName('user').AsString  := dmgMain.CurrentUserId;
    dmgMain.qryExec.ExecSQL;

    // Obtener ID del albarán insertado
    dmgMain.qryExec.Close;
    dmgMain.qryExec.SQL.Text := 'SELECT LAST_INSERT_ID() AS id';
    dmgMain.qryExec.Open;
    LAlbId := dmgMain.qryExec.FieldByName('id').AsInteger;
    dmgMain.qryExec.Close;

    if LAlbId = 0 then
      raise Exception.Create('No se pudo obtener el ID del albarán generado.');

    // Actualizar número de serie
    dmgMain.qryExec.SQL.Text :=
      'UPDATE ge_series SET sig_numero_albaran = :num + 1 ' +
      'WHERE empresa_id = :emp AND serie = :ser AND activo = 1';
    dmgMain.qryExec.ParamByName('num').AsInteger  := LNumero;
    dmgMain.qryExec.ParamByName('emp').AsInteger  := FEmpresaId;
    dmgMain.qryExec.ParamByName('ser').AsString   := LSerie;
    dmgMain.qryExec.ExecSQL;

    // Obtener líneas del pedido incluyendo bultos (cantidad_bulto es un concepto calculado)
    QryLines.SQL.Text :=
      'SELECT pl.id_articulo, pl.descripcion, pl.cantidad, pl.precio, ' +
      '       COALESCE(pl.bultos, 0) AS bultos, ' +
      '       COALESCE(ti.tipo, 10) AS tipo_iva, ' +
      '       COALESCE(NULLIF(a.id_maestro, 0), pl.id_articulo) AS id_articulo_stock ' +
      'FROM ge_pedidos_lineas pl ' +
      'LEFT JOIN ge_articulos a ON a.id = pl.id_articulo ' +
      'LEFT JOIN ge_tipos_iva ti ON ti.id = a.id_tipo_iva ' +
      'WHERE pl.id_pedido = :ped_id ' +
      'ORDER BY pl.id ASC';
    QryLines.ParamByName('ped_id').AsInteger := FPedidoId;
    QryLines.Open;

    LPosicion := 1;

    while not QryLines.Eof do
    begin
      LArtId        := QryLines.FieldByName('id_articulo').AsInteger;
      LArtStockId   := QryLines.FieldByName('id_articulo_stock').AsInteger;
      LDesc         := QryLines.FieldByName('descripcion').AsString;
      LCantTotalReq := QryLines.FieldByName('cantidad').AsFloat;
      LPrecio       := QryLines.FieldByName('precio').AsFloat;
      LBultosReq    := QryLines.FieldByName('bultos').AsFloat;
      LTipoIva      := QryLines.FieldByName('tipo_iva').AsFloat;
      LCantPendiente:= LCantTotalReq;

      if (LBultosReq > 0) and (LCantTotalReq > 0) then
        LCantBulto := LCantTotalReq / LBultosReq
      else
        LCantBulto := 0;

      if LCantTotalReq > 0 then
      begin
        // Buscar existencias de stock repartidas por lotes (FIFO) considerando stock disponible (unidades - reservadas)
        QryStock.Close;
        QryStock.SQL.Text :=
          'SELECT se.id AS stock_id, ' +
          '       GREATEST(0, se.cantidad_unidades - COALESCE(se.cantidad_reservada, 0)) AS disponible, ' +
          '       COALESCE(NULLIF(l.codigo_antiguo, ''''), l.codigo) AS lote_codigo, l.fecha AS fecha_fabricacion, ' +
          '       a.dias_vida ' +
          'FROM ge_stock_envasado se ' +
          'LEFT JOIN ge_lotes l ON se.id_lote = l.id ' +
          'LEFT JOIN ge_articulos a ON se.id_articulo = a.id ' +
          'WHERE se.id_articulo = :art ' +
          '  AND (se.cantidad_unidades - COALESCE(se.cantidad_reservada, 0)) > 0 ' +
          'ORDER BY l.fecha ASC, l.id ASC, se.id ASC';
        QryStock.ParamByName('art').AsInteger := LArtStockId;
        QryStock.Open;

        while (not QryStock.Eof) and (LCantPendiente > 0) do
        begin
          LStockId  := QryStock.FieldByName('stock_id').AsInteger;
          LDisp     := QryStock.FieldByName('disponible').AsFloat;
          LTomar    := Min(LCantPendiente, LDisp);
          LLoteCode := QryStock.FieldByName('lote_codigo').AsString;

          if LCantBulto > 0 then
            LBultosLinea := Ceil(LTomar / LCantBulto)
          else
            LBultosLinea := 0;

          // Calcular fecha de caducidad = fecha de fabricación + días de vida del artículo
          LHasCaducidad := False;
          LFechaCaducidad := 0;
          if (not QryStock.FieldByName('fecha_fabricacion').IsNull) and
             (not QryStock.FieldByName('dias_vida').IsNull) and
             (QryStock.FieldByName('dias_vida').AsInteger > 0) then
          begin
            LFabFecha := QryStock.FieldByName('fecha_fabricacion').AsDateTime;
            LDiasVida := QryStock.FieldByName('dias_vida').AsInteger;
            LFechaCaducidad := LFabFecha + LDiasVida;
            LHasCaducidad := True;
          end;

          LBaseLinea  := LTomar * LPrecio;
          LIvaLinea   := LBaseLinea * (LTipoIva / 100.0);
          LTotalLinea := LBaseLinea + LIvaLinea;

          LTotalBase := LTotalBase + LBaseLinea;
          LTotalIva  := LTotalIva + LIvaLinea;
          LTotalDoc  := LTotalDoc + LTotalLinea;

          // Crear línea de albarán por cada lote extraído arrastrando bultos y cantidad por bulto
          dmgMain.qryExec.Close;
          dmgMain.qryExec.SQL.Text :=
            'INSERT INTO ge_albaranes_lineas ' +
            '(albaran_id, articulo_id, articulo_codigo, descripcion, cantidad, bultos, cantidad_bulto, precio, ' +
            ' base, iva, total, tipo_iva, lotes, fecha_caducidad, posicion, id_user_creator, id_user_update) ' +
            'VALUES (:alb, :art, :artcod, :desc, :cant, :bultos, :cant_bulto, :precio, ' +
            '        :base, :iva_cant, :tot, :iva_tipo, :lote, :fcad, :pos, :user, :user)';
          dmgMain.qryExec.ParamByName('alb').AsInteger        := LAlbId;
          dmgMain.qryExec.ParamByName('art').AsInteger        := LArtId;
          dmgMain.qryExec.ParamByName('artcod').AsString      := IntToStr(LArtId);
          dmgMain.qryExec.ParamByName('desc').AsString        := LDesc;
          dmgMain.qryExec.ParamByName('cant').AsFloat         := LTomar;
          dmgMain.qryExec.ParamByName('bultos').AsFloat       := LBultosLinea;
          dmgMain.qryExec.ParamByName('cant_bulto').AsFloat   := LCantBulto;
          dmgMain.qryExec.ParamByName('precio').AsFloat       := LPrecio;
          dmgMain.qryExec.ParamByName('base').AsFloat         := LBaseLinea;
          dmgMain.qryExec.ParamByName('iva_cant').AsFloat     := LIvaLinea;
          dmgMain.qryExec.ParamByName('tot').AsFloat          := LTotalLinea;
          dmgMain.qryExec.ParamByName('iva_tipo').AsFloat     := LTipoIva;

          if LLoteCode <> '' then
            dmgMain.qryExec.ParamByName('lote').AsString      := LLoteCode
          else
            dmgMain.qryExec.ParamByName('lote').Clear;

          if LHasCaducidad then
            dmgMain.qryExec.ParamByName('fcad').AsDate        := LFechaCaducidad
          else
            dmgMain.qryExec.ParamByName('fcad').Clear;

          dmgMain.qryExec.ParamByName('pos').AsInteger        := LPosicion;
          dmgMain.qryExec.ParamByName('user').AsString       := dmgMain.CurrentUserId;
          dmgMain.qryExec.ExecSQL;

          // Añadir las unidades a cantidad_reservada (no se descuentan de cantidad_unidades hasta la preparación)
          dmgMain.qryExec.Close;
          dmgMain.qryExec.SQL.Text :=
            'UPDATE ge_stock_envasado SET cantidad_reservada = COALESCE(cantidad_reservada, 0) + :cant, ' +
            'updated_at = NOW(), id_user_update = :user WHERE id = :id';
          dmgMain.qryExec.ParamByName('cant').AsFloat  := LTomar;
          dmgMain.qryExec.ParamByName('user').AsString := dmgMain.CurrentUserId;
          dmgMain.qryExec.ParamByName('id').AsInteger  := LStockId;
          dmgMain.qryExec.ExecSQL;

          LCantPendiente := LCantPendiente - LTomar;
          Inc(LPosicion);
          QryStock.Next;
        end;

        // Si quedara cantidad pendiente por no disponer de lotes de stock
        if LCantPendiente > 0 then
        begin
          if LCantBulto > 0 then
            LBultosLinea := Ceil(LCantPendiente / LCantBulto)
          else
            LBultosLinea := 0;

          LBaseLinea  := LCantPendiente * LPrecio;
          LIvaLinea   := LBaseLinea * (LTipoIva / 100.0);
          LTotalLinea := LBaseLinea + LIvaLinea;

          LTotalBase := LTotalBase + LBaseLinea;
          LTotalIva  := LTotalIva + LIvaLinea;
          LTotalDoc  := LTotalDoc + LTotalLinea;

          dmgMain.qryExec.Close;
          dmgMain.qryExec.SQL.Text :=
            'INSERT INTO ge_albaranes_lineas ' +
            '(albaran_id, articulo_id, articulo_codigo, descripcion, cantidad, bultos, cantidad_bulto, precio, ' +
            ' base, iva, total, tipo_iva, posicion, id_user_creator, id_user_update) ' +
            'VALUES (:alb, :art, :artcod, :desc, :cant, :bultos, :cant_bulto, :precio, ' +
            '        :base, :iva_cant, :tot, :iva_tipo, :pos, :user, :user)';
          dmgMain.qryExec.ParamByName('alb').AsInteger        := LAlbId;
          dmgMain.qryExec.ParamByName('art').AsInteger        := LArtId;
          dmgMain.qryExec.ParamByName('artcod').AsString      := IntToStr(LArtId);
          dmgMain.qryExec.ParamByName('desc').AsString        := LDesc;
          dmgMain.qryExec.ParamByName('cant').AsFloat         := LCantPendiente;
          dmgMain.qryExec.ParamByName('bultos').AsFloat       := LBultosLinea;
          dmgMain.qryExec.ParamByName('cant_bulto').AsFloat   := LCantBulto;
          dmgMain.qryExec.ParamByName('precio').AsFloat       := LPrecio;
          dmgMain.qryExec.ParamByName('base').AsFloat         := LBaseLinea;
          dmgMain.qryExec.ParamByName('iva_cant').AsFloat     := LIvaLinea;
          dmgMain.qryExec.ParamByName('tot').AsFloat          := LTotalLinea;
          dmgMain.qryExec.ParamByName('iva_tipo').AsFloat     := LTipoIva;
          dmgMain.qryExec.ParamByName('pos').AsInteger        := LPosicion;
          dmgMain.qryExec.ParamByName('user').AsString       := dmgMain.CurrentUserId;
          dmgMain.qryExec.ExecSQL;

          Inc(LPosicion);
        end;
      end;

      QryLines.Next;
    end;

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

    Result := LAlbId;
  finally
    Qry.Free;
    QryLines.Free;
    QryStock.Free;
  end;
end;

// ---------------------------------------------------------------------------
// Genera y muestra el Documento de Carga en pantalla (resumen imprimible)
// ---------------------------------------------------------------------------
procedure TfrmTipsaRecogida.MostrarDocumentoCarga(const ACodRecogida: string);
var
  LDoc      : TStringList;
  LQry      : TFDQuery;
  LLine     : string;
  LTempFile : string;
begin
  LDoc := TStringList.Create;
  LQry := TFDQuery.Create(nil);
  try
    LDoc.Add('═══════════════════════════════════════════════════');
    LDoc.Add('            DOCUMENTO DE CARGA - TIPSA             ');
    LDoc.Add('═══════════════════════════════════════════════════');
    LDoc.Add('Nº Recogida TIPSA : ' + ACodRecogida);
    LDoc.Add('Pedido            : ' + edtPedidoNum.Text);
    LDoc.Add('Albarán generado  : ' + IntToStr(FAlbaranId));
    LDoc.Add('Fecha recogida    : ' + FormatDateTime('dd/mm/yyyy', dtpFechaRec.Date));
    LDoc.Add('Franja horaria    : ' +
             FormatDateTime('hh:nn', dtpHoraIni.Time) + ' - ' +
             FormatDateTime('hh:nn', dtpHoraFin.Time));
    LDoc.Add('Nº de bultos      : ' + IntToStr(spnBultos.Value));
    LDoc.Add('───────────────────────────────────────────────────');
    LDoc.Add('CLIENTE           : ' + edtCliente.Text);
    LDoc.Add('DESTINO RECOGIDA  : ' + edtDestino.Text);
    LDoc.Add('Dirección         : ' + edtDirRec.Text + ' ' + edtNumRec.Text);
    LDoc.Add('CP                : ' + edtCPRec.Text);
    LDoc.Add('Teléfono          : ' + edtTlfRec.Text);
    LDoc.Add('Persona contacto  : ' + edtContacto.Text);
    LDoc.Add('───────────────────────────────────────────────────');
    LDoc.Add('ARTÍCULOS DEL ENVÍO:');
    LDoc.Add('');

    // Líneas del pedido
    LQry.Connection := dmgMain.dbConn;
    LQry.SQL.Text :=
      'SELECT descripcion, cantidad FROM ge_pedidos_lineas WHERE id_pedido = :id';
    LQry.ParamByName('id').AsInteger := FPedidoId;
    LQry.Open;
    while not LQry.Eof do
    begin
      LLine := Format('  %-40s  %8s ud.',
        [LQry.FieldByName('descripcion').AsString,
         FormatFloat('#,##0.##', LQry.FieldByName('cantidad').AsFloat)]);
      LDoc.Add(LLine);
      LQry.Next;
    end;

    LDoc.Add('');
    LDoc.Add('═══════════════════════════════════════════════════');
    LDoc.Add('Firma transportista: _____________________________');
    LDoc.Add('');
    LDoc.Add('Generado: ' + FormatDateTime('dd/mm/yyyy hh:nn:ss', Now));

    // Guardar el documento en un archivo temporal y abrirlo con el bloc de notas
    LTempFile := GetEnvironmentVariable('TEMP') +
                 '\DocCarga_' + ACodRecogida + '_' +
                 FormatDateTime('yyyymmddhhnnss', Now) + '.txt';
    LDoc.SaveToFile(LTempFile, TEncoding.UTF8);

    // Abrir con el programa predeterminado (Bloc de notas / lector de texto)
    ShellExecute(0, 'open', PChar(LTempFile), nil, nil, SW_SHOWNORMAL);

  finally
    LDoc.Free;
    LQry.Free;
  end;
end;

// ---------------------------------------------------------------------------
// Descontar stock FIFO por artículo
// ---------------------------------------------------------------------------
procedure TfrmTipsaRecogida.DescontarStock(AIdArticulo: Integer; ACantidad: Double);
begin
  // Las unidades se reservan FIFO por lote en ge_stock_envasado durante GenerarAlbaran
end;

// ---------------------------------------------------------------------------
// CONFIRMAR RECOGIDA — Acción principal
// ---------------------------------------------------------------------------
procedure TfrmTipsaRecogida.btnConfirmarClick(Sender: TObject);
var
  LCodAge, LCodCli, LPassword, LAServDef    : string;
  LNomDes, LDirDes, LNomDes2, LCPDes       : string;
  LPobDes, LTlfDes                          : string;
  LEntorno                                  : Boolean;
  LClient                                   : TTipsaClient;
  LParams                                   : TTipsaRecogidaParams;
  LResult                                   : TTipsaRecogidaResult;
  LCodServ                                  : string;
  i                                         : Integer;
begin
  if not FStockVerificado then
  begin
    ShowMessage('Debe verificar el stock antes de confirmar la recogida.');
    Exit;
  end;
  if Trim(edtNomRec.Text) = '' then
  begin
    ShowMessage('Introduzca el nombre de contacto en la dirección de recogida.');
    edtNomRec.SetFocus;
    Exit;
  end;
  if Trim(edtCPRec.Text) = '' then
  begin
    ShowMessage('Introduzca el código postal de la dirección de recogida.');
    edtCPRec.SetFocus;
    Exit;
  end;

  if MessageDlg(
    'Se va a solicitar la recogida a TIPSA, generar el albarán de venta y ' +
    'descontar el stock.¿Confirma?', mtConfirmation, [mbYes, mbNo], 0) <> mrYes then
    Exit;

  btnConfirmar.Enabled := False;
  lblInfoResult.Caption := '⏳ Procesando...';
  lblInfoResult.Font.Color := clGray;
  Application.ProcessMessages;

  // Extraer código de servicio del combo
  LCodServ := Copy(cbTipoServ.Items[cbTipoServ.ItemIndex], 1, 2);

  // Cargar configuración TIPSA
  try
    LoadConfigTipsa(LCodAge, LCodCli, LPassword, LEntorno,
                    LNomDes, LDirDes, LNomDes2, LCPDes, LPobDes, LTlfDes,
                    LAServDef);
  except
    on E: Exception do
    begin
      lblInfoResult.Caption := '❌ ' + E.Message;
      lblInfoResult.Font.Color := clRed;
      btnConfirmar.Enabled := True;
      Exit;
    end;
  end;

  // Construir parámetros de recogida
  LParams.NomOri       := Trim(edtNomRec.Text);
  LParams.TipoViaOri   := 'Calle';
  LParams.DirOri       := Trim(edtDirRec.Text);
  LParams.NumOri       := Trim(edtNumRec.Text);
  LParams.CPOri        := Trim(edtCPRec.Text);
  LParams.TlfOri       := Trim(edtTlfRec.Text);

  LParams.NomDes       := LNomDes;       // empresa = destino en TIPSA
  LParams.TipoViaDes   := 'Calle';
  LParams.DirDes       := LDirDes;
  LParams.NumDes       := '';
  LParams.PobDes       := LPobDes;
  LParams.CPDes        := LCPDes;
  LParams.TlfDes       := LTlfDes;

  LParams.FechaRecogida := dtpFechaRec.Date;
  LParams.HoraIni       := dtpFechaRec.Date + dtpHoraIni.Time;
  LParams.HoraFin       := dtpFechaRec.Date + dtpHoraFin.Time;
  LParams.NumBultos     := spnBultos.Value;
  LParams.CodTipoServ   := LCodServ;
  LParams.Referencia    := edtPedidoNum.Text;
  LParams.PersContacto  := Trim(edtContacto.Text);
  LParams.Observaciones := Trim(memObs.Text);
  LParams.ObsDes        := '';
  LParams.Contenido     := 'Pedido ' + edtPedidoNum.Text;

  // Iniciar transacción
  dmgMain.dbConn.StartTransaction;
  try
    // 1. Llamar a GrabaRecogida10 en TIPSA
    lblInfoResult.Caption := '⏳ Contactando con TIPSA...';
    Application.ProcessMessages;

    LClient := TTipsaClient.Create(LCodAge, LCodCli, LPassword, LEntorno);
    try
      LClient.Login;
      LResult := LClient.GrabaRecogida(LParams);

      if not LResult.OK then
        raise Exception.CreateFmt('Error TIPSA: %s', [LResult.Error]);

      // 2. Generar Albarán de Venta
      lblInfoResult.Caption := '⏳ Generando albarán de venta...';
      Application.ProcessMessages;

      FAlbaranId := GenerarAlbaran(LResult.CodRecogida);
      if FAlbaranId = 0 then
        raise Exception.Create('No se pudo generar el albarán de venta.');

      // 3. Descontar stock FIFO por artículo
      lblInfoResult.Caption := '⏳ Descontando stock...';
      Application.ProcessMessages;

      for i := 0 to Length(FLineasStock) - 1 do
        DescontarStock(FLineasStock[i].IdArticulo, FLineasStock[i].CantPedida);

      // 4. Guardar recogida en ge_tipsa_recogidas
      dmgMain.qryExec.Close;
      dmgMain.qryExec.SQL.Text :=
        'INSERT INTO ge_tipsa_recogidas ' +
        '(pedido_id, albaran_id, empresa_id, cod_recogida_tipsa, guid_tipsa, ' +
        ' cod_tipo_servicio, num_bultos, fecha_recogida, hora_ini, hora_fin, ' +
        ' persona_contacto, referencia, observaciones, estado_codigo, estado_descripcion, ' +
        ' id_user_creator, id_user_update) ' +
        'VALUES (:ped, :alb, :emp, :cod, :guid, ' +
        '        :serv, :bul, :frec, :hini, :hfin, ' +
        '        :cont, :ref, :obs, 0, ''Documentado'', ' +
        '        :user, :user)';
      dmgMain.qryExec.ParamByName('ped').AsInteger  := FPedidoId;
      dmgMain.qryExec.ParamByName('alb').AsInteger  := FAlbaranId;
      dmgMain.qryExec.ParamByName('emp').AsInteger  := FEmpresaId;
      dmgMain.qryExec.ParamByName('cod').AsString   := LResult.CodRecogida;
      dmgMain.qryExec.ParamByName('guid').AsString  := LResult.GuidTipsa;
      dmgMain.qryExec.ParamByName('serv').AsString  := LCodServ;
      dmgMain.qryExec.ParamByName('bul').AsInteger  := spnBultos.Value;
      dmgMain.qryExec.ParamByName('frec').AsDate    := dtpFechaRec.Date;
      dmgMain.qryExec.ParamByName('hini').AsTime    := dtpHoraIni.Time;
      dmgMain.qryExec.ParamByName('hfin').AsTime    := dtpHoraFin.Time;
      dmgMain.qryExec.ParamByName('cont').AsString  := Trim(edtContacto.Text);
      dmgMain.qryExec.ParamByName('ref').AsString   := edtPedidoNum.Text;
      dmgMain.qryExec.ParamByName('obs').AsString   := Trim(memObs.Text);
      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;
      FRecogidaId := dmgMain.qryExec.FieldByName('id').AsInteger;
      dmgMain.qryExec.Close;

      // 5. Actualizar estado del pedido a EN PREPARACION
      dmgMain.qryExec.SQL.Text :=
        'UPDATE ge_pedidos SET estado = ''EN PREPARACION'', ' +
        'updated_at = NOW(), id_user_update = :user WHERE id = :id';
      dmgMain.qryExec.ParamByName('user').AsString  := dmgMain.CurrentUserId;
      dmgMain.qryExec.ParamByName('id').AsInteger   := FPedidoId;
      dmgMain.qryExec.ExecSQL;

      // Confirmar transacción
      dmgMain.dbConn.Commit;

      lblInfoResult.Caption :=
        '✅ Recogida confirmada. Nº TIPSA: ' + LResult.CodRecogida +
        ' | Albarán: ' + IntToStr(FAlbaranId);
      lblInfoResult.Font.Color := clGreen;

      // 6. Mostrar Documento de Carga
      MostrarDocumentoCarga(LResult.CodRecogida);

      // 7. Descargar y abrir etiqueta PDF de TIPSA
      try
        lblInfoResult.Caption := lblInfoResult.Caption + ' | ⏳ Descargando etiqueta...';
        Application.ProcessMessages;
        var LEtiquetaBytes := LClient.GetEtiqueta(LResult.CodRecogida, True);
        if Length(LEtiquetaBytes) > 0 then
        begin
          var LPdfFile := GetEnvironmentVariable('TEMP') + '\Etiqueta_TIPSA_' +
                          StringReplace(LResult.CodRecogida, '/', '_', [rfReplaceAll]) +
                          '_' + FormatDateTime('yyyymmddhhnnss', Now) + '.pdf';
          var LFS := TFileStream.Create(LPdfFile, fmCreate);
          try
            LFS.WriteBuffer(LEtiquetaBytes[0], Length(LEtiquetaBytes));
          finally
            LFS.Free;
          end;
          TPdfRotateService.RotarPdfArchivo180(LPdfFile);
          ShellExecute(0, 'open', PChar(LPdfFile), nil, nil, SW_SHOWNORMAL);
          lblInfoResult.Caption := StringReplace(lblInfoResult.Caption,
            '⏳ Descargando etiqueta...', '🏷️ Etiqueta abierta', []);
        end;
      except
        on E: Exception do
          ShowMessage('Aviso: No se pudo descargar la etiqueta de TIPSA.' + #13#10 +
                      'La recogida se ha registrado correctamente.' + #13#10 +
                      'Error: ' + E.Message);
      end;

      ModalResult := mrOk;

    finally
      LClient.Free;
    end;

  except
    on E: Exception do
    begin
      dmgMain.dbConn.Rollback;
      FAlbaranId  := 0;
      FRecogidaId := 0;
      lblInfoResult.Caption := '❌ Error: ' + E.Message;
      lblInfoResult.Font.Color := clRed;
      btnConfirmar.Enabled := True;
      TDbErrorHandler.HandleException(E, 'Error al confirmar recogida TIPSA');
    end;
  end;
end;

// ---------------------------------------------------------------------------
procedure TfrmTipsaRecogida.btnCancelarClick(Sender: TObject);
begin
  Close;
end;

end.
