unit uProcessor;

interface

uses
  System.SysUtils, System.Classes, Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, 
  uDM, uConfig, System.IOUtils, Winapi.Windows, System.JSON, System.Net.HttpClient, 
  System.Net.URLClient, System.Net.Mime, System.Net.HttpClientComponent,
  System.NetEncoding, System.DateUtils, System.Generics.Collections, System.Variants, System.RegularExpressions,
  uEmailWhatsApp;

type
  TAIResult = record
    DocType: string;      // Nombre de la carpeta/tipo
    Data: TJSONObject;    // Datos extraídos para las tablas
    Success: Boolean;
  end;

  TDocumentProcessor = class
  private
    class function FileToBase64(const AFilePath: string): string;
    class function ISO8601ToDate(const AValue: string): TDateTime;
    class function SanitizeForFileName(const AValue: string): string;
    class function LoadFileAsBlob(const AFilePath: string): TBytes;
    class procedure LinkDocument(const ATableName: string; const AIdRef: string; const AFilePath, ANewName: string);
    class function IsDuplicate(const ATableName, ANumero, ANif: string): Boolean;
    class function AnalyzeWithAI(const AFilePath: string): TAIResult;
    class procedure ProcessFactura(const AFilePath, ANewName: string; AData: TJSONObject);
    class procedure ProcessAlbaran(const AFilePath, ANewName: string; AData: TJSONObject);
    class procedure ProcessTrabajador(const AFilePath, ANewName: string; AData: TJSONObject);
    class function GetJSONString(AObj: TJSONObject; const AKey: string; const ADefault: string = ''): string;
    class function GetJSONFloat(AObj: TJSONObject; const AKey: string; const ADefault: Double = 0): Double;
    class function GetJSONInt(AObj: TJSONObject; const AKey: string; const ADefault: Integer = 0): Integer;
    class function SanitizeNIF(const AValue: string): string;
    class function CleanName(const AName: string): string;
    class function FindOrCreateProveedor(AQuery: TFDQuery; AData: TJSONObject): string;
    class procedure CheckAndInsertProveedorArticulo(AQuery: TFDQuery; AIdEmpresa, AIdProveedor: Integer; const ACodigo, ADescripcion: string; APrecio: Double; AIsFactura: Boolean);
  public
    class procedure ProcessFile(const AFilePath: string);
  end;

implementation

{ TDocumentProcessor }

class function TDocumentProcessor.AnalyzeWithAI(const AFilePath: string): TAIResult;
var
  HTTP: TNetHTTPClient;
  RequestBody, ResponseJSON, CandidateObj, SubContentObj, ContentObj, TextPart, Part, InlineData: TJSONObject;
  ContentsArr, PartsArr, CandidatesArr, SubPartsArr: TJSONArray;
  Response: IHTTPResponse;
  URL, MimeType, AIResponseText, Ext, FullPrompt: string;
  SS: TStringStream;
  JSValue: TJSONValue;
begin
  Result.Success := False;
  Result.Data := nil;

  if (Config.ApiKey = '') or (Config.IAModelo = '') then
    raise Exception.Create('Configuración de IA incompleta (Falta API Key o Modelo)');

  FullPrompt :=
    'Eres un sistema experto en OCR y extracción de datos de documentos comerciales y laborales españoles (facturas, albaranes, nóminas, contratos, tarjetas sanitarias y documentos de identidad de trabajadores). ' +
    'Analiza este documento y clasifícalo en uno de estos tipos: FACTURA, ALBARAN, TRABAJADOR. ' +
    sLineBreak + sLineBreak +
    'INSTRUCCIONES CRÍTICAS SOBRE CLASIFICACIÓN:' + sLineBreak +
    '- FACTURA: Factura comercial de compra/gasto emitida por un proveedor.' + sLineBreak +
    '- ALBARAN: Albarán o nota de entrega/recepción de mercancías o servicios de un proveedor.' + sLineBreak +
    '- TRABAJADOR: Cualquier documento personal, laboral o de identificación de un trabajador o persona física. ' +
    'Incluye: Tarjetas Sanitarias (Murciasalud, Servicio Murciano de Salud, TSI, SIP, SAS, CatSalut, SERMAS, etc.), ' +
    'Tarjetas Sanitarias Europeas, DNI, NIE / Tarjeta de Residencia, Pasaporte, Permiso de Trabajo, Nómina, Contrato de Trabajo, ' +
    'Informe de Vida Laboral, Finiquito, Certificado de Empresa, Reconocimiento Médico, etc.' + sLineBreak + sLineBreak +
    'INSTRUCCIONES CRÍTICAS SOBRE FECHAS:' + sLineBreak +
    '- Las fechas pueden estar ESCRITAS A MANO, impresas o en formato mixto.' + sLineBreak +
    '- Busca la fecha en toda la superficie del documento: cabecera, sellos, anotaciones manuscritas, fechas de expedición/caducidad.' + sLineBreak +
    '- Formatos habituales en España: "13 de Febrero de 2026", "13/02/2026", "13-02-2026", "13.02.26".' + sLineBreak +
    '- También puede aparecer como: "Aspe, 13 de Frero de 2026" (con erratas) o abreviado "13/Feb/26".' + sLineBreak +
    '- Si el año aparece con 2 dígitos (ej: "26"), interpreta como 20XX.' + sLineBreak +
    '- Devuelve SIEMPRE la fecha en formato YYYY-MM-DD (para TRABAJADOR usa la fecha de emisión/caducidad del documento si existe, o fecha actual).' + sLineBreak + sLineBreak +
    'INSTRUCCIONES SOBRE CÓDIGOS DE ARTÍCULO (FACTURAS Y ALBARANES):' + sLineBreak +
    '- Cada línea de detalle tiene una columna CÓDIGO o REFERENCIA. NUNCA la omitas.' + sLineBreak +
    '- El código puede ser numérico (ej: "010193"), alfanumérico (ej: "SPR-001") o un código de barras.' + sLineBreak +
    '- Si la columna de código está presente en el documento pero vacía para una línea concreta, usa cadena vacía "".' + sLineBreak +
    '- Si no hay columna de código visible en el documento, usa "" (cadena vacía).' + sLineBreak + sLineBreak +
    'FORMATO DE RESPUESTA: Devuelve ÚNICAMENTE un objeto JSON (sin markdown ni texto adicional) con esta estructura:' + sLineBreak +
    '{"tipo": "TIPO_DETECTADO", "datos": { ... }}' + sLineBreak + sLineBreak +
    'Para FACTURA y ALBARAN el objeto "datos" debe contener:' + sLineBreak +
    '- nif_proveedor: CIF/NIF del emisor (solo alfanumérico, sin guiones ni espacios)' + sLineBreak +
    '- nombre_proveedor: Razón social completa' + sLineBreak +
    '- direccion_proveedor: Dirección fiscal completa del proveedor' + sLineBreak +
    '- poblacion_proveedor: Población o localidad del proveedor' + sLineBreak +
    '- cp_proveedor: Código postal (5 dígitos)' + sLineBreak +
    '- provincia_proveedor: Provincia del proveedor' + sLineBreak +
    '- telefono_proveedor: Teléfono de contacto' + sLineBreak +
    '- numero: Número de factura o albarán' + sLineBreak +
    '- fecha: Fecha del documento en formato YYYY-MM-DD' + sLineBreak +
    '- base: Base imponible total' + sLineBreak +
    '- iva: Importe total de IVA' + sLineBreak +
    '- total: Importe total del documento' + sLineBreak +
    '- detalles: Array de objetos, cada uno con:' + sLineBreak +
    '  - codigo: Código o referencia del artículo (OBLIGATORIO, extraer de la columna CÓDIGO/REF del documento)' + sLineBreak +
    '  - descripcion: Descripción del producto o servicio' + sLineBreak +
    '  - bultos: Número de cajas, packs o bultos de la línea (tipo decimal, por defecto 1)' + sLineBreak +
    '  - cantidad_bulto: Cantidad de unidades que contiene cada bulto (tipo decimal, por defecto 1)' + sLineBreak +
    '  - cantidad: Cantidad total de unidades individuales (bultos * cantidad_bulto)' + sLineBreak +
    '  - precio: Precio unitario por unidad individual (precio_del_bulto / cantidad_bulto)' + sLineBreak +
    '  - tipo_iva: Porcentaje de IVA aplicado (entero: 0, 4, 10, 21)' + sLineBreak +
    '  - descuento: Porcentaje de descuento aplicado a la línea (0 si no hay, ej: 10 para 10%)' + sLineBreak +
    '  - descuento_importe: Importe absoluto del descuento aplicado a la línea (0 si no hay o si se especifica en porcentaje)' + sLineBreak +
    '  - total: Importe de la línea' + sLineBreak +
    '  - lote: Número de lote si aparece' + sLineBreak +
    '  - fecha_caducidad: Fecha de caducidad en YYYY-MM-DD si aparece' + sLineBreak + sLineBreak +
    'Para TRABAJADOR el objeto "datos" debe contener:' + sLineBreak +
    '- nif_trabajador: DNI o NIE del titular/trabajador (ej: 12345678Z, Y8399348S, X1234567A). Solo alfanumérico, sin guiones ni espacios.' + sLineBreak +
    '- nombre_trabajador: Nombre y apellidos completos del trabajador (ej: "REINA MARIA AVILA BUSTAMANTE" o "AVILA BUSTAMANTE, REINA MARIA").' + sLineBreak +
    '- tipo_documento: Tipo específico de documento (TARJETA_SANITARIA, DNI, NIE, PASAPORTE, PERMISO_TRABAJO, NOMINA, CONTRATO, VIDA_LABORAL, OTRO).' + sLineBreak +
    '- fecha: Fecha del documento (emisión, caducidad o fecha del documento en formato YYYY-MM-DD si es visible, o fecha actual).' + sLineBreak + sLineBreak +
    'INSTRUCCIONES ESPECÍFICAS PARA TARJETAS SANITARIAS Y DOCUMENTOS DE IDENTIDAD (TRABAJADOR):' + sLineBreak +
    '- REGLA MRZ / LÍNEAS DE CARACTERES ÓPTICOS EN TARJETAS SANITARIAS:' + sLineBreak +
    '  * En las tarjetas sanitarias (como las del Servicio Murciano de Salud / Murciasalud y otras comunidades autónomas), en el reverso o anverso suele haber 3 líneas de datos legibles por máquina (MRZ) con caracteres separados por "<".' + sLineBreak +
    '  * PRIMERA LÍNEA MRZ: Contiene datos como el código regional/CIP y, justo antes de los chevrons de relleno "<<<<<<", figura el NIF/NIE del titular (ejemplo: "IRESPE305184986Y8399348S<<<<<<" -> el NIF/NIE es "Y8399348S").' + sLineBreak +
    '  * TERCERA LÍNEA MRZ: Contiene los apellidos y nombre separados por "<" (ejemplo: "AVILA<BUSTAMANTE<<REINA<MARIA<" -> Apellidos: AVILA BUSTAMANTE, Nombre: REINA MARIA).' + sLineBreak +
    '  * Si en la tarjeta sanitaria no hay MRZ o se ve claramente el texto impreso, busca el NIF/NIE, DNI, o Documento de Identidad del titular en cualquier parte del documento.' + sLineBreak +
    '  * Prioriza SIEMPRE el DNI/NIE sobre el código CIP autonómico o número de Seguridad Social para el campo "nif_trabajador".' + sLineBreak +
    '  * El valor de "tipo_documento" para tarjetas de salud debe ser "TARJETA_SANITARIA".' + sLineBreak +
    '- REGLA NIF/NIE CON CERO INICIAL (SEGURIDAD SOCIAL / TGSS / INFORMES LABORALES):' + sLineBreak +
    '  * En documentos de la Seguridad Social, Vida Laboral, contratos o cartas oficiales, el NIF o NIE a menudo aparece precedido de un cero (ejemplo: "0Z2799157Q", "0X1234567A", "0Y8399348S" o "012345678Z").' + sLineBreak +
    '  * En estos casos, debes extraer el NIF/NIE oficial canónico eliminando dicho cero inicial sobrante (ej: de "0Z2799157Q" extraer "Z2799157Q", de "0Y8399348S" extraer "Y8399348S").' + sLineBreak + sLineBreak +
    'REGLAS ADICIONALES:' + sLineBreak +
    '- REGLA CRÍTICA DE PACKS/BULTOS: Analiza minuciosamente la descripción, envase o columnas en busca de indicaciones ' +
    'de unidades o peso (kg, litros, brik) por bulto/caja, como "caja-2,5 kg", "(CAJA 2,5 KG.)", "caja-6 litros", ' +
    '"50u", "20u", "10 uds", "6 BRIK X 1 L.". El número decimal o entero asociado (ej: 2.5 o 6) es "cantidad_bulto".' + sLineBreak +
    '- DETERMINACIÓN DEL PRECIO UNITARIO Y CANTIDAD: "bultos" es la cantidad de cajas/packs. "cantidad" es (bultos * cantidad_bulto). ' +
    'Para el "precio" unitario final: 1) Si (bultos * precio_documento) es aprox. igual al Importe de la línea, el precio es por caja, ' +
    'así que "precio" = precio_documento / cantidad_bulto. 2) Si (cantidad * precio_documento) es aprox. al Importe, el precio ya es ' +
    'por unidad/kg/litro, así que "precio" = precio_documento. Si no es pack/caja, usa bultos = 1, cantidad_bulto = 1 y precio = precio_documento.' + sLineBreak +
    '- REGLA CRÍTICA PARA LOTES MÚLTIPLES: Si un artículo/línea en el documento original físico contiene varios lotes o ' +
    'varias fechas de caducidad (desglosado con sus respectivas cantidades individuales, por ejemplo, en anotaciones o ' +
    'filas de desglose), NO debes sumar las cantidades ni unificar los lotes en una sola línea. En su lugar, debes generar ' +
    'un objeto de detalle independiente en el array "detalles" para cada lote, asignando a cada uno su lote, su fecha de ' +
    'caducidad y su cantidad específica correspondiente.' + sLineBreak +
    '- REGLA DE LOTES Y CADUCIDADES EN SECCIÓN APARTE O ÚLTIMA PÁGINA: Si los lotes y fechas de caducidad no figuran en la misma fila de los artículos ' +
    'sino que se detallan en una sección aparte, al final o en la última página del documento (relacionados por descripción de artículo, código, cantidad, ' +
    'o número de línea), debes buscar esa sección, relacionar cada lote/fecha de caducidad con su artículo correspondiente en la tabla de detalles, ' +
    'y asignarlos al objeto de detalle de dicho artículo.' + sLineBreak +
    '- Si no encuentras una columna "LOTE", búscalo en el texto de la descripción (ej: "LOTE: 2501020081").' + sLineBreak +
    '- REGLA CRÍTICA NIF/CIF ESPAÑOL: El NIF/CIF del emisor/titular es extremadamente crítico. ' +
    'Consiste habitualmente en una letra de tipo societario (A, B, C, D, E, F, G, H, J, P, Q, R, S, U, V, W) seguida de 8 caracteres, ' +
    'o un DNI (8 dígitos + letra), o un NIE (X, Y o Z seguido de 7 dígitos y letra de control). ' +
    'Por favor, asegúrate de no confundir caracteres similares por error visual OCR (ej: el número 8 con la letra B, el número 0 con la letra O, ' +
    'el número 1 con la letra I o l, el número 5 con la letra S, o el número 2 con la letra Z). Devuelve el NIF/CIF sanitizado, en mayúsculas y sin guiones, puntos ni espacios.' + sLineBreak + sLineBreak +
    '- No inventes datos. Si un campo no es legible, usa cadena vacía "" para textos o 0 para números.';

  Ext := TPath.GetExtension(AFilePath).ToLower;
  if Ext = '.pdf' then MimeType := 'application/pdf'
  else if (Ext = '.jpg') or (Ext = '.jpeg') then MimeType := 'image/jpeg'
  else if Ext = '.png' then MimeType := 'image/png'
  else
    raise Exception.Create('Extensión de archivo no soportada por la IA: ' + Ext);

  HTTP := TNetHTTPClient.Create(nil);
  RequestBody := TJSONObject.Create;
  try
    URL := 'https://generativelanguage.googleapis.com/v1beta/models/' + Config.IAModelo + ':generateContent?key=' + Config.ApiKey;
    
    ContentsArr := TJSONArray.Create;
    ContentObj := TJSONObject.Create;
    PartsArr := TJSONArray.Create;
    
    TextPart := TJSONObject.Create;
    TextPart.AddPair('text', FullPrompt);
    PartsArr.Add(TextPart);
    
    Part := TJSONObject.Create;
    InlineData := TJSONObject.Create;
    InlineData.AddPair('mime_type', MimeType);
    InlineData.AddPair('data', FileToBase64(AFilePath));
    Part.AddPair('inline_data', InlineData);
    PartsArr.Add(Part);
    
    ContentObj.AddPair('parts', PartsArr);
    ContentsArr.Add(ContentObj);
    RequestBody.AddPair('contents', ContentsArr);

    HTTP.ContentType := 'application/json';
    HTTP.ConnectionTimeout := 60000;  // 60 segundos para conectar
    HTTP.ResponseTimeout := 180000;   // 180 segundos para respuesta (evita error 12002)
    SS := TStringStream.Create(RequestBody.ToJSON, TEncoding.UTF8);
    try
      Response := HTTP.Post(URL, SS);
    finally
      SS.Free;
    end;
    
    if Response.StatusCode = 200 then
    begin
      ResponseJSON := TJSONObject.ParseJSONValue(Response.ContentAsString) as TJSONObject;
      if Assigned(ResponseJSON) then
      try
        CandidatesArr := ResponseJSON.FindValue('candidates') as TJSONArray;
        if Assigned(CandidatesArr) and (CandidatesArr.Count > 0) then
        begin
          CandidateObj := CandidatesArr.Items[0] as TJSONObject;
          SubContentObj := CandidateObj.FindValue('content') as TJSONObject;
          if Assigned(SubContentObj) then
          begin
            SubPartsArr := SubContentObj.FindValue('parts') as TJSONArray;
            if Assigned(SubPartsArr) and (SubPartsArr.Count > 0) then
            begin
              AIResponseText := (SubPartsArr.Items[0] as TJSONObject).FindValue('text').Value;
              AIResponseText := AIResponseText.Replace('```json', '').Replace('```', '').Trim;
              
              JSValue := TJSONObject.ParseJSONValue(AIResponseText);
              if Assigned(JSValue) and (JSValue is TJSONObject) then
              begin
                Result.DocType := JSValue.FindValue('tipo').Value;
                Result.Data := (JSValue.FindValue('datos') as TJSONObject).Clone as TJSONObject;
                Result.Success := True;
              end
              else
                raise Exception.Create('La respuesta de la IA no contiene un JSON estructurado valido: ' + AIResponseText);
              if Assigned(JSValue) then JSValue.Free;
            end
            else
              raise Exception.Create('La respuesta no contiene partes legibles ("parts")');
          end
          else
            raise Exception.Create('La respuesta no contiene contenido estructurado ("content")');
        end
        else
          raise Exception.Create('La IA no devolvió candidatos (posible bloqueo por seguridad o entrada no legible): ' + Response.ContentAsString);
      finally
        ResponseJSON.Free;
      end;
    end
    else
      raise Exception.Create('Error HTTP ' + Response.StatusCode.ToString + ' en API Gemini: ' + Response.ContentAsString);
  finally
    RequestBody.Free;
    HTTP.Free;
  end;
end;

class procedure TDocumentProcessor.ProcessFactura(const AFilePath, ANewName: string; AData: TJSONObject);
var
  Query: TFDQuery;
  IdProv: string;
  MasterID: Integer;
  Details: TJSONArray;
  I: Integer;
  DetObj: TJSONObject;
  LCant, LPrec, LDesc, LDescAbs, LBase, LTot: Double;
  LIva: Integer;
  CadStr, LoteStr, DocNumero, DescStr: string;
begin
  Query := TFDQuery.Create(nil);
  try
    Query.Connection := DM.FDConnection;
    
    // 1. Buscar o Crear Proveedor
    IdProv := FindOrCreateProveedor(Query, AData);

    DocNumero := GetJSONString(AData, 'numero', 'XX');

    // Comprobar Duplicado usando idproveedor e idempresa
    Query.SQL.Text := 'SELECT COUNT(*) FROM prov_facturas WHERE documento = :num AND idproveedor = :idprov AND idempresa = :idempresa';
    Query.ParamByName('num').DataType := ftString;
    Query.ParamByName('idprov').DataType := ftInteger;
    Query.ParamByName('idempresa').DataType := ftInteger;
    Query.ParamByName('num').AsString := DocNumero;
    Query.ParamByName('idprov').AsInteger := StrToIntDef(IdProv, 0);
    Query.ParamByName('idempresa').AsInteger := StrToIntDef(Config.IdEmpresa, 1);
    Query.Open;
    if Query.Fields[0].AsInteger > 0 then
    begin
      Query.Close;
      raise Exception.Create('Factura duplicada para el proveedor (Número: ' + DocNumero + ')');
    end;
    Query.Close;

    // 2. Insertar Cabecera
    Query.SQL.Text := 'INSERT INTO prov_facturas (idempresa, idproveedor, documento, fecha, base, totaliva, totalentrada) ' +
                      'VALUES (:idempresa, :idproveedor, :documento, :fecha, :base, :totaliva, :totalentrada)';
    
    Query.ParamByName('idempresa').DataType := ftInteger;
    Query.ParamByName('idproveedor').DataType := ftInteger;
    Query.ParamByName('documento').DataType := ftString;
    Query.ParamByName('fecha').DataType := ftDate;
    Query.ParamByName('base').DataType := ftFloat;
    Query.ParamByName('totaliva').DataType := ftFloat;
    Query.ParamByName('totalentrada').DataType := ftFloat;

    Query.ParamByName('idempresa').AsInteger := StrToIntDef(Config.IdEmpresa, 1);
    Query.ParamByName('idproveedor').AsInteger := StrToIntDef(IdProv, 0);
    Query.ParamByName('documento').AsString := DocNumero;
    Query.ParamByName('fecha').AsDate := ISO8601ToDate(GetJSONString(AData, 'fecha', DateToStr(Now)));
    Query.ParamByName('base').AsFloat := GetJSONFloat(AData, 'base');
    Query.ParamByName('totaliva').AsFloat := GetJSONFloat(AData, 'iva');
    Query.ParamByName('totalentrada').AsFloat := GetJSONFloat(AData, 'total');
    Query.ExecSQL;

    // Recuperar el ID auto-generado para la factura
    Query.SQL.Text := 'SELECT LAST_INSERT_ID()';
    Query.Open;
    MasterID := Query.Fields[0].AsInteger;
    Query.Close;

    // 3. Insertar Detalles
    if (AData.FindValue('detalles') <> nil) and (AData.FindValue('detalles') is TJSONArray) then
    begin
      Details := AData.FindValue('detalles') as TJSONArray;
      for I := 0 to Details.Count - 1 do
      begin
        DetObj := Details.Items[I] as TJSONObject;
        
        LCant := GetJSONFloat(DetObj, 'cantidad');
        LPrec := GetJSONFloat(DetObj, 'precio');
        LDesc := GetJSONFloat(DetObj, 'descuento');
        LDescAbs := GetJSONFloat(DetObj, 'descuento_importe');
        LIva  := GetJSONInt(DetObj, 'tipo_iva');
        
        // Calcular descuentos mutuos
        if (LDescAbs <> 0) and (LDesc = 0) then
        begin
          if LCant > 0 then
            LDescAbs := -Abs(LDescAbs / LCant)
          else
            LDescAbs := -Abs(LDescAbs);
            
          if LPrec > 0 then
            LDesc := (Abs(LDescAbs) / LPrec) * 100
          else
            LDesc := 0;
        end
        else if (LDesc <> 0) then
        begin
          LDescAbs := -Abs(LPrec * LDesc / 100);
        end
        else
        begin
          LDescAbs := 0;
          LDesc := 0;
        end;
          
        LBase := LCant * (LPrec + LDescAbs);
        LTot  := LBase * (1 + (LIva / 100));

        Query.SQL.Text := 'INSERT INTO prov_facturas_detalle (idempresa, idfactura, idproveedor, codigo, descripcion, ' +
                          'lote, cantidad, precio, tipo_iva, base, total, fecha_caducidad, descuento) ' +
                          'VALUES (:idempresa, :idfactura, :idproveedor, :codigo, :descripcion, :lote, :cantidad, :precio, :tipo_iva, :base, :total, :fecha_caducidad, :descuento)';

        Query.ParamByName('idempresa').DataType := ftInteger;
        Query.ParamByName('idfactura').DataType := ftInteger;
        Query.ParamByName('idproveedor').DataType := ftInteger;
        Query.ParamByName('codigo').DataType := ftString;
        Query.ParamByName('descripcion').DataType := ftString;
        Query.ParamByName('lote').DataType := ftString;
        Query.ParamByName('cantidad').DataType := ftFloat;
        Query.ParamByName('precio').DataType := ftFloat;
        Query.ParamByName('tipo_iva').DataType := ftInteger;
        Query.ParamByName('base').DataType := ftFloat;
        Query.ParamByName('total').DataType := ftFloat;
        Query.ParamByName('descuento').DataType := ftFloat;
        Query.ParamByName('fecha_caducidad').DataType := ftDate;

        Query.ParamByName('idempresa').AsInteger := StrToIntDef(Config.IdEmpresa, 1);
        Query.ParamByName('idfactura').AsInteger := MasterID;
        Query.ParamByName('idproveedor').AsInteger := StrToIntDef(IdProv, 0);
        Query.ParamByName('codigo').AsString := Copy(GetJSONString(DetObj, 'codigo', ''), 1, 25);
        DescStr := Copy(GetJSONString(DetObj, 'descripcion', 'Sin descripción'), 1, 50);
        Query.ParamByName('descripcion').AsString := DescStr;

        // Lote: 1) Campo JSON, 2) Regex en descripción, 3) Número de documento
        LoteStr := Trim(GetJSONString(DetObj, 'lote', ''));
        if (LoteStr = '') or (LoteStr = 'null') then
        begin
          if TRegEx.IsMatch(DescStr, 'LOTE[:\s]*([\w\-\.]+)', [roIgnoreCase]) then
            LoteStr := TRegEx.Match(DescStr, 'LOTE[:\s]*([\w\-\.]+)', [roIgnoreCase]).Groups[1].Value;
        end;
        if (LoteStr = '') or (LoteStr = 'null') then
          LoteStr := DocNumero;
        Query.ParamByName('lote').AsString := Copy(LoteStr, 1, 20);
        Query.ParamByName('cantidad').AsFloat := LCant;
        Query.ParamByName('precio').AsFloat := LPrec;
        Query.ParamByName('tipo_iva').AsInteger := LIva;
        Query.ParamByName('base').AsFloat := LBase;
        Query.ParamByName('total').AsFloat := LTot;
        Query.ParamByName('descuento').AsFloat := LDesc;

        CadStr := GetJSONString(DetObj, 'fecha_caducidad', '');
        if (CadStr <> '') and (CadStr <> 'null') then
          Query.ParamByName('fecha_caducidad').AsDate := ISO8601ToDate(CadStr)
        else
          Query.ParamByName('fecha_caducidad').Value := Null;
          
        Query.ExecSQL;
        CheckAndInsertProveedorArticulo(Query, StrToIntDef(Config.IdEmpresa, 1), StrToIntDef(IdProv, 0), GetJSONString(DetObj, 'codigo', ''), DescStr, LPrec, True);
      end;
    end;

    // 4. Vincular Documento
    LinkDocument('prov_facturas', MasterID.ToString, AFilePath, ANewName);
  finally
    Query.Free;
  end;
end;

class procedure TDocumentProcessor.ProcessAlbaran(const AFilePath, ANewName: string; AData: TJSONObject);
var
  Query: TFDQuery;
  IdProv: string;
  MasterID: Integer;
  Details: TJSONArray;
  I: Integer;
  DetObj: TJSONObject;
  LCant, LPrec, LDesc, LDescAbs, LBase, LTot: Double;
  LIva: Integer;
  CadStr, LoteStr, DocNumero, DescStr: string;
begin
  Query := TFDQuery.Create(nil);
  try
    Query.Connection := DM.FDConnection;
    
    // 1. Buscar o Crear Proveedor
    IdProv := FindOrCreateProveedor(Query, AData);

    DocNumero := GetJSONString(AData, 'numero', 'XX');

    // Comprobar Duplicado usando idproveedor e idempresa
    Query.SQL.Text := 'SELECT COUNT(*) FROM prov_albaranes WHERE documento = :num AND idproveedor = :idprov AND idempresa = :idempresa';
    Query.ParamByName('num').DataType := ftString;
    Query.ParamByName('idprov').DataType := ftInteger;
    Query.ParamByName('idempresa').DataType := ftInteger;
    Query.ParamByName('num').AsString := DocNumero;
    Query.ParamByName('idprov').AsInteger := StrToIntDef(IdProv, 0);
    Query.ParamByName('idempresa').AsInteger := StrToIntDef(Config.IdEmpresa, 1);
    Query.Open;
    if Query.Fields[0].AsInteger > 0 then
    begin
      Query.Close;
      raise Exception.Create('Albarán duplicado para el proveedor (Número: ' + DocNumero + ')');
    end;
    Query.Close;

    // 2. Insertar Cabecera
    Query.SQL.Text := 'INSERT INTO prov_albaranes (idempresa, idproveedor, documento, fecha, base, totaliva, totalentrada) ' +
                      'VALUES (:idempresa, :idproveedor, :documento, :fecha, :base, :totaliva, :totalentrada)';
    
    Query.ParamByName('idempresa').DataType := ftInteger;
    Query.ParamByName('idproveedor').DataType := ftInteger;
    Query.ParamByName('documento').DataType := ftString;
    Query.ParamByName('fecha').DataType := ftDate;
    Query.ParamByName('base').DataType := ftFloat;
    Query.ParamByName('totaliva').DataType := ftFloat;
    Query.ParamByName('totalentrada').DataType := ftFloat;

    Query.ParamByName('idempresa').AsInteger := StrToIntDef(Config.IdEmpresa, 1);
    Query.ParamByName('idproveedor').AsInteger := StrToIntDef(IdProv, 0);
    Query.ParamByName('documento').AsString := DocNumero;
    Query.ParamByName('fecha').AsDate := ISO8601ToDate(GetJSONString(AData, 'fecha', DateToStr(Now)));
    Query.ParamByName('base').AsFloat := GetJSONFloat(AData, 'base');
    Query.ParamByName('totaliva').AsFloat := GetJSONFloat(AData, 'iva');
    Query.ParamByName('totalentrada').AsFloat := GetJSONFloat(AData, 'total');
    Query.ExecSQL;

    // Recuperar el ID auto-generado para el albaran
    Query.SQL.Text := 'SELECT LAST_INSERT_ID()';
    Query.Open;
    MasterID := Query.Fields[0].AsInteger;
    Query.Close;

    // 3. Insertar Detalles
    if (AData.FindValue('detalles') <> nil) and (AData.FindValue('detalles') is TJSONArray) then
    begin
      Details := AData.FindValue('detalles') as TJSONArray;
      for I := 0 to Details.Count - 1 do
      begin
        DetObj := Details.Items[I] as TJSONObject;
        
        LCant := GetJSONFloat(DetObj, 'cantidad');
        LPrec := GetJSONFloat(DetObj, 'precio');
        LDesc := GetJSONFloat(DetObj, 'descuento');
        LDescAbs := GetJSONFloat(DetObj, 'descuento_importe');
        LIva  := GetJSONInt(DetObj, 'tipo_iva');
        
        // Calcular descuentos mutuos
        if (LDescAbs <> 0) and (LDesc = 0) then
        begin
          if LCant > 0 then
            LDescAbs := -Abs(LDescAbs / LCant)
          else
            LDescAbs := -Abs(LDescAbs);
            
          if LPrec > 0 then
            LDesc := (Abs(LDescAbs) / LPrec) * 100
          else
            LDesc := 0;
        end
        else if (LDesc <> 0) then
        begin
          LDescAbs := -Abs(LPrec * LDesc / 100);
        end
        else
        begin
          LDescAbs := 0;
          LDesc := 0;
        end;
          
        LBase := LCant * (LPrec + LDescAbs);
        LTot  := LBase * (1 + (LIva / 100));

        Query.SQL.Text := 'INSERT INTO prov_albaranes_detalle (idempresa, idalbaran, idproveedor, codigo, descripcion, ' +
                          'lote, cantidad, precio, tipo_iva, base, total, fecha_caducidad, descuento) ' +
                          'VALUES (:idempresa, :idalbaran, :idproveedor, :codigo, :descripcion, :lote, :cantidad, :precio, :tipo_iva, :base, :total, :fecha_caducidad, :descuento)';

        Query.ParamByName('idempresa').DataType := ftInteger;
        Query.ParamByName('idalbaran').DataType := ftInteger;
        Query.ParamByName('idproveedor').DataType := ftInteger;
        Query.ParamByName('codigo').DataType := ftString;
        Query.ParamByName('descripcion').DataType := ftString;
        Query.ParamByName('lote').DataType := ftString;
        Query.ParamByName('cantidad').DataType := ftFloat;
        Query.ParamByName('precio').DataType := ftFloat;
        Query.ParamByName('tipo_iva').DataType := ftInteger;
        Query.ParamByName('base').DataType := ftFloat;
        Query.ParamByName('total').DataType := ftFloat;
        Query.ParamByName('descuento').DataType := ftFloat;
        Query.ParamByName('fecha_caducidad').DataType := ftDate;

        Query.ParamByName('idempresa').AsInteger := StrToIntDef(Config.IdEmpresa, 1);
        Query.ParamByName('idalbaran').AsInteger := MasterID;
        Query.ParamByName('idproveedor').AsInteger := StrToIntDef(IdProv, 0);
        Query.ParamByName('codigo').AsString := Copy(GetJSONString(DetObj, 'codigo', ''), 1, 25);
        DescStr := Copy(GetJSONString(DetObj, 'descripcion', 'Sin descripción'), 1, 50);
        Query.ParamByName('descripcion').AsString := DescStr;

        // Lote: 1) Campo JSON, 2) Regex en descripción, 3) Número de documento
        LoteStr := Trim(GetJSONString(DetObj, 'lote', ''));
        if (LoteStr = '') or (LoteStr = 'null') then
        begin
          if TRegEx.IsMatch(DescStr, 'LOTE[:\s]*([\w\-\.]+)', [roIgnoreCase]) then
            LoteStr := TRegEx.Match(DescStr, 'LOTE[:\s]*([\w\-\.]+)', [roIgnoreCase]).Groups[1].Value;
        end;
        if (LoteStr = '') or (LoteStr = 'null') then
          LoteStr := DocNumero;
        Query.ParamByName('lote').AsString := Copy(LoteStr, 1, 20);
        Query.ParamByName('cantidad').AsFloat := LCant;
        Query.ParamByName('precio').AsFloat := LPrec;
        Query.ParamByName('tipo_iva').AsInteger := LIva;
        Query.ParamByName('base').AsFloat := LBase;
        Query.ParamByName('total').AsFloat := LTot;
        Query.ParamByName('descuento').AsFloat := LDesc;

        CadStr := GetJSONString(DetObj, 'fecha_caducidad', '');
        if (CadStr <> '') and (CadStr <> 'null') then
          Query.ParamByName('fecha_caducidad').AsDate := ISO8601ToDate(CadStr)
        else
          Query.ParamByName('fecha_caducidad').Value := Null;
          
        Query.ExecSQL;
        CheckAndInsertProveedorArticulo(Query, StrToIntDef(Config.IdEmpresa, 1), StrToIntDef(IdProv, 0), GetJSONString(DetObj, 'codigo', ''), DescStr, LPrec, False);
      end;
    end;

    // 4. Vincular Documento
    LinkDocument('prov_albaranes', MasterID.ToString, AFilePath, ANewName);
  finally
    Query.Free;
  end;
end;

class procedure TDocumentProcessor.ProcessTrabajador(const AFilePath, ANewName: string; AData: TJSONObject);
var
  Query: TFDQuery;
  IdT, CleanNif, FullName, NifWithZero, NifWithoutZero: string;
begin
  Query := TFDQuery.Create(nil);
  try
    Query.Connection := DM.FDConnection;
    
    CleanNif := SanitizeNIF(GetJSONString(AData, 'nif_trabajador'));
    FullName := Trim(GetJSONString(AData, 'nombre_trabajador'));

    if CleanNif <> '' then
    begin
      NifWithoutZero := CleanNif;
      if (Length(CleanNif) = 10) and (CleanNif[1] = '0') then
        NifWithoutZero := Copy(CleanNif, 2, Length(CleanNif) - 1);
      NifWithZero := '0' + NifWithoutZero;

      // 1. Buscar Trabajador en trabajadores por NIF (normal o con 0) e ide
      Query.SQL.Text := 'SELECT ID FROM trabajadores WHERE (NIF = :nif1 OR NIF = :nif2 OR TRIM(LEADING ''0'' FROM NIF) = :nif3) AND ide = :id_empresa';
      Query.ParamByName('nif1').DataType := ftString;
      Query.ParamByName('nif1').AsString := NifWithoutZero;
      Query.ParamByName('nif2').DataType := ftString;
      Query.ParamByName('nif2').AsString := NifWithZero;
      Query.ParamByName('nif3').DataType := ftString;
      Query.ParamByName('nif3').AsString := NifWithoutZero;
      Query.ParamByName('id_empresa').DataType := ftInteger;
      Query.ParamByName('id_empresa').AsInteger := StrToIntDef(Config.IdEmpresa, 1);
      Query.Open;
    end;

    // 2. Si no se encuentra con ide, buscar por NIF globalmente
    if (CleanNif <> '') and ((not Query.Active) or Query.Eof) then
    begin
      if Query.Active then Query.Close;
      Query.SQL.Text := 'SELECT ID FROM trabajadores WHERE NIF = :nif1 OR NIF = :nif2 OR TRIM(LEADING ''0'' FROM NIF) = :nif3 LIMIT 1';
      Query.ParamByName('nif1').DataType := ftString;
      Query.ParamByName('nif1').AsString := NifWithoutZero;
      Query.ParamByName('nif2').DataType := ftString;
      Query.ParamByName('nif2').AsString := NifWithZero;
      Query.ParamByName('nif3').DataType := ftString;
      Query.ParamByName('nif3').AsString := NifWithoutZero;
      Query.Open;
    end;

    // 3. Fallback por Nombre y Apellidos si no se encontró por NIF
    if ((not Query.Active) or Query.Eof) and (FullName <> '') then
    begin
      if Query.Active then Query.Close;
      Query.SQL.Text := 'SELECT ID FROM trabajadores WHERE (CONCAT(nombre, '' '', COALESCE(apellidos, '''')) LIKE :nombre ' +
                        'OR CONCAT(COALESCE(apellidos, ''''), '' '', nombre) LIKE :nombre) AND ide = :id_empresa';
      Query.ParamByName('nombre').DataType := ftString;
      Query.ParamByName('nombre').AsString := '%' + FullName + '%';
      Query.ParamByName('id_empresa').DataType := ftInteger;
      Query.ParamByName('id_empresa').AsInteger := StrToIntDef(Config.IdEmpresa, 1);
      Query.Open;
      
      if Query.Eof then
      begin
        Query.Close;
        Query.SQL.Text := 'SELECT ID FROM trabajadores WHERE (CONCAT(nombre, '' '', COALESCE(apellidos, '''')) LIKE :nombre ' +
                          'OR CONCAT(COALESCE(apellidos, ''''), '' '', nombre) LIKE :nombre) LIMIT 1';
        Query.ParamByName('nombre').DataType := ftString;
        Query.ParamByName('nombre').AsString := '%' + FullName + '%';
        Query.Open;
      end;
    end;
    
    if (not Query.Active) or Query.Eof then
      raise Exception.Create('Trabajador no encontrado con NIF: ' + CleanNif + ' o Nombre: ' + FullName);
      
    IdT := Query.Fields[0].AsString;
    Query.Close;

    // Vincular Documento directamente
    LinkDocument('trabajadores', IdT, AFilePath, ANewName);
  finally
    Query.Free;
  end;
end;

class procedure TDocumentProcessor.ProcessFile(const AFilePath: string);
var
  Analysis: TAIResult;
  DestDir, DestFile, NewName, DocYear, DocNum, DocNif: string;
  errorAsunto, errorCuerpo, localErrorMsg: string;
begin
  try
    Analysis := AnalyzeWithAI(AFilePath);
    
    if not Analysis.Success then
      raise Exception.Create('El documento no pudo ser clasificado por la IA');

    DM.FDConnection.StartTransaction;
    try
      // Generar nombre de archivo estructurado
      NewName := 'XX';
      if Analysis.DocType = 'FACTURA' then
      begin
        DocNum := GetJSONString(Analysis.Data, 'numero', 'XX');
        DocNif := SanitizeNIF(GetJSONString(Analysis.Data, 'nif_proveedor', 'XX'));
        
        if IsDuplicate('prov_facturas', DocNum, DocNif) then
          raise Exception.Create('Documento ya existe en la base de datos (Duplicado)');

        NewName := SanitizeForFileName(
          GetJSONString(Analysis.Data, 'fecha', DateToStr(Now)) + '_' +
          DocNif + '_' +
          GetJSONString(Analysis.Data, 'nombre_proveedor', 'XX') + '_' +
          'FAC_' +
          DocNum
        ) + TPath.GetExtension(AFilePath);
        
        ProcessFactura(AFilePath, NewName, Analysis.Data);
        
        DocYear := FormatDateTime('yyyy', ISO8601ToDate(GetJSONString(Analysis.Data, 'fecha', '')));
        DestDir := TPath.Combine(Config.PathFacturas, DocYear);
      end
      else if Analysis.DocType = 'ALBARAN' then
      begin
        DocNum := GetJSONString(Analysis.Data, 'numero', 'XX');
        DocNif := SanitizeNIF(GetJSONString(Analysis.Data, 'nif_proveedor', 'XX'));

        if IsDuplicate('prov_albaranes', DocNum, DocNif) then
          raise Exception.Create('Documento ya existe en la base de datos (Duplicado)');

        NewName := SanitizeForFileName(
          GetJSONString(Analysis.Data, 'fecha', DateToStr(Now)) + '_' +
          DocNif + '_' +
          GetJSONString(Analysis.Data, 'nombre_proveedor', 'XX') + '_' +
          'ALB_' +
          DocNum
        ) + TPath.GetExtension(AFilePath);
        
        ProcessAlbaran(AFilePath, NewName, Analysis.Data);

        DocYear := FormatDateTime('yyyy', ISO8601ToDate(GetJSONString(Analysis.Data, 'fecha', '')));
        DestDir := TPath.Combine(Config.PathAlbaranes, DocYear);
      end
      else if Analysis.DocType = 'TRABAJADOR' then
      begin
        DocNif := SanitizeNIF(GetJSONString(Analysis.Data, 'nif_trabajador', 'XX'));
        NewName := SanitizeForFileName(
          GetJSONString(Analysis.Data, 'fecha', FormatDateTime('yyyy-mm-dd', Now)) + '_' + 
          DocNif + '_' +
          GetJSONString(Analysis.Data, 'nombre_trabajador', 'XX') + '_' +
          GetJSONString(Analysis.Data, 'tipo_documento', 'TRABAJADOR') + '_' +
          'XX'
        ) + TPath.GetExtension(AFilePath);
        
        ProcessTrabajador(AFilePath, NewName, Analysis.Data);
        DestDir := Config.PathTrabajadores;
      end
      else
        raise Exception.Create('Tipo de documento desconocido: ' + Analysis.DocType);

      DM.FDConnection.Commit;
      
      if not TDirectory.Exists(DestDir) then TDirectory.CreateDirectory(DestDir);
      DestFile := TPath.Combine(DestDir, NewName);
      
      if TFile.Exists(DestFile) then TFile.Delete(DestFile);
      TFile.Move(AFilePath, DestFile);
      
      DM.Log('INFO', 'PROCESSOR', 'Procesado correctamente: ' + NewName, TPath.GetFileName(AFilePath));
      
    except
      on E: Exception do
      begin
        DM.FDConnection.Rollback;
        raise;
      end;
    end;
  except
    on E: Exception do
    begin
      DM.Log('ERROR', 'PROCESSOR', E.Message, TPath.GetFileName(AFilePath));
      
      // Send error email notification if configured
      if (Config.IdConfigEnvioError <> '') and (Trim(Config.EmailErrorDestino) <> '') then
      begin
        errorAsunto := 'Error en Gestión Documental - ' + TPath.GetFileName(AFilePath);
        errorCuerpo := 'Se ha producido un error crítico al procesar un documento.' + sLineBreak + sLineBreak +
                           'Documento: ' + TPath.GetFileName(AFilePath) + sLineBreak +
                           'Error: ' + E.Message + sLineBreak + sLineBreak +
                           'Por favor, revise la carpeta de Errores para más información.';
        localErrorMsg := '';
        EnviarEmailsIndividuales(Config.IdConfigEnvioError, Config.EmailErrorDestino, errorAsunto, errorCuerpo, '', localErrorMsg);
      end;
      
      DestDir := Config.PathErrores;
      if not TDirectory.Exists(DestDir) then TDirectory.CreateDirectory(DestDir);
      DestFile := TPath.Combine(DestDir, FormatDateTime('yy-mm-dd_', Now) + TPath.GetFileName(AFilePath));
      
      if TFile.Exists(DestFile) then TFile.Delete(DestFile);
      try
        TFile.Move(AFilePath, DestFile);
      except
      end;
    end;
  end;
  
  if Assigned(Analysis.Data) then Analysis.Data.Free;
end;

{ Métodos auxiliares permanecen similares o se adaptan }

class function TDocumentProcessor.FileToBase64(const AFilePath: string): string;
var
  Stream: TMemoryStream;
  Input: TBytes;
begin
  Stream := TMemoryStream.Create;
  try
    Stream.LoadFromFile(AFilePath);
    SetLength(Input, Stream.Size);
    Stream.Position := 0;
    if Stream.Size > 0 then
      Stream.ReadBuffer(Input[0], Stream.Size);
    Result := TNetEncoding.Base64.EncodeBytesToString(Input);
    Result := Result.Replace(#13, '').Replace(#10, '');
  finally
    Stream.Free;
  end;
end;

class function TDocumentProcessor.ISO8601ToDate(const AValue: string): TDateTime;
begin
  Result := Now;
  if AValue = '' then Exit;
  
  try
    if not TryISO8601ToDate(AValue, Result) then
    begin
      // Fallback a formato regional (ej. DD/MM/YYYY)
      if not TryStrToDate(AValue, Result) then
        Result := Now;
    end;
  except
    Result := Now;
  end;
end;

class function TDocumentProcessor.LoadFileAsBlob(const AFilePath: string): TBytes;
var
  Stream: TFileStream;
begin
  Stream := TFileStream.Create(AFilePath, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, Stream.Size);
    if Stream.Size > 0 then
      Stream.ReadBuffer(Result[0], Stream.Size);
  finally
    Stream.Free;
  end;
end;

class procedure TDocumentProcessor.LinkDocument(const ATableName: string; const AIdRef: string; const AFilePath, ANewName: string);
var
  Query: TFDQuery;
  Ext: string;
  BlobStream: TBytesStream;
begin
  Ext := TPath.GetExtension(AFilePath).ToLower;
  Query := TFDQuery.Create(nil);
  try
    Query.Connection := DM.FDConnection;
    Query.SQL.Text := 'INSERT INTO documentos (ide, fecha, archivo, nomarchivo, extension, idalbproveedor, idfactproveedor, idt, descripcion, publicar) ' +
                      'VALUES (:ide, :fecha, :archivo, :nomarchivo, :extension, :idalbproveedor, :idfactproveedor, :idt, :descripcion, 1)';
    
    // Definir tipos de datos y tamaños explícitamente para evitar desalineación de memoria
    Query.ParamByName('ide').DataType := ftInteger;
    Query.ParamByName('fecha').DataType := ftDate;
    Query.ParamByName('archivo').DataType := ftBlob;
    Query.ParamByName('nomarchivo').DataType := ftString;
    Query.ParamByName('nomarchivo').Size := 100;
    Query.ParamByName('extension').DataType := ftString;
    Query.ParamByName('extension').Size := 4;
    Query.ParamByName('idalbproveedor').DataType := ftInteger;
    Query.ParamByName('idfactproveedor').DataType := ftInteger;
    Query.ParamByName('idt').DataType := ftInteger;
    Query.ParamByName('descripcion').DataType := ftString;
    Query.ParamByName('descripcion').Size := 50;

    // Asignar valores
    Query.ParamByName('ide').AsInteger := StrToIntDef(Config.IdEmpresa, 1);
    Query.ParamByName('fecha').AsDate := Now;
    Query.ParamByName('nomarchivo').AsString := Copy(ANewName.Replace(#0, '').Trim, 1, 100);
    Query.ParamByName('extension').AsString := Copy(Ext, 1, 4);
    
    Query.ParamByName('idalbproveedor').Clear;
    Query.ParamByName('idfactproveedor').Clear;
    Query.ParamByName('idt').Clear;
    Query.ParamByName('descripcion').Clear;

    if (ATableName = 'prov_facturas') or (ATableName = 'ge_prov_facturas') then Query.ParamByName('idfactproveedor').AsInteger := StrToInt(AIdRef)
    else if (ATableName = 'prov_albaranes') or (ATableName = 'ge_prov_albaranes') then Query.ParamByName('idalbproveedor').AsInteger := StrToInt(AIdRef)
    else if (ATableName = 'trabajadores') or (ATableName = 'ge_trabajadores') then 
    begin
      Query.ParamByName('idt').AsInteger := StrToInt(AIdRef);
      Query.ParamByName('descripcion').AsString := Copy(ANewName.Replace(#0, '').Trim, 1, 50);
    end;

    // Asignar el BLOB usando LoadFromStream para evitar corrupción de offsets de memoria y doble liberación en FireDAC
    BlobStream := TBytesStream.Create(LoadFileAsBlob(AFilePath));
    try
      BlobStream.Position := 0;
      Query.ParamByName('archivo').LoadFromStream(BlobStream, ftBlob);
      Query.ExecSQL;
    finally
      BlobStream.Free;
    end;
  finally
    Query.Free;
  end;
end;

class function TDocumentProcessor.IsDuplicate(const ATableName, ANumero, ANif: string): Boolean;
var
  Query: TFDQuery;
  SQL: string;
begin
  Result := False;
  if (ANumero = '') or (ANif = '') then Exit;
  
  Query := TFDQuery.Create(nil);
  try
    Query.Connection := DM.FDConnection;
    if (ATableName = 'prov_facturas') or (ATableName = 'ge_prov_facturas') then
    begin
      SQL := 'SELECT COUNT(*) FROM prov_facturas f JOIN proveedores p ON f.idproveedor = p.id AND f.idempresa = p.Idempresa ' +
             'WHERE f.documento = :num AND p.nif = :nif AND f.idempresa = :idempresa';
      Query.SQL.Text := SQL;
      Query.ParamByName('num').DataType := ftString;
      Query.ParamByName('nif').DataType := ftString;
      Query.ParamByName('idempresa').DataType := ftInteger;
      Query.ParamByName('num').AsString := ANumero;
      Query.ParamByName('nif').AsString := ANif;
      Query.ParamByName('idempresa').AsInteger := StrToIntDef(Config.IdEmpresa, 1);
    end
    else if (ATableName = 'prov_albaranes') or (ATableName = 'ge_prov_albaranes') then
    begin
      SQL := 'SELECT COUNT(*) FROM prov_albaranes a JOIN proveedores p ON a.idproveedor = p.id AND a.idempresa = p.Idempresa ' +
             'WHERE a.documento = :num AND p.nif = :nif AND a.idempresa = :idempresa';
      Query.SQL.Text := SQL;
      Query.ParamByName('num').DataType := ftString;
      Query.ParamByName('nif').DataType := ftString;
      Query.ParamByName('idempresa').DataType := ftInteger;
      Query.ParamByName('num').AsString := ANumero;
      Query.ParamByName('nif').AsString := ANif;
      Query.ParamByName('idempresa').AsInteger := StrToIntDef(Config.IdEmpresa, 1);
    end
    else
      Exit;

    Query.Open;
    Result := Query.Fields[0].AsInteger > 0;
  finally
    Query.Free;
  end;
end;

class function TDocumentProcessor.SanitizeForFileName(const AValue: string): string;
const
  InvalidChars: array[0..9] of Char = ('\', '/', ':', '*', '?', '"', '<', '>', '|', ' ');
var
  i: Integer;
begin
  Result := AValue;
  for i := Low(InvalidChars) to High(InvalidChars) do
    Result := Result.Replace(InvalidChars[i], '-');
  Result := Result.Replace('.', ''); // Eliminar todos los puntos del nombre base
  while Pos('--', Result) > 0 do
    Result := Result.Replace('--', '-');
  Result := Result.Trim(['-']);
end;

class function TDocumentProcessor.SanitizeNIF(const AValue: string): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(AValue) do
  begin
    if CharInSet(AValue[I], ['A'..'Z', 'a'..'z', '0'..'9']) then
      Result := Result + AValue[I];
  end;
  Result := Result.ToUpper;

  // Si es un NIE español precedido por 0 (ej: 0Z2799157Q, 0X1234567A, 0Y8399348S - común en TGSS/Seguridad Social),
  // eliminamos el cero inicial para obtener el NIE estándar
  if (Length(Result) = 10) and (Result[1] = '0') and CharInSet(Result[2], ['X', 'Y', 'Z']) then
    Result := Copy(Result, 2, Length(Result) - 1)
  // Si es un DNI de 10 caracteres con 0 inicial seguido de 8 dígitos y letra (ej: 012345678Z), quitar el 0 inicial sobrante
  else if (Length(Result) = 10) and (Result[1] = '0') and CharInSet(Result[10], ['A'..'Z']) and
          (CharInSet(Result[2], ['0'..'9'])) and (CharInSet(Result[9], ['0'..'9'])) then
    Result := Copy(Result, 2, Length(Result) - 1);
end;

class function TDocumentProcessor.CleanName(const AName: string): string;
var
  I: Integer;
  S: string;
begin
  S := UpperCase(AName);
  S := S.Replace(' S.L.', '').Replace(' SL', '')
        .Replace(' S.A.', '').Replace(' SA', '')
        .Replace(' S.L.U.', '').Replace(' SLU', '')
        .Replace(' S.A.U.', '').Replace(' SAU', '')
        .Replace(' COOP.', '').Replace(' COOP', '')
        .Replace('S.L.', '').Replace('S.A.', '')
        .Replace('C.B.', '').Replace(' CB', '');
  Result := '';
  for I := 1 to Length(S) do
    if CharInSet(S[I], ['A'..'Z', '0'..'9']) then
      Result := Result + S[I];
end;

class function TDocumentProcessor.FindOrCreateProveedor(AQuery: TFDQuery; AData: TJSONObject): string;
var
  CleanNif, ANif, ANombre: string;
  ADireccion, ACodigoPostal, APoblacion, AProvincia, ATelefono: string;
  NewId: Integer;
  provAsunto, provCuerpo, localErrorMsg: string;
  CleanSearchName, CleanSearchDireccion, DbNombre, DbDireccion: string;
  IdEmp: Integer;
begin
  ANif := GetJSONString(AData, 'nif_proveedor');
  ANombre := GetJSONString(AData, 'nombre_proveedor', 'Proveedor Desconocido');
  ADireccion := GetJSONString(AData, 'direccion_proveedor');
  ACodigoPostal := GetJSONString(AData, 'cp_proveedor');
  APoblacion := GetJSONString(AData, 'poblacion_proveedor');
  AProvincia := GetJSONString(AData, 'provincia_proveedor');
  ATelefono := GetJSONString(AData, 'telefono_proveedor');
  IdEmp := StrToIntDef(Config.IdEmpresa, 1);

  CleanNif := SanitizeNIF(ANif);

  // Buscar primero por NIF si está disponible en el documento
  if CleanNif <> '' then
  begin
    AQuery.SQL.Text := 'SELECT id FROM proveedores WHERE nif = :nif AND Idempresa = :ide';
    AQuery.ParamByName('nif').DataType := ftString;
    AQuery.ParamByName('nif').AsString := CleanNif;
    AQuery.ParamByName('ide').DataType := ftInteger;
    AQuery.ParamByName('ide').AsInteger := IdEmp;
    AQuery.Open;

    if not AQuery.Eof then
    begin
      Result := AQuery.Fields[0].AsString;
      AQuery.Close;
      Exit;
    end;
    AQuery.Close;
    
    // Si no se encuentra con Idempresa, buscar por NIF globalmente
    AQuery.SQL.Text := 'SELECT id FROM proveedores WHERE nif = :nif LIMIT 1';
    AQuery.ParamByName('nif').DataType := ftString;
    AQuery.ParamByName('nif').AsString := CleanNif;
    AQuery.Open;
    if not AQuery.Eof then
    begin
      Result := AQuery.Fields[0].AsString;
      AQuery.Close;
      Exit;
    end;
    AQuery.Close;
  end;

  // Si no se encuentra por NIF, buscar coincidencia por nombre o por dirección para evitar duplicados por error OCR en NIF
  CleanSearchName := CleanName(ANombre);
  CleanSearchDireccion := CleanName(ADireccion);
  if (CleanSearchName <> '') or (CleanSearchDireccion <> '') then
  begin
    AQuery.SQL.Text := 'SELECT id, nif, nombre, direccion FROM proveedores WHERE Idempresa = :ide';
    AQuery.ParamByName('ide').DataType := ftInteger;
    AQuery.ParamByName('ide').AsInteger := IdEmp;
    AQuery.Open;
    while not AQuery.Eof do
    begin
      DbNombre := AQuery.FieldByName('nombre').AsString;
      DbDireccion := AQuery.FieldByName('direccion').AsString;
      if ((CleanSearchName <> '') and (CleanName(DbNombre) = CleanSearchName)) or
         ((CleanSearchDireccion <> '') and (CleanName(DbDireccion) = CleanSearchDireccion)) then
      begin
        Result := AQuery.FieldByName('id').AsString;
        DM.Log('WARNING', 'PROCESSOR', 'Proveedor coincidente por nombre o dirección. Usando ID existente: ' + 
               DbNombre + ' (NIF BD: ' + AQuery.FieldByName('nif').AsString + ', NIF OCR: ' + CleanNif + ')', '');
        AQuery.Close;
        Exit;
      end;
      AQuery.Next;
    end;
    AQuery.Close;
  end;

  if CleanNif = '' then
    raise Exception.Create('NIF de proveedor vacío o inválido, y no se encontró coincidencia por nombre: ' + ANombre);

  // No existe: calcular nuevo ID correlativo para la empresa
  AQuery.SQL.Text := 'SELECT COALESCE(MAX(id), 0) + 1 FROM proveedores WHERE Idempresa = :ide';
  AQuery.ParamByName('ide').DataType := ftInteger;
  AQuery.ParamByName('ide').AsInteger := IdEmp;
  AQuery.Open;
  NewId := AQuery.Fields[0].AsInteger;
  AQuery.Close;

  // Crear nuevo proveedor
  AQuery.SQL.Text := 'INSERT INTO proveedores (id, Idempresa, nif, nombre, nombre_corto, direccion, codpost, poblacion, provincia, telefono, activo) ' +
                     'VALUES (:id, :ide, :nif, :nombre, :nombre_corto, :dir, :cp, :pob, :prov, :tel, 1)';
  AQuery.ParamByName('id').DataType := ftInteger;
  AQuery.ParamByName('id').AsInteger := NewId;
  AQuery.ParamByName('ide').DataType := ftInteger;
  AQuery.ParamByName('ide').AsInteger := IdEmp;
  AQuery.ParamByName('nif').DataType := ftString;
  AQuery.ParamByName('nif').AsString := CleanNif;
  AQuery.ParamByName('nombre').DataType := ftString;
  AQuery.ParamByName('nombre').AsString := Copy(ANombre, 1, 40);
  AQuery.ParamByName('nombre_corto').DataType := ftString;
  AQuery.ParamByName('nombre_corto').AsString := Copy(ANombre, 1, 10);
  AQuery.ParamByName('dir').DataType := ftString;
  AQuery.ParamByName('dir').AsString := Copy(ADireccion, 1, 40);
  AQuery.ParamByName('cp').DataType := ftString;
  AQuery.ParamByName('cp').AsString := Copy(ACodigoPostal, 1, 5);
  AQuery.ParamByName('pob').DataType := ftString;
  AQuery.ParamByName('pob').AsString := Copy(APoblacion, 1, 40);
  AQuery.ParamByName('prov').DataType := ftString;
  AQuery.ParamByName('prov').AsString := Copy(AProvincia, 1, 30);
  AQuery.ParamByName('tel').DataType := ftString;
  AQuery.ParamByName('tel').AsString := Copy(ATelefono, 1, 12);
  AQuery.ExecSQL;

  Result := NewId.ToString;

  DM.Log('INFO', 'PROCESSOR', 'Proveedor creado automáticamente: ' + ANombre + ' (NIF: ' + CleanNif + ')', '');

  // Send email notification for new supplier if configured
  if (Config.IdConfigEnvioError <> '') and (Trim(Config.EmailErrorDestino) <> '') then
  begin
    provAsunto := 'Gestión Documental - Nuevo Proveedor Creado';
    provCuerpo := 'Se ha creado automáticamente un nuevo proveedor en la base de datos.' + sLineBreak + sLineBreak +
                       'NIF: ' + CleanNif + sLineBreak +
                       'Nombre: ' + ANombre + sLineBreak +
                       'Dirección: ' + ADireccion + sLineBreak +
                       'Población: ' + APoblacion + sLineBreak + sLineBreak +
                       'Por favor, revise los datos del proveedor si es necesario.';
    localErrorMsg := '';
    EnviarEmailsIndividuales(Config.IdConfigEnvioError, Config.EmailErrorDestino, provAsunto, provCuerpo, '', localErrorMsg);
  end;
end;

class function TDocumentProcessor.GetJSONString(AObj: TJSONObject; const AKey: string; const ADefault: string): string;
var
  V: TJSONValue;
begin
  Result := ADefault;
  if AObj = nil then Exit;
  V := AObj.FindValue(AKey);
  if V <> nil then
    Result := V.Value;
end;

class function TDocumentProcessor.GetJSONFloat(AObj: TJSONObject; const AKey: string; const ADefault: Double): Double;
var
  V: TJSONValue;
begin
  Result := ADefault;
  if AObj = nil then Exit;
  V := AObj.FindValue(AKey);
  if (V <> nil) and (V is TJSONNumber) then
    Result := TJSONNumber(V).AsDouble
  else if V <> nil then
    TryStrToFloat(V.Value.Replace('.', FormatSettings.DecimalSeparator).Replace(',', FormatSettings.DecimalSeparator), Result);
end;

class function TDocumentProcessor.GetJSONInt(AObj: TJSONObject; const AKey: string; const ADefault: Integer): Integer;
var
  V: TJSONValue;
begin
  Result := ADefault;
  if AObj = nil then Exit;
  V := AObj.FindValue(AKey);
  if (V <> nil) and (V is TJSONNumber) then
    Result := TJSONNumber(V).AsInt
  else if V <> nil then
    TryStrToInt(V.Value, Result);
end;

class procedure TDocumentProcessor.CheckAndInsertProveedorArticulo(AQuery: TFDQuery; AIdEmpresa, AIdProveedor: Integer; const ACodigo, ADescripcion: string; APrecio: Double; AIsFactura: Boolean);
var
  CurrentDesc: string;
begin
  if (ACodigo = '') or (AIdProveedor <= 0) then Exit;

  // 1. Comprobar si ya existe el artículo para este proveedor y código y obtener su descripción actual
  AQuery.SQL.Text := 'SELECT id, descripcion FROM proveedores_articulos ' +
                    'WHERE idempresa = :ide AND idproveedor = :idprov AND id = :cod LIMIT 1';
  AQuery.ParamByName('ide').DataType := ftInteger;
  AQuery.ParamByName('ide').AsInteger := AIdEmpresa;
  AQuery.ParamByName('idprov').DataType := ftInteger;
  AQuery.ParamByName('idprov').AsInteger := AIdProveedor;
  AQuery.ParamByName('cod').DataType := ftString;
  AQuery.ParamByName('cod').AsString := Copy(ACodigo, 1, 25);
  AQuery.Open;
  
  if not AQuery.Eof then
  begin
    CurrentDesc := AQuery.FieldByName('descripcion').AsString;
    AQuery.Close;
    
    // 2. Si ya existe, actualizamos último precio y, si es factura y la descripción es distinta, la descripción
    if AIsFactura and (CurrentDesc <> ADescripcion) then
    begin
      AQuery.SQL.Text := 'UPDATE proveedores_articulos ' +
                        'SET ult_precio = :precio, descripcion = :desc ' +
                        'WHERE idempresa = :ide AND idproveedor = :idprov AND id = :cod';
      AQuery.ParamByName('desc').DataType := ftString;
      AQuery.ParamByName('desc').AsString := Copy(ADescripcion, 1, 50);
    end
    else
    begin
      AQuery.SQL.Text := 'UPDATE proveedores_articulos ' +
                        'SET ult_precio = :precio ' +
                        'WHERE idempresa = :ide AND idproveedor = :idprov AND id = :cod';
    end;
    AQuery.ParamByName('precio').DataType := ftFloat;
    AQuery.ParamByName('precio').AsFloat := APrecio;
    AQuery.ParamByName('ide').DataType := ftInteger;
    AQuery.ParamByName('ide').AsInteger := AIdEmpresa;
    AQuery.ParamByName('idprov').DataType := ftInteger;
    AQuery.ParamByName('idprov').AsInteger := AIdProveedor;
    AQuery.ParamByName('cod').DataType := ftString;
    AQuery.ParamByName('cod').AsString := Copy(ACodigo, 1, 25);
    AQuery.ExecSQL;
  end
  else
  begin
    AQuery.Close;
    
    // 3. Insertar nuevo artículo de proveedor
    AQuery.SQL.Text := 'INSERT INTO proveedores_articulos (idempresa, idproveedor, id, descripcion, ult_precio, activo) ' +
                      'VALUES (:ide, :idprov, :cod, :desc, :precio, 1)';
    AQuery.ParamByName('ide').DataType := ftInteger;
    AQuery.ParamByName('ide').AsInteger := AIdEmpresa;
    AQuery.ParamByName('idprov').DataType := ftInteger;
    AQuery.ParamByName('idprov').AsInteger := AIdProveedor;
    AQuery.ParamByName('cod').DataType := ftString;
    AQuery.ParamByName('cod').AsString := Copy(ACodigo, 1, 25);
    AQuery.ParamByName('desc').DataType := ftString;
    AQuery.ParamByName('desc').AsString := Copy(ADescripcion, 1, 50);
    AQuery.ParamByName('precio').DataType := ftFloat;
    AQuery.ParamByName('precio').AsFloat := APrecio;
    AQuery.ExecSQL;
  end;
end;

end.
