From b8045d73e6557088067a93a7c3ce7b465c1024e2 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 23 Jan 2026 10:42:21 -0600 Subject: [PATCH] Se integro el sistema de extraer el tipo de cambio --- .../general_catalogs/exchange_rate/routes.py | 53 +++++++++- .../exchange_rate/services.py | 96 +++++++++++++++++++ backend/core/config.py | 7 +- backend/requirements.txt | 1 + docker-compose.yml | 3 + .../a76/general_catalogs/exchange-rate.ts | 22 ++++- .../exchange_rate/create-edit-dialog.svelte | 55 ++++++++++- .../exchange_rate/exchange-rate-guard.svelte | 5 +- .../invoices/create-edit-dialog.svelte | 4 +- 9 files changed, 228 insertions(+), 18 deletions(-) diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py index 00870adf..22ab8ec9 100644 --- a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py index f7749b43..56e550fc 100644 --- a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py @@ -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 diff --git a/backend/core/config.py b/backend/core/config.py index c27954eb..54301641 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index ff7a7cfa..00ebff8e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index c26575a5..ae4cb27c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/exchange-rate.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/exchange-rate.ts index b8b94a40..25a24b2f 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/exchange-rate.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/exchange-rate.ts @@ -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 { +): Promise> { 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 { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.get(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`); } @@ -65,7 +67,7 @@ export async function getExchangeRate( export async function createExchangeRate( data: ExchangeRateCreate, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.post(`/v1/a76/exchange-rate/?${params.toString()}`, data); } @@ -74,7 +76,7 @@ export async function updateExchangeRate( exchangeRateId: number, data: ExchangeRateUpdate, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.put( `/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`, @@ -85,7 +87,17 @@ export async function updateExchangeRate( export async function deleteExchangeRate( exchangeRateId: number, companyId: number -): Promise { +): Promise> { 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> { + return api.get(`/v1/a76/exchange-rate/dof-search?date=${date}`); +} diff --git a/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte index 47a47ca8..2b32120d 100644 --- a/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte @@ -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(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 @@
@@ -181,7 +210,23 @@
- +
+ + +
$