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

@@ -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")

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:

View File

@@ -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"

View File

@@ -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),
]

View File

@@ -478,6 +478,56 @@ export async function getDodaAltaStatus(
return api.get<DodaAltaStatusResponse>(`/v1/a76/doda/alta-status/${taskId}`);
}
export async function postDodaConsulta(
dodaId: number,
companyId: number,
variant: 'doda' | 'pita' = 'doda'
): Promise<ApiResponse<DodaAltaResponse>> {
const params = new URLSearchParams({
company_id: companyId.toString(),
variant,
});
return api.post<DodaAltaResponse>(`/v1/a76/doda/${dodaId}/consulta?${params}`, {});
}
export async function getDodaConsultaStatus(
taskId: string
): Promise<ApiResponse<DodaAltaStatusResponse>> {
return api.get<DodaAltaStatusResponse>(`/v1/a76/doda/consulta-status/${taskId}`);
}
export async function postDodaConsultaApply(
dodaId: number,
taskId: string,
companyId: number
): Promise<ApiResponse<Record<string, unknown>>> {
const params = new URLSearchParams({
company_id: companyId.toString(),
});
return api.post<Record<string, unknown>>(
`/v1/a76/doda/${dodaId}/consulta-apply/${taskId}?${params}`,
{}
);
}
export async function postDodaEliminar(
dodaId: number,
companyId: number,
variant: 'doda' | 'pita' = 'doda'
): Promise<ApiResponse<DodaAltaResponse>> {
const params = new URLSearchParams({
company_id: companyId.toString(),
variant,
});
return api.post<DodaAltaResponse>(`/v1/a76/doda/${dodaId}/eliminar?${params}`, {});
}
export async function getDodaEliminarStatus(
taskId: string
): Promise<ApiResponse<DodaAltaStatusResponse>> {
return api.get<DodaAltaStatusResponse>(`/v1/a76/doda/eliminar-status/${taskId}`);
}
export async function getDodaElegibilidad(
dodaId: number,
companyId: number,

View File

@@ -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<ApiResponse<DodaAltaStatusResponse>>;
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 @@
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>{m['sidebar.doda_alta.progress_title']()}</Dialog.Title>
<Dialog.Description>Alta {variantLabel} — Task ID: {taskId}</Dialog.Description>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description} {variantLabel} — Task ID: {taskId}</Dialog.Description>
</Dialog.Header>
<div class="space-y-4 py-2">

View File

@@ -1,357 +1,538 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { createColumns } from '$lib/components/dashboard/general_catalogs/doda/columns';
import DodaFormModal from '$lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte';
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Pencil, Plus, Trash2, RefreshCw } from 'lucide-svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/list';
import { dodaApi, type Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { companyStore } from '$lib/stores/company.svelte';
import { currentUser, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import { m } from '$lib/i18n/messages';
import {
Plus,
RefreshCw,
Pencil,
Trash2,
Search,
RotateCcw,
Send,
Loader2,
FileSpreadsheet,
Table
} from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { companyStore } from '$lib/stores/company.svelte';
import { isPitaCustomsClearance } from '$lib/components/dashboard/general_catalogs/doda/doda-form-helpers';
let { data } = $props();
import DataTable from '$lib/components/dashboard/general_catalogs/doda/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/general_catalogs/doda/columns';
import DodaProgressDialog from '$lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte';
import DodaExportExcelDialog from '$lib/components/dashboard/despacho/doda/doda-export-excel-dialog.svelte';
import { applyOptimisticDelete } from '$lib/components/dashboard/general_catalogs/doda/delete-list-state';
let dialogOpen = $state(false);
let editingItem = $state<Doda | null>(null);
let error = $state<string | null>(data.error || null);
let status = $state<number>(data.status || 200);
import {
getDodas,
deleteDoda,
exportDodaPedimentosDetail,
postDodaAlta,
postDodaConsulta,
postDodaConsultaApply,
postDodaEliminar,
getDodaAltaStatus,
getDodaElegibilidad,
getDodaConsultaStatus,
getDodaEliminarStatus,
type DodaAltaStatusResponse,
type Doda
} from '$lib/api/dashboard/a76/general_catalogs/doda';
// Permisos
const canView = $derived(userHasPermission($currentUser, 'cat_doda.view'));
const canCreate = $derived(userHasPermission($currentUser, 'cat_doda.create'));
const canEdit = $derived(userHasPermission($currentUser, 'cat_doda.edit'));
const canDelete = $derived(userHasPermission($currentUser, 'cat_doda.delete'));
let { data } = $props();
const isError = $derived(!canView || status >= 400 || error);
let allDodas = $state<Doda[]>(data.dodas?.items || []);
let dodaPage = $state(data.dodas?.page || 1);
let dodaPageSize = $state(50);
let dodaTotal = $state(data.dodas?.total || 0);
let dodaLoading = $state(false);
let dodaHasMore = $derived(allDodas.length < dodaTotal);
// Atajos
useShortcuts(
'Lista DODA',
obtenerAtajosListaDoda({
manejarNuevo: () => {
if (!canCreate) return;
editingItem = null;
dialogOpen = true;
},
manejarActualizar: () => reloadData()
})
);
let filters = $state({
integration_number: $page.url.searchParams.get('integration_number') || '',
patent: $page.url.searchParams.get('patent') || '',
status: $page.url.searchParams.get('status') || '',
operation_type: $page.url.searchParams.get('operation_type') || ''
});
// Filtros
let filters = $state({
integration_number: $page.url.searchParams.get('integration_number') || '',
patent: $page.url.searchParams.get('patent') || '',
status: $page.url.searchParams.get('status') || '',
operation_type: $page.url.searchParams.get('operation_type') || ''
});
let timeout: ReturnType<typeof setTimeout>;
let dodaFilterTimeout: ReturnType<typeof setTimeout>;
let allItems = $state<Doda[]>(data.dodas?.items || []);
let currentPage = $state(data.dodas?.page || 1);
let pageSize = $state(data.dodas?.page_size || 50);
let totalItems = $state(data.dodas?.total || 0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let selectedIds = $state<(string | number)[]>([]);
const selectedItem = $derived(
selectedIds.length === 1
? allItems.find((item) => String(item.id) === String(selectedIds[0])) ?? null
: null
);
let selectedDodaIds = $state<(string | number)[]>([]);
const selectedDoda = $derived(
selectedDodaIds.length === 1
? allDodas.find((item) => String(item.id) === String(selectedDodaIds[0])) ?? null
: null
);
const altaVariant = $derived<'doda' | 'pita'>(
selectedDoda && isPitaCustomsClearance(selectedDoda.customs_clearance) ? 'pita' : 'doda'
);
$effect(() => {
if (data.dodas) {
allItems = data.dodas.items || [];
currentPage = data.dodas.page || 1;
totalItems = data.dodas.total || 0;
pageSize = data.dodas.page_size || pageSize;
}
});
let progressDialogOpen = $state(false);
let exportDialogOpen = $state(false);
let currentTaskId = $state('');
let currentVariant = $state<'doda' | 'pita'>('doda');
let progressMode = $state<'alta' | 'consulta' | 'eliminar'>('alta');
let altaLoading = $state(false);
let consultaLoading = $state(false);
let eliminarExternoLoading = $state(false);
let deleteLoading = $state(false);
let pedimentosExportLoading = $state(false);
const hasIntegration = $derived(!!(selectedDoda?.integration_number || '').trim());
async function handleSearch() {
if (!browser) return;
clearTimeout(timeout);
timeout = setTimeout(async () => {
if (!companyStore.activeCompany) return;
loading = true;
error = null;
try {
const response = await dodaApi.list(1, pageSize, companyStore.activeCompany.id, {
integration_number: filters.integration_number || undefined,
patent: filters.patent || undefined,
status: filters.status || undefined,
operation_type: filters.operation_type || undefined
});
const payload = response.data;
if (payload?.items) {
allItems = payload.items;
currentPage = payload.page || 1;
totalItems = payload.total;
}
} catch (err) {
error = 'Error aplicando filtros';
} finally {
loading = false;
}
$effect(() => {
if (data.dodas) {
allDodas = data.dodas.items || [];
dodaPage = data.dodas.page || 1;
dodaTotal = data.dodas.total || 0;
}
});
const url = new URL($page.url);
Object.entries(filters).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
});
history.replaceState(history.state, '', url);
}, 500);
}
$effect(() => {
const _ = { ...filters };
clearTimeout(dodaFilterTimeout);
dodaFilterTimeout = setTimeout(() => reloadDodas(), 400);
});
function clearFilters() {
filters.integration_number = '';
filters.patent = '';
filters.status = '';
filters.operation_type = '';
handleSearch();
}
async function reloadDodas() {
if (!browser) return;
dodaLoading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
const active = Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== ''));
const res = await getDodas(1, dodaPageSize, active, Number(companyId));
if (res.data) {
allDodas = res.data.items;
dodaPage = 1;
dodaTotal = res.data.total;
selectedDodaIds = [];
}
} catch {
if (allDodas.length > 0) toast.error('Error al recargar DODAs');
} finally {
dodaLoading = false;
}
}
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const response = await dodaApi.list(currentPage + 1, pageSize, companyStore.activeCompany.id, {
integration_number: filters.integration_number || undefined,
patent: filters.patent || undefined,
status: filters.status || undefined,
operation_type: filters.operation_type || undefined
});
const payload = response.data;
if (payload?.items) {
allItems = [...allItems, ...payload.items];
currentPage = payload.page || (currentPage + 1);
totalItems = payload.total;
}
} catch (err) {
error = 'Error cargando mas datos';
} finally {
loading = false;
}
}
async function loadMoreDodas() {
if (dodaLoading || !dodaHasMore) return;
dodaLoading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
const active = Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== ''));
const res = await getDodas(dodaPage + 1, dodaPageSize, active, Number(companyId));
if (res.data) {
allDodas = [...allDodas, ...res.data.items];
dodaPage++;
dodaTotal = res.data.total;
}
} finally {
dodaLoading = false;
}
}
async function reloadData() {
if (!companyStore.activeCompany) return;
loading = true;
error = null;
try {
const response = await dodaApi.list(1, pageSize, companyStore.activeCompany.id, {
integration_number: filters.integration_number || undefined,
patent: filters.patent || undefined,
status: filters.status || undefined,
operation_type: filters.operation_type || undefined
});
const payload = response.data;
if (payload?.items) {
allItems = payload.items;
currentPage = payload.page || 1;
totalItems = payload.total;
}
} catch (err) {
error = 'Error al recargar datos';
} finally {
loading = false;
}
}
function handleEdit() {
if (selectedDoda) {
void goto(`/dashboard/general_catalogs/doda?doda_id=${selectedDoda.id}`, { noScroll: true });
}
}
function handleSuccess() {
dialogOpen = false;
editingItem = null;
selectedIds = [];
reloadData();
}
async function handleDelete() {
if (deleteLoading) return;
if (!companyStore.activeCompany) {
toast.error(m['sidebar.doda_alta.delete_missing_company']());
return;
}
if (selectedDodaIds.length !== 1) {
toast.error(m['sidebar.doda_alta.delete_select_one']());
return;
}
if (!selectedDoda) {
toast.error(m['sidebar.doda_alta.delete_not_found']());
return;
}
if (!confirm(m['sidebar.doda_alta.confirm_delete']())) return;
deleteLoading = true;
try {
const deletedId = selectedDoda.id;
await deleteDoda(deletedId, companyStore.activeCompany.id);
toast.success(m['sidebar.doda_alta.delete_success']());
const next = applyOptimisticDelete(allDodas, dodaTotal, deletedId);
allDodas = next.items;
dodaTotal = next.total;
selectedDodaIds = [];
await reloadDodas();
} catch (e) {
const msg = e instanceof Error ? e.message : m['sidebar.doda_alta.delete_error']();
toast.error(msg);
} finally {
deleteLoading = false;
}
}
function handleNew() {
if (!canCreate) return;
const url = new URL($page.url);
url.searchParams.set('doda_id', 'new');
goto(url.toString(), { replaceState: true });
}
async function handleExportPedimentos() {
if (pedimentosExportLoading) return;
if (!companyStore.activeCompany) {
toast.error(m['sidebar.doda_alta.delete_missing_company']());
return;
}
if (selectedDodaIds.length !== 1 || !selectedDoda) {
toast.error(m['sidebar.doda_alta.delete_select_one']());
return;
}
pedimentosExportLoading = true;
try {
await exportDodaPedimentosDetail(selectedDoda.id, companyStore.activeCompany.id, 'xls');
toast.success(m['sidebar.doda_alta.export_pedimentos_success']());
} catch (e) {
const msg = e instanceof Error ? e.message : m['sidebar.doda_alta.export_pedimentos_error']();
toast.error(msg || m['sidebar.doda_alta.export_pedimentos_error']());
} finally {
pedimentosExportLoading = false;
}
}
function handleEdit() {
if (!selectedItem || !canEdit) return;
const url = new URL($page.url);
url.searchParams.set('doda_id', selectedItem.id.toString());
goto(url.toString(), { replaceState: true });
}
function clearFilters() {
filters = { integration_number: '', patent: '', status: '', operation_type: '' };
}
async function handleDelete() {
if (!selectedItem || !canDelete || !companyStore.activeCompany) return;
if (confirm('¿Eliminar este registro?')) {
try {
await dodaApi.delete(selectedItem.id, companyStore.activeCompany.id);
selectedIds = [];
reloadData();
} catch (err) {
error = 'Error al eliminar';
}
}
}
async function handleAlta() {
if (!selectedDoda || !companyStore.activeCompany) return;
const companyId = companyStore.activeCompany.id;
const dodaId = selectedDoda.id;
const variant = altaVariant;
altaLoading = true;
try {
const elig = await getDodaElegibilidad(dodaId, companyId, variant);
if (elig.error) {
toast.error(`Error al verificar elegibilidad: ${elig.error}`);
return;
}
if (elig.data && !elig.data.can_alta) {
const msgs = elig.data.reasons.map((r) => `• ${r.message}`).join('\n');
toast.error(msgs || m['sidebar.doda_alta.eligibility_error']());
return;
}
const resp = await postDodaAlta(dodaId, companyId, variant);
if (resp.error) {
toast.error(`Error al enviar alta: ${resp.error}`);
return;
}
currentTaskId = resp.data!.task_id;
currentVariant = variant;
progressDialogOpen = true;
} finally {
altaLoading = false;
}
}
async function handlePrint() {
if (!selectedItem || !companyStore.activeCompany) return;
try {
const { printDoda } = await import('$lib/api/dashboard/a76/general_catalogs/doda');
await printDoda(selectedItem.id, companyStore.activeCompany.id);
} catch (err) {
error = 'Error al imprimir PDF';
}
}
async function handleConsultar() {
if (!selectedDoda || !companyStore.activeCompany || consultaLoading) return;
if (!hasIntegration) {
toast.error('El DODA aún no está generado para consultar.');
return;
}
consultaLoading = true;
try {
const resp = await postDodaConsulta(selectedDoda.id, companyStore.activeCompany.id, altaVariant);
if (resp.error || !resp.data?.task_id) {
toast.error(resp.error || 'Error al enviar consulta DODA');
return;
}
progressMode = 'consulta';
currentTaskId = resp.data.task_id;
currentVariant = altaVariant;
progressDialogOpen = true;
} finally {
consultaLoading = false;
}
}
function handleRowClick(row: Doda) {
selectedIds = selectedIds.includes(row.id) ? [] : [row.id];
}
async function handleEliminarExterno() {
if (!selectedDoda || !companyStore.activeCompany || eliminarExternoLoading) return;
if (!hasIntegration) {
toast.error('El DODA aún no está generado para eliminar externamente.');
return;
}
if (!confirm('¿Deseas eliminar este DODA en el servicio externo para volver a editarlo?')) return;
eliminarExternoLoading = true;
try {
const resp = await postDodaEliminar(selectedDoda.id, companyStore.activeCompany.id, altaVariant);
if (resp.error || !resp.data?.task_id) {
toast.error(resp.error || 'Error al enviar eliminación DODA');
return;
}
progressMode = 'eliminar';
currentTaskId = resp.data.task_id;
currentVariant = altaVariant;
progressDialogOpen = true;
} finally {
eliminarExternoLoading = false;
}
}
function handleRowDoubleClick(row: Doda) {
if (canEdit) {
const url = new URL($page.url);
url.searchParams.set('doda_id', row.id.toString());
goto(url.toString(), { replaceState: true });
}
}
async function onProgressComplete(_result: DodaAltaStatusResponse) {
progressDialogOpen = false;
if (progressMode === 'consulta' && selectedDoda && companyStore.activeCompany) {
const applyResp = await postDodaConsultaApply(
selectedDoda.id,
currentTaskId,
companyStore.activeCompany.id
);
if (applyResp.error) {
toast.error(`Consulta completada, pero no se pudo aplicar al DODA: ${applyResp.error}`);
}
}
await reloadDodas();
if (progressMode === 'eliminar') {
toast.success('Eliminación DODA completada. El registro quedó editable nuevamente.');
} else if (progressMode === 'consulta') {
toast.success('Consulta DODA completada y aplicada al registro.');
} else {
toast.success(m['sidebar.doda_alta.progress_success']());
}
}
const columns = $derived(createColumns('es', handleSuccess, { canEdit, canDelete }));
const progressTitle = $derived(
progressMode === 'consulta'
? 'Consulta DODA'
: progressMode === 'eliminar'
? 'Eliminación DODA'
: m['sidebar.doda_alta.progress_title']()
);
const progressDescription = $derived(
progressMode === 'consulta'
? 'Consulta'
: progressMode === 'eliminar'
? 'Eliminación'
: 'Alta'
);
const progressStatusGetter = $derived(
progressMode === 'consulta'
? getDodaConsultaStatus
: progressMode === 'eliminar'
? getDodaEliminarStatus
: getDodaAltaStatus
);
</script>
<div class="flex h-[calc(100svh-4rem)] flex-col gap-6 overflow-hidden p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]">
<div class="flex flex-none items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">DODA</h1>
<p class="text-muted-foreground">Catálogo de DODA</p>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
<div class="flex-none flex items-center justify-between">
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight">{m['sidebar.doda_alta.title']()}</h1>
<p class="text-muted-foreground">{m['sidebar.doda_alta.subtitle']()}</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" onclick={() => reloadDodas()} disabled={dodaLoading}>
<RefreshCw class="mr-2 h-4 w-4 {dodaLoading ? 'animate-spin' : ''}" />
{m['sidebar.doda_alta.refresh']()}
</Button>
<Button size="sm" onclick={() => goto('/dashboard/general_catalogs/doda?doda_id=new')}>
<Plus class="mr-2 h-4 w-4" />
{m['sidebar.doda_alta.action_new']()}
</Button>
</div>
</div>
<Card.Root class="flex min-h-0 flex-1 flex-col border bg-background">
<Card.Header>
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<Card.Title>{m['sidebar.doda_alta.table_title']()}</Card.Title>
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-[220px_180px_170px_170px_auto] xl:items-center">
<div class="relative">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={m['sidebar.doda_alta.filter_integration_number']()}
bind:value={filters.integration_number}
class="h-9 bg-card pl-9"
/>
</div>
<Input
placeholder={m['sidebar.doda_alta.filter_patent']()}
bind:value={filters.patent}
class="h-9 bg-card"
/>
<Select.Root
type="single"
value={filters.status}
onValueChange={(v) => (filters.status = v)}
>
<Select.Trigger class="h-9 w-full bg-card">
{filters.status || m['sidebar.doda_alta.filter_status']()}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Todos</Select.Item>
<Select.Item value="PENDIENTE">PENDIENTE</Select.Item>
<Select.Item value="GENERADO">GENERADO</Select.Item>
<Select.Item value="VALIDADO">VALIDADO</Select.Item>
<Select.Item value="ELIMINADO">ELIMINADO</Select.Item>
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={filters.operation_type}
onValueChange={(v) => (filters.operation_type = v)}
>
<Select.Trigger class="h-9 w-full bg-card">
{filters.operation_type === 'I'
? 'Importación'
: filters.operation_type === 'E'
? 'Exportación'
: m['sidebar.doda_alta.filter_operation_type']()}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Todas</Select.Item>
<Select.Item value="I">I - Importación</Select.Item>
<Select.Item value="E">E - Exportación</Select.Item>
</Select.Content>
</Select.Root>
<Button variant="outline" size="sm" class="h-9" onclick={clearFilters}>
<RotateCcw class="mr-2 h-4 w-4" />
Limpiar
</Button>
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 p-0">
<div class="rounded-md border bg-background">
<DataTable
data={allDodas}
columns={createColumns()}
loading={dodaLoading}
hasMore={dodaHasMore}
loadMore={loadMoreDodas}
selectedId={selectedDodaIds.length === 1 ? selectedDodaIds[0] : null}
onRowClick={(row) => {
selectedDodaIds = selectedDodaIds.includes(row.id) ? [] : [row.id];
}}
onRowDoubleClick={(item) =>
goto(`/dashboard/general_catalogs/doda?doda_id=${item.id}`, { noScroll: true })}
/>
</div>
</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground pt-2">
Mostrando {allDodas.length} de {dodaTotal} registros
<span class="ml-2"></span>
<span class="ml-2">Filtros activos: {Object.values(filters).filter((v) => v !== '').length}</span>
</div>
<div class="h-20"></div>
<div
class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={handleEdit}
disabled={selectedDodaIds.length !== 1 || hasIntegration}
>
<Pencil size={16} class="mr-2" />
{m['sidebar.doda_alta.action_edit']()}
</Button>
<Button
size="sm"
variant="secondary"
onclick={handleConsultar}
disabled={selectedDodaIds.length !== 1 || !hasIntegration || consultaLoading}
>
{#if consultaLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Search size={16} class="mr-2" />
{/if}
Consultar DODA
</Button>
<Button
size="sm"
variant="secondary"
class="border border-amber-500/30 bg-amber-500/10 text-amber-700 hover:bg-amber-500/20"
onclick={handleEliminarExterno}
disabled={selectedDodaIds.length !== 1 || !hasIntegration || eliminarExternoLoading}
>
{#if eliminarExternoLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
Eliminar DODA
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={selectedDodaIds.length !== 1 || deleteLoading}
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{#if deleteLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
{m['sidebar.doda_alta.action_delete']()}
</Button>
<Button variant="outline" size="sm" onclick={() => (exportDialogOpen = true)}>
<FileSpreadsheet size={16} class="mr-2" />
{m['sidebar.doda_alta.action_export_excel']()}
</Button>
<Button
size="sm"
variant="secondary"
class="border border-primary/25 bg-primary/10 text-primary hover:bg-primary/15"
onclick={handleExportPedimentos}
disabled={selectedDodaIds.length !== 1 || pedimentosExportLoading || !companyStore.activeCompany}
title={m['sidebar.doda_alta.action_export_pedimentos']()}
>
{#if pedimentosExportLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Table size={16} class="mr-2" />
{/if}
{m['sidebar.doda_alta.action_export_pedimentos']()}
</Button>
<Separator orientation="vertical" class="mx-1 h-8 hidden sm:block" />
<Button
size="sm"
onclick={handleAlta}
disabled={selectedDodaIds.length !== 1 || altaLoading || hasIntegration}
title={altaVariant === 'pita' ? 'PITA' : 'DODA'}
>
{#if altaLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Send size={16} class="mr-2" />
{/if}
{m['sidebar.doda_alta.action_generar']()}
</Button>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" class="h-9" onclick={reloadData}>
<RefreshCw class="mr-2 h-4 w-4" /> Actualizar
</Button>
{#if !isError && canCreate}
<Button class="h-9" onclick={handleNew}>
<Plus class="mr-2 h-4 w-4" /> Nuevo Registro
</Button>
<DodaExportExcelDialog bind:open={exportDialogOpen} companyId={companyStore.activeCompany?.id} />
{#if progressDialogOpen}
<DodaProgressDialog
bind:open={progressDialogOpen}
taskId={currentTaskId}
dodaId={selectedDoda?.id}
variant={currentVariant}
title={progressTitle}
description={progressDescription}
getStatus={progressStatusGetter}
onComplete={onProgressComplete}
onCancel={() => (progressDialogOpen = false)}
/>
{/if}
</div>
</div>
{#if isError}
<ErrorState
status={!canView ? 403 : status}
error={!canView ? 'Permission denied: cat_doda.view' : error || ''}
onRetry={reloadData}
/>
{:else}
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden border bg-background">
<Card.Header>
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<Card.Title>Listado de DODA</Card.Title>
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-[200px_140px_160px_160px_auto] xl:items-center">
<Input placeholder="Folio" bind:value={filters.integration_number} oninput={handleSearch} class="h-9 bg-card" />
<Input placeholder="Patente" bind:value={filters.patent} oninput={handleSearch} class="h-9 bg-card" />
<select
bind:value={filters.status}
onchange={handleSearch}
class="h-9 w-full rounded-md border border-input bg-card px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="">Estatus</option>
<option value="PENDIENTE">PENDIENTE</option>
<option value="GENERADO">GENERADO</option>
<option value="VALIDADO">VALIDADO</option>
<option value="ELIMINADO">ELIMINADO</option>
</select>
<select
bind:value={filters.operation_type}
onchange={handleSearch}
class="h-9 w-full rounded-md border border-input bg-card px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="">Operación</option>
<option value="I">I - Importación</option>
<option value="E">E - Exportación</option>
</select>
<Button variant="outline" size="sm" class="h-9" onclick={clearFilters}>
Limpiar
</Button>
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 flex-1 overflow-hidden p-0">
<div class="h-full overflow-hidden rounded-md border bg-background">
<InfiniteDataTable
data={allItems} {columns} {loading} {hasMore} {loadMore}
{selectedIds} onSelectedIdsChange={(ids) => (selectedIds = ids)}
onRowClick={handleRowClick}
onRowDoubleClick={handleRowDoubleClick}
/>
</div>
</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">
Mostrando {allItems.length} de {totalItems} registros
</div>
<div class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80">
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex items-center justify-between">
<div class="text-sm text-muted-foreground">
{#if selectedItem}
Seleccionado: <span class="font-medium text-foreground">{selectedItem.integration_number || 'S/N'}</span>
{:else}
Selecciona un registro para ver acciones
{/if}
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={reloadData} disabled={loading}>
Actualizar
</Button>
{#if canEdit}
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedItem}>
<Pencil size={16} class="mr-2" /> Editar
</Button>
{/if}
{#if canDelete}
<Button variant="outline" size="sm" onclick={handleDelete} disabled={!selectedItem} class="text-destructive hover:bg-destructive/10">
<Trash2 size={16} class="mr-2" /> Eliminar
</Button>
{/if}
<Button variant="secondary" size="sm" onclick={handlePrint} disabled={!selectedItem}>
Imprimir
</Button>
</div>
</div>
</div>
</div>
{/if}
{#if $page.url.searchParams.get('doda_id')}
<DodaFormModal
dodaIdParam={$page.url.searchParams.get('doda_id')!}
onClose={() => {
const url = new URL($page.url);
url.searchParams.delete('doda_id');
goto(url.toString(), { replaceState: true });
reloadData();
}}
onCreatedNavigateTo={(newId) => {
const url = new URL($page.url);
url.searchParams.set('doda_id', String(newId));
goto(url.toString(), { replaceState: true });
}}
/>
{/if}
</div>