diff --git a/backend/alembic/versions/c3d4e5f6a7b8_add_action_to_doda_alta_log.py b/backend/alembic/versions/c3d4e5f6a7b8_add_action_to_doda_alta_log.py new file mode 100644 index 00000000..e28c1542 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_add_action_to_doda_alta_log.py @@ -0,0 +1,29 @@ +"""add action column to doda_alta_log + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-04-28 13:20:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c3d4e5f6a7b8" +down_revision = "b2c3d4e5f6a7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "doda_alta_log", + sa.Column("action", sa.String(length=20), nullable=True), + schema="a76", + ) + + +def downgrade() -> None: + op.drop_column("doda_alta_log", "action", schema="a76") diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py index 72ef6aab..3ad314d6 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py index 581c5f55..085213ed 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_models.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py index f4b83615..ef325ab8 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_log_service.py @@ -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, diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py index ffca5a57..2a539bdb 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py @@ -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 diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py b/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py index a972bb66..85970890 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/external_service.py @@ -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() diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py index b5ed8848..05b18223 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py @@ -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 ============ diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/service.py b/backend/api/v1/modules/a76/general_catalogs/doda/service.py index ee60e5e5..33172fd7 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/service.py @@ -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: diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_alta_payloads.py b/backend/tests/unit/general_catalogs/doda/test_doda_alta_payloads.py new file mode 100644 index 00000000..4d18d0e0 --- /dev/null +++ b/backend/tests/unit/general_catalogs/doda/test_doda_alta_payloads.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from api.v1.modules.a76.general_catalogs.doda.alta_service import DodaAltaService + + +def test_build_consulta_payload_appends_numero_transaccion(monkeypatch): + service = DodaAltaService(db=None) + monkeypatch.setattr( + service, + "build_alta_payload", + lambda **kwargs: {"base": "payload"}, + ) + monkeypatch.setattr( + service, + "_latest_alta_log", + lambda *args, **kwargs: SimpleNamespace( + id=9, + result_json=json.dumps({"numero_transaccion": "TX-001"}), + integration_number="INT-001", + ), + ) + + payload = service.build_consulta_payload( + doda_id=1, tenant_id=1, company_id=1, variant="doda", user_email="u@test.com" + ) + assert payload["base"] == "payload" + assert payload["numero_transaccion"] == "TX-001" + + +def test_build_consulta_payload_fails_when_numero_transaccion_missing(monkeypatch): + service = DodaAltaService(db=None) + monkeypatch.setattr(service, "build_alta_payload", lambda **kwargs: {}) + monkeypatch.setattr( + service, + "_latest_alta_log", + lambda *args, **kwargs: SimpleNamespace( + id=10, result_json=json.dumps({"other": "value"}), integration_number="INT-001" + ), + ) + + with pytest.raises(ValueError, match="numero_transaccion"): + service.build_consulta_payload( + doda_id=1, tenant_id=1, company_id=1, variant="doda", user_email="" + ) + + +def test_build_eliminar_payload_appends_numero_integracion(monkeypatch): + service = DodaAltaService(db=None) + monkeypatch.setattr(service, "build_alta_payload", lambda **kwargs: {"base": "payload"}) + monkeypatch.setattr( + service, + "_latest_alta_log", + lambda *args, **kwargs: SimpleNamespace( + id=11, + result_json="{}", + integration_number="INT-900", + ), + ) + + payload = service.build_eliminar_payload( + doda_id=2, tenant_id=1, company_id=1, variant="doda", user_email="user@test.com" + ) + assert payload["base"] == "payload" + assert payload["numero_integracion"] == "INT-900" diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_external_service.py b/backend/tests/unit/general_catalogs/doda/test_doda_external_service.py new file mode 100644 index 00000000..8b5367f3 --- /dev/null +++ b/backend/tests/unit/general_catalogs/doda/test_doda_external_service.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Any, Dict + +from api.v1.modules.a76.general_catalogs.doda.external_service import DodaExternalService + + +class _FakeResponse: + def __init__(self, payload: Dict[str, Any]): + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> Dict[str, Any]: + return self._payload + + +class _FakeClient: + calls = [] + + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def post(self, url: str, json: Dict[str, Any]): + _FakeClient.calls.append(("POST", url, json)) + return _FakeResponse({"task_id": "t-1", "status": "queued", "message": "ok"}) + + def get(self, url: str): + _FakeClient.calls.append(("GET", url, None)) + return _FakeResponse({"task_id": "t-1", "status": "done", "message": "ok"}) + + +def test_external_service_supports_consulta_and_eliminar(monkeypatch): + from api.v1.modules.a76.general_catalogs.doda import external_service as module + + _FakeClient.calls = [] + monkeypatch.setattr(module, "httpx", module.httpx) + monkeypatch.setattr(module.httpx, "Client", _FakeClient) + + service = DodaExternalService() + service.base_url = "http://example.test" + + payload = {"foo": "bar"} + consulta = service.post_consulta(payload) + consulta_status = service.get_consulta_status("abc123") + eliminar = service.post_eliminar(payload) + eliminar_status = service.get_eliminar_status("abc123") + + assert consulta["task_id"] == "t-1" + assert consulta_status["status"] == "done" + assert eliminar["status"] == "queued" + assert eliminar_status["task_id"] == "t-1" + assert _FakeClient.calls == [ + ("POST", "http://example.test/api/v1/doda/consulta", payload), + ("GET", "http://example.test/api/v1/doda/consulta-status/abc123", None), + ("POST", "http://example.test/api/v1/doda/eliminar", payload), + ("GET", "http://example.test/api/v1/doda/eliminar-status/abc123", None), + ] diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts index 7e381174..770510dc 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/doda.ts @@ -478,6 +478,56 @@ export async function getDodaAltaStatus( return api.get(`/v1/a76/doda/alta-status/${taskId}`); } +export async function postDodaConsulta( + dodaId: number, + companyId: number, + variant: 'doda' | 'pita' = 'doda' +): Promise> { + const params = new URLSearchParams({ + company_id: companyId.toString(), + variant, + }); + return api.post(`/v1/a76/doda/${dodaId}/consulta?${params}`, {}); +} + +export async function getDodaConsultaStatus( + taskId: string +): Promise> { + return api.get(`/v1/a76/doda/consulta-status/${taskId}`); +} + +export async function postDodaConsultaApply( + dodaId: number, + taskId: string, + companyId: number +): Promise>> { + const params = new URLSearchParams({ + company_id: companyId.toString(), + }); + return api.post>( + `/v1/a76/doda/${dodaId}/consulta-apply/${taskId}?${params}`, + {} + ); +} + +export async function postDodaEliminar( + dodaId: number, + companyId: number, + variant: 'doda' | 'pita' = 'doda' +): Promise> { + const params = new URLSearchParams({ + company_id: companyId.toString(), + variant, + }); + return api.post(`/v1/a76/doda/${dodaId}/eliminar?${params}`, {}); +} + +export async function getDodaEliminarStatus( + taskId: string +): Promise> { + return api.get(`/v1/a76/doda/eliminar-status/${taskId}`); +} + export async function getDodaElegibilidad( dodaId: number, companyId: number, diff --git a/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte b/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte index 6580d455..a7498cd4 100644 --- a/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte +++ b/frontend/src/lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte @@ -7,6 +7,7 @@ getDodaAltaStatus, type DodaAltaStatusResponse } from '$lib/api/dashboard/a76/general_catalogs/doda'; + import type { ApiResponse } from '$lib/api'; import { m } from '$lib/i18n/messages'; let { @@ -14,6 +15,9 @@ taskId, dodaId, variant = 'doda', + title = m['sidebar.doda_alta.progress_title'](), + description = 'Task ID', + getStatus = getDodaAltaStatus, onComplete, onCancel }: { @@ -21,6 +25,9 @@ taskId: string; dodaId?: number; variant?: 'doda' | 'pita'; + title?: string; + description?: string; + getStatus?: (taskId: string) => Promise>; onComplete?: (result: DodaAltaStatusResponse) => void; onCancel?: () => void; } = $props(); @@ -84,7 +91,7 @@ if (!taskId || !pollingActive || pollInFlight) return; pollInFlight = true; try { - const res = await getDodaAltaStatus(taskId); + const res = await getStatus(taskId); if (res.error) { consecutivePollErrors += 1; @@ -151,8 +158,8 @@ - {m['sidebar.doda_alta.progress_title']()} - Alta {variantLabel} — Task ID: {taskId} + {title} + {description} {variantLabel} — Task ID: {taskId}
diff --git a/frontend/src/routes/dashboard/despacho/doda/+page.svelte b/frontend/src/routes/dashboard/despacho/doda/+page.svelte index 0aa358f9..4edc440c 100644 --- a/frontend/src/routes/dashboard/despacho/doda/+page.svelte +++ b/frontend/src/routes/dashboard/despacho/doda/+page.svelte @@ -1,357 +1,538 @@ -
-
-
-

DODA

-

Catálogo de DODA

+
+
+
+

{m['sidebar.doda_alta.title']()}

+

{m['sidebar.doda_alta.subtitle']()}

+
+
+ + +
+
+ + + +
+ {m['sidebar.doda_alta.table_title']()} +
+
+ + +
+ + (filters.status = v)} + > + + {filters.status || m['sidebar.doda_alta.filter_status']()} + + + Todos + PENDIENTE + GENERADO + VALIDADO + ELIMINADO + + + (filters.operation_type = v)} + > + + {filters.operation_type === 'I' + ? 'Importación' + : filters.operation_type === 'E' + ? 'Exportación' + : m['sidebar.doda_alta.filter_operation_type']()} + + + Todas + I - Importación + E - Exportación + + + +
+
+
+ +
+ { + selectedDodaIds = selectedDodaIds.includes(row.id) ? [] : [row.id]; + }} + onRowDoubleClick={(item) => + goto(`/dashboard/general_catalogs/doda?doda_id=${item.id}`, { noScroll: true })} + /> +
+
+
+ +
+ Mostrando {allDodas.length} de {dodaTotal} registros + + Filtros activos: {Object.values(filters).filter((v) => v !== '').length} +
+ +
+ +
+
+
+ + + + + +