feature/csv-for-envoices

This commit is contained in:
hreyes
2026-02-23 13:05:02 -06:00
parent bbe74906ec
commit 0683aaa801
21 changed files with 1232 additions and 255 deletions

5
.gitignore vendored
View File

@@ -23,9 +23,11 @@ wheels/
*.egg *.egg
.pnpm-store/ .pnpm-store/
# Environment # Environment (no subir: cada quien puede usar puertos distintos vía .env)
.env .env
.env.local .env.local
backend/.env
frontend/.env
backend/SCRIPTS/ backend/SCRIPTS/
# IDEs # IDEs
.vscode/ .vscode/
@@ -62,4 +64,3 @@ node_modules/
*.dockerignore *.dockerignore
postgres-data/ postgres-data/
backend/uploads/ backend/uploads/
docker-compose.yml

0
backend/api/__init__.py Normal file
View File

View File

View File

View File

View File

@@ -1,5 +1,6 @@
from datetime import datetime from datetime import datetime
from uuid import uuid4 from uuid import uuid4
import base64
import os import os
import json import json
import logging import logging
@@ -12,24 +13,39 @@ from core.config import settings
from core.database import get_core_db from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource from core.security import get_current_user, validate_access_to_resource
from .tasks import scan_file, insert_valid_rows from .tasks import (
scan_file,
insert_valid_rows,
IMPORT_FILE_KEY_PREFIX,
IMPORT_META_KEY_PREFIX,
IMPORT_REDIS_TTL,
)
from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_redis():
"""Redis client (same broker as Celery so worker can read)."""
import redis
url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0"))
return redis.Redis.from_url(url, decode_responses=False)
@router.post("/upload/{model_target}", response_model=ImportJobResponse) @router.post("/upload/{model_target}", response_model=ImportJobResponse)
async def upload_import_file( async def upload_import_file(
model_target: Literal["invoice_header", "invoice_details"], model_target: Literal["invoice_header", "invoice_details"],
file: UploadFile = File(...), file: UploadFile = File(...),
footer_config: Optional[str] = Form(None), # JSON string with settings footer_config: Optional[str] = Form(None), # JSON string with settings
company_id: int = Query(..., description="Company ID"), # Required for context template_id: Optional[str] = Form(None), # id de la plantilla (ej. imp_temp_header) para respetar columnas
company_id: int = Query(..., description="Company ID"), # Required for context
operation_type: Optional[str] = Query("imp"), operation_type: Optional[str] = Query("imp"),
db: Session = Depends(get_core_db), db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user), current_user: Dict[str, Any] = Depends(get_current_user),
): ):
""" """
Step 1: Upload CSV, save to temp, trigger scan task. Step 1: Upload CSV, save to temp, trigger scan task.
Si se envía template_id, solo se leen las columnas de esa plantilla.
""" """
# 1. Validate Access & Get Tenant # 1. Validate Access & Get Tenant
try: try:
@@ -40,40 +56,51 @@ async def upload_import_file(
if not file.filename.endswith(".csv"): if not file.filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="Only .csv files allowed") raise HTTPException(status_code=400, detail="Only .csv files allowed")
job_id = str(uuid4()) job_id = str(uuid4())
contents = await file.read()
# Ensure directory exists (Safety check)
upload_dir = os.path.join(os.getcwd(), "uploads", "temp") meta_data = {
os.makedirs(upload_dir, exist_ok=True) "tenant_id": tenant_id,
"company_id": company_id,
file_path = os.path.join(upload_dir, f"{job_id}.csv") "user_id": current_user.get("id"),
meta_path = os.path.join(upload_dir, f"{job_id}.meta.json") "footer_config": footer_config,
"operation_type": operation_type,
"template_id": template_id,
}
# Store file and meta in Redis so the Celery worker can read them (no shared filesystem needed)
try: try:
# Save CSV redis_client = _get_redis()
contents = await file.read() redis_client.set(
f"{IMPORT_FILE_KEY_PREFIX}{job_id}",
base64.b64encode(contents),
ex=IMPORT_REDIS_TTL,
)
redis_client.set(
f"{IMPORT_META_KEY_PREFIX}{job_id}",
json.dumps(meta_data).encode("utf-8"),
ex=IMPORT_REDIS_TTL,
)
except Exception as e:
logger.error(f"Redis store error: {e}")
raise HTTPException(status_code=500, detail="Failed to queue file for processing.")
# Optional: also write to local disk (e.g. for same-machine worker or debugging)
try:
upload_dir = os.path.join(os.getcwd(), "uploads", "temp")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, f"{job_id}.csv")
meta_path = os.path.join(upload_dir, f"{job_id}.meta.json")
with open(file_path, "wb") as f: with open(file_path, "wb") as f:
f.write(contents) f.write(contents)
# Save Metadata (Context)
meta_data = {
"tenant_id": tenant_id,
"company_id": company_id,
"user_id": current_user.get("id"),
"footer_config": footer_config,
"operation_type": operation_type,
}
with open(meta_path, "w") as f: with open(meta_path, "w") as f:
json.dump(meta_data, f) json.dump(meta_data, f)
except Exception as e: except Exception as e:
logger.error(f"File save error: {e}") logger.warning(f"Local file save failed (worker will use Redis): {e}")
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
# Trigger Celery Task (Async) # Trigger Celery Task (Async). Worker loads file from Redis.
# Use our job_id as the Celery task_id for easier tracking scan_file.apply_async(args=[job_id, model_target, footer_config], task_id=job_id)
scan_file.apply_async(args=[job_id, file_path, model_target, footer_config], task_id=job_id)
return ImportJobResponse( return ImportJobResponse(
job_id=job_id, job_id=job_id,
@@ -84,24 +111,56 @@ async def upload_import_file(
@router.get("/{job_id}/status") @router.get("/{job_id}/status")
async def get_import_status(job_id: str): async def get_import_status(job_id: str):
""" """
Poll this endpoint to get % progress or final report. Poll to get progress or final report. Always returns an object with "status".
""" """
# In a real app, query Redis or DB.
# For MVP, we might mock or use Celery AsyncResult if backend shares Redis.
task_result = celery_app.AsyncResult(job_id) task_result = celery_app.AsyncResult(job_id)
if task_result.state == 'PENDING': if task_result.state == "PENDING":
return {"status": "processing", "progress": 0} return {"status": "processing", "progress": 0}
elif task_result.state == 'PROGRESS': if task_result.state == "PROGRESS":
return { return {
"status": "processing", "status": "processing",
"progress": task_result.info.get('current', 0), "progress": (task_result.info or {}).get("current", 0),
"total": task_result.info.get('total', 0) "total": (task_result.info or {}).get("total", 0),
} }
elif task_result.state == 'SUCCESS': if task_result.state == "SUCCESS":
return task_result.result # Should return the report result = task_result.result
else: if isinstance(result, dict) and "status" in result:
return {"status": task_result.state, "error": str(task_result.info)} return result
return {"status": "finished", "result": result}
# FAILURE: obtener mensaje real (traceback, result o get(propagate=False))
logger.warning("Import task %s failed: state=%s", job_id, task_result.state)
err_msg = None
tb = getattr(task_result, "traceback", None)
if tb:
logger.debug("Task traceback: %s", tb[:500] if isinstance(tb, str) else tb)
if tb and isinstance(tb, str):
lines = [l.strip() for l in tb.strip().split("\n") if l.strip()]
if lines:
err_msg = lines[-1]
if not err_msg and len(lines) > 1:
err_msg = lines[-2] + " " + (lines[-1] or "")
if not err_msg:
try:
exc = task_result.get(propagate=False)
if exc is not None:
err_msg = str(exc)
except Exception:
pass
if not err_msg:
result = getattr(task_result, "result", None)
info = getattr(task_result, "info", None)
if result is not None and not isinstance(result, dict):
err_msg = str(result)
elif isinstance(result, dict) and (result.get("error") or result.get("message")):
err_msg = result.get("error") or result.get("message")
if not err_msg and isinstance(info, str):
err_msg = info
elif not err_msg and isinstance(info, dict) and "error" in info:
err_msg = str(info["error"])
if not err_msg:
err_msg = "Task failed"
return {"status": "failed", "error": err_msg}
@router.post("/{job_id}/commit") @router.post("/{job_id}/commit")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
"""
Configuración de plantillas CSV: columnas que trae cada plantilla y cómo se mapean.
La plantilla se respeta tal cual: solo se leen columnas definidas aquí; el resto se ignora.
Solo se escribe en BD lo que los modelos de facturas aceptan (respetando models).
"""
from typing import Dict, List, Any, Optional
# Cada plantilla define sus columnas canónicas y alias (otros nombres que aceptamos en el CSV).
# canonical = nombre estándar con el que trabajamos internamente; debe coincidir con lo que
# espera la lógica de validación e insert (tasks.py).
# aliases = cabeceras alternativas que la plantilla .xls puede traer (ej. "Num Factura" → NUM FACTURA).
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
# --- Encabezado factura: Impo Temp (EstructuraEncFacImpoTemp.xls) ---
"imp_temp_header": [
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "ID"]},
{"canonical": "FECHA FACTURA", "aliases": ["FECHA"]},
{"canonical": "FECHA EMISION"},
{"canonical": "CLAVE PROVEEDOR"},
{"canonical": "CLAVE VENDIDO A"},
{"canonical": "CLAVE ENVIADO A"},
{"canonical": "REGIMEN", "aliases": ["CLAVEDOCUMENTO"]},
{"canonical": "ADUANA DE CRUCE"},
{"canonical": "CLAVE MONEDA"},
{"canonical": "CLAVE INCOTERM"},
{"canonical": "TIPO MONEDA"},
{"canonical": "TIPO DE CAMBIO"},
{"canonical": "TIPO PESO"},
{"canonical": "TIPO TRANSPORTE"},
{"canonical": "REMESA"},
{"canonical": "AGENTE ADUANAL"},
{"canonical": "FLETES"},
{"canonical": "VALOR SEGUROS"},
{"canonical": "SEGUROS"},
{"canonical": "EMBALAJES"},
{"canonical": "OTROS INCREMENTABLES"},
{"canonical": "NUM PROYECTO", "aliases": ["NUMPROYECTO"]},
{"canonical": "ORDEN COMPRA", "aliases": ["ORDENCOMPRA"]},
{"canonical": "FACTURA ALTERNA"},
{"canonical": "FACTURA EXPO REF", "aliases": ["FACTURAEXPOREF"]},
{"canonical": "OBSERVACIONES E"},
{"canonical": "OBSERVACIONES I"},
{"canonical": "E DOCUMENT"},
{"canonical": "NUM OPERACION"},
{"canonical": "CLAVE TRANSPORTISTA"},
{"canonical": "NOMBRE CONDUCTOR"},
{"canonical": "NUMERO TRANSPORTE"},
{"canonical": "PRECINTO"},
],
# --- Encabezado factura: Impo Def (EstructuraEncFacImpoDef.xls) - misma estructura ---
"imp_def_header": None, # se resuelve igual que imp_temp_header
# --- Encabezado factura: Expo (EstructuraEncFacExpoCamReg.xls) - misma estructura ---
"exp_def_header": None,
# --- Partidas factura: Impo Temp (EstructuraParFacImpoTempAF.xls) ---
"imp_temp_details": [
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA"]},
{"canonical": "LINEA", "aliases": ["RENGLON", "PARTIDA"]},
{"canonical": "NUMPARTE", "aliases": ["NUMERO PARTE"]},
{"canonical": "PRECIO UNITARIO", "aliases": ["PRECIOUNITARIO"]},
{"canonical": "VALOR COMERCIAL", "aliases": ["VALORCOMERCIAL"]},
{"canonical": "CANTIDAD"},
{"canonical": "CANTIDAD BULTOS", "aliases": ["CANTIDADBULTOS"]},
{"canonical": "DESCRIPCION"},
{"canonical": "PAIS ORIGEN", "aliases": ["PAISORIGEN"]},
{"canonical": "FRACCION"},
{"canonical": "ORDEN DE COMPRA", "aliases": ["ORDENCOMPRA"]},
],
# --- Partidas: Impo Def y Expo - misma estructura ---
"imp_def_details": None,
"exp_def_details": None,
}
def _resolve_template_columns(template_id: str) -> Optional[List[Dict[str, Any]]]:
cols = TEMPLATE_COLUMNS.get(template_id)
if cols is not None:
return cols
if template_id in ("imp_def_header", "exp_def_header"):
return TEMPLATE_COLUMNS.get("imp_temp_header")
if template_id in ("imp_def_details", "exp_def_details"):
return TEMPLATE_COLUMNS.get("imp_temp_details")
return None
def build_normalized_lookup(template_id: str, normalize_header_fn) -> Dict[str, str]:
"""
Construye un diccionario: normalized_header -> canonical_name.
normalize_header_fn(str) -> str debe ser la función que normaliza cabeceras (ej. mayúsculas, sin acentos).
"""
cols = _resolve_template_columns(template_id)
if not cols:
return {}
lookup: Dict[str, str] = {}
for item in cols:
canonical = item["canonical"]
lookup[normalize_header_fn(canonical)] = canonical
for alias in item.get("aliases") or []:
lookup[normalize_header_fn(alias)] = canonical
return lookup
def row_from_template(row: Dict[str, Any], template_id: str, normalize_header_fn) -> Dict[str, Any]:
"""
A partir de una fila CSV (dict header->value) y un template_id, devuelve un dict
solo con las columnas de la plantilla, usando nombres canónicos.
Así la plantilla se respeta: solo entran columnas definidas en la plantilla.
"""
lookup = build_normalized_lookup(template_id, normalize_header_fn)
if not lookup:
# Sin template definido: comportamiento legacy (normalizar todo)
return {normalize_header_fn(k): v for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
return out

View File

@@ -7,6 +7,7 @@ from api.v1.modules.public.reference_data.invoice_types.models import InvoiceTyp
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.public.reference_data.transport_types.models import TransportType from api.v1.modules.public.reference_data.transport_types.models import TransportType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen
from api.v1.modules.public.reference_data.incoterms.models import Incoterm from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode from api.v1.modules.public.reference_data.transport_modes.models import TransportMode

View File

@@ -19,6 +19,14 @@ class InvoiceHeaderBase(BaseModel):
operation_type: Optional[OperationType] = Field( operation_type: Optional[OperationType] = Field(
..., description="Operation type: imp/exp/sm/ctm" ..., description="Operation type: imp/exp/sm/ctm"
) )
@field_validator("operation_type", mode="before")
@classmethod
def normalize_operation_type(cls, v):
"""Accept DB string (e.g. 'IMP') and coerce to enum value ('imp')."""
if isinstance(v, str):
return v.lower() if v else v
return v
invoice_type: Optional[str] = Field( invoice_type: Optional[str] = Field(
None, max_length=5, description="Invoice type key" None, max_length=5, description="Invoice type key"
) )
@@ -198,9 +206,9 @@ class InvoiceComplianceMxBase(BaseModel):
class InvoiceFinancialsBase(BaseModel): class InvoiceFinancialsBase(BaseModel):
"""Base fields for Financials""" """Base fields for Financials"""
currency: Currency = Field(None, max_length=7, description="Currency code") currency: Optional[Currency] = Field(None, max_length=7, description="Currency code")
currency_type: Optional[str] = Field("USD", description="Currency type") currency_type: Optional[str] = Field("USD", description="Currency type")
exchange_rate: Decimal = Field(0.00, description="Exchange rate") exchange_rate: Optional[Decimal] = Field(0.00, description="Exchange rate")
exchange_rate_mm: Optional[Decimal] = Field( exchange_rate_mm: Optional[Decimal] = Field(
None, description="Exchange rate currency to currency" None, description="Exchange rate currency to currency"
) )

View File

@@ -45,11 +45,13 @@ class InvoiceService:
# Apply filters if provided # Apply filters if provided
if filters: if filters:
if filters.get("status"): if filters.get("status") is not None:
query = query.filter(models.InvoiceHeader.status == filters["status"]) query = query.filter(models.InvoiceHeader.is_updated == filters["status"])
if filters.get("operation_type"): if filters.get("operation_type"):
ot = filters["operation_type"]
ot_val = ot.value if hasattr(ot, "value") else ot
query = query.filter( query = query.filter(
models.InvoiceHeader.operation_type == filters["operation_type"] models.InvoiceHeader.operation_type == ot_val
) )
if filters.get("invoice_type"): if filters.get("invoice_type"):
query = query.filter( query = query.filter(
@@ -67,10 +69,9 @@ class InvoiceService:
f"%{filters['pedimento']}%" f"%{filters['pedimento']}%"
) )
) )
if ( ot_exp = filters.get("operation_type")
not filters.get("invoice_type") ot_exp_val = ot_exp.value if hasattr(ot_exp, "value") else ot_exp
and filters.get("operation_type") == "exp" if not filters.get("invoice_type") and ot_exp_val == "exp":
):
query = query.filter(models.InvoiceHeader.operation_type != "REPAR") query = query.filter(models.InvoiceHeader.operation_type != "REPAR")
if filters.get("manifest_number"): if filters.get("manifest_number"):

View File

@@ -12,7 +12,7 @@ from .general_catalogs.router import router as general_catalogs_router
from .invoices.routes import router as invoices_router from .invoices.routes import router as invoices_router
from .items.routes import router as items_router from .items.routes import router as items_router
from .classes import router as classes_router from .classes import router as classes_router
from .classes import router as classes_router
from .clients_and_providers import router as client_and_provider_router from .clients_and_providers import router as client_and_provider_router
from .imports.routes import router as imports_router from .imports.routes import router as imports_router
from .invoice_settings.routes import router as invoice_settings_router from .invoice_settings.routes import router as invoice_settings_router

View File

@@ -1,6 +1,13 @@
import os import os
from celery import Celery from celery import Celery
# Orden: PedimentoCode y RegimenPedimento antes de CodePedimentoRegimen (mapper)
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import (
CodePedimentoRegimen,
)
valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0") valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0")

View File

@@ -11,11 +11,23 @@ from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from .config import settings
from .exceptions import BaseAPIException from .exceptions import BaseAPIException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _cors_headers(request: Request) -> Dict[str, str]:
"""CORS headers for error responses so browser does not block on 4xx/5xx."""
origin = request.headers.get("origin")
if not origin or origin not in settings.cors_origins_list:
return {}
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
}
async def base_exception_handler( async def base_exception_handler(
request: Request, request: Request,
exc: BaseAPIException, exc: BaseAPIException,
@@ -36,10 +48,13 @@ async def base_exception_handler(
if hasattr(exc, "errors") and exc.errors: if hasattr(exc, "errors") and exc.errors:
logger.warning(f"Validation errors details: {exc.errors}") logger.warning(f"Validation errors details: {exc.errors}")
return JSONResponse( response = JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content=jsonable_encoder(exc.to_dict()), content=jsonable_encoder(exc.to_dict()),
) )
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
async def validation_exception_handler( async def validation_exception_handler(
@@ -65,7 +80,7 @@ async def validation_exception_handler(
extra={"errors": errors}, extra={"errors": errors},
) )
return JSONResponse( response = JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={ content={
"error": "VALIDATION_ERROR", "error": "VALIDATION_ERROR",
@@ -74,6 +89,9 @@ async def validation_exception_handler(
"errors": errors, "errors": errors,
}, },
) )
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
async def integrity_error_handler( async def integrity_error_handler(
@@ -102,7 +120,7 @@ async def integrity_error_handler(
elif "not null" in orig_msg: elif "not null" in orig_msg:
error_message = "Falta un campo requerido." error_message = "Falta un campo requerido."
return JSONResponse( response = JSONResponse(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
content={ content={
"error": "DATABASE_INTEGRITY_ERROR", "error": "DATABASE_INTEGRITY_ERROR",
@@ -110,6 +128,9 @@ async def integrity_error_handler(
"status_code": status.HTTP_409_CONFLICT, "status_code": status.HTTP_409_CONFLICT,
}, },
) )
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
async def sqlalchemy_error_handler( async def sqlalchemy_error_handler(
@@ -128,7 +149,7 @@ async def sqlalchemy_error_handler(
exc_info=True, exc_info=True,
) )
return JSONResponse( response = JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={ content={
"error": "DATABASE_ERROR", "error": "DATABASE_ERROR",
@@ -136,6 +157,9 @@ async def sqlalchemy_error_handler(
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR, "status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
}, },
) )
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
async def general_exception_handler( async def general_exception_handler(
@@ -154,7 +178,7 @@ async def general_exception_handler(
exc_info=True, exc_info=True,
) )
return JSONResponse( response = JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={ content={
"error": "INTERNAL_SERVER_ERROR", "error": "INTERNAL_SERVER_ERROR",
@@ -162,6 +186,9 @@ async def general_exception_handler(
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR, "status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
}, },
) )
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
def register_exception_handlers(app) -> None: def register_exception_handlers(app) -> None:

View File

@@ -17,8 +17,13 @@ from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType
from api.v1.modules.public.reference_data.material_types.models import MaterialType from api.v1.modules.public.reference_data.material_types.models import MaterialType
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
# Orden: PedimentoCode y RegimenPedimento antes de CodePedimentoRegimen para que
# SQLAlchemy resuelva los nombres en relationship() al configurar el mapper
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import (
CodePedimentoRegimen,
)
from api.v1.modules.public.reference_data.sectors.models import Sector from api.v1.modules.public.reference_data.sectors.models import Sector
from api.v1.modules.public.reference_data.states.models import State from api.v1.modules.public.reference_data.states.models import State
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
@@ -114,6 +119,20 @@ logger = logging.getLogger(__name__)
register_exception_handlers(app) register_exception_handlers(app)
def _cors_headers_for_request(request: Request):
"""Return CORS headers if request Origin is allowed (so error responses don't get blocked by browser)."""
origin = request.headers.get("origin")
if not origin:
return {}
allowed = settings.cors_origins_list
if origin in allowed:
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
}
return {}
# Add validation error handler # Add validation error handler
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError): async def validation_exception_handler(request: Request, exc: RequestValidationError):
@@ -121,10 +140,13 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
f"Validation error for {request.method} {request.url.path}: {exc.errors()}" f"Validation error for {request.method} {request.url.path}: {exc.errors()}"
) )
logger.error(f"Request body: {await request.body()}") logger.error(f"Request body: {await request.body()}")
return JSONResponse( response = JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": exc.errors(), "body": exc.body}, content={"detail": exc.errors(), "body": exc.body},
) )
for k, v in _cors_headers_for_request(request).items():
response.headers[k] = v
return response
# Add HTTP exception handler # Add HTTP exception handler
@@ -133,10 +155,13 @@ async def http_exception_handler(request: Request, exc: HTTPException):
logger.error( logger.error(
f"HTTP {exc.status_code} for {request.method} {request.url.path}: {exc.detail}" f"HTTP {exc.status_code} for {request.method} {request.url.path}: {exc.detail}"
) )
return JSONResponse( response = JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content={"detail": exc.detail}, content={"detail": exc.detail},
) )
for k, v in _cors_headers_for_request(request).items():
response.headers[k] = v
return response
def run_migrations(): def run_migrations():

View File

@@ -341,6 +341,39 @@ export const api = {
validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}/`) validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}/`)
}, },
imports: {
upload: (
file: File,
modelTarget: string,
footerConfig: any,
companyId: number,
operationType: string,
templateId?: string
) => {
const formData = new FormData();
formData.append('file', file);
if (footerConfig) {
formData.append('footer_config', JSON.stringify(footerConfig));
}
if (templateId) {
formData.append('template_id', templateId);
}
const queryParams = new URLSearchParams({
company_id: String(companyId),
operation_type: operationType || 'imp'
}).toString();
return fetchApi(`/v1/a76/imports/upload/${modelTarget}?${queryParams}`, {
method: 'POST',
body: formData
});
},
status: (jobId: string) => api.get(`/v1/a76/imports/${jobId}/status`),
commit: (jobId: string, modelTarget: string) =>
api.post(`/v1/a76/imports/${jobId}/commit`, { model_target: modelTarget })
},
// Generic request for custom needs (like file uploads) // Generic request for custom needs (like file uploads)
request: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, options) request: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, options)
}; };

View File

@@ -143,17 +143,53 @@
{#if scanResults.error_count > 0} {#if scanResults.error_count > 0}
<div <div
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3" class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3 mb-4"
> >
<XCircle class="w-5 h-5 text-destructive mt-0.5 shrink-0" /> <XCircle class="w-5 h-5 text-destructive mt-0.5 shrink-0" />
<div class="text-sm text-destructive-foreground/90"> <div class="text-sm text-destructive-foreground/90">
<p class="font-semibold mb-1">Se detectaron problemas en el archivo</p> <p class="font-semibold mb-1">Se detectaron problemas en el archivo</p>
<p> <p>
Las filas con errores serán omitidas automáticamente. Solo se importarán los Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para
registros válidos. importar solo las filas válidas (las erróneas se omitirán).
</p> </p>
</div> </div>
</div> </div>
{#if scanResults.errors && scanResults.errors.length > 0}
<div class="border rounded-lg overflow-hidden shadow-sm">
<div class="bg-muted/50 px-4 py-2 border-b flex justify-between items-center">
<h5 class="text-xs font-bold text-foreground uppercase tracking-wide">
Detalle de errores (para corregir en el CSV)
</h5>
<span
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
>
{scanResults.errors.length} error(es)
</span>
</div>
<div class="max-h-60 overflow-y-auto bg-card relative">
<table class="w-full text-xs text-left">
<thead
class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm"
>
<tr>
<th class="px-4 py-2 w-16">Línea</th>
<th class="px-4 py-2 w-40">Columna</th>
<th class="px-4 py-2">Mensaje</th>
</tr>
</thead>
<tbody class="divide-y">
{#each scanResults.errors as err}
<tr class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-2 font-mono text-muted-foreground">{err.line}</td>
<td class="px-4 py-2 font-mono font-medium text-foreground">{err.col || '-'}</td>
<td class="px-4 py-2 text-destructive">{err.msg || '-'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{:else} {:else}
<div class="rounded-md bg-primary/5 border border-primary/10 p-4 flex items-start gap-3"> <div class="rounded-md bg-primary/5 border border-primary/10 p-4 flex items-start gap-3">
<CheckCircle2 class="w-5 h-5 text-primary mt-0.5 shrink-0" /> <CheckCircle2 class="w-5 h-5 text-primary mt-0.5 shrink-0" />

View File

@@ -123,7 +123,7 @@
ondragover={(e) => handleDragOver(e, item.disabled)} ondragover={(e) => handleDragOver(e, item.disabled)}
ondrop={(e) => handleDrop(e, item)} ondrop={(e) => handleDrop(e, item)}
oncontextmenu={(e) => handleContextMenu(e, item)} oncontextmenu={(e) => handleContextMenu(e, item)}
roles="button" role="button"
tabindex={item.disabled ? -1 : 0} tabindex={item.disabled ? -1 : 0}
onclick={() => handleClick(item.id, item.disabled)} onclick={() => handleClick(item.id, item.disabled)}
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)} onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}

View File

@@ -8,6 +8,20 @@ export type { CustomsBroker };
export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBroker>[] { export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBroker>[] {
return [ return [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
const idSnippet = createRawSnippet<[{ id: number }]>((getId) => {
const { id } = getId();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm">${id}</code>`
};
});
return renderSnippet(idSnippet, { id: row.original.id });
}
},
{ {
accessorKey: "broker_key", accessorKey: "broker_key",
header: "Clave", header: "Clave",

View File

@@ -249,7 +249,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Encabezado', title: 'Encabezado',
icon: FileText, icon: FileText,
group: 'Impo. Def.', group: 'Impo. Def.',
modelTarget: 'InvoiceHeader', modelTarget: 'invoice_header',
templateUrl: '/csv/EstructuraEncFacImpoDef.xls' templateUrl: '/csv/EstructuraEncFacImpoDef.xls'
}, },
{ {
@@ -257,7 +257,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Partidas', title: 'Partidas',
icon: Package, icon: Package,
group: 'Impo. Def.', group: 'Impo. Def.',
modelTarget: 'InvoiceSalesDetails', modelTarget: 'invoice_details',
templateUrl: '/csv/EstructuraParFacImpoDefAF.xls' templateUrl: '/csv/EstructuraParFacImpoDefAF.xls'
}, },
{ {
@@ -274,7 +274,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Encabezado', title: 'Encabezado',
icon: FileText, icon: FileText,
group: 'Compras Mex.', group: 'Compras Mex.',
modelTarget: 'InvoiceHeader', modelTarget: 'invoice_header',
disabled: true, disabled: true,
}, },
{ {
@@ -282,7 +282,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Partidas', title: 'Partidas',
icon: Package, icon: Package,
group: 'Compras Mex.', group: 'Compras Mex.',
modelTarget: 'InvoiceSalesDetails', modelTarget: 'invoice_details',
disabled: true, disabled: true,
}, },
{ {
@@ -302,7 +302,7 @@ export const exportacionConfig: CsvUploadItem[] = [
title: 'Encabezado', title: 'Encabezado',
icon: FileText, icon: FileText,
group: 'Expo. Def./Cam. Reg.', group: 'Expo. Def./Cam. Reg.',
modelTarget: 'InvoiceHeader', modelTarget: 'invoice_header',
templateUrl: '/csv/EstructuraEncFacExpoCamReg.xls' templateUrl: '/csv/EstructuraEncFacExpoCamReg.xls'
}, },
{ {
@@ -310,7 +310,7 @@ export const exportacionConfig: CsvUploadItem[] = [
title: 'Partidas', title: 'Partidas',
icon: Package, icon: Package,
group: 'Expo. Def./Cam. Reg.', group: 'Expo. Def./Cam. Reg.',
modelTarget: 'InvoiceSalesDetails', modelTarget: 'invoice_details',
templateUrl: '/csv/EstructuraParExpoCamReg.xls' templateUrl: '/csv/EstructuraParExpoCamReg.xls'
}, },
{ {

View File

@@ -38,25 +38,45 @@
}); });
async function handleUpload(file: File, config: CsvUploadItem) { async function handleUpload(file: File, config: CsvUploadItem) {
console.log('handleUpload started', { file, config });
isUploading = true; isUploading = true;
activeModelTarget = config.modelTarget || null; activeModelTarget = config.modelTarget || null;
scanResults = null; scanResults = null;
const currentSettings = allSettings[activeTab] || {}; const currentSettings = allSettings[activeTab] || {};
const footerConfig = { ...currentSettings };
if (activeTab === 'importacion') {
footerConfig.invoice_type = config.id?.startsWith('imp_def_') ? 'DEF' : 'TEM';
}
const companyId = companyStore.activeCompany?.id || 1; const companyId = companyStore.activeCompany?.id || 1;
const opType = activeTab === 'exportacion' ? 'exp' : 'imp'; const opType = activeTab === 'exportacion' ? 'exp' : 'imp';
const res = await api.imports.upload( console.log('Calling api.imports.upload', {
file, activeModelTarget,
config.modelTarget || '', footerConfig,
currentSettings,
companyId, companyId,
opType opType
); });
if (res.data?.job_id) { try {
currentJobId = res.data.job_id; const res = await api.imports.upload(
pollStatus(); file,
} else { config.modelTarget || '',
toast.error('Error al subir el archivo'); footerConfig,
companyId,
opType,
config.id
);
console.log('Upload response', res);
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
} else {
console.error('Upload failed with response', res);
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
isUploading = false; isUploading = false;
} }
} }
@@ -64,54 +84,71 @@
async function pollStatus() { async function pollStatus() {
if (!currentJobId) return; if (!currentJobId) return;
const res = await api.imports.status(currentJobId); console.log('Polling status for job', currentJobId);
if (res.data?.status === 'waiting_confirmation') { try {
scanResults = res.data; const res = await api.imports.status(currentJobId);
showResultModal = true; console.log('Poll response', res);
toast.success('Escaneo completado. Revisa los resultados.'); if (res.error && !res.data) {
isUploading = false; toast.error(res.error || 'Error al consultar el estado');
} else if (res.data?.status === 'failed') { isUploading = false;
toast.error('Error en el procesamiento: ' + (res.data.error || 'Error desconocido')); currentJobId = null;
isUploading = false; return;
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
} else if (res.data?.status === 'warning') {
// Caso cuando no se insertaron registros pero hay información de rechazo
commitResults = res.data;
showResultModal = true;
const inserted = res.data?.inserted || 0;
const skippedInvalid = res.data?.skipped_invalid || 0;
const skippedFk = res.data?.skipped_missing_fk || 0;
const totalSkipped = skippedInvalid + skippedFk;
if (inserted === 0) {
toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`);
} else {
toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
} }
isUploading = false; if (res.data?.status === 'waiting_confirmation') {
} else if (res.data?.status === 'finished') { scanResults = res.data;
commitResults = res.data; showResultModal = true;
showResultModal = true; toast.success('Escaneo completado. Revisa los resultados.');
const inserted = res.data?.inserted || 0; isUploading = false;
const skippedInvalid = res.data?.skipped_invalid || 0; } else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') {
const skippedFk = res.data?.skipped_missing_fk || 0; toast.error('Error en el procesamiento: ' + (res.data.error || 'Error desconocido'));
const skippedDetails = res.data?.skipped_details || []; isUploading = false;
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
} else if (res.data?.status === 'warning') {
// Caso cuando no se insertaron registros pero hay información de rechazo
commitResults = res.data;
showResultModal = true;
const inserted = res.data?.inserted || 0;
const skippedInvalid = res.data?.skipped_invalid || 0;
const skippedFk = res.data?.skipped_missing_fk || 0;
const totalSkipped = skippedInvalid + skippedFk;
if (inserted > 0) { if (inserted === 0) {
toast.success(`Importación completada: ${inserted} registros insertados`); toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`);
if (skippedInvalid > 0 || skippedFk > 0) { } else {
const totalSkipped = skippedInvalid + skippedFk; toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
toast.warning(`${totalSkipped} registros fueron rechazados`);
} }
isUploading = false;
} else if (res.data?.status === 'finished') {
commitResults = res.data;
showResultModal = true;
const inserted = res.data?.inserted || 0;
const skippedInvalid = res.data?.skipped_invalid || 0;
const skippedFk = res.data?.skipped_missing_fk || 0;
const skippedDetails = res.data?.skipped_details || [];
if (inserted > 0) {
toast.success(`Importación completada: ${inserted} registros insertados`);
if (skippedInvalid > 0 || skippedFk > 0) {
const totalSkipped = skippedInvalid + skippedFk;
toast.warning(`${totalSkipped} registros fueron rechazados`);
}
} else {
toast.error('No se insertaron registros. Revisa los errores a continuación.');
}
isUploading = false;
} else { } else {
toast.error('No se insertaron registros. Revisa los errores a continuación.'); // Continue polling
console.log('Status not final, polling again in 2s...', res.data?.status);
setTimeout(pollStatus, 2000);
} }
isUploading = false; } catch (e) {
} else { console.error('Poll exception', e);
// Continue polling // Retry on network error? Or fail?
// For now, let's keep retrying a few times or hard fail.
// Let's just log and retry.
setTimeout(pollStatus, 2000); setTimeout(pollStatus, 2000);
} }
} }
@@ -174,36 +211,38 @@
{/if} {/if}
</div> </div>
<ProcessingResultModal {#if scanResults || commitResults}
bind:open={showResultModal} <ProcessingResultModal
{scanResults} bind:open={showResultModal}
{commitResults} {scanResults}
{isUploading} {commitResults}
onConfirm={async () => { {isUploading}
if (currentJobId && activeModelTarget) { onConfirm={async () => {
try { if (currentJobId && activeModelTarget) {
isUploading = true; try {
const res = await api.imports.commit(currentJobId, activeModelTarget); isUploading = true;
if (res.data?.commit_job_id) { const res = await api.imports.commit(currentJobId, activeModelTarget);
currentJobId = res.data.commit_job_id; if (res.data?.commit_job_id) {
pollStatus(); currentJobId = res.data.commit_job_id;
pollStatus();
}
} catch (err) {
toast.error('Error al iniciar la importación');
isUploading = false;
} }
} catch (err) {
toast.error('Error al iniciar la importación');
isUploading = false;
} }
} }}
}} onCancel={() => {
onCancel={() => { currentJobId = null;
currentJobId = null; scanResults = null;
scanResults = null; commitResults = null;
commitResults = null; showResultModal = false;
showResultModal = false; }}
}} onClose={() => {
onClose={() => { currentJobId = null;
currentJobId = null; scanResults = null;
scanResults = null; commitResults = null;
commitResults = null; showResultModal = false;
showResultModal = false; }}
}} />
/> {/if}