Se integro el sistema de extraer el tipo de cambio

This commit is contained in:
2026-01-23 10:42:21 -06:00
parent bf26268f23
commit b8045d73e6
9 changed files with 228 additions and 18 deletions

View File

@@ -27,10 +27,25 @@ route_handler = TenantCRUDRoutes(
max_page_size=100,
)
router = route_handler.router
crud_router = route_handler.router
# Create a custom router for specific endpoints that must be matched BEFORE generic CRUD routes
# We use the same prefix so they are grouped together
from fastapi import APIRouter
custom_router = APIRouter(prefix="/exchange-rate", tags=[])
@custom_router.get("/test-ping")
async def test_ping():
return {"message": "pong"}
# Master router to export
router = APIRouter()
# Include custom routes FIRST to avoid shadowing by /{id}
router.include_router(custom_router)
# router.include_router(crud_router)
@router.get(
@custom_router.get(
"/",
response_model=Dict[str, Any],
summary="List Exchange Rates",
@@ -66,3 +81,37 @@ async def list_exchange_rates(
"page": page,
"page_size": page_size,
}
@custom_router.get(
"/dof-search",
response_model=Dict[str, Any],
summary="Fetch Exchange Rate from DOF",
description="Fetches the exchange rate from the Official Journal of the Federation (DOF) for a specific date.",
)
async def fetch_exchange_rate_dof(
date: str = Query(..., description="Date in YYYY-MM-DD format"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
# This endpoint can be public or protected. Assuming protected for now.
# No specific tenant validation needed since it's an external query,
# but good to ensure user is authenticated.
try:
print(f"DEBUG: Route called with date={date}")
rate = ExchangeRateService.fetch_from_dof(date)
if rate is None:
return {"success": False, "message": "No se encontró el tipo de cambio en el DOF para la fecha especificada o el servicio no está disponible.", "value": None}
return {"success": True, "value": rate}
except Exception as e:
print(f"DEBUG: Error in route: {e}")
import traceback
traceback.print_exc()
return {"success": False, "message": f"Error interno: {str(e)}", "value": None}
# Include routers at the end to ensure all routes are registered
# Include custom routes FIRST to avoid shadowing by /{id} of crud_router
router.include_router(custom_router)
router.include_router(crud_router)

View File

@@ -1,10 +1,18 @@
from typing import Optional, Tuple, List, Dict, Any
from datetime import datetime, time
import requests
import re
from sqlalchemy.orm import Session
from sqlalchemy import cast, Date
from . import dto, models
import urllib3
from core.config import settings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class ExchangeRateService:
@@ -135,3 +143,91 @@ class ExchangeRateService:
db.delete(exchange_rate)
db.commit()
return True
@staticmethod
def _get_external_api_token(base_url, username, password) -> Optional[str]:
"""Helper to get authentication token from external API"""
try:
login_url = f"{base_url}/auth/login"
payload = {"username": username, "password": password}
headers = {"Content-Type": "application/json"}
response = requests.post(login_url, json=payload, headers=headers, timeout=5)
if response.status_code not in [200, 201]:
print(f"External API Login Failed: {response.status_code} - {response.text}")
return None
data = response.json()
return data.get("token") or data.get("access_token")
except Exception as e:
print(f"External API Login Error: {e}")
return None
@staticmethod
def fetch_from_dof(date_str: str) -> Optional[float]:
"""
Fetches the exchange rate from an external API (replacing direct DOF scraping).
The API handles date logic (holidays, weekends) automatically.
Args:
date_str (str): Date in 'YYYY-MM-DD' format.
Returns:
Optional[float]: The exchange rate value if found, None otherwise.
"""
# API Credentials
API_BASE_URL = settings.EXTERNAL_API_URL
API_USER = settings.EXTERNAL_API_USER
API_PASS = settings.EXTERNAL_API_PASSWORD
if not API_USER or not API_PASS:
print("ERROR: External API credentials not properly configured in settings")
return None
try:
print(f"DEBUG: Fetching External API for date: {date_str}")
# 1. Get Token
token = ExchangeRateService._get_external_api_token(API_BASE_URL, API_USER, API_PASS)
if not token:
print("Failed to obtain external API token")
return None
# 2. Fetch Exchange Rate
# The API endpoint is /tipoCambio/{YYYY-MM-DD}
tc_endpoint = f"{API_BASE_URL}/tipoCambio/{date_str}"
# Auth header: The API expects just the token string in common usage, but we try standard first
# based on user feedback/code: 'Authorization:' . $token
headers = {
"Authorization": token,
"Content-Type": "application/json"
}
response = requests.get(tc_endpoint, headers=headers, timeout=5)
# Retry logic as per PHP reference (if 401, maybe formatting issue, but requests handles headers well)
if response.status_code == 401:
# Try with Bearer prefix just in case, though PHP code suggested raw token
print("DEBUG: 401 received, retrying with Bearer prefix...")
headers["Authorization"] = f"Bearer {token}"
response = requests.get(tc_endpoint, headers=headers, timeout=5)
if response.status_code != 200:
print(f"External API TC Error: {response.status_code} - {response.text}")
return None
data = response.json()
# Expected response: {"Id":..., "Fecha":"...", "TipoCambio":17.452, "Mov":"..."}
if "TipoCambio" in data:
val = float(data["TipoCambio"])
print(f"DEBUG: External API returned value: {val}")
return val
print(f"DEBUG: 'TipoCambio' key not found in response: {data}")
return None
except Exception as e:
print(f"Error fetching from External API: {e}")
return None

View File

@@ -42,8 +42,13 @@ class Settings(BaseSettings):
# License
LICENSE_CHECK_ENABLED: bool = True
# External APIs
EXTERNAL_API_URL: str = "http://74.208.80.245:3000"
EXTERNAL_API_USER: str = ""
EXTERNAL_API_PASSWORD: str = ""
model_config = SettingsConfigDict(
env_file=".env", case_sensitive=True, extra="ignore", env_file_encoding="utf-8"
env_file=[".env", "../.env"], case_sensitive=True, extra="ignore", env_file_encoding="utf-8"
)
@property

View File

@@ -21,6 +21,7 @@ passlib[bcrypt]==1.7.4
httpx==0.28.1
requests==2.32.5
# Utilities
python-multipart==0.0.20
python-dotenv==1.1.1

View File

@@ -175,6 +175,9 @@ services:
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}
- EXTERNAL_API_URL=${EXTERNAL_API_URL}
- EXTERNAL_API_USER=${EXTERNAL_API_USER}
- EXTERNAL_API_PASSWORD=${EXTERNAL_API_PASSWORD}
ports:
- "8000:8000"
depends_on:

View File

@@ -37,10 +37,12 @@ export interface ExchangeRateFilters {
page_size?: number;
}
import type { ApiResponse } from '$lib/api';
export async function getExchangeRates(
companyId: number,
filters?: ExchangeRateFilters
): Promise<ExchangeRateListResponse> {
): Promise<ApiResponse<ExchangeRateListResponse>> {
const params = new URLSearchParams({ company_id: companyId.toString() });
if (filters) {
@@ -57,7 +59,7 @@ export async function getExchangeRates(
export async function getExchangeRate(
exchangeRateId: number,
companyId: number
): Promise<ExchangeRate> {
): Promise<ApiResponse<ExchangeRate>> {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.get<ExchangeRate>(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`);
}
@@ -65,7 +67,7 @@ export async function getExchangeRate(
export async function createExchangeRate(
data: ExchangeRateCreate,
companyId: number
): Promise<ExchangeRate> {
): Promise<ApiResponse<ExchangeRate>> {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.post<ExchangeRate>(`/v1/a76/exchange-rate/?${params.toString()}`, data);
}
@@ -74,7 +76,7 @@ export async function updateExchangeRate(
exchangeRateId: number,
data: ExchangeRateUpdate,
companyId: number
): Promise<ExchangeRate> {
): Promise<ApiResponse<ExchangeRate>> {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.put<ExchangeRate>(
`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`,
@@ -85,7 +87,17 @@ export async function updateExchangeRate(
export async function deleteExchangeRate(
exchangeRateId: number,
companyId: number
): Promise<void> {
): Promise<ApiResponse<void>> {
const params = new URLSearchParams({ company_id: companyId.toString() });
return api.delete(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`);
}
export interface DofResponse {
success: boolean;
message?: string;
value?: number | null;
}
export async function getDofExchangeRate(date: string): Promise<ApiResponse<DofResponse>> {
return api.get<DofResponse>(`/v1/a76/exchange-rate/dof-search?date=${date}`);
}

View File

@@ -8,10 +8,11 @@
import {
createExchangeRate,
updateExchangeRate,
type ExchangeRate
type ExchangeRate,
getDofExchangeRate
} from "$lib/api/dashboard/a76/general_catalogs/exchange-rate";
import { companyStore } from "$lib/stores/company.svelte";
import { Scale, BadgeDollarSign, Info, AlertCircle, ArrowRight } from "lucide-svelte";
import { Scale, BadgeDollarSign, Info, AlertCircle, ArrowRight, CloudDownload } from "lucide-svelte";
import { fly, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
@@ -44,6 +45,7 @@
});
let loading = $state(false);
let scraping = $state(false);
let error = $state<string | null>(null);
let showConfirmation = $state(false);
@@ -65,11 +67,40 @@
local_currency: 'MXN',
foreign_currency: 'USD'
};
// Si es modo contexto (falta dato) y tenemos fecha, intentar cargar automáticamente del DOF
if (initialDate && !item) {
// Opcional: Auto-consultar
// fetchFromDof(initialDate);
}
}
error = null;
}
});
async function fetchFromDof(date: string) {
if (!date) return;
scraping = true;
error = null;
try {
const response = await getDofExchangeRate(date);
// The API returns an ApiResponse object, so we need to access response.data
// response.data contains { success: boolean, value: number, message: string }
if (response.data?.success && response.data?.value) {
formData.value = response.data.value;
toast.success(`Tipo de cambio obtenido del DOF: ${response.data.value}`);
} else {
toast.error(response.data?.message || response.error || 'No se pudo obtener el dato del DOF');
// No bloquear, permitir manual
}
} catch (e) {
console.error(e);
toast.error('Error al consultar el servicio del DOF');
} finally {
scraping = false;
}
}
function handleSubmit() {
error = null;
try {
@@ -124,8 +155,6 @@
<Dialog.Content
class="fixed left-[50%] top-[50%] z-[10000] w-full max-w-[480px] translate-x-[-50%] translate-y-[-50%] border-0 bg-transparent shadow-2xl p-0 sm:rounded-xl overflow-hidden"
transition={scale}
params={{ duration: 300, easing: cubicOut, start: 0.95 }}
>
<div class="bg-background flex flex-col h-full rounded-xl overflow-hidden border border-border">
@@ -181,7 +210,23 @@
</div>
<div class="grid gap-2">
<Label for="value" class="text-sm font-medium text-muted-foreground ml-1">Tipo de Cambio (MXN/USD)</Label>
<div class="flex items-center justify-between">
<Label for="value" class="text-sm font-medium text-muted-foreground ml-1">Tipo de Cambio (MXN/USD)</Label>
<Button
type="button"
variant="ghost"
size="sm"
class="h-6 text-xs text-primary hover:text-primary/80 px-2 -mr-2"
disabled={scraping || loading || !formData.date}
onclick={() => fetchFromDof(formData.date)}
>
{#if scraping}
<span class="animate-spin mr-1.5"></span> Consultando...
{:else}
<CloudDownload size={14} class="mr-1.5" /> Consultar DOF
{/if}
</Button>
</div>
<div class="relative group">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground font-semibold">$</div>
<Input

View File

@@ -25,9 +25,8 @@
});
console.log('[ExchangeRateGuard] Response (stringified):', JSON.stringify(response, null, 2));
// api.get returns { data: ..., status: ... } but types say otherwise
const actualResponse = response as any;
const items = actualResponse.data?.items || [];
// api.get returns { data: ..., status: ... } and types now reflect that
const items = response.data?.items || [];
if (items.length === 0) {
console.log('[ExchangeRateGuard] No rate found, opening modal');

View File

@@ -354,8 +354,8 @@
page_size: 1
});
const actualResponse = response as any;
const items = actualResponse.data?.items || [];
// api.get returns { data: ..., status: ... } and types now reflect that
const items = response.data?.items || [];
// Verificar estrictamente que haya items
if (items.length === 0) {