unit uTipsaClient;

{
  uTipsaClient.pas
  Cliente SOAP para el API DinaPaq de TIPSA.

  Descripción:
    Implementa la comunicación con el Web Service SOAP de TIPSA (DinaPaq) sin
    importar el WSDL ni usar la generación automática de Delphi. Las peticiones
    XML se construyen manualmente como cadenas de texto, lo que permite mayor
    control y robustez ante cambios de versión del servicio.

  Librerías usadas (todas incluidas en Delphi estándar):
    - System.Net.HttpClient → peticiones HTTPS POST
    - System.NetEncoding   → decodificación Base64 de la etiqueta PDF
    - System.DateUtils     → formateo de fechas para SOAP

  URL Producción :
    Login : https://ws.tipsa-dinapaq.com/SOAP?service=LoginWSService
    Serv  : https://ws.tipsa-dinapaq.com/SOAP?service=WebServService
  URL Validación :
    Login : https://wsval.tipsa-dinapaq.com/SOAP?service=LoginWSService
    Serv  : https://wsval.tipsa-dinapaq.com/SOAP?service=WebServService
}

interface

uses
  System.SysUtils, System.Classes, System.Net.HttpClient, System.NetEncoding,
  uTipsaTypes;

type
  // -------------------------------------------------------------------------
  // Parámetros para solicitar una recogida (GrabaRecogida10)
  // -------------------------------------------------------------------------
  TTipsaRecogidaParams = record
    // Origen de la recogida (donde irá el mensajero — datos del CLIENTE/DESTINO)
    NomOri        : string;  // Nombre de contacto en la recogida
    TipoViaOri    : string;  // Tipo de vía (ej: 'Calle')
    DirOri        : string;  // Nombre de la calle
    NumOri        : string;  // Número
    CPOri         : string;  // Código postal
    TlfOri        : string;  // Teléfono de contacto

    // Destino de la recogida (donde llevar los paquetes — datos EMPRESA)
    NomDes        : string;
    TipoViaDes    : string;
    DirDes        : string;
    NumDes        : string;
    PobDes        : string;
    CPDes         : string;
    TlfDes        : string;

    // Datos de la recogida
    FechaRecogida : TDateTime;  // Fecha solicitada
    HoraIni       : TDateTime;  // Hora inicio franja
    HoraFin       : TDateTime;  // Hora fin franja
    NumBultos     : Integer;
    CodTipoServ   : string;     // Tipo de servicio (ej: '48')
    Referencia    : string;
    PersContacto  : string;     // Nombre persona de contacto
    Observaciones : string;
    ObsDes        : string;
    Contenido     : string;
  end;

  // -------------------------------------------------------------------------
  // Resultado de una solicitud de recogida
  // -------------------------------------------------------------------------
  TTipsaRecogidaResult = record
    OK              : Boolean;
    CodRecogida     : string;    // strCodOut
    GuidTipsa       : string;    // strGuidOut
    FechaAlta       : TDateTime;
    CodAgeOrigen    : string;
    CodAgeDestino   : string;
    Error           : string;
  end;

  // -------------------------------------------------------------------------
  // Cliente SOAP TIPSA (TTipsaClient)
  // -------------------------------------------------------------------------
  TTipsaClient = class
  private
    FSessionGUID  : string;   // GUID de sesión obtenido en Login
    FURLLogin     : string;   // URL endpoint de Login
    FURLServ      : string;   // URL endpoint de operaciones
    FCodAge       : string;   // Código de agencia
    FCodCli       : string;   // Código de cliente
    FPassword     : string;   // Contraseña

    // -----------------------------------------------------------------------
    // Construye el sobre SOAP completo con el GUID de sesión en la cabecera
    // -----------------------------------------------------------------------
    function BuildEnvelope(const ABodyContent: string): string;

    // -----------------------------------------------------------------------
    // Ejecuta una petición HTTP POST SOAP y devuelve el XML de respuesta
    // -----------------------------------------------------------------------
    function PostSOAP(const AURL, ASoapAction, ABody: string): string;

    // -----------------------------------------------------------------------
    // Extrae el valor de texto de un elemento XML simple (primer match)
    // -----------------------------------------------------------------------
    function ExtractNode(const AXML, ANodeName: string): string;

    // -----------------------------------------------------------------------
    // Formatea una fecha para el SOAP de GrabaRecogida ('YYYY/MM/DD')
    // -----------------------------------------------------------------------
    function FormatFechaRec(const ADate: TDateTime): string;

    // -----------------------------------------------------------------------
    // Formatea fecha+hora para el SOAP ('YYYY/MM/DD HH:MM:SS')
    // -----------------------------------------------------------------------
    function FormatHoraRec(const ADateTime: TDateTime): string;

    // -----------------------------------------------------------------------
    // Formatea fecha ISO 8601 para GrabaEnvio ('YYYY-MM-DDTHH:MM:SS.000+01:00')
    // -----------------------------------------------------------------------
    function FormatFechaEnvio(const ADate: TDateTime): string;

    // -----------------------------------------------------------------------
    // Escapa caracteres especiales XML
    // -----------------------------------------------------------------------
    function XmlEscape(const AText: string): string;

  public
    // -----------------------------------------------------------------------
    // Constructor
    //   ACodAge    : Código agencia TIPSA
    //   ACodCli    : Código cliente TIPSA
    //   APassword  : Contraseña TIPSA
    //   AValidacion: True = entorno de pruebas wsval.tipsa-dinapaq.com
    // -----------------------------------------------------------------------
    constructor Create(const ACodAge, ACodCli, APassword: string;
                       AValidacion: Boolean = False);

    // -----------------------------------------------------------------------
    // Login: obtiene el GUID de sesión que se usará en todas las llamadas
    // Devuelve True si el login fue correcto
    // -----------------------------------------------------------------------
    function Login: Boolean;

    // -----------------------------------------------------------------------
    // GrabaEnvio24: registra un envío a entregar al destinatario (Flujo A)
    // -----------------------------------------------------------------------
    function GrabaEnvio(const AParams: TTipsaEnvioParams): TTipsaEnvioResult;

    // -----------------------------------------------------------------------
    // ConsEtiquetaEnvio6: obtiene la etiqueta de un envío en PDF (array de bytes)
    //   AAlbaranTipsa : Número de albarán TIPSA
    //   AFormatoPDF   : True=PDF, False=ZPL
    //   AIdRepDet     : ID de plantilla de etiqueta (233 = estándar)
    // -----------------------------------------------------------------------
    function GetEtiqueta(const AAlbaranTipsa: string;
                         AFormatoPDF: Boolean = True;
                         AIdRepDet: Integer = 233): TBytes;

    // -----------------------------------------------------------------------
    // ConsEnvEstados: consulta los estados de seguimiento de un envío/recogida
    // -----------------------------------------------------------------------
    function GetEstados(const AAlbaranTipsa: string): TTipsaEstados;

    // -----------------------------------------------------------------------
    // GrabaRecogida10: solicita una recogida al transportista (Flujo B)
    // -----------------------------------------------------------------------
    function GrabaRecogida(const AParams: TTipsaRecogidaParams): TTipsaRecogidaResult;

    // -----------------------------------------------------------------------
    // Propiedad de sólo lectura: GUID de sesión activo
    // -----------------------------------------------------------------------
    property SessionGUID: string read FSessionGUID;
  end;

implementation

uses
  System.DateUtils, Vcl.Dialogs;

// ---------------------------------------------------------------------------
// Constantes internas
// ---------------------------------------------------------------------------
const
  // Namespaces del sobre SOAP
  NS_SOAPENV = 'http://schemas.xmlsoap.org/soap/envelope/';
  NS_TEMPURI = 'http://tempuri.org/';

  // URLs base
  URL_PROD_LOGIN = 'https://ws.tipsa-dinapaq.com/SOAP?service=LoginWSService';
  URL_PROD_SERV  = 'https://ws.tipsa-dinapaq.com/SOAP?service=WebServService';
  URL_VAL_LOGIN  = 'https://wsval.tipsa-dinapaq.com/SOAP?service=LoginWSService';
  URL_VAL_SERV   = 'https://wsval.tipsa-dinapaq.com/SOAP?service=WebServService';

// ---------------------------------------------------------------------------
constructor TTipsaClient.Create(const ACodAge, ACodCli, APassword: string;
                                AValidacion: Boolean);
begin
  inherited Create;
  FCodAge   := ACodAge;
  FCodCli   := ACodCli;
  FPassword := APassword;
  if AValidacion then
  begin
    FURLLogin := URL_VAL_LOGIN;
    FURLServ  := URL_VAL_SERV;
  end
  else
  begin
    FURLLogin := URL_PROD_LOGIN;
    FURLServ  := URL_PROD_SERV;
  end;
end;

// ---------------------------------------------------------------------------
function TTipsaClient.XmlEscape(const AText: string): string;
begin
  Result := AText;
  Result := Result.Replace('&',  '&amp;',  [rfReplaceAll]);
  Result := Result.Replace('<',  '&lt;',   [rfReplaceAll]);
  Result := Result.Replace('>',  '&gt;',   [rfReplaceAll]);
  Result := Result.Replace('"',  '&quot;', [rfReplaceAll]);
  Result := Result.Replace('''', '&apos;', [rfReplaceAll]);
end;

// ---------------------------------------------------------------------------
function TTipsaClient.FormatFechaRec(const ADate: TDateTime): string;
begin
  // Formato requerido por GrabaRecogida: 'YYYY/MM/DD'
  Result := FormatDateTime('yyyy/mm/dd', ADate);
end;

// ---------------------------------------------------------------------------
function TTipsaClient.FormatHoraRec(const ADateTime: TDateTime): string;
begin
  // Formato requerido por GrabaRecogida: 'YYYY/MM/DD HH:MM:SS'
  Result := FormatDateTime('yyyy/mm/dd hh:nn:ss', ADateTime);
end;

// ---------------------------------------------------------------------------
function TTipsaClient.FormatFechaEnvio(const ADate: TDateTime): string;
begin
  // Formato requerido por GrabaEnvio: '2023-11-21'
  Result := FormatDateTime('yyyy-mm-dd', ADate);
end;

// ---------------------------------------------------------------------------
function TTipsaClient.BuildEnvelope(const ABodyContent: string): string;
begin
  // Construye el sobre SOAP con el GUID de sesión en la cabecera ROClientIDHeader
  Result :=
    '<?xml version="1.0" encoding="UTF-8"?>' +
    '<soapenv:Envelope' +
    ' xmlns:soapenv="' + NS_SOAPENV + '"' +
    ' xmlns:tem="' + NS_TEMPURI + '">' +
    '<soapenv:Header>' +
    '<tem:ROClientIDHeader>' +
    '<tem:ID>' + FSessionGUID + '</tem:ID>' +
    '</tem:ROClientIDHeader>' +
    '</soapenv:Header>' +
    '<soapenv:Body>' +
    ABodyContent +
    '</soapenv:Body>' +
    '</soapenv:Envelope>';
end;

// ---------------------------------------------------------------------------
function TTipsaClient.PostSOAP(const AURL, ASoapAction, ABody: string): string;
var
  LClient   : THTTPClient;
  LRequest  : TStringStream;
  LResponse : IHTTPResponse;
begin
  Result := '';
  LClient := THTTPClient.Create;
  try
    LClient.ConnectionTimeout := 15000;  // 15 segundos
    LClient.ResponseTimeout   := 30000;  // 30 segundos
    LClient.CustomHeaders['SOAPAction']  := ASoapAction;
    LClient.ContentType := 'text/xml;charset=utf-8';

    ShowMessage('=== PETICIÓN SOAP TIPSA ===' + #13#10 +
                'URL: ' + AURL + #13#10 +
                'SOAPAction: ' + ASoapAction + #13#10 +
                'Cuerpo (Body):' + #13#10 + ABody);

    LRequest := TStringStream.Create(ABody, TEncoding.UTF8);
    try
      LResponse := LClient.Post(AURL, LRequest);
      Result := LResponse.ContentAsString(TEncoding.UTF8);
    finally
      LRequest.Free;
    end;
  finally
    LClient.Free;
  end;
end;

// ---------------------------------------------------------------------------
function TTipsaClient.ExtractNode(const AXML, ANodeName: string): string;
var
  LOpenTag  : string;
  LCloseTag : string;
  LPosOpen  : Integer;
  LPosClose : Integer;
  LStart    : Integer;
begin
  Result := '';
  // Busca <v1:ANodeName> o <ANodeName> ignorando prefijos de namespace
  LOpenTag  := ':' + ANodeName + '>';
  LCloseTag := ':' + ANodeName + '>';

  LPosOpen := Pos(LOpenTag, AXML);
  if LPosOpen = 0 then
  begin
    // Intento sin prefijo
    LOpenTag  := '<' + ANodeName + '>';
    LCloseTag := '</' + ANodeName + '>';
    LPosOpen  := Pos(LOpenTag, AXML);
    if LPosOpen = 0 then Exit;
    LStart    := LPosOpen + Length(LOpenTag);
    LPosClose := Pos(LCloseTag, AXML, LStart);
    if LPosClose > LStart then
      Result := Copy(AXML, LStart, LPosClose - LStart);
    Exit;
  end;

  // Encontró con prefijo — ahora localiza el '>' real de apertura
  LStart := LPosOpen + Length(LOpenTag);
  // Retrocede para buscar el '<'
  LPosOpen := LStart - Length(LOpenTag);
  // Ahora buscamos el cierre '</' + sufijo (el prefijo puede variar)
  LPosClose := Pos('</', AXML, LStart);
  while LPosClose > 0 do
  begin
    if Pos(ANodeName + '>', AXML, LPosClose) = LPosClose + 2 then
    begin
      Result := Copy(AXML, LStart, LPosClose - LStart);
      Exit;
    end;
    // Prueba con prefijo más largo
    LPosClose := Pos('</', AXML, LPosClose + 2);
  end;
end;

// ---------------------------------------------------------------------------
// LOGIN
// ---------------------------------------------------------------------------
function TTipsaClient.Login: Boolean;
var
  LBody     : string;
  LResponse : string;
  LResult   : string;
  LError    : string;
begin
  Result := False;
  FSessionGUID := '';

  // Construye el cuerpo de la petición de login
  // (la cabecera ROClientIDHeader va vacía en el login)
  LBody :=
    '<?xml version="1.0" encoding="UTF-8"?>' +
    '<soapenv:Envelope' +
    ' xmlns:soapenv="' + NS_SOAPENV + '"' +
    ' xmlns:tem="' + NS_TEMPURI + '">' +
    '<soapenv:Header>' +
    '<tem:ROClientIDHeader>' +
    '<tem:ID></tem:ID>' +
    '</tem:ROClientIDHeader>' +
    '</soapenv:Header>' +
    '<soapenv:Body>' +
    '<tem:LoginWSService___LoginCli>' +
    '<tem:strCodAge>' + XmlEscape(FCodAge) + '</tem:strCodAge>' +
    '<tem:strCod>' + XmlEscape(FCodCli) + '</tem:strCod>' +
    '<tem:strPass>' + XmlEscape(FPassword) + '</tem:strPass>' +
    '</tem:LoginWSService___LoginCli>' +
    '</soapenv:Body>' +
    '</soapenv:Envelope>';

  try
    LResponse := PostSOAP(FURLLogin, 'LoginWSService___LoginCli', LBody);
  except
    on E: Exception do
      raise Exception.CreateFmt('Error de conexión con TIPSA: %s', [E.Message]);
  end;

  // Verificar resultado
  LResult := ExtractNode(LResponse, 'Result');
  LError  := ExtractNode(LResponse, 'strError');

  if SameText(Trim(LResult), 'true') and (Trim(LError) = '0') then
  begin
    FSessionGUID := ExtractNode(LResponse, 'strSesion');
    Result := FSessionGUID <> '';
  end
  else
    raise Exception.CreateFmt(
      'Login TIPSA fallido. Código error: %s', [LError]);
end;

// ---------------------------------------------------------------------------
// GRABA ENVIO 24 (Flujo A)
// ---------------------------------------------------------------------------
function TTipsaClient.GrabaEnvio(const AParams: TTipsaEnvioParams): TTipsaEnvioResult;
var
  LBody     : string;
  LResponse : string;
  LFecha    : string;
begin
  Result.OK := False;
  Result.Error := '';

  if FSessionGUID = '' then
    raise Exception.Create('Debe llamar a Login() antes de GrabaEnvio().');

  LFecha := FormatFechaEnvio(Now);

  LBody :=
    '<tem:WebServService___GrabaEnvio24>' +
    '<tem:strCodAgeCargo>' + XmlEscape(FCodAge) + '</tem:strCodAgeCargo>' +
    '<tem:strCodAgeOri>' + XmlEscape(FCodAge) + '</tem:strCodAgeOri>' +
    '<tem:dtFecha>' + LFecha + '</tem:dtFecha>' +
    '<tem:strCodTipoServ>' + XmlEscape(AParams.CodTipoServ) + '</tem:strCodTipoServ>' +
    '<tem:strCodCli>' + XmlEscape(FCodCli) + '</tem:strCodCli>' +
    '<tem:strNomOri>' + XmlEscape(AParams.NomOri) + '</tem:strNomOri>' +
    '<tem:strDirOri>' + XmlEscape(AParams.DirOri) + '</tem:strDirOri>' +
    '<tem:strPobOri>' + XmlEscape(AParams.PobOri) + '</tem:strPobOri>' +
    '<tem:strCPOri>' + XmlEscape(AParams.CPOri) + '</tem:strCPOri>' +
    '<tem:strTlfOri>' + XmlEscape(AParams.TlfOri) + '</tem:strTlfOri>' +
    '<tem:strNomDes>' + XmlEscape(AParams.NomDes) + '</tem:strNomDes>' +
    '<tem:strDirDes>' + XmlEscape(AParams.DirDes) + '</tem:strDirDes>' +
    '<tem:strPobDes>' + XmlEscape(AParams.PobDes) + '</tem:strPobDes>' +
    '<tem:strCPDes>' + XmlEscape(AParams.CPDes) + '</tem:strCPDes>' +
    '<tem:strCodPais>' + XmlEscape(AParams.CodPais) + '</tem:strCodPais>' +
    '<tem:strTlfDes>' + XmlEscape(AParams.TlfDes) + '</tem:strTlfDes>' +
    '<tem:intPaq>' + IntToStr(AParams.NumBultos) + '</tem:intPaq>' +
    '<tem:strRef>' + XmlEscape(AParams.Referencia) + '</tem:strRef>' +
    '<tem:boInsert>true</tem:boInsert>' +
    '<tem:strContenido>' + XmlEscape(AParams.Observaciones) + '</tem:strContenido>' +
    '</tem:WebServService___GrabaEnvio24>';

  try
    LResponse := PostSOAP(FURLServ, 'WebServService___GrabaEnvio24',
                          BuildEnvelope(LBody));
  except
    on E: Exception do
    begin
      Result.Error := 'Error de conexión: ' + E.Message;
      Exit;
    end;
  end;

  Result.AlbaranTipsa := Trim(ExtractNode(LResponse, 'strAlbaranOut'));
  Result.GuidTipsa    := Trim(ExtractNode(LResponse, 'strGuidOut'));
  Result.CodAgeDestino := Trim(ExtractNode(LResponse, 'strCodAgeDesOut'));
  Result.TipoEnvio    := Trim(ExtractNode(LResponse, 'strTipoEnvOut'));

  try
    Result.FechaEntrega := StrToDateTime(
      Copy(Trim(ExtractNode(LResponse, 'dtFecEntrOut')), 1, 10));
  except
    Result.FechaEntrega := 0;
  end;

  Result.OK := Result.AlbaranTipsa <> '';
  if not Result.OK then
    Result.Error := 'La API no devolvió número de albarán. Respuesta: ' + LResponse;
end;

// ---------------------------------------------------------------------------
// GET ETIQUETA (Flujo A)
// ---------------------------------------------------------------------------
function TTipsaClient.GetEtiqueta(const AAlbaranTipsa: string;
                                   AFormatoPDF: Boolean;
                                   AIdRepDet: Integer): TBytes;
var
  LFormato  : string;
  LBody     : string;
  LResponse : string;
  LBase64   : string;
begin
  SetLength(Result, 0);

  if FSessionGUID = '' then
    raise Exception.Create('Debe llamar a Login() antes de GetEtiqueta().');

  if AFormatoPDF then LFormato := 'pdf' else LFormato := 'zpl';

  LBody :=
    '<tem:WebServService___ConsEtiquetaEnvio6>' +
    '<tem:strCodAgeOri>' + XmlEscape(FCodAge) + '</tem:strCodAgeOri>' +
    '<tem:strCodAgeCargo>' + XmlEscape(FCodAge) + '</tem:strCodAgeCargo>' +
    '<tem:StrAlbaran>' + XmlEscape(AAlbaranTipsa) + '</tem:StrAlbaran>' +
    '<tem:intIdRepDet>' + IntToStr(AIdRepDet) + '</tem:intIdRepDet>' +
    '<tem:strFormato>' + LFormato + '</tem:strFormato>' +
    '</tem:WebServService___ConsEtiquetaEnvio6>';

  try
    LResponse := PostSOAP(FURLServ, 'WebServService___ConsEtiquetaEnvio6',
                          BuildEnvelope(LBody));
  except
    on E: Exception do
      raise Exception.CreateFmt('Error al obtener etiqueta: %s', [E.Message]);
  end;

  LBase64 := Trim(ExtractNode(LResponse, 'strEtiqueta'));
  if LBase64 = '' then
    raise Exception.CreateFmt(
      'No se recibió etiqueta para el albarán TIPSA %s', [AAlbaranTipsa]);

  // Decodificar de Base64 a bytes binarios
  Result := TNetEncoding.Base64.DecodeStringToBytes(LBase64);
end;

// ---------------------------------------------------------------------------
// GET ESTADOS (Flujo A + B — tracking)
// ---------------------------------------------------------------------------
function TTipsaClient.GetEstados(const AAlbaranTipsa: string): TTipsaEstados;
var
  LBody     : string;
  LResponse : string;
  LCData    : string;
  LPos      : Integer;
  LEnd      : Integer;
  LEntry    : string;
  LItems    : TArray<string>;
  LItem     : string;
  LEstado   : TTipsaEstado;
  LCount    : Integer;

  // Extrae el valor de un atributo XML del tipo attr="valor"
  function ExtractAttr(const AXml, AAttr: string): string;
  var
    LAttrPos : Integer;
    LValStart : Integer;
    LValEnd   : Integer;
  begin
    Result := '';
    LAttrPos := Pos(AAttr + '="', AXml);
    if LAttrPos = 0 then Exit;
    LValStart := LAttrPos + Length(AAttr) + 2;
    LValEnd   := Pos('"', AXml, LValStart);
    if LValEnd > LValStart then
      Result := Copy(AXml, LValStart, LValEnd - LValStart);
  end;

begin
  SetLength(Result, 0);

  if FSessionGUID = '' then
    raise Exception.Create('Debe llamar a Login() antes de GetEstados().');

  LBody :=
    '<tem:WebServService___ConsEnvEstados>' +
    '<tem:strCodAgeCargo>' + XmlEscape(FCodAge) + '</tem:strCodAgeCargo>' +
    '<tem:strCodAgeOri>' + XmlEscape(FCodAge) + '</tem:strCodAgeOri>' +
    '<tem:strAlbaran>' + XmlEscape(AAlbaranTipsa) + '</tem:strAlbaran>' +
    '</tem:WebServService___ConsEnvEstados>';

  try
    LResponse := PostSOAP(FURLServ, 'WebServService___ConsEnvEstados',
                          BuildEnvelope(LBody));
  except
    on E: Exception do
      raise Exception.CreateFmt('Error al consultar estados: %s', [E.Message]);
  end;

  // La respuesta viene en un CDATA con XML interno:
  // <![CDATA[<CONSULTA><ENV_ESTADOS I_ID="1" V_COD_TIPO_EST="1" D_FEC_HORA_ALTA="..." />...</CONSULTA>]]>
  LPos := Pos('<![CDATA[', LResponse);
  LEnd := Pos(']]>', LResponse);
  if (LPos = 0) or (LEnd = 0) then Exit;

  LCData := Copy(LResponse, LPos + 9, LEnd - LPos - 9);

  // Parsear cada elemento ENV_ESTADOS
  LCount := 0;
  LPos := 1;
  repeat
    LPos := Pos('<ENV_ESTADOS ', LCData, LPos);
    if LPos = 0 then Break;
    LEnd := Pos('/>', LCData, LPos);
    if LEnd = 0 then Break;

    LEntry := Copy(LCData, LPos, LEnd - LPos + 2);

    LEstado.IdEstado  := StrToIntDef(ExtractAttr(LEntry, 'I_ID'), 0);
    LEstado.Codigo    := StrToIntDef(ExtractAttr(LEntry, 'V_COD_TIPO_EST'), -1);
    LEstado.CodUsuario := ExtractAttr(LEntry, 'V_COD_USU_ALTA');

    try
      LEstado.FechaHora := StrToDateTime(ExtractAttr(LEntry, 'D_FEC_HORA_ALTA'));
    except
      LEstado.FechaHora := 0;
    end;

    LEstado.Descripcion := TipsaEstadoDescripcion(LEstado.Codigo);

    SetLength(Result, LCount + 1);
    Result[LCount] := LEstado;
    Inc(LCount);
    LPos := LEnd + 2;
  until LPos = 0;
end;

// ---------------------------------------------------------------------------
// GRABA RECOGIDA 10 (Flujo B)
// ---------------------------------------------------------------------------
function TTipsaClient.GrabaRecogida(const AParams: TTipsaRecogidaParams): TTipsaRecogidaResult;
var
  LBody     : string;
  LResponse : string;
begin
  Result.OK := False;
  Result.Error := '';

  if FSessionGUID = '' then
    raise Exception.Create('Debe llamar a Login() antes de GrabaRecogida().');

  LBody :=
    '<tem:WebServService___GrabaRecogida10>' +
    '<tem:strCodAgeSol>' + XmlEscape(FCodAge) + '</tem:strCodAgeSol>' +
    '<tem:strCodAgeCargo>' + XmlEscape(FCodAge) + '</tem:strCodAgeCargo>' +
    '<tem:dtFecRec>' + FormatFechaRec(AParams.FechaRecogida) + '</tem:dtFecRec>' +
    '<tem:dtHoraRecIni>' + FormatHoraRec(AParams.HoraIni) + '</tem:dtHoraRecIni>' +
    '<tem:dtHoraRecFin>' + FormatHoraRec(AParams.HoraFin) + '</tem:dtHoraRecFin>' +
    '<tem:intBul>' + IntToStr(AParams.NumBultos) + '</tem:intBul>' +
    '<tem:strNomOri>' + XmlEscape(AParams.NomOri) + '</tem:strNomOri>' +
    '<tem:strTipoViaOri>' + XmlEscape(AParams.TipoViaOri) + '</tem:strTipoViaOri>' +
    '<tem:strDirOri>' + XmlEscape(AParams.DirOri) + '</tem:strDirOri>' +
    '<tem:strNumOri>' + XmlEscape(AParams.NumOri) + '</tem:strNumOri>' +
    '<tem:strCPOri>' + XmlEscape(AParams.CPOri) + '</tem:strCPOri>' +
    '<tem:strTlfOri>' + XmlEscape(AParams.TlfOri) + '</tem:strTlfOri>' +
    '<tem:strNomDes>' + XmlEscape(AParams.NomDes) + '</tem:strNomDes>' +
    '<tem:strTipoViaDes>' + XmlEscape(AParams.TipoViaDes) + '</tem:strTipoViaDes>' +
    '<tem:strDirDes>' + XmlEscape(AParams.DirDes) + '</tem:strDirDes>' +
    '<tem:strNumDes>' + XmlEscape(AParams.NumDes) + '</tem:strNumDes>' +
    '<tem:strPobDes>' + XmlEscape(AParams.PobDes) + '</tem:strPobDes>' +
    '<tem:strCPDes>' + XmlEscape(AParams.CPDes) + '</tem:strCPDes>' +
    '<tem:strTlfDes>' + XmlEscape(AParams.TlfDes) + '</tem:strTlfDes>' +
    '<tem:strObs>' + XmlEscape(AParams.Observaciones) + '</tem:strObs>' +
    '<tem:strCodCli>' + XmlEscape(FCodCli) + '</tem:strCodCli>' +
    '<tem:strPersContacto>' + XmlEscape(AParams.PersContacto) + '</tem:strPersContacto>' +
    '<tem:strCodTipoServ>' + XmlEscape(AParams.CodTipoServ) + '</tem:strCodTipoServ>' +
    '<tem:strRef>' + XmlEscape(AParams.Referencia) + '</tem:strRef>' +
    '<tem:strObsDes>' + XmlEscape(AParams.ObsDes) + '</tem:strObsDes>' +
    '<tem:strContenido>' + XmlEscape(AParams.Contenido) + '</tem:strContenido>' +
    '</tem:WebServService___GrabaRecogida10>';

  try
    LResponse := PostSOAP(FURLServ, 'WebServService___GrabaRecogida10',
                          BuildEnvelope(LBody));
  except
    on E: Exception do
    begin
      Result.Error := 'Error de conexión: ' + E.Message;
      Exit;
    end;
  end;

  Result.CodRecogida   := Trim(ExtractNode(LResponse, 'strCodOut'));
  Result.GuidTipsa     := Trim(ExtractNode(LResponse, 'strGuidOut'));
  Result.CodAgeOrigen  := Trim(ExtractNode(LResponse, 'strCodAgeOriOut'));
  Result.CodAgeDestino := Trim(ExtractNode(LResponse, 'strCodAgeDesOut'));

  try
    Result.FechaAlta := StrToDateTime(
      Copy(Trim(ExtractNode(LResponse, 'dtFecHoraAltaOut')), 1, 19));
  except
    Result.FechaAlta := 0;
  end;

  Result.OK := Result.CodRecogida <> '';
  if not Result.OK then
    Result.Error := 'La API no devolvió código de recogida. Respuesta: ' + LResponse;
end;

end.
