unit frm_ClientesPrecios;

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, System.Math, Vcl.Graphics,
  Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls, Vcl.Buttons, Vcl.Grids, Vcl.DBGrids,
  Vcl.ExtCtrls, 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, uAppTheme, uBaseForm, FireDAC.Comp.DataSet;

type
  TfrmClientesPrecios = class(TForm)
    pnlBottom: TPanel;
    btnCerrar: TBitBtn;
    pnlTop: TPanel;
    lblCliente: TLabel;
    lblClienteNombre: TLabel;
    lblGrupo: TLabel;
    lblGrupoNombre: TLabel;
    lblFiltroAno: TLabel;
    cbbFiltroAno: TComboBox;
    lblFiltroOrigen: TLabel;
    cbbFiltroOrigen: TComboBox;
    lblFiltroFamilia: TLabel;
    cbbFiltroFamilia: TComboBox;
    pnlMain: TPanel;
    dbgPrecios: TDBGrid;
    btnAyuda: TBitBtn;
    dsPrecios: TDataSource;
    qryPrecios: TFDQuery;
    btnEliminar: TSpeedButton;
    btnAnadir: TSpeedButton;
    btnImprimir: TSpeedButton;
    btnHistorico: TSpeedButton;
    procedure FormShow(Sender: TObject);
    procedure btnCerrarClick(Sender: TObject);
    procedure btnAyudaClick(Sender: TObject);
    procedure btnAnadirClick(Sender: TObject);
    procedure btnEliminarClick(Sender: TObject);
    procedure btnImprimirClick(Sender: TObject);
    procedure btnHistoricoClick(Sender: TObject);
    procedure cbbFiltroAnoChange(Sender: TObject);
    procedure cbbFiltroOrigenChange(Sender: TObject);
    procedure cbbFiltroFamiliaChange(Sender: TObject);
    procedure qryPreciosCalcFields(DataSet: TDataSet);
    procedure DateFieldSetText(Sender: TField; const Text: string);
    procedure PrecioBaseChange(Sender: TField);
    procedure PrecioConIvaChange(Sender: TField);
    procedure DescuentoPorcentajeChange(Sender: TField);
    procedure dbgPreciosKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
    procedure dbgPreciosDrawColumnCell(Sender: TObject; const Rect: TRect;
      DataCol: Integer; Column: TColumn; State: TGridDrawState);
    procedure qryPreciosAfterPost(DataSet: TDataSet);
    procedure qryPreciosUpdateRecord(ASender: TDataSet; ARequest: TFDUpdateRequest;
      var AAction: TFDErrorAction; AOptions: TFDUpdateRowOptions);
  private
    FClienteId: string;
    FClienteNombre: string;
    FClienteEmail: string;
    FClienteTelefono: string;
    FGrupoId: string;
    FGrupoNombre: string;
    FUpdatingCalcField: Boolean;
    procedure DetectarGrupoCliente;
    procedure ActualizarAnosFiltro;
    procedure CargarFamilias;
    procedure AplicarFiltros;
    procedure LoadPrecios;
    procedure ConfigureColumns;
  public
    property ClienteId: string read FClienteId write FClienteId;
    property ClienteNombre: string read FClienteNombre write FClienteNombre;
    property ClienteEmail: string read FClienteEmail write FClienteEmail;
    property ClienteTelefono: string read FClienteTelefono write FClienteTelefono;
    property GrupoId: string read FGrupoId write FGrupoId;
    property GrupoNombre: string read FGrupoNombre write FGrupoNombre;
  end;

var
  frmClientesPrecios: TfrmClientesPrecios;

implementation

uses
  dmg_Main, uDbErrorHandler, frm_SelectProduct, System.UITypes, frm_PrintOptions, frxClass,
  frm_VentasPreciosHistorico;

{$R *.dfm}

procedure TfrmClientesPrecios.FormShow(Sender: TObject);
begin
  TAppTheme.ApplyToForm(Self);

  DetectarGrupoCliente;

  lblClienteNombre.Caption := FClienteNombre;

  if (FGrupoId <> '') and (FGrupoId <> '0') then
  begin
    lblGrupoNombre.Caption := FGrupoNombre + ' (ID ' + FGrupoId + ')';
    lblGrupoNombre.Font.Color := clWindowText;
    lblGrupoNombre.Font.Style := [fsBold];
  end
  else
  begin
    lblGrupoNombre.Caption := '[Sin grupo asignado]';
    lblGrupoNombre.Font.Color := clGray;
    lblGrupoNombre.Font.Style := [];
  end;

  // Cargar opciones del combo de filtrado de origen (Tag = 999 para no disparar change tracking)
  cbbFiltroOrigen.Items.Clear;
  cbbFiltroOrigen.Items.Add('Todos los Precios (Exclusivos y Grupo)');
  cbbFiltroOrigen.Items.Add('Solo Precios Exclusivos');
  if (FGrupoId <> '') and (FGrupoId <> '0') then
    cbbFiltroOrigen.Items.Add('Solo Precios del Grupo (' + FGrupoNombre + ')')
  else
    cbbFiltroOrigen.Items.Add('Solo Precios del Grupo');
  cbbFiltroOrigen.ItemIndex := 0;

  // Cargar combo de familias de artículo (Tag = 999)
  CargarFamilias;

  qryPrecios.Close;
  qryPrecios.Connection := dmgMain.dbConn;
  qryPrecios.CachedUpdates := True;
  qryPrecios.OnUpdateRecord := qryPreciosUpdateRecord;
  qryPrecios.AfterPost := qryPreciosAfterPost;

  dbgPrecios.OnKeyDown := dbgPreciosKeyDown;
  dbgPrecios.OnDrawColumnCell := dbgPreciosDrawColumnCell;

  ActualizarAnosFiltro;
  LoadPrecios;
  ConfigureColumns;
end;

procedure TfrmClientesPrecios.DetectarGrupoCliente;
var
  LQry: TFDQuery;
  LIdInt: Integer;
begin
  if FClienteId = '' then Exit;

  // Si ya se especificaron todos los datos desde fuera, no es necesario consultar
  if (FGrupoId <> '') and (FGrupoNombre <> '') and (FClienteEmail <> '') and (FClienteTelefono <> '') then Exit;

  LQry := TFDQuery.Create(nil);
  try
    LQry.Connection := dmgMain.dbConn;
    LQry.SQL.Text :=
      'SELECT c.clientesgrupo_id, g.nombre AS grupo_nombre, c.email, c.telefono ' +
      'FROM ge_clientes c ' +
      'LEFT JOIN ge_clientes_grupos g ON c.clientesgrupo_id = g.id ' +
      'WHERE c.id = :cli';

    if TryStrToInt(FClienteId, LIdInt) then
      LQry.ParamByName('cli').AsInteger := LIdInt
    else
      LQry.ParamByName('cli').AsString := FClienteId;

    LQry.Open;
    if not LQry.IsEmpty then
    begin
      if (FClienteEmail = '') and not LQry.FieldByName('email').IsNull then
        FClienteEmail := LQry.FieldByName('email').AsString;
      if (FClienteTelefono = '') and not LQry.FieldByName('telefono').IsNull then
        FClienteTelefono := LQry.FieldByName('telefono').AsString;

      if (FGrupoId = '') and not LQry.FieldByName('clientesgrupo_id').IsNull then
      begin
        if LQry.FieldByName('clientesgrupo_id').AsInteger > 0 then
        begin
          FGrupoId := LQry.FieldByName('clientesgrupo_id').AsString;
          FGrupoNombre := LQry.FieldByName('grupo_nombre').AsString;
        end
        else
        begin
          FGrupoId := '';
          FGrupoNombre := '';
        end;
      end;
    end;
  finally
    LQry.Free;
  end;
end;

procedure TfrmClientesPrecios.ActualizarAnosFiltro;
var
  Qry: TFDQuery;
  LSelectedText: string;
  LAno: Integer;
  LHasNullDates: Boolean;
  LClienteIdInt, LGrupoIdInt: Integer;
  LHasGroup: Boolean;
  LCurrentYear, LDummy: Word;
begin
  if not Assigned(cbbFiltroAno) then Exit;

  LSelectedText := cbbFiltroAno.Text;
  cbbFiltroAno.Items.BeginUpdate;
  try
    cbbFiltroAno.Items.Clear;
    cbbFiltroAno.Items.Add('(Todos los ejercicios)');

    LHasGroup := (FGrupoId <> '') and (FGrupoId <> '0');
    LHasNullDates := False;

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

      // Consultar años de ge_clientes_precios y ge_clientes_grupos_precios (si aplica)
      if LHasGroup then
      begin
        Qry.SQL.Text :=
          'SELECT DISTINCT YEAR(valido_desde) as ano ' +
          'FROM ge_clientes_precios ' +
          'WHERE id_cliente = :cli AND valido_desde IS NOT NULL ' +
          'UNION ' +
          'SELECT DISTINCT YEAR(valido_desde) as ano ' +
          'FROM ge_clientes_grupos_precios ' +
          'WHERE id_cliente_grupo = :grp AND valido_desde IS NOT NULL ' +
          'ORDER BY ano DESC';
        if TryStrToInt(FClienteId, LClienteIdInt) then
          Qry.ParamByName('cli').AsInteger := LClienteIdInt
        else
          Qry.ParamByName('cli').AsString := FClienteId;

        if TryStrToInt(FGrupoId, LGrupoIdInt) then
          Qry.ParamByName('grp').AsInteger := LGrupoIdInt
        else
          Qry.ParamByName('grp').AsString := FGrupoId;
      end
      else
      begin
        Qry.SQL.Text :=
          'SELECT DISTINCT YEAR(valido_desde) as ano ' +
          'FROM ge_clientes_precios ' +
          'WHERE id_cliente = :cli AND valido_desde IS NOT NULL ' +
          'ORDER BY ano DESC';
        if TryStrToInt(FClienteId, LClienteIdInt) then
          Qry.ParamByName('cli').AsInteger := LClienteIdInt
        else
          Qry.ParamByName('cli').AsString := FClienteId;
      end;

      Qry.Open;
      while not Qry.Eof do
      begin
        LAno := Qry.FieldByName('ano').AsInteger;
        if LAno > 1900 then
        begin
          if cbbFiltroAno.Items.IndexOf(IntToStr(LAno)) < 0 then
            cbbFiltroAno.Items.Add(IntToStr(LAno));
        end;
        Qry.Next;
      end;

      // Comprobar si hay precios con fechas nulas
      Qry.Close;
      if LHasGroup then
      begin
        Qry.SQL.Text :=
          'SELECT COUNT(*) FROM ge_clientes_precios ' +
          'WHERE id_cliente = :cli AND (valido_desde IS NULL OR valido_hasta IS NULL) ' +
          'UNION ALL ' +
          'SELECT COUNT(*) FROM ge_clientes_grupos_precios ' +
          'WHERE id_cliente_grupo = :grp AND (valido_desde IS NULL OR valido_hasta IS NULL)';
        if TryStrToInt(FClienteId, LClienteIdInt) then
          Qry.ParamByName('cli').AsInteger := LClienteIdInt
        else
          Qry.ParamByName('cli').AsString := FClienteId;
        if TryStrToInt(FGrupoId, LGrupoIdInt) then
          Qry.ParamByName('grp').AsInteger := LGrupoIdInt
        else
          Qry.ParamByName('grp').AsString := FGrupoId;
      end
      else
      begin
        Qry.SQL.Text :=
          'SELECT COUNT(*) FROM ge_clientes_precios ' +
          'WHERE id_cliente = :cli AND (valido_desde IS NULL OR valido_hasta IS NULL)';
        if TryStrToInt(FClienteId, LClienteIdInt) then
          Qry.ParamByName('cli').AsInteger := LClienteIdInt
        else
          Qry.ParamByName('cli').AsString := FClienteId;
      end;

      Qry.Open;
      while not Qry.Eof do
      begin
        if Qry.Fields[0].AsInteger > 0 then
          LHasNullDates := True;
        Qry.Next;
      end;

      if LHasNullDates then
        cbbFiltroAno.Items.Add('(Sin fechas definidas)');
    finally
      Qry.Free;
    end;

    DecodeDate(Date, LCurrentYear, LDummy, LDummy);
    if (LSelectedText <> '') and (cbbFiltroAno.Items.IndexOf(LSelectedText) >= 0) then
      cbbFiltroAno.ItemIndex := cbbFiltroAno.Items.IndexOf(LSelectedText)
    else if cbbFiltroAno.Items.IndexOf(IntToStr(LCurrentYear)) >= 0 then
      cbbFiltroAno.ItemIndex := cbbFiltroAno.Items.IndexOf(IntToStr(LCurrentYear))
    else if cbbFiltroAno.Items.Count > 1 then
      cbbFiltroAno.ItemIndex := 1
    else
      cbbFiltroAno.ItemIndex := 0;
  finally
    cbbFiltroAno.Items.EndUpdate;
  end;
end;

procedure TfrmClientesPrecios.CargarFamilias;
var
  LQry: TFDQuery;
  LPrevId, LIdx: Integer;
begin
  if not Assigned(cbbFiltroFamilia) then Exit;

  LPrevId := 0;
  if (cbbFiltroFamilia.ItemIndex > 0) and (cbbFiltroFamilia.Items.Objects[cbbFiltroFamilia.ItemIndex] <> nil) then
    LPrevId := Integer(cbbFiltroFamilia.Items.Objects[cbbFiltroFamilia.ItemIndex]);

  cbbFiltroFamilia.Items.BeginUpdate;
  try
    cbbFiltroFamilia.Items.Clear;
    cbbFiltroFamilia.Items.AddObject('(Todas las familias)', TObject(NativeInt(0)));

    LQry := TFDQuery.Create(nil);
    try
      LQry.Connection := dmgMain.dbConn;
      LQry.SQL.Text := 'SELECT id, descripcion FROM ge_familias WHERE activo = 1 ORDER BY descripcion';
      LQry.Open;
      while not LQry.Eof do
      begin
        cbbFiltroFamilia.Items.AddObject(LQry.FieldByName('descripcion').AsString,
          TObject(NativeInt(LQry.FieldByName('id').AsInteger)));
        LQry.Next;
      end;
    finally
      LQry.Free;
    end;

    cbbFiltroFamilia.Items.AddObject('(Sin familia asignada)', TObject(NativeInt(-1)));

    cbbFiltroFamilia.ItemIndex := 0;
    if LPrevId <> 0 then
    begin
      for LIdx := 1 to cbbFiltroFamilia.Items.Count - 1 do
      begin
        if Integer(cbbFiltroFamilia.Items.Objects[LIdx]) = LPrevId then
        begin
          cbbFiltroFamilia.ItemIndex := LIdx;
          Break;
        end;
      end;
    end;
  finally
    cbbFiltroFamilia.Items.EndUpdate;
  end;
end;

procedure TfrmClientesPrecios.cbbFiltroAnoChange(Sender: TObject);
begin
  LoadPrecios;
end;

procedure TfrmClientesPrecios.LoadPrecios;
var
  LIdInt, LGrupoIdInt: Integer;
  LSQL: string;
  LHasGroup: Boolean;
  LFiltroAno: Integer;
  LWhereAnoCli, LWhereAnoGrp: string;
begin
  LHasGroup := (FGrupoId <> '') and (FGrupoId <> '0');

  // Filtro por año de tarifas
  LFiltroAno := 0;
  LWhereAnoCli := '';
  LWhereAnoGrp := '';
  if Assigned(cbbFiltroAno) and TryStrToInt(Trim(cbbFiltroAno.Text), LFiltroAno) then
  begin
    LWhereAnoCli := '  AND ((YEAR(cp.valido_desde) = ' + IntToStr(LFiltroAno) + ') ' +
                    '       OR (cp.valido_desde IS NULL AND YEAR(cp.valido_hasta) = ' + IntToStr(LFiltroAno) + ')) ';
    LWhereAnoGrp := '  AND ((YEAR(cgp.valido_desde) = ' + IntToStr(LFiltroAno) + ') ' +
                    '       OR (cgp.valido_desde IS NULL AND YEAR(cgp.valido_hasta) = ' + IntToStr(LFiltroAno) + ')) ';
  end
  else if Assigned(cbbFiltroAno) and (Pos('Sin fecha', cbbFiltroAno.Text) > 0) then
  begin
    LWhereAnoCli := '  AND (cp.valido_desde IS NULL AND cp.valido_hasta IS NULL) ';
    LWhereAnoGrp := '  AND (cgp.valido_desde IS NULL AND cgp.valido_hasta IS NULL) ';
  end;

  // Consulta que unifica precios exclusivos de cliente y precios del grupo (si aplica)
  // Se aplica CONVERT(... USING utf8mb4) COLLATE utf8mb4_general_ci en cadenas según la directiva de UNION
  LSQL :=
    'SELECT ' +
    '  cp.id, ' +
    '  1 AS origen_tipo, ' +
    '  CONVERT(''Exclusivo'' USING utf8mb4) COLLATE utf8mb4_general_ci AS origen, ' +
    '  cp.id_cliente, ' +
    '  0 AS id_cliente_grupo, ' +
    '  cp.id_articulo, ' +
    '  cp.precio, ' +
    '  cp.valido_desde, ' +
    '  cp.valido_hasta, ' +
    '  CAST(NULL AS SIGNED) AS und_min, ' +
    '  CAST(NULL AS SIGNED) AS und_max, ' +
    '  a.id AS codigo, ' +
    '  CONVERT(a.descripcion USING utf8mb4) COLLATE utf8mb4_general_ci AS articulo_desc, ' +
    '  a.id_familia, ' +
    '  CONVERT(COALESCE(fam.descripcion, '''') USING utf8mb4) COLLATE utf8mb4_general_ci AS familia_desc, ' +
    '  a.pvp1 AS tarifa_pvp1, ' +
    '  COALESCE(t.tipo, 10.0) AS tipo_iva, ' +
    '  ROUND(cp.precio * (1 + (COALESCE(t.tipo, 10.00) / 100)), 2) AS precio_con_iva, ' +
    '  CASE WHEN a.pvp1 IS NOT NULL AND a.pvp1 > 0 ' +
    '       THEN ROUND(((a.pvp1 - cp.precio) / a.pvp1) * 100, 2) ' +
    '       ELSE 0.00 END AS descuento_pct ' +
    'FROM ge_clientes_precios cp ' +
    'INNER JOIN ge_articulos a ON cp.id_articulo = a.id ' +
    'LEFT JOIN ge_familias fam ON a.id_familia = fam.id ' +
    'LEFT JOIN ge_tipos_iva t ON a.id_tipo_iva = t.id ' +
    'WHERE cp.id_cliente = :id_cliente ' + LWhereAnoCli;

  if LHasGroup then
  begin
    LSQL := LSQL +
      'UNION ALL ' +
      'SELECT ' +
      '  cgp.id, ' +
      '  2 AS origen_tipo, ' +
      '  CONVERT(CONCAT(''Grupo ('', COALESCE(g.nombre, ''Grupo''), '')'') USING utf8mb4) COLLATE utf8mb4_general_ci AS origen, ' +
      '  0 AS id_cliente, ' +
      '  cgp.id_cliente_grupo, ' +
      '  cgp.id_articulo, ' +
      '  cgp.precio, ' +
      '  cgp.valido_desde, ' +
      '  cgp.valido_hasta, ' +
      '  cgp.und_min, ' +
      '  cgp.und_max, ' +
      '  a.id AS codigo, ' +
      '  CONVERT(a.descripcion USING utf8mb4) COLLATE utf8mb4_general_ci AS articulo_desc, ' +
      '  a.id_familia, ' +
      '  CONVERT(COALESCE(fam.descripcion, '''') USING utf8mb4) COLLATE utf8mb4_general_ci AS familia_desc, ' +
      '  a.pvp1 AS tarifa_pvp1, ' +
      '  COALESCE(t.tipo, 10.0) AS tipo_iva, ' +
      '  ROUND(cgp.precio * (1 + (COALESCE(t.tipo, 10.00) / 100)), 2) AS precio_con_iva, ' +
      '  CASE WHEN a.pvp1 IS NOT NULL AND a.pvp1 > 0 ' +
      '       THEN ROUND(((a.pvp1 - cgp.precio) / a.pvp1) * 100, 2) ' +
      '       ELSE 0.00 END AS descuento_pct ' +
      'FROM ge_clientes_grupos_precios cgp ' +
      'INNER JOIN ge_clientes_grupos g ON cgp.id_cliente_grupo = g.id ' +
      'INNER JOIN ge_articulos a ON cgp.id_articulo = a.id ' +
      'LEFT JOIN ge_familias fam ON a.id_familia = fam.id ' +
      'LEFT JOIN ge_tipos_iva t ON a.id_tipo_iva = t.id ' +
      'WHERE cgp.id_cliente_grupo = :id_grupo ' + LWhereAnoGrp;
  end;

  LSQL := LSQL + 'ORDER BY articulo_desc, origen_tipo, und_min, valido_desde';

  qryPrecios.Close;
  qryPrecios.SQL.Text := LSQL;

  if TryStrToInt(FClienteId, LIdInt) then
    qryPrecios.ParamByName('id_cliente').AsInteger := LIdInt
  else
    qryPrecios.ParamByName('id_cliente').AsString := FClienteId;

  if LHasGroup then
  begin
    if TryStrToInt(FGrupoId, LGrupoIdInt) then
      qryPrecios.ParamByName('id_grupo').AsInteger := LGrupoIdInt
    else
      qryPrecios.ParamByName('id_grupo').AsString := FGrupoId;
  end;

  qryPrecios.Open;

  if qryPrecios.FindField('tarifa_pvp1') is TNumericField then
    TNumericField(qryPrecios.FieldByName('tarifa_pvp1')).DisplayFormat := '#,##0.00';

  if qryPrecios.FindField('precio') is TNumericField then
  begin
    TNumericField(qryPrecios.FieldByName('precio')).DisplayFormat := '#,##0.00';
    qryPrecios.FieldByName('precio').OnChange := PrecioBaseChange;
  end;

  if qryPrecios.FindField('und_min') is TNumericField then
    TNumericField(qryPrecios.FieldByName('und_min')).DisplayFormat := '#,##0';

  if qryPrecios.FindField('und_max') is TNumericField then
    TNumericField(qryPrecios.FieldByName('und_max')).DisplayFormat := '#,##0';

  if qryPrecios.FindField('descuento_pct') is TNumericField then
  begin
    TNumericField(qryPrecios.FieldByName('descuento_pct')).DisplayFormat := '0.00 %';
    qryPrecios.FieldByName('descuento_pct').ProviderFlags := [];
    qryPrecios.FieldByName('descuento_pct').ReadOnly := False;
    qryPrecios.FieldByName('descuento_pct').OnChange := DescuentoPorcentajeChange;
  end;

  if qryPrecios.FindField('precio_con_iva') is TNumericField then
  begin
    TNumericField(qryPrecios.FieldByName('precio_con_iva')).DisplayFormat := '#,##0.00';
    qryPrecios.FieldByName('precio_con_iva').ProviderFlags := [];
    qryPrecios.FieldByName('precio_con_iva').ReadOnly := False;
    qryPrecios.FieldByName('precio_con_iva').OnChange := PrecioConIvaChange;
  end;

  if qryPrecios.FindField('valido_desde') is TDateTimeField then
  begin
    TDateTimeField(qryPrecios.FieldByName('valido_desde')).DisplayFormat := 'dd/mm/yyyy';
    TDateTimeField(qryPrecios.FieldByName('valido_desde')).EditMask := '!99/99/9999;1; ';
    TDateTimeField(qryPrecios.FieldByName('valido_desde')).OnSetText := DateFieldSetText;
  end;
  if qryPrecios.FindField('valido_hasta') is TDateTimeField then
  begin
    TDateTimeField(qryPrecios.FieldByName('valido_hasta')).DisplayFormat := 'dd/mm/yyyy';
    TDateTimeField(qryPrecios.FieldByName('valido_hasta')).EditMask := '!99/99/9999;1; ';
    TDateTimeField(qryPrecios.FieldByName('valido_hasta')).OnSetText := DateFieldSetText;
  end;

  AplicarFiltros;
end;

procedure TfrmClientesPrecios.ConfigureColumns;
begin
  dbgPrecios.Columns.Clear;
  with dbgPrecios.Columns.Add do
  begin
    FieldName := 'origen';
    Title.Caption := 'Origen / Tipo';
    Width := 130;
    ReadOnly := True;
  end;
  with dbgPrecios.Columns.Add do
  begin
    FieldName := 'codigo';
    Title.Caption := 'Ref. Art.';
    Width := 65;
    ReadOnly := True;
  end;
  with dbgPrecios.Columns.Add do
  begin
    FieldName := 'articulo_desc';
    Title.Caption := 'Artículo';
    Width := 195;
    ReadOnly := True;
  end;
  with dbgPrecios.Columns.Add do
  begin
    FieldName := 'und_min';
    Title.Caption := 'Und. Mín.';
    Width := 65;
  end;
  with dbgPrecios.Columns.Add do
  begin
    FieldName := 'und_max';
    Title.Caption := 'Und. Máx.';
    Width := 65;
  end;
  with dbgPrecios.Columns.Add do
  begin
    FieldName := 'tarifa_pvp1';
    Title.Caption := 'Tarifa Base';
    Width := 80;
    ReadOnly := True;
  end;
  with dbgPrecios.Columns.Add do
  begin
    FieldName := 'precio';
    Title.Caption := 'Precio Sin IVA';
    Width := 90;
  end;
  with dbgPrecios.Columns.Add do
  begin
    FieldName := 'descuento_pct';
    Title.Caption := '% Dto.';
    Width := 65;
  end;
  with dbgPrecios.Columns.Add do
  begin
    FieldName := 'precio_con_iva';
    Title.Caption := 'Precio Con IVA';
    Width := 95;
  end;
  with dbgPrecios.Columns.Add do
  begin
    FieldName := 'valido_desde';
    Title.Caption := 'Válido Desde';
    Width := 85;
  end;
  with dbgPrecios.Columns.Add do
  begin
    FieldName := 'valido_hasta';
    Title.Caption := 'Válido Hasta';
    Width := 85;
  end;
end;

procedure TfrmClientesPrecios.cbbFiltroOrigenChange(Sender: TObject);
begin
  AplicarFiltros;
end;

procedure TfrmClientesPrecios.cbbFiltroFamiliaChange(Sender: TObject);
begin
  AplicarFiltros;
end;

procedure TfrmClientesPrecios.AplicarFiltros;
var
  LFilter: string;
  LFamiliaId: Integer;
begin
  if not qryPrecios.Active then Exit;

  LFilter := '';

  // 1. Filtro por origen de precio (Exclusivo vs Grupo)
  case cbbFiltroOrigen.ItemIndex of
    1: LFilter := '(origen_tipo = 1)';
    2: LFilter := '(origen_tipo = 2)';
  end;

  // 2. Filtro por familia de artículo
  if Assigned(cbbFiltroFamilia) and (cbbFiltroFamilia.ItemIndex > 0) then
  begin
    LFamiliaId := Integer(cbbFiltroFamilia.Items.Objects[cbbFiltroFamilia.ItemIndex]);
    if LFamiliaId > 0 then
    begin
      if LFilter <> '' then
        LFilter := LFilter + ' AND (id_familia = ' + IntToStr(LFamiliaId) + ')'
      else
        LFilter := '(id_familia = ' + IntToStr(LFamiliaId) + ')';
    end
    else if LFamiliaId = -1 then
    begin
      if LFilter <> '' then
        LFilter := LFilter + ' AND ((id_familia IS NULL) OR (id_familia = 0))'
      else
        LFilter := '((id_familia IS NULL) OR (id_familia = 0))';
    end;
  end;

  qryPrecios.DisableControls;
  try
    if LFilter <> '' then
    begin
      qryPrecios.Filter := LFilter;
      qryPrecios.Filtered := True;
    end
    else
    begin
      qryPrecios.Filter := '';
      qryPrecios.Filtered := False;
    end;
  finally
    qryPrecios.EnableControls;
  end;
end;

procedure TfrmClientesPrecios.dbgPreciosDrawColumnCell(Sender: TObject; const Rect: TRect;
  DataCol: Integer; Column: TColumn; State: TGridDrawState);
var
  LGrid: TDBGrid;
  LIsGroup: Boolean;
begin
  LGrid := Sender as TDBGrid;
  if not Assigned(LGrid.DataSource) or not Assigned(LGrid.DataSource.DataSet) or
     not LGrid.DataSource.DataSet.Active or LGrid.DataSource.DataSet.IsEmpty then
  begin
    LGrid.DefaultDrawColumnCell(Rect, DataCol, Column, State);
    Exit;
  end;

  LIsGroup := (LGrid.DataSource.DataSet.FindField('origen_tipo') <> nil) and
              (LGrid.DataSource.DataSet.FieldByName('origen_tipo').AsInteger = 2);

  // Si la fila no está seleccionada, resaltar suavemente los precios que provienen del grupo
  if not (gdSelected in State) then
  begin
    if LIsGroup then
    begin
      LGrid.Canvas.Brush.Color := $00FFF5EA; // Fondo azul grisáceo tenue y elegante
      LGrid.Canvas.Font.Color := $00804000;  // Texto azul marino corporativo
    end
    else
    begin
      LGrid.Canvas.Brush.Color := clWindow;
      LGrid.Canvas.Font.Color := clWindowText;
    end;
  end;

  // Enfatizar en negrita la columna de origen
  if SameText(Column.FieldName, 'origen') then
    LGrid.Canvas.Font.Style := LGrid.Canvas.Font.Style + [fsBold];

  LGrid.DefaultDrawColumnCell(Rect, DataCol, Column, State);
end;

procedure TfrmClientesPrecios.btnImprimirClick(Sender: TObject);
var
  LReportName, LReportPath: string;
  LMemTable: TFDMemTable;
  LBookmark: TBookmark;
  LObjTitulo, LObjEmp, LImgObj: TfrxComponent;
  LDesc, LBaseName, LTier, LNormKey: string;
  LPrecio, LTipoIva, LPrecioConIva, LPvp: Double;
  QryEmp: TFDQuery;
  LEmpId, LEmpTexto, LLogoPath: string;
  LStream: TMemoryStream;
  LFiltroAno: Integer;
  LTituloSufijo: string;

  procedure ParseArticleDesc(const ADesc: string; out ABaseName, ATier, ANormKey: string);
  var
    S, SUpper: string;
    P, J: Integer;
  begin
    S := Trim(ADesc);
    SUpper := UpperCase(S);
    ATier := '';

    // 1. Detectar XL o L
    if (Pos(' XL', SUpper) > 0) or (Pos('XL ', SUpper) > 0) or (Pos('(XL', SUpper) > 0) then
      ATier := 'XL'
    else if (Pos(' L', SUpper) > 0) or (Pos('L ', SUpper) > 0) or (Pos('(L', SUpper) > 0) then
      ATier := 'L';

    if ATier = '' then
    begin
      if (Pos('1100', SUpper) > 0) or (Pos('1200', SUpper) > 0) then
        ATier := 'XL'
      else if (Pos('550', SUpper) > 0) or (Pos('600', SUpper) > 0) then
        ATier := 'L'
      else
        ATier := 'XL';
    end;

    // 2. Limpiar expresiones de peso
    ABaseName := S;
    while True do
    begin
      P := Pos('(', ABaseName);
      if P > 0 then
      begin
        J := Pos(')', ABaseName);
        if J > P then
        begin
          if (Pos('GR', UpperCase(Copy(ABaseName, P, J - P + 1))) > 0) or
             (Pos('G', UpperCase(Copy(ABaseName, P, J - P + 1))) > 0) then
          begin
            Delete(ABaseName, P, J - P + 1);
            Continue;
          end;
        end;
      end;
      Break;
    end;

    ABaseName := StringReplace(ABaseName, '1100-1200 GR', '', [rfIgnoreCase, rfReplaceAll]);
    ABaseName := StringReplace(ABaseName, '1100/1200GR', '', [rfIgnoreCase, rfReplaceAll]);
    ABaseName := StringReplace(ABaseName, '550/600GR', '', [rfIgnoreCase, rfReplaceAll]);
    ABaseName := StringReplace(ABaseName, '550-600GR', '', [rfIgnoreCase, rfReplaceAll]);

    SUpper := UpperCase(ABaseName);
    if (Length(SUpper) >= 3) and (Copy(SUpper, Length(SUpper) - 2, 3) = ' XL') then
      Delete(ABaseName, Length(ABaseName) - 2, 3)
    else if (Length(SUpper) >= 2) and (Copy(SUpper, Length(SUpper) - 1, 2) = ' L') then
      Delete(ABaseName, Length(ABaseName) - 1, 2);

    ABaseName := StringReplace(ABaseName, ' XL ', ' ', [rfIgnoreCase, rfReplaceAll]);
    ABaseName := StringReplace(ABaseName, ' L ', ' ', [rfIgnoreCase, rfReplaceAll]);
    ABaseName := Trim(ABaseName);

    ANormKey := UpperCase(ABaseName);
    ANormKey := StringReplace(ANormKey, 'Á', 'A', [rfReplaceAll]);
    ANormKey := StringReplace(ANormKey, 'É', 'E', [rfReplaceAll]);
    ANormKey := StringReplace(ANormKey, 'Í', 'I', [rfReplaceAll]);
    ANormKey := StringReplace(ANormKey, 'Ó', 'O', [rfReplaceAll]);
    ANormKey := StringReplace(ANormKey, 'Ú', 'U', [rfReplaceAll]);
  end;
begin
  if qryPrecios.IsEmpty then
  begin
    ShowMessage('No hay precios disponibles para imprimir o exportar.');
    Exit;
  end;

  if qryPrecios.State in [dsEdit, dsInsert] then
    qryPrecios.Post;

  if Assigned(cbbFiltroAno) and TryStrToInt(Trim(cbbFiltroAno.Text), LFiltroAno) then
    LReportName := 'Tarifa_' + IntToStr(LFiltroAno) + '_' + StringReplace(FClienteNombre, ' ', '_', [rfReplaceAll])
  else
    LReportName := 'Tarifa_Precios_' + StringReplace(FClienteNombre, ' ', '_', [rfReplaceAll]);

  // Localizar la plantilla FastReport (apaisada corporativa con cuadrícula de doble tamaño XL/L)
  LReportPath := ExtractFilePath(ParamStr(0)) + 'reportes\TarifaPreciosCliente.fr3';
  if not FileExists(LReportPath) then
    LReportPath := ExtractFilePath(ParamStr(0)) + '..\reportes\TarifaPreciosCliente.fr3';
  if not FileExists(LReportPath) then
    LReportPath := ExtractFilePath(ParamStr(0)) + '..\..\reportes\TarifaPreciosCliente.fr3';
  if not FileExists(LReportPath) then
    LReportPath := ExtractFilePath(ParamStr(0)) + 'reportes\TarifaPreciosGrupo.fr3';
  if not FileExists(LReportPath) then
    LReportPath := ExtractFilePath(ParamStr(0)) + '..\reportes\TarifaPreciosGrupo.fr3';
  if not FileExists(LReportPath) then
    LReportPath := 'c:\fuentes\proyectos delphi\RAUL_ASENCIO\GESTION\reportes\TarifaPreciosCliente.fr3';
  if not FileExists(LReportPath) then
    LReportPath := 'c:\fuentes\proyectos delphi\RAUL_ASENCIO\GESTION\reportes\TarifaPreciosGrupo.fr3';

  if not FileExists(LReportPath) then
  begin
    ShowMessage('No se ha encontrado la plantilla de informe:' + sLineBreak + LReportPath);
    TfrmPrintOptions.ShowOptions(Self, nil, dbgPrecios, LReportName, FClienteEmail, FClienteTelefono);
    Exit;
  end;

  LMemTable := TFDMemTable.Create(nil);
  try
    // Crear estructura del MemTable para la cuadrícula pivotada por concepto y tamaños XL / L
    LMemTable.FieldDefs.Clear;
    LMemTable.FieldDefs.Add('norm_key', ftString, 120);
    LMemTable.FieldDefs.Add('codigo', ftWideString, 50);
    LMemTable.FieldDefs.Add('concepto', ftWideString, 250);
    LMemTable.FieldDefs.Add('xl_sin_iva', ftWideString, 30);
    LMemTable.FieldDefs.Add('xl_con_iva', ftWideString, 30);
    LMemTable.FieldDefs.Add('xl_pvp', ftWideString, 30);
    LMemTable.FieldDefs.Add('l_sin_iva', ftWideString, 30);
    LMemTable.FieldDefs.Add('l_con_iva', ftWideString, 30);
    LMemTable.FieldDefs.Add('l_pvp', ftWideString, 30);
    LMemTable.CreateDataSet;
    LMemTable.IndexFieldNames := 'norm_key';

    LBookmark := qryPrecios.GetBookmark;
    qryPrecios.DisableControls;
    try
      qryPrecios.First;
      while not qryPrecios.Eof do
      begin
        LDesc := qryPrecios.FieldByName('articulo_desc').AsString;
        ParseArticleDesc(LDesc, LBaseName, LTier, LNormKey);

        LPrecio := qryPrecios.FieldByName('precio').AsFloat;
        LTipoIva := qryPrecios.FieldByName('tipo_iva').AsFloat;
        LPrecioConIva := Round(LPrecio * (1 + (LTipoIva / 100)) * 100) / 100;
        if (qryPrecios.FindField('tarifa_pvp1') <> nil) and (qryPrecios.FieldByName('tarifa_pvp1').AsFloat > 0) then
          LPvp := Round(qryPrecios.FieldByName('tarifa_pvp1').AsFloat * (1 + (LTipoIva / 100)) * 100) / 100
        else
          LPvp := 0;

        if LMemTable.Locate('norm_key', LNormKey, []) then
          LMemTable.Edit
        else
        begin
          LMemTable.Append;
          LMemTable.FieldByName('norm_key').AsString := LNormKey;
          LMemTable.FieldByName('codigo').AsString := '';
          LMemTable.FieldByName('concepto').AsString := LBaseName;
          LMemTable.FieldByName('xl_sin_iva').AsString := '';
          LMemTable.FieldByName('xl_con_iva').AsString := '';
          LMemTable.FieldByName('xl_pvp').AsString := '';
          LMemTable.FieldByName('l_sin_iva').AsString := '';
          LMemTable.FieldByName('l_con_iva').AsString := '';
          LMemTable.FieldByName('l_pvp').AsString := '';
        end;

        if LMemTable.FieldByName('codigo').AsString = '' then
          LMemTable.FieldByName('codigo').AsString := qryPrecios.FieldByName('codigo').AsString
        else if Pos(qryPrecios.FieldByName('codigo').AsString, LMemTable.FieldByName('codigo').AsString) = 0 then
          LMemTable.FieldByName('codigo').AsString := LMemTable.FieldByName('codigo').AsString + ' / ' + qryPrecios.FieldByName('codigo').AsString;

        // Si ya tiene valor asignado (por ejemplo, precio exclusivo previo), respetarlo y no sobrescribir
        if LTier = 'L' then
        begin
          if LMemTable.FieldByName('l_sin_iva').AsString = '' then
          begin
            LMemTable.FieldByName('l_sin_iva').AsString := FormatFloat('#,##0.00 €', LPrecio);
            LMemTable.FieldByName('l_con_iva').AsString := FormatFloat('#,##0.00 €', LPrecioConIva);
            if LPvp > 0 then
              LMemTable.FieldByName('l_pvp').AsString := FormatFloat('#,##0.00 €', LPvp);
          end;
        end
        else
        begin
          if LMemTable.FieldByName('xl_sin_iva').AsString = '' then
          begin
            LMemTable.FieldByName('xl_sin_iva').AsString := FormatFloat('#,##0.00 €', LPrecio);
            LMemTable.FieldByName('xl_con_iva').AsString := FormatFloat('#,##0.00 €', LPrecioConIva);
            if LPvp > 0 then
              LMemTable.FieldByName('xl_pvp').AsString := FormatFloat('#,##0.00 €', LPvp);
          end;
        end;

        LMemTable.Post;
        qryPrecios.Next;
      end;
    finally
      if qryPrecios.BookmarkValid(LBookmark) then
        qryPrecios.GotoBookmark(LBookmark);
      qryPrecios.FreeBookmark(LBookmark);
      qryPrecios.EnableControls;
    end;

    LMemTable.IndexFieldNames := 'concepto';
    LMemTable.First;

    // Vincular frxDBMaster al dataset pivotado en memoria
    dmgMain.frxDBMaster.UserName := 'frxDBmaster';
    dmgMain.frxDBMaster.DataSet := LMemTable;

    dmgMain.frxInforme.Clear;
    dmgMain.frxInforme.LoadFromFile(LReportPath);
    dmgMain.frxInforme.DataSets.Clear;
    dmgMain.frxInforme.DataSets.Add(dmgMain.frxDBMaster);
    dmgMain.frxInforme.EnabledDataSets.Clear;
    dmgMain.frxInforme.EnabledDataSets.Add(dmgMain.frxDBMaster);
    dmgMain.frxInforme.ReportOptions.Name := LReportName;

    // Actualizar cabecera del informe con datos y logo de la empresa activa
    LEmpId := dmgMain.CurrentCompanyId;
    if LEmpId = '' then
      LEmpId := '1';

    QryEmp := TFDQuery.Create(nil);
    try
      QryEmp.Connection := dmgMain.dbConn;
      QryEmp.SQL.Text := 'SELECT id, nombre, cif_nif, direccion, cp, poblacion, provincia, telefono1, email, logo FROM ge_empresas WHERE id = :id LIMIT 1';
      QryEmp.ParamByName('id').AsString := LEmpId;
      QryEmp.Open;

      if not QryEmp.IsEmpty then
      begin
        LEmpTexto := Trim(QryEmp.FieldByName('nombre').AsString);
        if Trim(QryEmp.FieldByName('cif_nif').AsString) <> '' then
          LEmpTexto := LEmpTexto + sLineBreak + 'CIF: ' + Trim(QryEmp.FieldByName('cif_nif').AsString);
        if Trim(QryEmp.FieldByName('direccion').AsString) <> '' then
          LEmpTexto := LEmpTexto + sLineBreak + Trim(QryEmp.FieldByName('direccion').AsString);
        if Trim(QryEmp.FieldByName('cp').AsString + QryEmp.FieldByName('poblacion').AsString) <> '' then
        begin
          if Trim(QryEmp.FieldByName('provincia').AsString) <> '' then
            LEmpTexto := LEmpTexto + sLineBreak + Trim(QryEmp.FieldByName('cp').AsString + ' ' + QryEmp.FieldByName('poblacion').AsString + ' (' + QryEmp.FieldByName('provincia').AsString + ')')
          else
            LEmpTexto := LEmpTexto + sLineBreak + Trim(QryEmp.FieldByName('cp').AsString + ' ' + QryEmp.FieldByName('poblacion').AsString);
        end;
        if Trim(QryEmp.FieldByName('telefono1').AsString) <> '' then
          LEmpTexto := LEmpTexto + sLineBreak + 'Tel: ' + Trim(QryEmp.FieldByName('telefono1').AsString);

        LObjEmp := dmgMain.frxInforme.FindObject('memEmpresaInfo');
        if Assigned(LObjEmp) and (LObjEmp is TfrxMemoView) then
        begin
          TfrxMemoView(LObjEmp).AllowExpressions := False;
          TfrxMemoView(LObjEmp).Text := LEmpTexto;
        end;

        LImgObj := dmgMain.frxInforme.FindObject('imgLogo');
        if Assigned(LImgObj) and (LImgObj is TfrxPictureView) then
        begin
          if not TBlobField(QryEmp.FieldByName('logo')).IsNull then
          begin
            LStream := TMemoryStream.Create;
            try
              TBlobField(QryEmp.FieldByName('logo')).SaveToStream(LStream);
              LStream.Position := 0;
              if LStream.Size > 0 then
              begin
                TfrxPictureView(LImgObj).Picture.LoadFromStream(LStream);
                TfrxPictureView(LImgObj).Visible := True;
              end
              else
                TfrxPictureView(LImgObj).Visible := False;
            finally
              LStream.Free;
            end;
          end
          else
          begin
            LLogoPath := ExtractFilePath(ParamStr(0)) + 'logo_raul.png';
            if not FileExists(LLogoPath) then
              LLogoPath := ExtractFilePath(ParamStr(0)) + 'logo.png';
            if not FileExists(LLogoPath) then
              LLogoPath := 'c:\fuentes\proyectos delphi\RAUL_ASENCIO\GESTION\logo_raul.png';

            if FileExists(LLogoPath) then
            begin
              TfrxPictureView(LImgObj).Picture.LoadFromFile(LLogoPath);
              TfrxPictureView(LImgObj).Visible := True;
            end
            else
              TfrxPictureView(LImgObj).Visible := False;
          end;
        end;
      end;
    finally
      QryEmp.Free;
    end;

    // Actualizar título del informe
    LObjTitulo := dmgMain.frxInforme.FindObject('lblTitulo');
    if Assigned(LObjTitulo) and (LObjTitulo is TfrxMemoView) then
    begin
      TfrxMemoView(LObjTitulo).AllowExpressions := False;
      if Assigned(cbbFiltroAno) and TryStrToInt(Trim(cbbFiltroAno.Text), LFiltroAno) then
        LTituloSufijo := ' (TARIFA ' + IntToStr(LFiltroAno) + ')'
      else
        LTituloSufijo := '';
      TfrxMemoView(LObjTitulo).Text := 'PRECIO VENTA: ' + UpperCase(FClienteNombre) + LTituloSufijo;
    end;

    dmgMain.frxInforme.PrepareReport(True);

    // Abrir diálogo estándar de opciones de impresión y exportación (Visualizar, Imprimir, PDF, Excel, Email, WhatsApp)
    TfrmPrintOptions.ShowOptions(Self, dmgMain.frxInforme, dbgPrecios, LReportName, FClienteEmail, FClienteTelefono);
  finally
    LMemTable.Free;
  end;
end;

procedure TfrmClientesPrecios.btnHistoricoClick(Sender: TObject);
var
  LArticuloId: Integer;
  LArticuloDesc: string;
  LClienteId: Integer;
  LTitulo: string;
begin
  LClienteId := StrToIntDef(FClienteId, 0);
  LArticuloId := 0;
  LArticuloDesc := '';

  if qryPrecios.Active and not qryPrecios.IsEmpty then
  begin
    LArticuloId := qryPrecios.FieldByName('id_articulo').AsInteger;
    LArticuloDesc := qryPrecios.FieldByName('articulo_desc').AsString;
  end;

  if LArticuloId > 0 then
    LTitulo := 'Histórico: ' + LArticuloDesc + ' - ' + FClienteNombre
  else
    LTitulo := 'Histórico de Precios - ' + FClienteNombre;

  TfrmVentasPreciosHistorico.MostrarHistorico(
    Self,
    'CLIENTE',
    LArticuloId,
    LClienteId,
    0,
    LTitulo
  );
end;

procedure TfrmClientesPrecios.qryPreciosCalcFields(DataSet: TDataSet);
var
  LPrecioBase, LTarifa, LTipoIva, LDtoPct, LPrecioConIva: Double;
  FldPrecio, FldTarifa, FldTipoIva, FldPrecioConIva, FldDtoPct: TField;
begin
  if FUpdatingCalcField then Exit;
  FldPrecio := DataSet.FindField('precio');
  FldTarifa := DataSet.FindField('tarifa_pvp1');
  FldTipoIva := DataSet.FindField('tipo_iva');
  FldPrecioConIva := DataSet.FindField('precio_con_iva');
  FldDtoPct := DataSet.FindField('descuento_pct');

  if Assigned(FldPrecio) and Assigned(FldTipoIva) and Assigned(FldPrecioConIva) then
  begin
    LPrecioBase := FldPrecio.AsFloat;
    LTipoIva := FldTipoIva.AsFloat;
    LPrecioConIva := SimpleRoundTo(LPrecioBase * (1 + (LTipoIva / 100)), -2);
    FldPrecioConIva.AsFloat := LPrecioConIva;

    if Assigned(FldTarifa) and Assigned(FldDtoPct) then
    begin
      LTarifa := FldTarifa.AsFloat;
      if LTarifa > 0 then
        LDtoPct := SimpleRoundTo(((LTarifa - LPrecioBase) / LTarifa) * 100, -2)
      else
        LDtoPct := 0;
      FldDtoPct.AsFloat := LDtoPct;
    end;
  end;
end;

procedure TfrmClientesPrecios.DateFieldSetText(Sender: TField; const Text: string);
var
  LCleanText, LDateStr: string;
  LDate: TDateTime;
  LParts: TArray<string>;
  LDay, LMonth, LYear: Integer;
begin
  LCleanText := StringReplace(StringReplace(Text, '/', '', [rfReplaceAll]), ' ', '', [rfReplaceAll]);
  if LCleanText = '' then
  begin
    Sender.Clear;
    Exit;
  end;

  LDateStr := Trim(Text);

  // 1. Si contiene barras '/', dividir por '/'
  if Pos('/', LDateStr) > 0 then
  begin
    LParts := LDateStr.Split(['/']);
    if Length(LParts) = 3 then
    begin
      LDay := StrToIntDef(Trim(LParts[0]), 0);
      LMonth := StrToIntDef(Trim(LParts[1]), 0);
      LYear := StrToIntDef(Trim(LParts[2]), 0);
      if (LYear > 0) and (LYear < 100) then
        LYear := 2000 + LYear;

      if (LDay >= 1) and (LDay <= 31) and (LMonth >= 1) and (LMonth <= 12) and (LYear >= 1900) then
      begin
        try
          Sender.AsDateTime := EncodeDate(LYear, LMonth, LDay);
          Exit;
        except
        end;
      end;
    end;
  end
  else
  begin
    // 2. Si no tiene barras (extraído por EditMask), parsear por longitud de dígitos
    if Length(LCleanText) = 6 then // DDMMYY (ej. 230726)
    begin
      LDay := StrToIntDef(Copy(LCleanText, 1, 2), 0);
      LMonth := StrToIntDef(Copy(LCleanText, 3, 2), 0);
      LYear := 2000 + StrToIntDef(Copy(LCleanText, 5, 2), 0);
      if (LDay >= 1) and (LDay <= 31) and (LMonth >= 1) and (LMonth <= 12) and (LYear >= 1900) then
      begin
        try
          Sender.AsDateTime := EncodeDate(LYear, LMonth, LDay);
          Exit;
        except
        end;
      end;
    end;

    if Length(LCleanText) = 8 then // DDMMYYYY (ej. 23072026)
    begin
      LDay := StrToIntDef(Copy(LCleanText, 1, 2), 0);
      LMonth := StrToIntDef(Copy(LCleanText, 3, 2), 0);
      LYear := StrToIntDef(Copy(LCleanText, 5, 4), 0);
      if (LDay >= 1) and (LDay <= 31) and (LMonth >= 1) and (LMonth <= 12) and (LYear >= 1900) then
      begin
        try
          Sender.AsDateTime := EncodeDate(LYear, LMonth, LDay);
          Exit;
        except
        end;
      end;
    end;
  end;

  if TryStrToDate(LDateStr, LDate) then
    Sender.AsDateTime := LDate
  else
    Sender.Clear;
end;

procedure TfrmClientesPrecios.PrecioBaseChange(Sender: TField);
var
  LPrecioBase, LTarifa, LTipoIva, LPrecioConIvaCalc, LDtoPctCalc: Double;
  FldPrecioConIva, FldTarifa, FldTipoIva, FldDtoPct: TField;
begin
  if FUpdatingCalcField or not (qryPrecios.State in [dsEdit, dsInsert]) then Exit;

  FldPrecioConIva := qryPrecios.FindField('precio_con_iva');
  FldTarifa := qryPrecios.FindField('tarifa_pvp1');
  FldTipoIva := qryPrecios.FindField('tipo_iva');
  FldDtoPct := qryPrecios.FindField('descuento_pct');

  if Assigned(FldPrecioConIva) and Assigned(FldTipoIva) then
  begin
    FUpdatingCalcField := True;
    try
      LPrecioBase := Sender.AsFloat;
      LTipoIva := FldTipoIva.AsFloat;
      LPrecioConIvaCalc := SimpleRoundTo(LPrecioBase * (1 + (LTipoIva / 100)), -2);
      FldPrecioConIva.AsFloat := LPrecioConIvaCalc;

      if Assigned(FldTarifa) and Assigned(FldDtoPct) then
      begin
        LTarifa := FldTarifa.AsFloat;
        if LTarifa > 0 then
          LDtoPctCalc := SimpleRoundTo(((LTarifa - LPrecioBase) / LTarifa) * 100, -2)
        else
          LDtoPctCalc := 0;
        FldDtoPct.AsFloat := LDtoPctCalc;
      end;
    finally
      FUpdatingCalcField := False;
    end;
  end;
end;

procedure TfrmClientesPrecios.DescuentoPorcentajeChange(Sender: TField);
var
  LDtoPct, LTarifa, LTipoIva, LPrecioBaseCalc, LPrecioConIvaCalc: Double;
  FldPrecio, FldPrecioConIva, FldTarifa, FldTipoIva: TField;
begin
  if FUpdatingCalcField or not (qryPrecios.State in [dsEdit, dsInsert]) then Exit;

  FldPrecio := qryPrecios.FindField('precio');
  FldPrecioConIva := qryPrecios.FindField('precio_con_iva');
  FldTarifa := qryPrecios.FindField('tarifa_pvp1');
  FldTipoIva := qryPrecios.FindField('tipo_iva');

  if Assigned(FldPrecio) and Assigned(FldTarifa) and Assigned(FldTipoIva) then
  begin
    FUpdatingCalcField := True;
    try
      LDtoPct := Sender.AsFloat;
      LTarifa := FldTarifa.AsFloat;
      LTipoIva := FldTipoIva.AsFloat;

      LPrecioBaseCalc := SimpleRoundTo(LTarifa * (1 - (LDtoPct / 100)), -2);
      FldPrecio.AsFloat := LPrecioBaseCalc;

      if Assigned(FldPrecioConIva) then
      begin
        LPrecioConIvaCalc := SimpleRoundTo(LPrecioBaseCalc * (1 + (LTipoIva / 100)), -2);
        FldPrecioConIva.AsFloat := LPrecioConIvaCalc;
      end;
    finally
      FUpdatingCalcField := False;
    end;
  end;
end;

procedure TfrmClientesPrecios.PrecioConIvaChange(Sender: TField);
var
  LPrecioConIva, LTarifa, LTipoIva, LPrecioBaseCalc, LDtoPctCalc: Double;
  FldPrecio, FldTarifa, FldTipoIva, FldDtoPct: TField;
begin
  if FUpdatingCalcField or not (qryPrecios.State in [dsEdit, dsInsert]) then Exit;

  FldPrecio := qryPrecios.FindField('precio');
  FldTarifa := qryPrecios.FindField('tarifa_pvp1');
  FldTipoIva := qryPrecios.FindField('tipo_iva');
  FldDtoPct := qryPrecios.FindField('descuento_pct');

  if Assigned(FldPrecio) and Assigned(FldTipoIva) then
  begin
    FUpdatingCalcField := True;
    try
      LPrecioConIva := Sender.AsFloat;
      LTipoIva := FldTipoIva.AsFloat;
      if (1 + (LTipoIva / 100)) <> 0 then
      begin
        LPrecioBaseCalc := SimpleRoundTo(LPrecioConIva / (1 + (LTipoIva / 100)), -2);
        FldPrecio.AsFloat := LPrecioBaseCalc;

        if Assigned(FldTarifa) and Assigned(FldDtoPct) then
        begin
          LTarifa := FldTarifa.AsFloat;
          if LTarifa > 0 then
            LDtoPctCalc := SimpleRoundTo(((LTarifa - LPrecioBaseCalc) / LTarifa) * 100, -2)
          else
            LDtoPctCalc := 0;
          FldDtoPct.AsFloat := LDtoPctCalc;
        end;
      end;
    finally
      FUpdatingCalcField := False;
    end;
  end;
end;

procedure TfrmClientesPrecios.dbgPreciosKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
begin
  if Key = VK_RETURN then
  begin
    Key := 0; // Consumir la tecla Enter
    if dbgPrecios.SelectedIndex < dbgPrecios.Columns.Count - 1 then
    begin
      // Mover a la siguiente columna
      dbgPrecios.SelectedIndex := dbgPrecios.SelectedIndex + 1;
    end
    else
    begin
      // Si está en la última columna, guardar cambios en la fila si está editando y pasar al siguiente registro
      if qryPrecios.State in [dsEdit, dsInsert] then
        qryPrecios.Post;
      qryPrecios.Next;
      dbgPrecios.SelectedIndex := 3; // Volver a la primera columna editable (Und. Mín.)
    end;
  end;
end;

procedure TfrmClientesPrecios.qryPreciosAfterPost(DataSet: TDataSet);
begin
  qryPrecios.ApplyUpdates;
end;

procedure TfrmClientesPrecios.btnCerrarClick(Sender: TObject);
begin
  if qryPrecios.State in [dsEdit, dsInsert] then
    qryPrecios.Post;
  if qryPrecios.ChangeCount > 0 then
    qryPrecios.ApplyUpdates;
  Close;
end;

procedure TfrmClientesPrecios.btnAyudaClick(Sender: TObject);
var
  LBaseDir, LHelpFilePath: string;
  LHelpLines: TStringList;
begin
  LBaseDir := ExtractFilePath(ParamStr(0));
  LHelpFilePath := LBaseDir + 'help\TfrmClientesPrecios.txt';
  
  if not FileExists(LHelpFilePath) then
    LHelpFilePath := LBaseDir + '..\help\TfrmClientesPrecios.txt';
  if not FileExists(LHelpFilePath) then
    LHelpFilePath := LBaseDir + '..\..\help\TfrmClientesPrecios.txt';

  if FileExists(LHelpFilePath) then
  begin
    LHelpLines := TStringList.Create;
    try
      LHelpLines.LoadFromFile(LHelpFilePath, TEncoding.UTF8);
      MessageDlg(LHelpLines.Text, mtInformation, [mbOK], 0);
    finally
      LHelpLines.Free;
    end;
  end
  else
  begin
    MessageDlg('AYUDA: PRECIOS DE CLIENTE Y GRUPO' + sLineBreak + sLineBreak +
               '1. PRECIOS EXCLUSIVOS Y DE GRUPO:' + sLineBreak +
               '- Se muestran las tarifas exclusivas del cliente junto con las tarifas aplicables del grupo al que pertenece.' + sLineBreak +
               '- La columna "Origen / Tipo" identifica si la tarifa es propia del cliente o asignada al grupo.' + sLineBreak + sLineBreak +
               '2. FILTRO RÁPIDO:' + sLineBreak +
               '- El desplegable "Mostrar" permite visualizar todos los precios, solo los exclusivos o solo los de grupo.' + sLineBreak + sLineBreak +
               '3. IMPRESIÓN Y EXPORTACIÓN:' + sLineBreak +
               '- El botón "Imprimir" abre las opciones para previsualizar, imprimir, generar PDF o exportar la lista a Excel.',
               mtInformation, [mbOK], 0);
  end;
end;

procedure TfrmClientesPrecios.btnAnadirClick(Sender: TObject);
var
  i: Integer;
  LArtId: string;
  LPrice: Double;
  LFiltroAno: Integer;
begin
  if FClienteId = '' then Exit;

  if not Assigned(frmSelectProduct) then
    Application.CreateForm(TfrmSelectProduct, frmSelectProduct);

  frmSelectProduct.MultiSelect := True;
  try
    if frmSelectProduct.ShowModal = mrOk then
    begin
      for i := 0 to frmSelectProduct.SelectedIds.Count - 1 do
      begin
        LArtId := frmSelectProduct.SelectedIds[i];
        LPrice := StrToFloatDef(frmSelectProduct.SelectedPrices[i], 0);

        // Verificar si ya existe para evitar duplicados en precios exclusivos
        dmgMain.qryExec.Close;
        dmgMain.qryExec.SQL.Text := 'SELECT COUNT(*) FROM ge_clientes_precios WHERE id_cliente = :cli AND id_articulo = :art';
        dmgMain.qryExec.ParamByName('cli').AsInteger := StrToIntDef(FClienteId, 0);
        dmgMain.qryExec.ParamByName('art').AsInteger := StrToIntDef(LArtId, 0);
        dmgMain.qryExec.Open;
        if dmgMain.qryExec.Fields[0].AsInteger = 0 then
        begin
          // Insertar directamente en BD
          dmgMain.qryExec.Close;
          if Assigned(cbbFiltroAno) and TryStrToInt(Trim(cbbFiltroAno.Text), LFiltroAno) then
          begin
            dmgMain.qryExec.SQL.Text :=
              'INSERT INTO ge_clientes_precios (id_cliente, id_articulo, precio, valido_desde, valido_hasta, id_user_creator, id_user_update) ' +
              'VALUES (:id_cliente, :id_articulo, :precio, :desde, :hasta, :user, :user)';
            dmgMain.qryExec.ParamByName('id_cliente').AsInteger := StrToIntDef(FClienteId, 0);
            dmgMain.qryExec.ParamByName('id_articulo').AsInteger := StrToIntDef(LArtId, 0);
            dmgMain.qryExec.ParamByName('precio').AsFloat := LPrice;
            dmgMain.qryExec.ParamByName('desde').AsDate := EncodeDate(LFiltroAno, 1, 1);
            dmgMain.qryExec.ParamByName('hasta').AsDate := EncodeDate(LFiltroAno, 12, 31);
            dmgMain.qryExec.ParamByName('user').AsString := dmgMain.CurrentUserId;
          end
          else
          begin
            dmgMain.qryExec.SQL.Text :=
              'INSERT INTO ge_clientes_precios (id_cliente, id_articulo, precio, id_user_creator, id_user_update) ' +
              'VALUES (:id_cliente, :id_articulo, :precio, :user, :user)';
            dmgMain.qryExec.ParamByName('id_cliente').AsInteger := StrToIntDef(FClienteId, 0);
            dmgMain.qryExec.ParamByName('id_articulo').AsInteger := StrToIntDef(LArtId, 0);
            dmgMain.qryExec.ParamByName('precio').AsFloat := LPrice;
            dmgMain.qryExec.ParamByName('user').AsString := dmgMain.CurrentUserId;
          end;
          dmgMain.qryExec.ExecSQL;
        end;
        dmgMain.qryExec.Close;
      end;

      // Refrescar combo de años y rejilla
      ActualizarAnosFiltro;
      LoadPrecios;
    end;
  finally
    frmSelectProduct.MultiSelect := False;
  end;
end;

procedure TfrmClientesPrecios.btnEliminarClick(Sender: TObject);
var
  LOrigenTipo: Integer;
  LId: Integer;
  LMsg: string;
begin
  if qryPrecios.IsEmpty then Exit;

  LOrigenTipo := qryPrecios.FieldByName('origen_tipo').AsInteger;
  LId := qryPrecios.FieldByName('id').AsInteger;

  if LOrigenTipo = 2 then
  begin
    LMsg := '⚠️ ATENCIÓN: El precio seleccionado pertenece al Grupo de Clientes "' + FGrupoNombre + '".' + sLineBreak + sLineBreak +
            'Si lo elimina, se suprimirá para TODOS los clientes asociados a este grupo.' + sLineBreak + sLineBreak +
            '¿Está seguro de que desea eliminar este precio de grupo?';
    if MessageDlg(LMsg, mtWarning, [mbYes, mbNo], 0) <> mrYes then
      Exit;

    dmgMain.qryExec.Close;
    dmgMain.qryExec.SQL.Text := 'DELETE FROM ge_clientes_grupos_precios WHERE id = :id';
    dmgMain.qryExec.ParamByName('id').AsInteger := LId;
    dmgMain.qryExec.ExecSQL;
    dmgMain.qryExec.Close;
  end
  else
  begin
    if MessageDlg('¿Desea eliminar el precio específico seleccionado?', mtConfirmation, [mbYes, mbNo], 0) <> mrYes then
      Exit;

    dmgMain.qryExec.Close;
    dmgMain.qryExec.SQL.Text := 'DELETE FROM ge_clientes_precios WHERE id = :id';
    dmgMain.qryExec.ParamByName('id').AsInteger := LId;
    dmgMain.qryExec.ExecSQL;
    dmgMain.qryExec.Close;
  end;

  LoadPrecios;
end;

procedure TfrmClientesPrecios.qryPreciosUpdateRecord(ASender: TDataSet; ARequest: TFDUpdateRequest;
  var AAction: TFDErrorAction; AOptions: TFDUpdateRowOptions);
var
  LOrigenTipo: Integer;
begin
  LOrigenTipo := ASender.FieldByName('origen_tipo').AsInteger;

  if ARequest = arUpdate then
  begin
    try
      dmgMain.qryExec.Close;
      if LOrigenTipo = 2 then
      begin
        dmgMain.qryExec.SQL.Text :=
          'UPDATE ge_clientes_grupos_precios SET precio = :precio, ' +
          'und_min = :und_min, und_max = :und_max, ' +
          'valido_desde = :valido_desde, valido_hasta = :valido_hasta, ' +
          'id_user_update = :user, update_at = CURRENT_TIMESTAMP ' +
          'WHERE id = :id';

        dmgMain.qryExec.ParamByName('id').AsInteger := ASender.FieldByName('id').AsInteger;
        dmgMain.qryExec.ParamByName('precio').AsFloat := ASender.FieldByName('precio').AsFloat;

        if ASender.FieldByName('und_min').IsNull then
          dmgMain.qryExec.ParamByName('und_min').Clear
        else
          dmgMain.qryExec.ParamByName('und_min').AsInteger := ASender.FieldByName('und_min').AsInteger;

        if ASender.FieldByName('und_max').IsNull then
          dmgMain.qryExec.ParamByName('und_max').Clear
        else
          dmgMain.qryExec.ParamByName('und_max').AsInteger := ASender.FieldByName('und_max').AsInteger;
      end
      else
      begin
        dmgMain.qryExec.SQL.Text :=
          'UPDATE ge_clientes_precios SET precio = :precio, ' +
          'valido_desde = :valido_desde, valido_hasta = :valido_hasta, ' +
          'id_user_update = :user, update_at = CURRENT_TIMESTAMP ' +
          'WHERE id = :id';

        dmgMain.qryExec.ParamByName('id').AsInteger := ASender.FieldByName('id').AsInteger;
        dmgMain.qryExec.ParamByName('precio').AsFloat := ASender.FieldByName('precio').AsFloat;
      end;

      dmgMain.qryExec.ParamByName('valido_desde').DataType := ftDate;
      if ASender.FieldByName('valido_desde').IsNull then
        dmgMain.qryExec.ParamByName('valido_desde').Clear
      else
        dmgMain.qryExec.ParamByName('valido_desde').AsDate := ASender.FieldByName('valido_desde').AsDateTime;

      dmgMain.qryExec.ParamByName('valido_hasta').DataType := ftDate;
      if ASender.FieldByName('valido_hasta').IsNull then
        dmgMain.qryExec.ParamByName('valido_hasta').Clear
      else
        dmgMain.qryExec.ParamByName('valido_hasta').AsDate := ASender.FieldByName('valido_hasta').AsDateTime;

      dmgMain.qryExec.ParamByName('user').AsString := dmgMain.CurrentUserId;
      dmgMain.qryExec.ExecSQL;
      AAction := eaApplied;
    except
      on E: Exception do
      begin
        AAction := eaFail;
        TDbErrorHandler.HandleException(E, 'Error al guardar los cambios del precio');
      end;
    end;
  end
  else if ARequest = arDelete then
  begin
    try
      dmgMain.qryExec.Close;
      if LOrigenTipo = 2 then
        dmgMain.qryExec.SQL.Text := 'DELETE FROM ge_clientes_grupos_precios WHERE id = :id'
      else
        dmgMain.qryExec.SQL.Text := 'DELETE FROM ge_clientes_precios WHERE id = :id';

      dmgMain.qryExec.ParamByName('id').AsInteger := ASender.FieldByName('id').AsInteger;
      dmgMain.qryExec.ExecSQL;
      AAction := eaApplied;
    except
      on E: Exception do
      begin
        AAction := eaFail;
        TDbErrorHandler.HandleException(E, 'Error al eliminar el precio');
      end;
    end;
  end
  else
    AAction := eaApplied;
end;

end.
