Merge branch 'feature/doda-endpoints-faltantes' into feature/visible-clicks-csv

This commit is contained in:
2026-04-29 08:32:07 -06:00
13 changed files with 1209 additions and 336 deletions

View File

@@ -8,6 +8,7 @@ from pydantic import BaseModel, Field
class DodaAltaLogCreateDTO(BaseModel):
doda_id: Optional[int] = None
variant: Optional[str] = Field(None, max_length=10)
action: Optional[str] = Field(None, max_length=20)
responsible: Optional[str] = Field(None, max_length=20)
patent: Optional[str] = Field(None, max_length=10)
dispatch_customs: Optional[str] = Field(None, max_length=10)
@@ -29,6 +30,7 @@ class DodaAltaLogResponseDTO(BaseModel):
id: int
doda_id: Optional[int] = None
variant: Optional[str] = None
action: Optional[str] = None
responsible: Optional[str] = None
patent: Optional[str] = None
dispatch_customs: Optional[str] = None

View File

@@ -23,6 +23,7 @@ class DodaAltaLog(Base, TenantScopedMixin, TimestampMixin):
# Tipo de alta (doda / pita)
variant: Mapped[str | None] = mapped_column(String(10), nullable=True)
action: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Datos copiados del DODA al momento del envío (para historial)
responsible: Mapped[str | None] = mapped_column(String(20), nullable=True)

View File

@@ -115,6 +115,7 @@ class DodaAltaLogService:
tenant_id: int,
variant: str,
ext_result: dict,
action: str = "alta",
) -> DodaAltaLog:
"""
Crea un registro de log a partir de la respuesta del servicio externo de alta.
@@ -127,6 +128,7 @@ class DodaAltaLogService:
dto = DodaAltaLogCreateDTO(
doda_id=doda.id,
variant=variant,
action=action,
responsible=doda.responsible,
patent=doda.patent,
dispatch_customs=doda.dispatch_customs,

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import base64
import json
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@@ -16,6 +17,7 @@ from core.storage_s3 import get_object_bytes, object_exists
from api.v1.modules.a76.customs_brokers import models as cb_models
from .models import Doda, DodaContainer, DodaAmericanPedimento, DodaPedimento
from .alta_log_models import DodaAltaLog
from .payload_normalizer import (
normalize_aduana_despacho,
normalize_aduana_seccion,
@@ -487,6 +489,40 @@ class DodaAltaService:
# Build full payload
# ------------------------------------------------------------------
def _latest_alta_log(
self,
doda_id: int,
tenant_id: int,
company_id: int,
variant: str,
) -> Optional[DodaAltaLog]:
return (
self.db.query(DodaAltaLog)
.filter(
DodaAltaLog.doda_id == doda_id,
DodaAltaLog.tenant_id == tenant_id,
DodaAltaLog.company_id == company_id,
DodaAltaLog.variant == variant,
DodaAltaLog.deleted_at.is_(None),
)
.order_by(DodaAltaLog.id.desc())
.first()
)
@staticmethod
def _extract_numero_transaccion(log_record: DodaAltaLog) -> str:
raw_json = (log_record.result_json or "").strip()
if raw_json:
try:
parsed = json.loads(raw_json)
for key in ("numero_transaccion", "transaction_number"):
value = parsed.get(key)
if value:
return str(value).strip()
except Exception:
logger.warning("No se pudo parsear result_json de DodaAltaLog id=%s", log_record.id)
return ""
def build_alta_payload(
self,
doda_id: int,
@@ -549,3 +585,59 @@ class DodaAltaService:
}
return payload
def build_consulta_payload(
self,
doda_id: int,
tenant_id: int,
company_id: int,
variant: str = "doda",
user_email: str = "",
) -> Dict[str, Any]:
payload = self.build_alta_payload(
doda_id=doda_id,
tenant_id=tenant_id,
company_id=company_id,
variant=variant,
user_email=user_email,
)
latest_log = self._latest_alta_log(doda_id, tenant_id, company_id, variant)
if not latest_log:
raise ValueError(
"No existe un alta DODA previa para construir la consulta (falta task/log)."
)
numero_transaccion = self._extract_numero_transaccion(latest_log)
if not numero_transaccion:
raise ValueError(
"No se encontro numero_transaccion en el ultimo resultado de alta DODA."
)
payload["numero_transaccion"] = numero_transaccion
return payload
def build_eliminar_payload(
self,
doda_id: int,
tenant_id: int,
company_id: int,
variant: str = "doda",
user_email: str = "",
) -> Dict[str, Any]:
payload = self.build_alta_payload(
doda_id=doda_id,
tenant_id=tenant_id,
company_id=company_id,
variant=variant,
user_email=user_email,
)
latest_log = self._latest_alta_log(doda_id, tenant_id, company_id, variant)
if not latest_log:
raise ValueError(
"No existe un alta DODA previa para construir la eliminacion."
)
numero_integracion = (latest_log.integration_number or "").strip()
if not numero_integracion:
raise ValueError(
"No se encontro numero_integracion en el historial de alta DODA."
)
payload["numero_integracion"] = numero_integracion
return payload

View File

@@ -17,6 +17,10 @@ class DodaExternalService:
Endpoints:
POST {base_url}/api/v1/doda/alta
GET {base_url}/api/v1/doda/alta-status/{task_id}
POST {base_url}/api/v1/doda/consulta
GET {base_url}/api/v1/doda/consulta-status/{task_id}
POST {base_url}/api/v1/doda/eliminar
GET {base_url}/api/v1/doda/eliminar-status/{task_id}
Usa COVE_API_URL como URL base (la misma variable que COVE y Expediente).
"""
@@ -61,3 +65,43 @@ class DodaExternalService:
response = client.get(url)
response.raise_for_status()
return response.json()
def post_consulta(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Envia consulta DODA y retorna {task_id, status, message}."""
url = f"{self.base_url.rstrip('/')}/api/v1/doda/consulta"
with httpx.Client(
timeout=httpx.Timeout(60.0, connect=10.0), verify=self.verify_ssl
) as client:
response = client.post(url, json=payload)
response.raise_for_status()
return response.json()
def get_consulta_status(self, task_id: str) -> Dict[str, Any]:
"""Consulta el estado de una tarea de consulta DODA."""
url = f"{self.base_url.rstrip('/')}/api/v1/doda/consulta-status/{task_id}"
with httpx.Client(
timeout=httpx.Timeout(30.0, connect=10.0), verify=self.verify_ssl
) as client:
response = client.get(url)
response.raise_for_status()
return response.json()
def post_eliminar(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Envia eliminacion DODA y retorna {task_id, status, message}."""
url = f"{self.base_url.rstrip('/')}/api/v1/doda/eliminar"
with httpx.Client(
timeout=httpx.Timeout(60.0, connect=10.0), verify=self.verify_ssl
) as client:
response = client.post(url, json=payload)
response.raise_for_status()
return response.json()
def get_eliminar_status(self, task_id: str) -> Dict[str, Any]:
"""Consulta el estado de una tarea de eliminacion DODA."""
url = f"{self.base_url.rstrip('/')}/api/v1/doda/eliminar-status/{task_id}"
with httpx.Client(
timeout=httpx.Timeout(30.0, connect=10.0), verify=self.verify_ssl
) as client:
response = client.get(url)
response.raise_for_status()
return response.json()

View File

@@ -60,6 +60,47 @@ from core.security import get_current_user, validate_access_to_resource
logger = logging.getLogger(__name__)
def _coalesce_external_result_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
result = payload.get("result")
if isinstance(result, dict):
return result
return payload
def _apply_consulta_success_to_doda(
doda: Doda,
payload: Dict[str, Any],
) -> None:
source = _coalesce_external_result_payload(payload)
mapping = {
"integration_number": "integration_number",
"numero_integracion": "integration_number",
"transaction_number": "transaction_number",
"numero_transaccion": "transaction_number",
"sat_digital_seal": "sat_digital_seal",
"sello_digital_sat": "sat_digital_seal",
"sat_certificate": "sat_certificate",
"certificado_sat": "sat_certificate",
"serial_number": "serial_number",
"numero_serie": "serial_number",
"electronic_signature": "electronic_signature",
"firma_electronica": "electronic_signature",
"original_chain": "original_chain",
"cadena_original": "original_chain",
"sat_original_chain": "sat_original_chain",
"cadena_original_sat": "sat_original_chain",
"linq_sat_qr": "linq_sat_qr",
"link_sat_qr": "linq_sat_qr",
"xml_doda_sent_path": "xml_doda_sent_path",
"xml_doda_response_path": "xml_doda_response_path",
"status": "status",
}
for src_key, dst_attr in mapping.items():
value = source.get(src_key)
if value is not None and value != "":
setattr(doda, dst_attr, str(value))
# Router independiente para rutas literales (deben registrarse antes que /{id})
router = APIRouter(prefix="/doda", tags=["doda"])
@@ -638,6 +679,7 @@ async def post_doda_alta(
tenant_id=int(tenant_id),
variant=variant,
ext_result=result,
action="alta",
)
except Exception:
logger.exception("Error persistiendo DodaAltaLog para doda_id=%s", doda_id)
@@ -673,6 +715,269 @@ async def get_doda_alta_status(
) from exc
@router.post(
"/{doda_id}/consulta",
summary="Enviar Consulta DODA al servicio externo (asíncrono)",
tags=["doda-alta"],
)
async def post_doda_consulta(
doda_id: int,
company_id: int = Query(..., description="Company ID"),
variant: str = Query("doda", description="Tipo de alta: 'doda' o 'pita'"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
) -> Dict[str, Any]:
tenant_id = validate_access_to_resource(db, company_id, current_user)
user_email = (
current_user.get("email")
or current_user.get("preferred_username")
or ""
)
service = DodaAltaService(db)
try:
payload = service.build_consulta_payload(
doda_id=doda_id,
tenant_id=int(tenant_id),
company_id=company_id,
variant=variant,
user_email=user_email,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
ext_result: Dict[str, Any]
try:
ext = DodaExternalService()
ext_result = ext.post_consulta(payload)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except Exception as exc:
logger.exception("Error al enviar consulta DODA al servicio externo: doda_id=%s", doda_id)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Error al contactar el servicio DODA externo (consulta): {exc}",
) from exc
doda_record = DodaService.get_by_id(db, doda_id, int(tenant_id), company_id)
if doda_record:
try:
DodaAltaLogService.create_from_alta_result(
db=db,
doda=doda_record,
company_id=company_id,
tenant_id=int(tenant_id),
variant=variant,
ext_result=ext_result,
action="consulta",
)
except Exception:
logger.exception("Error persistiendo DodaAltaLog(consulta) para doda_id=%s", doda_id)
return ext_result
@router.get(
"/consulta-status/{task_id}",
summary="Consultar estado de tarea de Consulta DODA",
tags=["doda-alta"],
)
async def get_doda_consulta_status(
task_id: str,
current_user: dict = Depends(get_current_user),
) -> Any:
try:
ext = DodaExternalService()
return ext.get_consulta_status(task_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except Exception as exc:
logger.exception("Error consultando consulta-status DODA task_id=%s", task_id)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Error al consultar el estado de la consulta DODA: {exc}",
) from exc
@router.post(
"/{doda_id}/consulta-apply/{task_id}",
summary="Aplicar resultado exitoso de consulta DODA al registro local",
tags=["doda-alta"],
)
async def post_doda_consulta_apply(
doda_id: int,
task_id: str,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
) -> Dict[str, Any]:
tenant_id = int(validate_access_to_resource(db, company_id, current_user))
doda_record = DodaService.get_by_id(db, doda_id, tenant_id, company_id)
if not doda_record:
raise HTTPException(status_code=404, detail="DODA no encontrado.")
try:
ext = DodaExternalService()
status_payload = ext.get_consulta_status(task_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except Exception as exc:
logger.exception("Error al consultar consulta-status para aplicar: task_id=%s", task_id)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Error al consultar el estado de la consulta DODA: {exc}",
) from exc
task_state = str(status_payload.get("state") or status_payload.get("status") or "").upper()
if task_state != "SUCCESS":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="La tarea de consulta aún no está en estado SUCCESS.",
)
try:
_apply_consulta_success_to_doda(doda_record, status_payload)
if not (doda_record.status or "").strip():
doda_record.status = "VALIDADO"
db.add(doda_record)
db.commit()
db.refresh(doda_record)
except Exception as exc:
db.rollback()
logger.exception("Error aplicando consulta-status al DODA id=%s", doda_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"No se pudo aplicar el resultado de consulta al DODA: {exc}",
) from exc
return {
"message": "Resultado de consulta aplicado correctamente.",
"doda_id": doda_id,
"task_id": task_id,
"state": task_state,
}
@router.post(
"/{doda_id}/eliminar",
summary="Enviar Eliminación DODA al servicio externo (asíncrono)",
tags=["doda-alta"],
)
async def post_doda_eliminar(
doda_id: int,
company_id: int = Query(..., description="Company ID"),
variant: str = Query("doda", description="Tipo de alta: 'doda' o 'pita'"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
) -> Dict[str, Any]:
tenant_id = validate_access_to_resource(db, company_id, current_user)
user_email = (
current_user.get("email")
or current_user.get("preferred_username")
or ""
)
service = DodaAltaService(db)
try:
payload = service.build_eliminar_payload(
doda_id=doda_id,
tenant_id=int(tenant_id),
company_id=company_id,
variant=variant,
user_email=user_email,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
ext_result: Dict[str, Any]
try:
ext = DodaExternalService()
ext_result = ext.post_eliminar(payload)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except Exception as exc:
logger.exception("Error al enviar eliminación DODA al servicio externo: doda_id=%s", doda_id)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Error al contactar el servicio DODA externo (eliminación): {exc}",
) from exc
doda_record = DodaService.get_by_id(db, doda_id, int(tenant_id), company_id)
if doda_record:
try:
DodaAltaLogService.create_from_alta_result(
db=db,
doda=doda_record,
company_id=company_id,
tenant_id=int(tenant_id),
variant=variant,
ext_result=ext_result,
action="eliminar",
)
except Exception:
logger.exception("Error persistiendo DodaAltaLog(eliminar) para doda_id=%s", doda_id)
try:
doda_record.integration_number = None
doda_record.transaction_number = None
doda_record.status = "PENDIENTE"
doda_record.sat_digital_seal = None
doda_record.sat_certificate = None
doda_record.serial_number = None
doda_record.electronic_signature = None
doda_record.original_chain = None
doda_record.sat_original_chain = None
doda_record.linq_sat_qr = None
doda_record.xml_doda_sent_path = None
doda_record.xml_doda_response_path = None
db.add(doda_record)
db.commit()
except Exception:
db.rollback()
logger.exception("Error desprocesando DODA local tras eliminación id=%s", doda_id)
return ext_result
@router.get(
"/eliminar-status/{task_id}",
summary="Consultar estado de tarea de Eliminación DODA",
tags=["doda-alta"],
)
async def get_doda_eliminar_status(
task_id: str,
current_user: dict = Depends(get_current_user),
) -> Any:
try:
ext = DodaExternalService()
return ext.get_eliminar_status(task_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except Exception as exc:
logger.exception("Error consultando eliminar-status DODA task_id=%s", task_id)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Error al consultar el estado de la eliminación DODA: {exc}",
) from exc
# ============ DODA ALTA LOG CRUD ============

View File

@@ -37,6 +37,19 @@ logger = logging.getLogger(__name__)
class DodaService:
"""Servicio para gestión de DODA"""
@staticmethod
def _ensure_editable_doda(doda: Optional[Doda]) -> None:
if not doda:
return
if (doda.integration_number or "").strip():
raise HTTPException(
status_code=422,
detail=(
"El DODA ya fue generado (tiene número de integración). "
"Elimínelo primero para poder editarlo."
),
)
@staticmethod
def _invalidate_report_after_mutation(
db: Session,
@@ -132,6 +145,7 @@ class DodaService:
db_doda = DodaService.get_by_id(db, id, tenant_id, company_id)
if not db_doda:
return None
DodaService._ensure_editable_doda(db_doda)
for key, value in doda_data.model_dump(exclude_unset=True).items():
setattr(db_doda, key, value)
@@ -195,6 +209,7 @@ class DodaService:
doda = db.query(Doda).filter(Doda.id == doda_id).first()
if not doda:
return None
DodaService._ensure_editable_doda(doda)
cv = (container_data.container_value or "").strip()
if not cv:
@@ -259,6 +274,8 @@ class DodaService:
)
if not db_container:
return None
doda = db.get(Doda, doda_id)
DodaService._ensure_editable_doda(doda)
for key, value in container_data.model_dump(exclude_unset=True).items():
setattr(db_container, key, value)
@@ -308,6 +325,8 @@ class DodaService:
)
if not db_container:
raise HTTPException(status_code=404, detail="Contenedor no encontrado.")
doda = db.get(Doda, doda_id)
DodaService._ensure_editable_doda(doda)
has_seals = bool(db_container.seals_detail)
if not has_seals and db_container.seals:
@@ -376,6 +395,7 @@ class DodaService:
doda = db.query(Doda).filter(Doda.id == doda_id).first()
if not doda:
raise HTTPException(status_code=404, detail="DODA no encontrado.")
DodaService._ensure_editable_doda(doda)
container = (
db.query(DodaContainer)
@@ -387,6 +407,8 @@ class DodaService:
)
if not container:
raise HTTPException(status_code=404, detail="Contenedor no encontrado.")
doda = db.get(Doda, doda_id)
DodaService._ensure_editable_doda(doda)
raw_value = (seal_data.seal_value or "").strip()
if not raw_value:
@@ -504,6 +526,7 @@ class DodaService:
doda = db.query(Doda).filter(Doda.id == doda_id).first()
if not doda:
return None
DodaService._ensure_editable_doda(doda)
tipo = (pedimento_data.american_pedimento_type or "").strip()
valor = (pedimento_data.american_pedimento_value or "").strip()
@@ -595,8 +618,9 @@ class DodaService:
raise HTTPException(
status_code=404, detail="Pedimento americano no encontrado."
)
doda = db.get(Doda, doda_id)
DodaService._ensure_editable_doda(doda)
try:
doda = db.get(Doda, doda_id)
db.delete(db_pedimento)
db.commit()
if doda:
@@ -625,6 +649,7 @@ class DodaService:
doda = db.query(Doda).filter(Doda.id == doda_id).first()
if not doda:
return None
DodaService._ensure_editable_doda(doda)
max_line = (
db.query(DodaPedimento)
@@ -681,8 +706,9 @@ class DodaService:
)
if not db_pedimento:
raise HTTPException(status_code=404, detail="Pedimento no encontrado.")
doda = db.get(Doda, doda_id)
DodaService._ensure_editable_doda(doda)
try:
doda = db.get(Doda, doda_id)
db.delete(db_pedimento)
db.commit()
if doda: