Merge pull request 'feature/DOF_tipo_cambio' (#82) from feature/DOF_tipo_cambio into feature/ventana_tipo_cambio
Reviewed-on: ADUANASOFT/anexo76#82
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -168,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"
|
||||
/>
|
||||
@@ -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
|
||||
@@ -202,19 +247,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="gap-2 sm:gap-0 pt-2">
|
||||
<Button type="button" variant="ghost" onclick={() => open = false} disabled={loading} class="hover:bg-muted/50">
|
||||
<Dialog.Footer class="grid grid-cols-4 gap-2 pt-2">
|
||||
<Button type="button" variant="outline" class="w-full text-xs" disabled>
|
||||
Cargar TC
|
||||
</Button>
|
||||
<Button type="button" variant="outline" class="w-full text-xs" disabled>
|
||||
Ayuda
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onclick={() => open = false} disabled={loading} class="w-full hover:bg-muted/50">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading} class="min-w-[140px] shadow-sm font-medium" size="lg">
|
||||
<Button type="submit" disabled={loading} class="w-full shadow-sm font-medium">
|
||||
{#if loading}
|
||||
<span class="animate-spin mr-2">⟳</span> Guardando...
|
||||
<span class="animate-spin mr-2">⟳</span>
|
||||
{:else}
|
||||
{#if isMissingRateContext}
|
||||
Guardar y Continuar
|
||||
{:else}
|
||||
{isEdit ? 'Actualizar' : 'Crear Registro'}
|
||||
{/if}
|
||||
Ok
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user