Resolviendo conflicto

This commit is contained in:
2026-05-08 17:44:16 -05:00
39 changed files with 1499 additions and 386 deletions

View File

@@ -9,7 +9,12 @@ from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.invoices.models import InvoiceHeader
from core.database import rls_company_var, rls_tenant_var
from core.database import (
RLS_COMPANY_KEY,
RLS_TENANT_KEY,
rls_company_var,
rls_tenant_var,
)
from .services.service import AuditService
from .utils.serialization import serialize_for_json
@@ -56,13 +61,17 @@ def _resolve_audit_company_tenant(session: Session, target) -> tuple:
if company_id is not None:
resolution_source = "company_self_id"
if company_id is None:
company_id = rls_company_var.get()
company_id = session.info.get(RLS_COMPANY_KEY)
if company_id is None:
company_id = rls_company_var.get()
if company_id is not None:
resolution_source = "rls_context"
tenant_id = getattr(target, "tenant_id", None)
if tenant_id is None:
tenant_id = rls_tenant_var.get()
tenant_id = session.info.get(RLS_TENANT_KEY)
if tenant_id is None:
tenant_id = rls_tenant_var.get()
if tenant_id is not None and resolution_source == "target":
resolution_source = "rls_context"

View File

@@ -164,16 +164,13 @@ class TariffFractionService:
search_description = term
try:
usa_items = await usa_service.search(
usa_items, total = await usa_service.search_with_total(
fraccion=search_term,
descripcion=search_description,
skip=skip,
limit=limit,
)
items = [TariffFractionMapper.to_domain_usa(item) for item in usa_items]
total = len(items) + skip
if len(items) == limit:
total += 1
return items, total
except Exception as e:
import traceback
@@ -245,30 +242,25 @@ class TariffFractionService:
# Note: Sitar search might not return total count.
# We fetch page items. Pagination might be tricky if Sitar doesn't return total.
# Assuming Sitar returns a list.
sitar_items = await sitar_service.search(
sitar_items, total = await sitar_service.search_with_total(
fraccion=sitar_fraccion,
nico=sitar_nico,
description=sitar_description,
nivel=level_filter, # Dynamic level
skip=skip,
limit=limit
limit=limit,
)
# STRICT API USAGE:
# We do NOT fallback to local DB on empty list, as user requested strict API consumption.
# We also do NOT attempt enrichment as codes mismatch (API uses '010191A' vs Local '01012101').
# Map items
items = [TariffFractionMapper.to_domain(item) for item in sitar_items]
# Legacy browse behavior: keep table in ascending fracción order.
items = sorted(items, key=lambda row: ((row.code or ""), (row.nico or "")))
# Estimate total (Sitar service doesn't return total currently)
# If we got full limit, assume there are more.
total = len(items) + skip
if len(items) == limit:
total += 1 # Indicate more pages
# total comes from SITAR PaginatedFraccionesResponse (matches API-wide count for the query).
return items, total
except Exception as e:

View File

@@ -103,7 +103,7 @@ async def list_us_tariff_fractions(
search_description = search
try:
sitar_items = await svc.search(
sitar_items, total = await svc.search_with_total(
fraccion=search_term,
descripcion=search_description,
skip=skip,
@@ -118,10 +118,6 @@ async def list_us_tariff_fractions(
"pages": 0,
}
total = len(sitar_items) + skip
if len(sitar_items) == page_size:
total += 1
items = [
USTariffFractionResponseDTO.model_validate(_sitar_row_to_us_response_payload(row))
for row in sitar_items

View File

@@ -3,6 +3,7 @@ from typing import Dict, Any, Optional
from core.config import settings
from core.database import get_core_db
from core.exceptions import BaseAPIException
from core.security import collect_user_role_names, get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from sqlalchemy import func, or_, and_
@@ -69,6 +70,8 @@ def get_creation_data(
return InvoiceCatalogService.get_creation_data(db, tenant_id, company_id)
except HTTPException:
raise
except BaseAPIException:
raise
except Exception as e:
logger.exception("get_creation_data failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al cargar datos de creación: {str(e)}")
@@ -97,6 +100,8 @@ def get_edition_data(
return data
except HTTPException:
raise
except BaseAPIException:
raise
except Exception as e:
logger.exception("get_edition_data failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al cargar datos de edición: {str(e)}")
@@ -191,6 +196,8 @@ def create_invoice(
return services.InvoiceService.create(db, data, tenant_id, company_id)
except HTTPException:
raise
except BaseAPIException:
raise
except Exception as e:
logger.exception("create_invoice failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al guardar factura: {str(e)}")
@@ -337,6 +344,8 @@ def list_invoices(
"page": page,
"page_size": page_size
}
except BaseAPIException:
raise
except Exception as e:
logger.exception("list_invoices failed: %s", e)
raise HTTPException(status_code=500, detail=f"Internal server error in invoices list: {str(e)}")

View File

@@ -63,6 +63,7 @@ class UserInfoResponseDTO(BaseModel):
preferred_username: Optional[str] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
avatar_url: Optional[str] = None
roles: list[str] = []
permissions: list[str] = []

View File

@@ -1,6 +1,7 @@
import logging
import httpx
from typing import Any, Dict
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from core.config import settings
from fastapi import HTTPException
@@ -23,6 +24,96 @@ class AuthService:
def __init__(self, db: Session):
self.db = db
@staticmethod
def _clean_text(value: Any) -> Optional[str]:
if isinstance(value, str):
cleaned = value.strip()
if cleaned:
return cleaned
return None
def _pick_text(self, *candidates: Any) -> Optional[str]:
for candidate in candidates:
value = self._clean_text(candidate)
if value:
return value
return None
def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]:
try:
claims = jwt.get_unverified_claims(access_token)
return claims if isinstance(claims, dict) else {}
except JWTError:
return {}
except Exception:
return {}
async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]:
"""
Fallback de datos de usuario consultando el Hub admin API.
Es opcional y no debe romper /me si falla.
"""
if not keycloak_user_id:
return None
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={
"username": settings.HUB_ADMIN_EMAIL,
"password": settings.HUB_ADMIN_PASSWORD,
},
)
if login_resp.status_code != 200:
return None
admin_token = login_resp.json().get("access_token")
if not admin_token:
return None
user_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
if user_resp.status_code == 200:
payload = user_resp.json()
return payload if isinstance(payload, dict) else None
except Exception as exc:
logger.debug("kc_admin_user_lookup_failed: %s", exc)
return None
def _extract_avatar_url(self, *sources: Any) -> Optional[str]:
for source in sources:
if not isinstance(source, dict):
continue
direct = self._pick_text(
source.get("avatar_url"),
source.get("avatarUrl"),
source.get("picture"),
source.get("photo"),
)
if direct:
return direct
attrs = source.get("attributes")
if isinstance(attrs, dict):
attr_candidate = attrs.get("avatar_url")
if isinstance(attr_candidate, list) and attr_candidate:
value = self._clean_text(attr_candidate[0])
if value:
return value
if isinstance(attr_candidate, str):
value = self._clean_text(attr_candidate)
if value:
return value
return None
async def login(
self,
login_data: LoginRequestDTO,
@@ -55,6 +146,38 @@ class AuthService:
except Exception as exc:
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
# Sync de perfil/avatar desde Workspace usando el mismo bearer.
# No bloquea login si Workspace no responde.
access_token = data.get("access_token")
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(access_token)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "login",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
# AUDIT LOG: Login Success
try:
from api.v1.modules.a76.audit_log.services.service import AuditService
@@ -102,7 +225,37 @@ class AuthService:
)
if response.status_code == 200:
return TokenResponseDTO(**response.json())
data = response.json()
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(
data.get("access_token", "")
)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "refresh",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=data.get("access_token"),
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
return TokenResponseDTO(**data)
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
@@ -115,9 +268,78 @@ class AuthService:
Obtiene información del usuario desde el Hub
"""
from core.security import verify_token
from core.workspace_profile_sync import sync_workspace_profile_for_user
# Aprovechamos la verificación (y cache) de security.py
user_info = await verify_token(access_token)
return UserInfoResponseDTO(**user_info)
kc_user = self._decode_kc_user_from_token(access_token)
keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub"))
needs_admin_fallback = any(
not self._clean_text(user_info.get(field))
for field in ("email", "preferred_username")
) or self._extract_avatar_url(user_info) is None
kc_admin_user = None
if needs_admin_fallback:
kc_admin_user = await self._get_kc_admin_user(keycloak_user_id)
first_name = self._pick_text(
user_info.get("first_name"),
user_info.get("given_name"),
kc_user.get("given_name"),
kc_user.get("first_name"),
(kc_admin_user or {}).get("firstName"),
(kc_admin_user or {}).get("first_name"),
)
last_name = self._pick_text(
user_info.get("last_name"),
user_info.get("family_name"),
kc_user.get("family_name"),
kc_user.get("last_name"),
(kc_admin_user or {}).get("lastName"),
(kc_admin_user or {}).get("last_name"),
)
full_name = self._pick_text(
f"{first_name} {last_name}" if first_name and last_name else None,
first_name,
last_name,
)
enriched_user_info = dict(user_info)
enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub")
enriched_user_info["email"] = self._pick_text(
user_info.get("email"),
(kc_admin_user or {}).get("email"),
kc_user.get("email"),
)
enriched_user_info["preferred_username"] = self._pick_text(
user_info.get("preferred_username"),
user_info.get("username"),
kc_user.get("preferred_username"),
kc_user.get("username"),
(kc_admin_user or {}).get("username"),
)
enriched_user_info["avatar_url"] = self._extract_avatar_url(
user_info,
kc_user,
kc_admin_user or {},
)
enriched_user_info["name"] = self._pick_text(
user_info.get("name"),
full_name,
kc_user.get("name"),
enriched_user_info.get("preferred_username"),
)
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=enriched_user_info.get("sub"),
tenant_id=enriched_user_info.get("tenant_id"),
workspace_profile=enriched_user_info,
)
return UserInfoResponseDTO(**enriched_user_info)
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
"""

View File

@@ -29,8 +29,10 @@ def track_and_dispatch(
if company_id is not None:
headers["rls_company_id"] = str(int(company_id))
token_t = rls_tenant_var.set(int(tenant_id))
token_c = rls_company_var.set(int(company_id) if company_id is not None else None)
prev_tenant = rls_tenant_var.get()
prev_company = rls_company_var.get()
rls_tenant_var.set(int(tenant_id))
rls_company_var.set(int(company_id) if company_id is not None else None)
try:
celery_task = task.apply_async(
args=args or [],
@@ -39,8 +41,8 @@ def track_and_dispatch(
headers=headers,
)
finally:
rls_tenant_var.reset(token_t)
rls_company_var.reset(token_c)
rls_tenant_var.set(prev_tenant)
rls_company_var.set(prev_company)
tracker = TaskTrackerService(db)
tracker.register_dispatch(

View File

@@ -2,6 +2,7 @@
Modelo de relación entre usuarios (Keycloak) y tenants
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -13,6 +14,7 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
DateTime,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -56,6 +58,17 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin):
avatar_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="URL de la imagen de perfil"
)
workspace_user_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True, comment="User ID (sub) proveniente de Workspace"
)
workspace_avatar_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="Avatar URL sincronizado desde Workspace"
)
workspace_profile_synced_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Última sincronización de perfil con Workspace",
)
# Caché local de nombre/apellido (fuente de verdad = Keycloak vía Hub;
# se sincroniza al editar perfil desde Anexo76)
first_name: Mapped[Optional[str]] = mapped_column(

View File

@@ -154,6 +154,7 @@ def get_user_avatar_image(
@router.get("/me/profile", response_model=UserResponseDTO)
async def get_my_profile(
request: Request,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
@@ -180,7 +181,17 @@ async def get_my_profile(
)
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
return await service.get_current_user_profile(keycloak_user_id, current_user=current_user)
auth_header = request.headers.get("Authorization") or ""
access_token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip() or None
)
return await service.get_current_user_profile(
keycloak_user_id,
current_user=current_user,
access_token=access_token,
)
@router.put("/me/profile", response_model=UserResponseDTO)
@@ -214,6 +225,14 @@ async def update_my_profile(
status_code=400, detail="User does not belong to any tenant"
)
# Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub
# y no debe mutarse localmente en Anexo76.
if current_user.get("sub"):
raise HTTPException(
status_code=409,
detail="Avatar is managed by Workspace for this user",
)
auth_header = request.headers.get("Authorization") or ""
access_token = (
auth_header[7:].strip()

View File

@@ -2,6 +2,7 @@ import logging
import httpx
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from fastapi import HTTPException
from sqlalchemy import and_, func
@@ -15,6 +16,30 @@ from ..user_tenant.models import UserTenant
logger = logging.getLogger(__name__)
def _is_valid_http_url(url: Optional[str]) -> bool:
if not url or not isinstance(url, str):
return False
parsed = urlparse(url.strip())
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]:
if not user_tenant or not user_tenant.avatar_url:
return None
avatar_out = str(user_tenant.avatar_url)
if avatar_out.startswith("http://") or avatar_out.startswith("https://"):
return avatar_out if _is_valid_http_url(avatar_out) else None
from core.s3_keys import public_user_avatar_api_path
# Entregamos siempre el endpoint público del backend para assets locales/S3.
return public_user_avatar_api_path(
user_tenant.tenant_id,
user_tenant.keycloak_user_id,
)
def _normalize_user(
user_data: Dict[str, Any],
role: Optional[str] = None,
@@ -43,18 +68,20 @@ def _normalize_user(
# Agregar campos de perfil si user_tenant está disponible
if user_tenant:
avatar_out = user_tenant.avatar_url
if avatar_out:
from core.s3_keys import public_user_avatar_api_path
workspace_avatar = (
user_tenant.workspace_avatar_url
if _is_valid_http_url(user_tenant.workspace_avatar_url)
else None
)
legacy_avatar = _legacy_avatar_public_url(user_tenant)
avatar_out = workspace_avatar or legacy_avatar
# Siempre devolver la URL pública del endpoint de servicio de imágenes,
# independientemente de si es clave S3 (tenants/...) o ruta local (/uploads/...).
avatar_out = public_user_avatar_api_path(
user_tenant.tenant_id, user_tenant.keycloak_user_id
)
normalized.update(
{
"avatar_url": avatar_out,
"workspace_avatar_url": workspace_avatar,
"legacy_avatar_url": legacy_avatar,
"workspace_user_id": user_tenant.workspace_user_id,
"phone": user_tenant.phone,
"bio": user_tenant.bio,
"preferences": user_tenant.preferences or {},
@@ -629,11 +656,27 @@ class UserService:
"usage_percentage": round(usage_percentage, 2),
}
async def get_current_user_profile(self, keycloak_user_id: str, current_user: Dict[str, Any] = None) -> Dict[str, Any]:
async def get_current_user_profile(
self,
keycloak_user_id: str,
current_user: Dict[str, Any] = None,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""Obtiene el perfil completo del usuario actual"""
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
user_info = current_user or {"id": keycloak_user_id}
# Perfil "me": sincronización con cache corto (5 min).
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=keycloak_user_id,
tenant_id=self.tenant_id,
)
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
).first()

View File

@@ -6,7 +6,7 @@ All specific resource services inherit from this.
"""
import os
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime, timedelta
import httpx
@@ -17,6 +17,29 @@ class SitarAPIBaseService:
_token: Optional[str] = None
_token_expires: Optional[datetime] = None
@staticmethod
def _parse_paginated_list_and_total(data: Any) -> Tuple[List[Any], int]:
"""
Paginated list endpoints (fracciones, fracciones-usa) return:
{ "data": [...], "total", "page", "limit", "total_pages" }.
Older responses may be a plain JSON array; then total is len(rows) for that page only.
"""
if isinstance(data, list):
return data, len(data)
if isinstance(data, dict) and isinstance(data.get("data"), list):
rows = data["data"]
raw_total = data.get("total")
total = int(raw_total) if raw_total is not None else len(rows)
return rows, total
raise ValueError(
f"Unexpected SITAR list response shape: {type(data).__name__}"
)
@staticmethod
def _unwrap_paginated_list(data: Any) -> List[Any]:
rows, _ = SitarAPIBaseService._parse_paginated_list_and_total(data)
return rows
def __init__(self):
"""Initialize base service with API credentials"""
self.base_url = os.getenv("SITAR_API_URL")

View File

@@ -1,7 +1,7 @@
"""Fracciones Service"""
import asyncio
from typing import Optional, List
from typing import Optional, List, Tuple
from ..common import SitarAPIBaseService
from .schemas import FraccionesResponse
@@ -18,7 +18,7 @@ class FraccionesService(SitarAPIBaseService):
cls._instance = cls()
return cls._instance
async def search(
async def search_with_total(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
@@ -26,8 +26,8 @@ class FraccionesService(SitarAPIBaseService):
nivel: Optional[int] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesResponse]:
"""Search Mexican tariff fractions"""
) -> Tuple[List[FraccionesResponse], int]:
"""Search Mexican tariff fractions; total matches SITAR PaginatedFraccionesResponse.total."""
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
@@ -38,8 +38,29 @@ class FraccionesService(SitarAPIBaseService):
if nivel is not None:
params["nivel"] = nivel
data = await self._make_request("GET", "/api/v1/fracciones/", params=params)
return [FraccionesResponse(**item) for item in data]
raw = await self._make_request("GET", "/api/v1/fracciones/", params=params)
rows, total = self._parse_paginated_list_and_total(raw)
return [FraccionesResponse(**item) for item in rows], total
async def search(
self,
fraccion: Optional[str] = None,
nico: Optional[str] = None,
description: Optional[str] = None,
nivel: Optional[int] = None,
skip: int = 0,
limit: int = 100,
) -> List[FraccionesResponse]:
"""Search Mexican tariff fractions"""
items, _ = await self.search_with_total(
fraccion=fraccion,
nico=nico,
description=description,
nivel=nivel,
skip=skip,
limit=limit,
)
return items
async def get_by_id(self, sysid: int) -> FraccionesResponse:
"""Get single Fraccion record by SYSID"""

View File

@@ -1,7 +1,7 @@
"""Fracciones USA Service"""
import logging
from typing import Optional, List
from typing import Optional, List, Tuple
from ..common import SitarAPIBaseService
from .schemas import FraccionesUSAResponse
@@ -22,6 +22,24 @@ class FraccionesUSAService(SitarAPIBaseService):
cls._instance = cls()
return cls._instance
async def search_with_total(
self,
fraccion: Optional[str] = None,
descripcion: Optional[str] = None,
skip: int = 0,
limit: int = 100,
) -> Tuple[List[FraccionesUSAResponse], int]:
"""Search USA tariff fractions; total matches SITAR PaginatedFraccionesUSAResponse.total."""
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if descripcion:
params["descripcion"] = descripcion
raw = await self._make_request("GET", "api/v1/fracciones-usa/", params=params)
rows, total = self._parse_paginated_list_and_total(raw)
return [FraccionesUSAResponse(**item) for item in rows], total
async def search(
self,
fraccion: Optional[str] = None,
@@ -30,14 +48,13 @@ class FraccionesUSAService(SitarAPIBaseService):
limit: int = 100,
) -> List[FraccionesUSAResponse]:
"""Search USA tariff fractions"""
params = {"skip": skip, "limit": min(limit, 1000)}
if fraccion:
params["fraccion"] = fraccion
if descripcion:
params["descripcion"] = descripcion
data = await self._make_request("GET", "api/v1/fracciones-usa/", params=params)
return [FraccionesUSAResponse(**item) for item in data]
items, _ = await self.search_with_total(
fraccion=fraccion,
descripcion=descripcion,
skip=skip,
limit=limit,
)
return items
async def get_by_id(self, consecutivo: int) -> FraccionesUSAResponse:
"""Get single USA Fraccion record by CONSECUTIVO"""
@@ -68,10 +85,9 @@ class FraccionesUSAService(SitarAPIBaseService):
params["descripcion"] = descripcion
try:
data = service._make_request_sync("GET", "api/v1/fracciones-usa/", params=params)
if not isinstance(data, list):
return []
return [FraccionesUSAResponse(**item) for item in data]
raw = service._make_request_sync("GET", "api/v1/fracciones-usa/", params=params)
rows, _ = service._parse_paginated_list_and_total(raw)
return [FraccionesUSAResponse(**item) for item in rows]
except Exception as exc:
logger.warning("SITAR fracciones-usa search_sync failed: %s", exc)
return []

View File

@@ -1,19 +1,20 @@
"""PROSEC Schemas"""
"""PROSEC Schemas — aligned with SITAR OpenAPI ProsecResponse."""
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class ProsecResponse(BaseModel):
"""PROSEC (Programa de Promoción Sectorial)"""
"""PROSEC (Programa de Promoción Sectorial)."""
FRACCION: Optional[str] = Field(None, max_length=10)
PRODUCTO: Optional[str] = Field(None, max_length=999)
TASA: Optional[str] = Field(None, max_length=19)
SECTOR: Optional[str] = Field(None, max_length=2)
ANEXO: Optional[str] = Field(None, max_length=19)
DOF: Optional[str] = Field(None, max_length=8)
NOTAS: Optional[str] = Field(None, max_length=5000)
FRACCION: Optional[str] = Field("", max_length=10)
ARTICULO: Optional[str] = Field("", max_length=3)
SECTOR: Optional[str] = Field("", max_length=6)
TASATXT: Optional[str] = Field("", max_length=19)
TASANUM: Optional[str] = Field(default="0")
TIPOTASA: Optional[int] = Field(default=0)
DOF: Optional[str] = Field("", max_length=8)
OBSERVACION: Optional[str] = ""
NICO: Optional[str] = Field("", max_length=2)
SYSID: int

View File

@@ -1,21 +1,23 @@
"""TLCS Schemas"""
"""TLCS Schemas — aligned with SITAR OpenAPI TLCSResponse."""
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class TLCSResponse(BaseModel):
"""TLCS (Tratados de Libre Comercio) response model"""
"""TLCS (Tratados de Libre Comercio) response model."""
FRACCION: Optional[str] = Field(None, max_length=8)
PAIS: Optional[str] = Field(None, max_length=3)
TASATXT: Optional[str] = Field(None, max_length=19)
TASANUM: Optional[str] = None
TASACALCULADA: Optional[str] = Field(None, max_length=19)
TLC: Optional[str] = Field(None, max_length=6)
NOTA: Optional[str] = None
FRACCION: str = Field(max_length=10)
PAIS: str = Field(max_length=3)
ORDEN: Optional[int] = None
TASATXT: Optional[str] = Field(None, max_length=44)
TIPOTASA: Optional[int] = None
TASA1NUM: Optional[str] = None
FACTOR1: Optional[str] = None
TASA2NUM: Optional[str] = None
FACTOR2: Optional[str] = None
DOF: Optional[str] = Field(None, max_length=8)
OBSERVACION: Optional[str] = None
NOTAS: Optional[str] = Field(None, max_length=999)
NICO: Optional[str] = Field("", max_length=2)
SYSID: int