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, 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], response_model=Dict[str, Any],
summary="List Exchange Rates", summary="List Exchange Rates",
@@ -66,3 +81,37 @@ async def list_exchange_rates(
"page": page, "page": page,
"page_size": page_size, "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 typing import Optional, Tuple, List, Dict, Any
from datetime import datetime, time from datetime import datetime, time
import requests
import re
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import cast, Date from sqlalchemy import cast, Date
from . import dto, models from . import dto, models
import urllib3
from core.config import settings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class ExchangeRateService: class ExchangeRateService:
@@ -135,3 +143,91 @@ class ExchangeRateService:
db.delete(exchange_rate) db.delete(exchange_rate)
db.commit() db.commit()
return True 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
LICENSE_CHECK_ENABLED: bool = True 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( 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 @property

View File

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

View File

@@ -175,6 +175,9 @@ services:
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000} - 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: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:

View File

@@ -37,10 +37,12 @@ export interface ExchangeRateFilters {
page_size?: number; page_size?: number;
} }
import type { ApiResponse } from '$lib/api';
export async function getExchangeRates( export async function getExchangeRates(
companyId: number, companyId: number,
filters?: ExchangeRateFilters filters?: ExchangeRateFilters
): Promise<ExchangeRateListResponse> { ): Promise<ApiResponse<ExchangeRateListResponse>> {
const params = new URLSearchParams({ company_id: companyId.toString() }); const params = new URLSearchParams({ company_id: companyId.toString() });
if (filters) { if (filters) {
@@ -57,7 +59,7 @@ export async function getExchangeRates(
export async function getExchangeRate( export async function getExchangeRate(
exchangeRateId: number, exchangeRateId: number,
companyId: number companyId: number
): Promise<ExchangeRate> { ): Promise<ApiResponse<ExchangeRate>> {
const params = new URLSearchParams({ company_id: companyId.toString() }); const params = new URLSearchParams({ company_id: companyId.toString() });
return api.get<ExchangeRate>(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`); return api.get<ExchangeRate>(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`);
} }
@@ -65,7 +67,7 @@ export async function getExchangeRate(
export async function createExchangeRate( export async function createExchangeRate(
data: ExchangeRateCreate, data: ExchangeRateCreate,
companyId: number companyId: number
): Promise<ExchangeRate> { ): Promise<ApiResponse<ExchangeRate>> {
const params = new URLSearchParams({ company_id: companyId.toString() }); const params = new URLSearchParams({ company_id: companyId.toString() });
return api.post<ExchangeRate>(`/v1/a76/exchange-rate/?${params.toString()}`, data); return api.post<ExchangeRate>(`/v1/a76/exchange-rate/?${params.toString()}`, data);
} }
@@ -74,7 +76,7 @@ export async function updateExchangeRate(
exchangeRateId: number, exchangeRateId: number,
data: ExchangeRateUpdate, data: ExchangeRateUpdate,
companyId: number companyId: number
): Promise<ExchangeRate> { ): Promise<ApiResponse<ExchangeRate>> {
const params = new URLSearchParams({ company_id: companyId.toString() }); const params = new URLSearchParams({ company_id: companyId.toString() });
return api.put<ExchangeRate>( return api.put<ExchangeRate>(
`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`, `/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`,
@@ -85,7 +87,17 @@ export async function updateExchangeRate(
export async function deleteExchangeRate( export async function deleteExchangeRate(
exchangeRateId: number, exchangeRateId: number,
companyId: number companyId: number
): Promise<void> { ): Promise<ApiResponse<void>> {
const params = new URLSearchParams({ company_id: companyId.toString() }); const params = new URLSearchParams({ company_id: companyId.toString() });
return api.delete(`/v1/a76/exchange-rate/${exchangeRateId}?${params.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 { import {
createExchangeRate, createExchangeRate,
updateExchangeRate, updateExchangeRate,
type ExchangeRate type ExchangeRate,
getDofExchangeRate
} from "$lib/api/dashboard/a76/general_catalogs/exchange-rate"; } from "$lib/api/dashboard/a76/general_catalogs/exchange-rate";
import { companyStore } from "$lib/stores/company.svelte"; 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 { fly, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
@@ -44,6 +45,7 @@
}); });
let loading = $state(false); let loading = $state(false);
let scraping = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let showConfirmation = $state(false); let showConfirmation = $state(false);
@@ -65,11 +67,40 @@
local_currency: 'MXN', local_currency: 'MXN',
foreign_currency: 'USD' 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; 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() { function handleSubmit() {
error = null; error = null;
try { try {
@@ -124,8 +155,6 @@
<Dialog.Content <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" 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"> <div class="bg-background flex flex-col h-full rounded-xl overflow-hidden border border-border">
@@ -181,7 +210,23 @@
</div> </div>
<div class="grid gap-2"> <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="relative group">
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground font-semibold">$</div> <div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground font-semibold">$</div>
<Input <Input

View File

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

View File

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