From b8045d73e6557088067a93a7c3ce7b465c1024e2 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 23 Jan 2026 10:42:21 -0600 Subject: [PATCH 1/4] 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 @@
- +
+ + +
$
Date: Fri, 23 Jan 2026 10:48:45 -0600 Subject: [PATCH 2/4] Se habilito buscar por fecha del usuario --- .../dashboard/exchange_rate/create-edit-dialog.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2b32120d..2e23c655 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 @@ -197,7 +197,7 @@ id="date" type="date" bind:value={formData.date} - disabled={loading || (isMissingRateContext && !!initialDate)} + disabled={loading} required class="pl-3 h-11 text-base bg-muted/30 focus:bg-background transition-colors" /> From 83f2a0fa2ec0669677b0a16e7124a6ab171c9e50 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 23 Jan 2026 11:16:29 -0600 Subject: [PATCH 3/4] Se agregaron botones similar a SCAII --- .../exchange_rate/create-edit-dialog.svelte | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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 2e23c655..f85eec38 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 @@ -247,19 +247,21 @@
- - + + - From f38d9a3fbc65c9fd42ee2a138f9cdd51a9fbbd6c Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Fri, 23 Jan 2026 11:34:20 -0600 Subject: [PATCH 4/4] Eliminacion de script de pruebas --- scripts/cleanup_exchange_rate.py | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 scripts/cleanup_exchange_rate.py diff --git a/scripts/cleanup_exchange_rate.py b/scripts/cleanup_exchange_rate.py deleted file mode 100644 index 276d364e..00000000 --- a/scripts/cleanup_exchange_rate.py +++ /dev/null @@ -1,30 +0,0 @@ -import sys -import os -from datetime import datetime - -# Add backend directory to path -sys.path.append('/home/josmar/dev/anexo76/backend') - -from api.v1.modules.a76.general_catalogs.exchange_rate.services import ExchangeRateService -from core.database import get_core_db -# from api.v1.common.tenant_crud_routes import get_core_db # This might be needing another import path - -db = next(get_core_db()) - -today = datetime.now().strftime("%Y-%m-%d") -tenant_id = 1 -company_id = 1 - -print(f"Checking for exchange rate on {today}...") -filters = {"date": today} -rates, total = ExchangeRateService.get_all(db, tenant_id, company_id, filters=filters) - -if total > 0: - print(f"Found {total} exchange rate(s) for today. Deleting...") - for rate in rates: - ExchangeRateService.delete(db, rate.id, tenant_id, company_id) - print("Deleted.") -else: - print("No exchange rate found for today.") - -db.close()