Merge pull request 'fix/fracciones_americanas' (#171) from fix/fracciones_americanas into development

Reviewed-on: ADUANASOFT/anexo76#171
This commit is contained in:
2026-02-27 16:39:39 +00:00
9 changed files with 188 additions and 167 deletions

View File

@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from core.security import get_current_user, get_tenant_from_token
from .dto import (
TariffFractionCreateDTO,
@@ -27,6 +27,7 @@ router = APIRouter(prefix="/tariff-fractions", tags=["a76 / general catalogs / t
description="Get paginated list of Tariff Fractions with optional search filter (global catalog)",
)
async def list_tariff_fractions(
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=10000, description="Page size"),
search: Optional[str] = Query(None, description="Search in code, fraction, description, nico, or umt"),
@@ -47,8 +48,9 @@ async def list_tariff_fractions(
# Service.get_all calls Sitar (async) or DB (sync).
# This should be fine.
tenant_id = current_user.get("tenant_id")
company_id = current_user.get("company_id") # Assuming user is context-aware or we use a default?
tenant_id = get_tenant_from_token(current_user)
if tenant_id is None:
tenant_id = current_user.get("tenant_id")
# If using headers for selected company, it might be in current_user context if middleware sets it.
items, total = await TariffFractionService.get_all(
@@ -121,7 +123,7 @@ async def create_tariff_fraction(
pass
us_dto = USTariffFractionCreateDTO(
code=fraction_data.code,
code=fraction_data.fraction, # Store the punctuated fraction in the DB
description=fraction_data.description,
unit_of_measure=fraction_data.umt,
ad_valorem=ad_valorem,
@@ -131,7 +133,7 @@ async def create_tariff_fraction(
fixed_cost=None
)
created = USTariffFractionService.create(db, tenant_id, company_id, us_dto)
created = USTariffFractionService.create(db, us_dto, tenant_id, company_id)
return TariffFractionService.to_domain_usa_local(created)
else:
@@ -171,12 +173,13 @@ async def update_tariff_fraction(
pass
us_dto = USTariffFractionUpdateDTO(
code=fraction_data.fraction,
description=fraction_data.description,
unit_of_measure=fraction_data.umt,
ad_valorem=ad_valorem
)
updated = USTariffFractionService.update(db, tenant_id, company_id, tariff_fraction_id, us_dto)
updated = USTariffFractionService.update(db, tariff_fraction_id, tenant_id, us_dto, company_id)
if not updated:
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return TariffFractionService.to_domain_usa_local(updated)
@@ -202,7 +205,7 @@ async def delete_tariff_fraction(
if catalog == "american":
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
success = USTariffFractionService.delete(db, tenant_id, company_id, tariff_fraction_id)
success = USTariffFractionService.delete(db, tariff_fraction_id, tenant_id, company_id)
if not success:
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return {"ok": True}

View File

@@ -96,10 +96,14 @@ class TariffFractionService:
# US format: 1234.56.78.90. For now return as is or use helper if available.
# item is USTariffFraction (imported inside method to avoid circular import if needed, or assumed available)
# Remove formatting (e.g. dots) for the 'code' property
code_str = str(item.code)
clean_code = code_str.replace(".", "").replace("-", "")
return TariffFraction(
id=item.id,
code=item.code,
fraction=item.code, # TODO: Format if needed
code=clean_code,
fraction=code_str,
description=item.description or "(Sin descripción)",
nico=None,
umt=item.unit_of_measure,
@@ -133,11 +137,11 @@ class TariffFractionService:
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.service import USTariffFractionService
# Use local service directly
usa_items, total = USTariffFractionService._get_all_local(
usa_items, total = USTariffFractionService.get_all(
db, tenant_id, company_id, skip, limit, filters
)
items = [TariffFractionMapper.to_domain_usa_local(item) for item in usa_items]
items = [TariffFractionService.to_domain_usa_local(item) for item in usa_items]
return items, total
# USA CATALOG HANDLING (API - 'Fracciones US')

View File

@@ -3,9 +3,9 @@ DTOs para fracciones arancelarias americanas
"""
from datetime import datetime
from typing import Optional
from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field, ConfigDict, model_validator
class USTariffFractionCreateDTO(BaseModel):
@@ -23,6 +23,7 @@ class USTariffFractionCreateDTO(BaseModel):
class USTariffFractionUpdateDTO(BaseModel):
"""DTO para actualizar fracción arancelaria americana"""
code: Optional[str] = Field(None, max_length=16)
prefix: Optional[str] = Field(None, max_length=10)
type_code: Optional[str] = Field(None, max_length=10)
ad_valorem: Optional[float] = None
@@ -38,6 +39,7 @@ class USTariffFractionResponseDTO(BaseModel):
id: int
code: str
fraction: Optional[str] = None
prefix: Optional[str] = None
type_code: Optional[str] = None
ad_valorem: Optional[float] = None
@@ -46,3 +48,36 @@ class USTariffFractionResponseDTO(BaseModel):
description: Optional[str] = None
created_at: datetime
updated_at: datetime
@model_validator(mode="before")
@classmethod
def format_code_and_fraction(cls, data: Any) -> Any:
# Check if data is an ORM model or dict
if hasattr(data, "code"):
raw_code = data.code
elif isinstance(data, dict):
raw_code = data.get("code")
else:
return data
if raw_code:
code_str = str(raw_code)
# fraction keeps the original formatted string
fraction = code_str
# code strips dots and hyphens
code = code_str.replace(".", "").replace("-", "")
if isinstance(data, dict):
data["code"] = code
data["fraction"] = fraction
else:
# If it's an ORM object, we can't easily modify the object's attribute
# cleanly without side effects for other things, so we convert it to dict
new_data = {
c.name: getattr(data, c.name) for c in data.__table__.columns
}
new_data["code"] = code
new_data["fraction"] = fraction
return new_data
return data

View File

@@ -17,10 +17,20 @@ from .dto import (
)
from .service import USTariffFractionService
# Create base router with generic CRUD routes - REMOVED strictly read-only from Sitar
# Writes are disabled at API level, but Service still supports fallback writes if needed internally
# Create router using TenantCRUDRoutes factory for basic CRUD operations
crud_router = TenantCRUDRoutes(
service=USTariffFractionService,
create_schema=USTariffFractionCreateDTO,
update_schema=USTariffFractionUpdateDTO,
response_schema=USTariffFractionResponseDTO,
prefix="/us-tariff-fractions",
tags=["a76 / general catalogs / us tariff fractions"],
resource_name="US Tariff Fraction",
id_name="id",
enable_list=False, # We implement our custom list endpoint
)
router = APIRouter(prefix="/us-tariff-fractions", tags=["a76 / general catalogs / us tariff fractions"])
router = crud_router.router
# Custom list endpoint with search filter
@router.get(
@@ -44,8 +54,8 @@ async def list_us_tariff_fractions(
if search:
filters["search"] = search
# Updated to async call with Sitar integration
items, total = await USTariffFractionService.get_all(
# Updated to sync call
items, total = USTariffFractionService.get_all(
db, tenant_id, company_id, skip, page_size, filters
)
@@ -56,24 +66,3 @@ async def list_us_tariff_fractions(
"page_size": page_size,
"pages": (total + page_size - 1) // page_size,
}
@router.get(
"/{us_tariff_fraction_id}",
response_model=USTariffFractionResponseDTO,
summary="Get US Tariff Fraction by ID",
description="Get a specific US tariff fraction by ID (Lookups in Local DB for legacy compatibility)",
)
async def get_us_tariff_fraction(
us_tariff_fraction_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
item = USTariffFractionService.get_by_id(db, tenant_id, company_id, us_tariff_fraction_id)
if not item:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="US Tariff fraction not found")
return USTariffFractionResponseDTO.model_validate(item)

View File

@@ -1,73 +1,21 @@
"""
Service para fracciones arancelarias americanas
"""
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import zlib
import logging
import re
from decimal import Decimal
from .models import USTariffFraction
from .dto import USTariffFractionCreateDTO, USTariffFractionUpdateDTO
from api.v1.modules.sitar.fracciones_usa.service import FraccionesUSAService
from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse
logger = logging.getLogger(__name__)
class USTariffFractionMapper:
"""Helper to map Sitar USA responses to Local domain objects"""
@staticmethod
def to_domain(fraccion: FraccionesUSAResponse, tenant_id: int, company_id: int) -> USTariffFraction:
# Generate a deterministic numeric ID based on the unique code
# We use CRC32 to get a consistent integer implementation-independent
fake_id = zlib.crc32((fraccion.FRACCION_SIN_PUNTO or "").encode('utf-8'))
# Parse numeric values safely
ad_valorem = None
if fraccion.TARIFA1:
try:
# Extract numbers from string like "5.2%" or similar if present
# Assuming TARIFA1 might be clean number or percentage string
clean_val = re.sub(r'[^\d.]', '', str(fraccion.TARIFA1))
if clean_val:
ad_valorem = Decimal(clean_val)
except:
pass
fixed_cost = None
if fraccion.ESPECIFICO:
try:
clean_val = re.sub(r'[^\d.]', '', str(fraccion.ESPECIFICO))
if clean_val:
fixed_cost = Decimal(clean_val)
except:
pass
return USTariffFraction(
id=fake_id, # Updated to use fake_id instead of sitar consecutive if needed, or consistent hash
tenant_id=tenant_id,
company_id=company_id,
code=fraccion.FRACCION_SIN_PUNTO or "",
prefix=None, # Not mapped from Sitar response currently
type_code=None,
ad_valorem=ad_valorem,
fixed_cost=fixed_cost,
unit_of_measure=fraccion.UNIDADCANTIDAD,
description=fraccion.DESCRIPCION
)
class USTariffFractionService:
"""Service para gestionar fracciones arancelarias americanas"""
@staticmethod
async def get_all(
def get_all(
db: Session,
tenant_id: int,
company_id: int,
@@ -75,60 +23,7 @@ class USTariffFractionService:
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[USTariffFraction], int]:
"""
Obtiene todas las fracciones arancelarias americanas con filtros opcionales.
Estrategia: Sitar API -> Fallback Local DB
"""
# 1. Try Sitar API
try:
sitar_service = FraccionesUSAService.get_instance()
sitar_fraccion = None
has_filters = False
if filters and filters.get("search"):
term = filters["search"]
# Sitar only filters by fraction code
if term.replace(".", "").isdigit():
sitar_fraccion = term
has_filters = True
sitar_items = await sitar_service.search(
fraccion=sitar_fraccion,
skip=skip,
limit=limit
)
# If Sitar returns empty list AND we didn't have specific filters, attempt fallback
if not sitar_items and not has_filters:
logger.warning("Sitar return empty list for USA broad query. Attempting fallback to local DB.")
return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters)
# Map items
items = [USTariffFractionMapper.to_domain(item, tenant_id, company_id) for item in sitar_items]
# Estimate total
total = len(items) + skip
if len(items) == limit:
total += 1
return items, total
except Exception as e:
logger.error(f"Error fetching USA Fractions from Sitar API, falling back to local DB: {e}")
return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters)
@staticmethod
def _get_all_local(
db: Session,
tenant_id: int,
company_id: int,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[USTariffFraction], int]:
"""Lógica original de consulta local"""
"""Obtiene todas las fracciones locales con filtros opcionales."""
query = db.query(USTariffFraction).filter(
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
@@ -150,13 +45,14 @@ class USTariffFractionService:
return items, total
return items, total
@staticmethod
def get_by_id(
db: Session, tenant_id: int, company_id: int, fraction_id: int
db: Session, fraction_id: int, tenant_id: int, company_id: int
) -> Optional[USTariffFraction]:
"""
Obtiene por ID.
Legacy: Consulta Local DB.
Obtiene por ID local.
"""
return (
db.query(USTariffFraction)
@@ -168,14 +64,12 @@ class USTariffFractionService:
.first()
)
# WRITE OPERATIONS - DEPRECATED / LOCAL ONLY
@staticmethod
def create(
db: Session,
fraction_data: USTariffFractionCreateDTO,
tenant_id: int,
company_id: int,
fraction_data: USTariffFractionCreateDTO,
) -> USTariffFraction:
try:
db_fraction = USTariffFraction(
@@ -198,13 +92,13 @@ class USTariffFractionService:
@staticmethod
def update(
db: Session,
tenant_id: int,
company_id: int,
fraction_id: int,
tenant_id: int,
fraction_data: USTariffFractionUpdateDTO,
company_id: int,
) -> Optional[USTariffFraction]:
db_fraction = USTariffFractionService.get_by_id(
db, tenant_id, company_id, fraction_id
db, fraction_id, tenant_id, company_id
)
if not db_fraction:
return None
@@ -219,10 +113,10 @@ class USTariffFractionService:
@staticmethod
def delete(
db: Session, tenant_id: int, company_id: int, fraction_id: int
db: Session, fraction_id: int, tenant_id: int, company_id: int
) -> bool:
db_fraction = USTariffFractionService.get_by_id(
db, tenant_id, company_id, fraction_id
db, fraction_id, tenant_id, company_id
)
if not db_fraction:
return False

44
backend/test_debug.py Normal file
View File

@@ -0,0 +1,44 @@
import sys
import os
sys.path.append('/app')
sys.path.append('/home/josmar/dev/anexo76/backend')
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import asyncio
import logging
# Disable logging to keep output clean
logging.basicConfig(level=logging.ERROR)
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.service import TariffFractionService
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.dto import TariffFractionResponseDTO
async def test():
# Use the database URL from the environment or default
db_url = "postgresql://postgres:postgres@anexo76-postgres-a76:5432/anexo76_core"
engine = create_engine(db_url)
Session = sessionmaker(bind=engine)
db = Session()
try:
print("Starting test for catalog='american'...")
items, total = await TariffFractionService.get_all(
db, skip=0, limit=10, filters=None, catalog="american", tenant_id=1, company_id=1
)
print(f"Service Success! Total: {total}")
print("Validating items with TariffFractionResponseDTO...")
for item in items:
dto = TariffFractionResponseDTO.model_validate(item)
print(f"DTO: ID={dto.id}, Code={dto.code}, Fraction={dto.fraction}")
except Exception as e:
print(f"Error caught: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
db.close()
if __name__ == "__main__":
asyncio.run(test())

39
backend/test_user_info.py Normal file
View File

@@ -0,0 +1,39 @@
import sys
import os
sys.path.append('/app')
sys.path.append('/home/josmar/dev/anexo76/backend')
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
def test():
db_url = "postgresql://postgres:postgres@anexo76-postgres-a76:5432/anexo76_core"
engine = create_engine(db_url)
Session = sessionmaker(bind=engine)
db = Session()
try:
print("Checking users and tenants...")
# Check users
users = db.execute(text("SELECT id, email, tenant_id FROM a76.users")).fetchall()
for u in users:
print(f"User ID: {u.id} | Email: {u.email} | Tenant ID: {u.tenant_id}")
# Check companies
companies = db.execute(text("SELECT id, name, tenant_id FROM a76.companies")).fetchall()
for c in companies:
print(f"Company ID: {c.id} | Name: {c.name} | Tenant ID: {c.tenant_id}")
# Check fractions
fractions = db.execute(text("SELECT id, code, tenant_id, company_id FROM a76.us_tariff_fractions")).fetchall()
print(f"Total US Fractions in DB: {len(fractions)}")
for f in fractions:
print(f"Fraction ID: {f.id} | Code: {f.code} | Tenant ID: {f.tenant_id} | Company ID: {f.company_id}")
except Exception as e:
print(f"Error: {e}")
finally:
db.close()
if __name__ == "__main__":
test()

View File

@@ -66,6 +66,13 @@
}
});
// Sync code with fractionFormatted for US catalog
$effect(() => {
if (catalog === 'usa' || catalog === 'american') {
code = fractionFormatted.replace(/\./g, '').replace(/-/g, '');
}
});
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
@@ -126,17 +133,23 @@
<div class="grid gap-4 py-4">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="code">Clave / Código {catalog === 'mex' ? '(Sin puntos)' : ''}</Label>
<Input id="code" bind:value={code} disabled={!!fraction} placeholder="Ej. 01012101" />
{#if fraction}
<p class="text-xs text-muted-foreground">
El código no se puede modificar una vez creado.
</p>
{/if}
<Label for="code">{catalog === 'mex' ? 'Clave / Código (Sin puntos)' : 'Clave'}</Label>
<Input id="code" bind:value={code} disabled={true} placeholder="Ej. 01012101" />
<p class="text-[10px] text-muted-foreground">
{catalog === 'mex'
? 'El código se genera automáticamente.'
: 'La clave se deriva de la fracción sin puntos.'}
</p>
</div>
<div class="space-y-2">
<Label for="fraction">Fracción {catalog === 'mex' ? '(Con puntos)' : ''}</Label>
<Input id="fraction" bind:value={fractionFormatted} placeholder="Ej. 0101.21.01" />
<Label for="fraction"
>{catalog === 'mex' ? 'Fracción (Con puntos)' : 'Fracción (HTS Code)'}</Label
>
<Input
id="fraction"
bind:value={fractionFormatted}
placeholder={catalog === 'mex' ? 'Ej. 0101.21.01' : 'Ej. 1234.56.78'}
/>
</div>
</div>

View File

@@ -310,7 +310,7 @@
fraction={selectedFraction}
{catalog}
onSuccess={() => {
loadFractions();
loadFractions(true);
isFormDialogOpen = false;
}}
/>