Antes, si Gemini Vision fallaba (cuota/API), el 100% de los comprobantes del chat web quedaban sin poder leerse. Ahora el job intenta primero un servicio OCR externo configurable (imagen -> texto) y le pasa el texto a Gemini para que solo lo estructure en JSON, más barato y sin la cuota de visión. Si el OCR no está habilitado o falla, cae al método anterior (Gemini leyendo la imagen directamente) sin romper nada. Agrega configuración en el panel (URL/token/habilitado + probar conexión) y la spec del servicio OCR a desplegar en docs/ocr-service-spec.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
285 lines
11 KiB
PHP
285 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\ChatMessage;
|
|
use App\Models\recarga as Recarga;
|
|
use App\Models\Saldo;
|
|
use App\Models\SolicitudRecarga;
|
|
use App\Models\User;
|
|
use App\Services\GeminiVisionService;
|
|
use App\Services\OcrExtractionService;
|
|
use App\Services\PagoValidadorService;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Valida un comprobante de pago del chat web fuera del request HTTP.
|
|
*
|
|
* Antes esto corría dentro del propio request de Livewire: Gemini (hasta 80s)
|
|
* más la lectura IMAP (20-60s) superaban el límite del gateway y el usuario
|
|
* recibía un 504. Peor aún, con SESSION_DRIVER=file el request mantenía
|
|
* bloqueado el archivo de sesión, así que el wire:poll del chat no podía
|
|
* avanzar y la conversación entera se congelaba.
|
|
*
|
|
* Ahora el request solo guarda la imagen y despacha este job. El job escribe
|
|
* su progreso como mensajes normales del chat (que el poll va mostrando) y
|
|
* deja el desenlace en caché para que el componente aplique la recarga.
|
|
*
|
|
* Espejo del flujo de Telegram, pero independiente: ValidarPagoTelegramJob
|
|
* no se toca.
|
|
*/
|
|
class ValidarPagoWebJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public int $tries = 1;
|
|
public int $timeout = 100;
|
|
|
|
private const MAX_INTENTOS = 9;
|
|
private const RESULTADO_TTL = 1800;
|
|
|
|
public function __construct(
|
|
private int $convId,
|
|
private int $usuarioId,
|
|
private int $solicitudId,
|
|
private string $comprobantePath,
|
|
private ?string $nombreRemitente = null,
|
|
private ?array $datosPago = null,
|
|
private int $intento = 1,
|
|
) {}
|
|
|
|
/** Clave donde el componente Livewire lee el desenlace. */
|
|
public static function claveResultado(int $convId, int $solicitudId): string
|
|
{
|
|
return "pago_web_{$convId}_{$solicitudId}";
|
|
}
|
|
|
|
public function handle(): void
|
|
{
|
|
$solicitud = SolicitudRecarga::pendiente($this->usuarioId);
|
|
|
|
if (! $solicitud || $solicitud->id !== $this->solicitudId) {
|
|
Log::info("[ValidarPagoWebJob] conv={$this->convId}: la solicitud ya no está pendiente, abortando.");
|
|
$this->resolver('cancelado');
|
|
return;
|
|
}
|
|
|
|
// ── Paso 1: leer el comprobante con Gemini (solo en el primer intento)
|
|
if ($this->datosPago === null) {
|
|
$datos = $this->leerComprobante();
|
|
|
|
if ($datos === null) {
|
|
return; // leerComprobante() ya avisó al usuario y cerró el flujo
|
|
}
|
|
|
|
$valorIA = (int) ($datos['valor'] ?? 0);
|
|
|
|
if ($valorIA !== $solicitud->monto) {
|
|
$this->bot(
|
|
"⚠️ El monto del comprobante ($" . number_format($valorIA) . ") no coincide "
|
|
. "con tu solicitud de recarga ($" . number_format($solicitud->monto) . ")."
|
|
);
|
|
$this->resolver('monto_incorrecto');
|
|
return;
|
|
}
|
|
|
|
$this->datosPago = $datos;
|
|
$this->bot("📧 Datos leídos correctamente. Ahora estoy buscando tu pago en el banco...");
|
|
}
|
|
|
|
// ── Paso 2: buscar el pago en el correo
|
|
$resultado = app(PagoValidadorService::class)->validar(
|
|
$this->datosPago, $this->usuarioId, $this->nombreRemitente, 'web'
|
|
);
|
|
|
|
$motivo = $resultado['motivo'] ?? '';
|
|
|
|
if ($resultado['estado'] === 'confirmado') {
|
|
// El saldo se acredita aquí y no en el componente: si el usuario
|
|
// cierra o recarga la pestaña mientras validamos, el dinero tiene
|
|
// que quedar abonado igual. El componente solo se encarga después
|
|
// de completar la compra que hubiera quedado pendiente.
|
|
$this->aplicarRecarga($solicitud);
|
|
$this->resolver('confirmado', ['monto' => $solicitud->monto]);
|
|
return;
|
|
}
|
|
|
|
if ($resultado['estado'] === 'ya_usado') {
|
|
$this->bot("⛔ Comprobante ya utilizado. Este pago ya fue registrado anteriormente.");
|
|
$this->resolver('ya_usado');
|
|
return;
|
|
}
|
|
|
|
if ($motivo === 'sin_coincidencia' || $motivo === 'sin_correos') {
|
|
$this->reintentarOAgotar();
|
|
return;
|
|
}
|
|
|
|
$this->bot("⚠️ No confirmado: " . match ($motivo) {
|
|
'correo_deshabilitado' => 'la verificación por correo no está configurada',
|
|
'error_imap' => 'no pudimos leer el correo del banco',
|
|
'error_sistema' => 'error interno, intenta de nuevo en unos minutos',
|
|
default => 'no se pudo verificar automáticamente',
|
|
});
|
|
$this->resolver('error');
|
|
}
|
|
|
|
/**
|
|
* El pago aún no aparece en el correo: reintentar con espera o rendirse.
|
|
* El banco puede tardar un par de minutos en enviar la notificación.
|
|
*/
|
|
private function reintentarOAgotar(): void
|
|
{
|
|
if ($this->intento === 1) {
|
|
$this->bot(
|
|
"⏳ Estamos validando tu pago, esto puede tardar unos minutos.\n"
|
|
. "Te confirmo por aquí en cuanto lo encuentre."
|
|
);
|
|
}
|
|
|
|
if ($this->intento < self::MAX_INTENTOS) {
|
|
self::dispatch(
|
|
$this->convId,
|
|
$this->usuarioId,
|
|
$this->solicitudId,
|
|
$this->comprobantePath,
|
|
$this->nombreRemitente,
|
|
$this->datosPago,
|
|
$this->intento + 1,
|
|
)->delay(now()->addSeconds(20));
|
|
return;
|
|
}
|
|
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'validacion',
|
|
'contenido' => 'No pudimos confirmar tu pago automáticamente.',
|
|
'payload' => [
|
|
'estado' => 'pendiente',
|
|
'motivo' => 'No encontramos el pago en el correo del banco.',
|
|
'ia_datos' => $this->datosPago,
|
|
'botones' => [
|
|
['label' => '🔄 Reintentar', 'action' => 'pago.reintentar', 'data' => []],
|
|
['label' => '← Menú principal', 'action' => 'menu.principal', 'data' => []],
|
|
],
|
|
],
|
|
'leido' => true,
|
|
]);
|
|
|
|
$this->resolver('agotado', ['datosPago' => $this->datosPago]);
|
|
}
|
|
|
|
/** Acredita el saldo, deja registro de la recarga y cierra la solicitud. */
|
|
private function aplicarRecarga(SolicitudRecarga $solicitud): void
|
|
{
|
|
$monto = (int) $solicitud->monto;
|
|
|
|
DB::transaction(function () use ($solicitud, $monto) {
|
|
$user = User::with('saldo')->find($this->usuarioId);
|
|
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $this->usuarioId, 'valor' => 0]);
|
|
|
|
$saldo->update(['valor' => $saldo->valor + $monto]);
|
|
|
|
Recarga::create([
|
|
'usuario_id' => $this->usuarioId,
|
|
'saldo_id' => $saldo->id,
|
|
'monto' => $monto,
|
|
'valor_recarga' => $monto,
|
|
'status' => 'Confirmado',
|
|
'reference' => 'breb-chat-' . $solicitud->id,
|
|
]);
|
|
|
|
$solicitud->confirmar();
|
|
|
|
$this->bot(
|
|
"✅ ¡Tu recarga de $" . number_format($monto) . " fue aplicada!\n"
|
|
. "Tu nuevo saldo es $" . number_format($saldo->valor) . "."
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Lee el comprobante con Gemini. Devuelve null (y avisa al usuario) si no
|
|
* se pudo interpretar.
|
|
*/
|
|
private function leerComprobante(): ?array
|
|
{
|
|
$ruta = storage_path('app/' . $this->comprobantePath);
|
|
|
|
if (! is_file($ruta)) {
|
|
Log::warning("[ValidarPagoWebJob] conv={$this->convId}: no existe {$ruta}");
|
|
$this->bot("No pude recuperar la imagen del comprobante. Envíala de nuevo, por favor.");
|
|
$this->resolver('error');
|
|
return null;
|
|
}
|
|
|
|
$base64 = base64_encode(file_get_contents($ruta));
|
|
$mime = mime_content_type($ruta) ?: 'image/jpeg';
|
|
$gemini = app(GeminiVisionService::class);
|
|
$datos = null;
|
|
|
|
// Camino nuevo: OCR externo (texto) + Gemini solo-texto. Evita depender
|
|
// de la cuota/estabilidad de Gemini Vision, que es lo que fallaba antes.
|
|
$ocr = app(OcrExtractionService::class);
|
|
if ($ocr->habilitado()) {
|
|
$texto = $ocr->extraerTexto($base64, $mime);
|
|
if ($texto) {
|
|
$datos = $gemini->extraerPagoDeTexto($texto, $this->usuarioId, 'web');
|
|
} else {
|
|
Log::warning('[ValidarPagoWebJob] OCR no devolvio texto, cae a lectura por vision.');
|
|
}
|
|
}
|
|
|
|
// Si el OCR no esta habilitado, no respondio, o Gemini no pudo
|
|
// estructurar su texto: cae al metodo anterior (Gemini lee la imagen).
|
|
if (! $datos || isset($datos['__error']) || isset($datos['__parse_error'])) {
|
|
$datos = $gemini->extraerPago($base64, $mime, $this->usuarioId, 'web');
|
|
}
|
|
|
|
if (! $datos || isset($datos['__error']) || isset($datos['__parse_error'])) {
|
|
Log::warning('[ValidarPagoWebJob] Gemini: ' . ($datos['__error'] ?? $datos['__parse_error'] ?? 'sin respuesta'));
|
|
$this->bot("No pude leer el comprobante. Intenta con una foto más clara o con mejor iluminación.");
|
|
$this->resolver('error');
|
|
return null;
|
|
}
|
|
|
|
return $datos;
|
|
}
|
|
|
|
/** Si el job muere (excepción o timeout) el chat no puede quedarse colgado. */
|
|
public function failed(?\Throwable $e): void
|
|
{
|
|
Log::error('[ValidarPagoWebJob] falló: ' . ($e?->getMessage() ?? 'sin detalle'));
|
|
$this->bot("Tuvimos un problema validando tu comprobante. Intenta enviarlo de nuevo o pide un asesor.");
|
|
$this->resolver('error');
|
|
}
|
|
|
|
private function bot(string $contenido): void
|
|
{
|
|
ChatMessage::create([
|
|
'conversation_id' => $this->convId,
|
|
'tipo' => 'bot',
|
|
'tipo_ui' => 'text',
|
|
'contenido' => $contenido,
|
|
'leido' => true,
|
|
]);
|
|
}
|
|
|
|
private function resolver(string $estado, array $extra = []): void
|
|
{
|
|
Cache::put(
|
|
self::claveResultado($this->convId, $this->solicitudId),
|
|
array_merge(['estado' => $estado], $extra),
|
|
self::RESULTADO_TTL
|
|
);
|
|
}
|
|
}
|