Files
sirpremiumv2/app/Jobs/ValidarPagoWebJob.php
T
LizandroandClaude Sonnet 5 5e0c0367d9 feat: costo OCR en dashboard, logs correctos y fix de thinking tokens
- OcrExtractionService: registra cada llamada en ai_usage_logs (servicio=ocr,
  costo=5 COP/foto) y ahora tambien loggea cuando SI funciona, no solo
  cuando falla (antes era imposible confirmar por log que el OCR corrio).
- GeminiVisionService: el log decia siempre "[Gemini Vision]" sin importar
  si la llamada fue de imagen o de texto post-OCR, haciendo imposible
  distinguir cual camino se uso realmente. Ahora usa el servicio real.
- GeminiVisionService: agrega thinkingConfig.thinkingBudget=0 (igual que
  GeminiIntentService) para que el modelo no gaste tokens narrando su
  razonamiento en texto plano antes del JSON final -- se estaba colando
  ese razonamiento en la respuesta y comiendose el presupuesto de tokens.
- GeminiIntentService: resultado ya no queda forzado a 'ok'; ahora filtra
  bloques 'thought' igual que vision, y guarda tokens/raw en el detalle
  para poder diagnosticar sin acceso al servidor.
- ShowAiLogs: card y filtro de costo OCR en el dashboard de consumo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 01:52:59 +00:00

286 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->bot("Tu solicitud de recarga ya no está activa (puede que hayas iniciado una nueva). Si ya transferiste, escribe a un asesor para confirmar tu pago manualmente.");
$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, $this->usuarioId, 'web');
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
);
}
}