feature/api-doda

This commit is contained in:
2026-04-26 18:13:20 -06:00
parent d78d8f3599
commit 9d232998d6
49 changed files with 6974 additions and 1356 deletions

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import List, Optional
from pydantic import BaseModel, Field
class DodaAltaLogCreateDTO(BaseModel):
doda_id: Optional[int] = None
variant: Optional[str] = Field(None, max_length=10)
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)
operation_type: Optional[str] = Field(None, max_length=5)
integration_number: Optional[str] = Field(None, max_length=50)
task_id: Optional[str] = Field(None, max_length=255)
status: Optional[str] = Field(None, max_length=30)
message: Optional[str] = Field(None, max_length=2000)
result_json: Optional[str] = None
class DodaAltaLogUpdateDTO(BaseModel):
status: Optional[str] = Field(None, max_length=30)
message: Optional[str] = Field(None, max_length=2000)
result_json: Optional[str] = None
class DodaAltaLogResponseDTO(BaseModel):
id: int
doda_id: Optional[int] = None
variant: Optional[str] = None
responsible: Optional[str] = None
patent: Optional[str] = None
dispatch_customs: Optional[str] = None
operation_type: Optional[str] = None
integration_number: Optional[str] = None
task_id: Optional[str] = None
status: Optional[str] = None
message: Optional[str] = None
result_json: Optional[str] = None
company_id: int
tenant_id: int
created_at: Optional[str] = None
updated_at: Optional[str] = None
model_config = {"from_attributes": True}
class DodaAltaLogListResponse(BaseModel):
items: List[DodaAltaLogResponseDTO]
total: int
page: int
page_size: int

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class DodaAltaLog(Base, TenantScopedMixin, TimestampMixin):
"""
Registro histórico de envíos de alta DODA/PITA al servicio externo.
Cada fila corresponde a un intento de alta para un DODA específico.
"""
__tablename__ = "doda_alta_log"
__table_args__ = ({"schema": "a76"},)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Referencia al DODA origen
doda_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
# Tipo de alta (doda / pita)
variant: Mapped[str | None] = mapped_column(String(10), nullable=True)
# Datos copiados del DODA al momento del envío (para historial)
responsible: Mapped[str | None] = mapped_column(String(20), nullable=True)
patent: Mapped[str | None] = mapped_column(String(10), nullable=True)
dispatch_customs: Mapped[str | None] = mapped_column(String(10), nullable=True)
operation_type: Mapped[str | None] = mapped_column(String(5), nullable=True)
integration_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Respuesta del servicio externo
task_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
status: Mapped[str | None] = mapped_column(String(30), nullable=True)
message: Mapped[str | None] = mapped_column(String(2000), nullable=True)
result_json: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,140 @@
from __future__ import annotations
import json
import logging
from typing import Optional
from sqlalchemy.orm import Session
from .alta_log_dto import (
DodaAltaLogCreateDTO,
DodaAltaLogListResponse,
DodaAltaLogResponseDTO,
DodaAltaLogUpdateDTO,
)
from .alta_log_models import DodaAltaLog
from .models import Doda
logger = logging.getLogger(__name__)
class DodaAltaLogService:
@staticmethod
def list(
db: Session,
company_id: int,
tenant_id: int,
page: int = 1,
page_size: int = 50,
doda_id: Optional[int] = None,
search: Optional[str] = None,
) -> DodaAltaLogListResponse:
query = (
db.query(DodaAltaLog)
.filter(
DodaAltaLog.company_id == company_id,
DodaAltaLog.tenant_id == tenant_id,
DodaAltaLog.deleted_at.is_(None),
)
)
if doda_id:
query = query.filter(DodaAltaLog.doda_id == doda_id)
if search:
like = f"%{search}%"
query = query.filter(
DodaAltaLog.task_id.ilike(like)
| DodaAltaLog.integration_number.ilike(like)
| DodaAltaLog.patent.ilike(like)
| DodaAltaLog.status.ilike(like)
)
total = query.count()
items = (
query.order_by(DodaAltaLog.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return DodaAltaLogListResponse(
items=[DodaAltaLogResponseDTO.model_validate(r) for r in items],
total=total,
page=page,
page_size=page_size,
)
@staticmethod
def get(
db: Session, record_id: int, company_id: int, tenant_id: int
) -> Optional[DodaAltaLog]:
return (
db.query(DodaAltaLog)
.filter(
DodaAltaLog.id == record_id,
DodaAltaLog.company_id == company_id,
DodaAltaLog.tenant_id == tenant_id,
DodaAltaLog.deleted_at.is_(None),
)
.first()
)
@staticmethod
def create(
db: Session, dto: DodaAltaLogCreateDTO, company_id: int, tenant_id: int
) -> DodaAltaLog:
record = DodaAltaLog(
company_id=company_id,
tenant_id=tenant_id,
**dto.model_dump(exclude_none=False),
)
db.add(record)
db.commit()
db.refresh(record)
return record
@staticmethod
def update(
db: Session, record: DodaAltaLog, dto: DodaAltaLogUpdateDTO
) -> DodaAltaLog:
for field, value in dto.model_dump(exclude_unset=True).items():
setattr(record, field, value)
db.commit()
db.refresh(record)
return record
@staticmethod
def delete(db: Session, record: DodaAltaLog) -> None:
from datetime import datetime
record.deleted_at = datetime.utcnow()
db.commit()
@staticmethod
def create_from_alta_result(
db: Session,
doda: Doda,
company_id: int,
tenant_id: int,
variant: str,
ext_result: dict,
) -> DodaAltaLog:
"""
Crea un registro de log a partir de la respuesta del servicio externo de alta.
Llamado automáticamente al completar `POST /{doda_id}/alta`.
"""
task_id = ext_result.get("task_id") or ext_result.get("id") or ""
status = ext_result.get("status") or "pending"
message = ext_result.get("message") or ""
dto = DodaAltaLogCreateDTO(
doda_id=doda.id,
variant=variant,
responsible=doda.responsible,
patent=doda.patent,
dispatch_customs=doda.dispatch_customs,
operation_type=doda.operation_type,
integration_number=doda.integration_number,
task_id=task_id,
status=status,
message=message,
result_json=json.dumps(ext_result),
)
return DodaAltaLogService.create(db, dto, company_id, tenant_id)

View File

@@ -0,0 +1,551 @@
from __future__ import annotations
import base64
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from cryptography.hazmat.primitives import padding as crypto_padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from sqlalchemy import or_
from sqlalchemy.orm import Session
from core.config import settings
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 .payload_normalizer import (
normalize_aduana_despacho,
normalize_aduana_seccion,
normalize_caat,
normalize_doda_pedimento_row,
normalize_fast_id,
normalize_id_transporte,
normalize_numero_gafete,
normalize_patente,
normalize_tipo_operacion,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Response schemas (inline para no añadir dependencias externas)
# ---------------------------------------------------------------------------
@dataclass
class ElegibilidadReason:
field: str
message: str
solution: str = ""
@dataclass
class ElegibilidadResponse:
can_alta: bool
reasons: List[ElegibilidadReason] = field(default_factory=list)
# ---------------------------------------------------------------------------
# FIEL encryption (mismo esquema AES-256-CBC que COVE / expediente_archivos)
# ---------------------------------------------------------------------------
def _encrypt_fiel(raw_fiel: str) -> str:
normalized = (raw_fiel or "").strip()
if not normalized:
return ""
key_bytes = (settings.COVE_FIEL_HASH_KEY or "").encode("utf-8")
iv_bytes = (settings.COVE_FIEL_HASH_IV or "").encode("utf-8")
if not key_bytes or not iv_bytes:
return normalized
key32 = key_bytes[:32].ljust(32, b"\0")
iv16 = iv_bytes[:16].ljust(16, b"\0")
padder = crypto_padding.PKCS7(algorithms.AES.block_size).padder()
padded = padder.update(normalized.encode("utf-8")) + padder.finalize()
cipher = Cipher(algorithms.AES(key32), modes.CBC(iv16))
enc = cipher.encryptor()
encrypted = enc.update(padded) + enc.finalize()
return base64.b64encode(encrypted).decode("ascii")
# ---------------------------------------------------------------------------
# Main service
# ---------------------------------------------------------------------------
class DodaAltaService:
"""
Servicio de dominio para construir el payload de alta DODA y verificar
elegibilidad antes de enviarlo al servicio externo.
"""
def __init__(self, db: Session) -> None:
self.db = db
# ------------------------------------------------------------------
# Broker resolution
# ------------------------------------------------------------------
def _resolve_broker(
self,
responsible_key: Optional[str],
company_id: int,
tenant_id: int,
) -> Optional[cb_models.CustomsBroker]:
"""
Resuelve el agente aduanal a partir de Doda.responsible (ClaveAA del legacy).
Prioridad: broker_key exacto → license exacto.
"""
normalized = (responsible_key or "").strip()
if not normalized:
return None
brokers = (
self.db.query(cb_models.CustomsBroker)
.filter(
or_(
cb_models.CustomsBroker.broker_key == normalized,
cb_models.CustomsBroker.license == normalized,
),
cb_models.CustomsBroker.company_id == company_id,
cb_models.CustomsBroker.tenant_id == tenant_id,
cb_models.CustomsBroker.deleted_at.is_(None),
)
.order_by(cb_models.CustomsBroker.id.desc())
.all()
)
if not brokers:
return None
exact = next(
(b for b in brokers if (b.broker_key or "").strip() == normalized),
None,
)
return exact or brokers[0]
# ------------------------------------------------------------------
# configuracion_vu DODA
# ------------------------------------------------------------------
def _build_configuracion_vu_doda(
self,
broker: cb_models.CustomsBroker,
errors: List[ElegibilidadReason],
) -> Optional[Dict[str, Any]]:
"""
Construye configuracion_vu usando los campos DODA del CustomsBrokerVU:
doda_certificate_path, doda_key_path, doda_fiel_access_key.
"""
vu = broker.vu if broker else None
if not vu:
errors.append(ElegibilidadReason(
field="vu",
message="El agente aduanal no tiene configuración VU.",
solution="Configura la sección VU/DODA del agente aduanal.",
))
return None
doda_cert_path = (getattr(vu, "doda_certificate_path", None) or "").strip()
doda_key_path = (getattr(vu, "doda_key_path", None) or "").strip()
doda_fiel = (getattr(vu, "doda_fiel_access_key", None) or "").strip()
if not doda_cert_path or not doda_key_path:
errors.append(ElegibilidadReason(
field="vu.doda_certificate_path",
message="Faltan rutas de certificado o llave DODA en la configuración VU del agente.",
solution="Sube el .cer y .key DODA en la pestaña DODA del agente aduanal.",
))
return None
if not doda_fiel:
errors.append(ElegibilidadReason(
field="vu.doda_fiel_access_key",
message="La clave FIEL DODA no está configurada en la VU del agente.",
solution="Captura la clave FIEL DODA en la configuración VU del agente aduanal.",
))
return None
cer_b64: Optional[str] = None
key_b64: Optional[str] = None
try:
if not object_exists(doda_cert_path):
errors.append(ElegibilidadReason(
field="vu.doda_certificate_path",
message="El certificado DODA no existe en el almacenamiento.",
solution="Vuelve a subir el .cer DODA en la configuración VU del agente.",
))
else:
cer_b64 = base64.b64encode(get_object_bytes(doda_cert_path)).decode("ascii")
if not object_exists(doda_key_path):
errors.append(ElegibilidadReason(
field="vu.doda_key_path",
message="La llave DODA no existe en el almacenamiento.",
solution="Vuelve a subir el .key DODA en la configuración VU del agente.",
))
else:
key_b64 = base64.b64encode(get_object_bytes(doda_key_path)).decode("ascii")
except Exception:
logger.exception("Error leyendo certificados DODA desde S3")
errors.append(ElegibilidadReason(
field="vu",
message="Error leyendo certificados DODA desde el almacenamiento.",
solution="Verifica la configuración de S3/MinIO y las rutas de los certificados.",
))
return None
if errors:
return None
rfc_ciec = (
(getattr(vu, "query_tax_id", None) or "").strip()
or (getattr(broker, "tax_id", None) or "").strip()
)
clave_fiel = _encrypt_fiel(doda_fiel)
return {
"rfc_ciec": rfc_ciec,
"archivo_cer_base64": cer_b64 or "",
"archivo_key_base64": key_b64 or "",
"clave_fiel": clave_fiel,
}
def _attach_user_email(
self, configuracion_vu: Dict[str, Any], user_email: str
) -> Dict[str, Any]:
"""Agrega el email del usuario autenticado al bloque configuracion_vu."""
configuracion_vu["email"] = (user_email or "").strip()
return configuracion_vu
# ------------------------------------------------------------------
# Payload builders for child collections
# ------------------------------------------------------------------
def _build_contenedores(
self, containers: List[DodaContainer]
) -> List[Dict[str, Any]]:
result = []
for c in containers:
candados = []
for seal in (c.seals_detail or []):
if seal.seal_value:
candados.append({"candado": seal.seal_value})
# Fallback: si no hay filas en seals_detail pero hay string legado en seals
if not candados and c.seals:
for raw in c.seals.split(","):
val = raw.strip()
if val:
candados.append({"candado": val})
val = (c.container_value or "").strip()
result.append({
"valor_contenedor": val,
"candados": candados,
})
return result
def _build_pedimentos_americanos(
self, american_pedimentos: List[DodaAmericanPedimento]
) -> List[Dict[str, Any]]:
return [
{
"tipo_pedimento_americano": p.american_pedimento_type or "",
"valor_pedimento_americano": p.american_pedimento_value or "",
}
for p in american_pedimentos
]
def _build_pedimentos(
self, pedimentos_detail: List[DodaPedimento]
) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for p in pedimentos_detail:
row = normalize_doda_pedimento_row(
document=p.document,
authorization_patent=p.authorization_patent,
shipment=p.shipment,
cove=p.cove,
umc=p.umc,
dta_niu=p.dta_niu,
pedimento_type=p.pedimento_type,
effective=p.effective_amount_usd,
diff=p.difference_amount_usd,
)
out.append(row)
return out
# ------------------------------------------------------------------
# Elegibilidad (validaciones del legacy Clarion activas)
# ------------------------------------------------------------------
def check_elegibilidad(
self,
doda_id: int,
tenant_id: int,
company_id: int,
variant: str = "doda",
user_email: Optional[str] = None,
) -> ElegibilidadResponse:
"""
Verifica si el DODA cumple los requisitos para enviar el alta.
Porta las validaciones activas del código Clarion legacy.
"""
reasons: List[ElegibilidadReason] = []
# --- Email del usuario autenticado ---
if not (user_email or "").strip():
reasons.append(ElegibilidadReason(
field="user_email",
message="El usuario no tiene correo electrónico registrado.",
solution="Configura un correo electrónico en tu perfil de Keycloak.",
))
doda = self.db.query(Doda).filter(
Doda.id == doda_id,
Doda.tenant_id == tenant_id,
Doda.company_id == company_id,
).first()
if not doda:
reasons.append(ElegibilidadReason(
field="doda_id",
message=f"DODA {doda_id} no encontrado.",
))
return ElegibilidadResponse(can_alta=False, reasons=reasons)
# --- Campos obligatorios (ramas activas del legacy) ---
if not (doda.responsible or "").strip():
reasons.append(ElegibilidadReason(
field="responsible",
message="El campo Responsable se encuentra vacío.",
solution="Captura la clave del agente aduanal responsable.",
))
if not (doda.dispatch_customs or "").strip():
reasons.append(ElegibilidadReason(
field="dispatch_customs",
message="El campo Aduana de Despacho se encuentra vacío.",
solution="Captura la clave de la aduana de despacho.",
))
if not (doda.customs_sections or "").strip():
reasons.append(ElegibilidadReason(
field="customs_sections",
message="El campo Aduana Sección (E/S) se encuentra vacío.",
solution="Captura la sección aduanera.",
))
if not (doda.operation_type or "").strip():
reasons.append(ElegibilidadReason(
field="operation_type",
message="El campo Tipo de Operación se encuentra vacío.",
solution="Selecciona el tipo de operación.",
))
if not (doda.caat or "").strip():
reasons.append(ElegibilidadReason(
field="caat",
message="El campo CAAT se encuentra vacío.",
solution="Captura el código CAAT del transportista.",
))
if not (doda.transport_identification or "").strip():
reasons.append(ElegibilidadReason(
field="transport_identification",
message="El campo Identificación de Transporte se encuentra vacío.",
solution="Captura el número de identificación del transporte.",
))
if not (doda.patent or "").strip():
reasons.append(ElegibilidadReason(
field="patent",
message="El campo Patente se encuentra vacío.",
solution="La patente se llena automáticamente al seleccionar el responsable.",
))
# --- Gafete único: obligatorio si variant=doda ---
if variant.lower() == "doda":
if not (doda.unique_badge_number or "").strip():
reasons.append(ElegibilidadReason(
field="unique_badge_number",
message="El campo Número de Gafete Único es obligatorio para el Alta DODA.",
solution="Captura el número de gafete único del conductor.",
))
# --- Contenedores máximo 4 (legacy: IF SQL2:C2 = 4 THEN MESSAGE) ---
containers = doda.containers or []
if len(containers) > 4:
reasons.append(ElegibilidadReason(
field="containers",
message=f"El DODA tiene {len(containers)} contenedores. El máximo permitido es 4.",
solution="Elimina los contenedores sobrantes antes de enviar el alta.",
))
# --- Precintos: máximo 8 en todo el DODA (legacy gDoda_Contenedores_Candados) ---
seal_count = 0
for c in containers:
details = getattr(c, "seals_detail", None) or []
if details:
seal_count += len(details)
else:
legacy = (getattr(c, "seals", None) or "").strip()
if legacy:
seal_count += len([s for s in legacy.split(",") if s.strip()])
if seal_count > 8:
reasons.append(ElegibilidadReason(
field="containers",
message=f"El DODA tiene {seal_count} precintos. El máximo permitido es 8.",
solution="Elimina precintos hasta quedar en 8 o menos.",
))
# --- Pedimentos americanos: tipo obligatorio y rango según operación (legacy, salvo PITA) ---
clearance = getattr(doda, "customs_clearance", None)
if clearance != 1:
op = (doda.operation_type or "").strip().upper()
if op in ("I", "1"):
allowed_tipo = {"1", "2", "3", "4", "5"}
elif op in ("E", "2"):
allowed_tipo = {"6", "7", "8"}
else:
allowed_tipo = set()
for idx, ap in enumerate(doda.american_pedimentos or [], 1):
tipo = (ap.american_pedimento_type or "").strip()
if not tipo:
reasons.append(ElegibilidadReason(
field="american_pedimentos",
message=f"Pedimento americano (línea {idx}): el tipo es obligatorio para este tipo de despacho.",
solution="Captura el tipo de pedimento americano (15 importación, 68 exportación).",
))
elif allowed_tipo and tipo not in allowed_tipo:
reasons.append(ElegibilidadReason(
field="american_pedimentos",
message=(
f"Pedimento americano (línea {idx}): el tipo '{tipo}' no corresponde al tipo de operación."
),
solution="Corrige el tipo según importación (15) o exportación (68).",
))
# --- Patente vs. patente del agente aduanal (legacy: DODA:Patente <> AgeAdu:Patente) ---
broker = self._resolve_broker(doda.responsible, company_id, tenant_id)
if (doda.responsible or "").strip() and not broker:
reasons.append(ElegibilidadReason(
field="responsible",
message=f"La clave de Responsable '{doda.responsible}' no existe en el catálogo de agentes aduanales.",
solution="Selecciona un agente aduanal válido del catálogo.",
))
elif broker and (doda.patent or "").strip():
broker_patent = (broker.license or "").strip()
doda_patent = (doda.patent or "").strip()
if broker_patent and doda_patent and doda_patent != broker_patent:
reasons.append(ElegibilidadReason(
field="patent",
message=(
f"La patente declarada en el DODA '{doda_patent}' es distinta "
f"a la patente del responsable '{broker_patent}'."
),
solution="Verifica o actualiza la patente del DODA para que coincida con la del agente.",
))
# --- Certificados DODA en VU del agente (equivalente a RutaArchivosXMLDODA) ---
if broker:
vu = broker.vu
if not vu:
reasons.append(ElegibilidadReason(
field="vu",
message="El agente aduanal no tiene configuración VU.",
solution="Configura la sección VU/DODA del agente aduanal.",
))
else:
if not (getattr(vu, "doda_certificate_path", None) or "").strip():
reasons.append(ElegibilidadReason(
field="vu.doda_certificate_path",
message="No hay certificado DODA (.cer) configurado en la VU del agente.",
solution="Sube el certificado .cer DODA en la pestaña DODA del agente aduanal.",
))
if not (getattr(vu, "doda_key_path", None) or "").strip():
reasons.append(ElegibilidadReason(
field="vu.doda_key_path",
message="No hay llave DODA (.key) configurada en la VU del agente.",
solution="Sube la llave .key DODA en la pestaña DODA del agente aduanal.",
))
if not (getattr(vu, "doda_fiel_access_key", None) or "").strip():
reasons.append(ElegibilidadReason(
field="vu.doda_fiel_access_key",
message="La clave FIEL DODA no está configurada en la VU del agente.",
solution="Captura la clave FIEL DODA en la configuración VU del agente.",
))
return ElegibilidadResponse(
can_alta=len(reasons) == 0,
reasons=reasons,
)
# ------------------------------------------------------------------
# Build full payload
# ------------------------------------------------------------------
def build_alta_payload(
self,
doda_id: int,
tenant_id: int,
company_id: int,
variant: str = "doda",
user_email: str = "",
) -> Dict[str, Any]:
"""
Construye el payload completo para POST /api/v1/doda/alta.
Lanza ValueError si hay problemas de configuración críticos.
"""
doda = self.db.query(Doda).filter(
Doda.id == doda_id,
Doda.tenant_id == tenant_id,
Doda.company_id == company_id,
).first()
if not doda:
raise ValueError(f"DODA {doda_id} no encontrado.")
broker = self._resolve_broker(doda.responsible, company_id, tenant_id)
if not broker:
raise ValueError(
f"No se encontró el agente aduanal con clave '{doda.responsible}'."
)
errors: List[ElegibilidadReason] = []
configuracion_vu = self._build_configuracion_vu_doda(broker, errors)
if errors or not configuracion_vu:
msgs = "; ".join(r.message for r in errors)
raise ValueError(f"Error en configuración VU DODA: {msgs}")
self._attach_user_email(configuracion_vu, user_email)
containers = doda.containers or []
american_pedimentos = doda.american_pedimentos or []
pedimentos_detail = doda.pedimentos_detail or []
# El API externo espera "1" para DODA y "2" para PITA (ver DODARequest.despacho_aduanero)
despacho_aduanero = "1" if variant.lower() == "doda" else "2"
payload: Dict[str, Any] = {
"configuracion_vu": configuracion_vu,
"despacho_aduanero": despacho_aduanero,
"numero_gafete_unico": normalize_numero_gafete(
doda.unique_badge_number
),
"aduana_despacho": normalize_aduana_despacho(doda.dispatch_customs),
"aduana_seccion": normalize_aduana_seccion(doda.customs_sections),
"patente": normalize_patente(doda.patent),
"caat": normalize_caat(doda.caat),
"id_transporte": normalize_id_transporte(doda.transport_identification),
"fast_id": normalize_fast_id(doda.fast_id),
"tipo_operacion": normalize_tipo_operacion(doda.operation_type),
"contenedores": self._build_contenedores(containers),
"pedimentos_americanos": self._build_pedimentos_americanos(american_pedimentos),
"cfdi_carta_porte": {"cfdi_carta_porte": ""},
"pedimentos": self._build_pedimentos(pedimentos_detail),
}
return payload

View File

@@ -6,7 +6,7 @@ from datetime import datetime
from decimal import Decimal
from typing import Optional, List
from pydantic import BaseModel, Field
from pydantic import AliasChoices, BaseModel, Field
# ============ DODA CONTAINER SEAL DTOS ============
@@ -24,7 +24,9 @@ class DodaContainerSealResponseDTO(BaseModel):
"""DTO para responder con datos de un candado"""
id: int
doda_sys_id: int
doda_sys_id: int = Field(
validation_alias=AliasChoices("doda_sys_id", "doda_id")
)
seal_line: int
seal_value: Optional[str] = None
@@ -62,7 +64,9 @@ class DodaContainerResponseDTO(BaseModel):
"""DTO para responder con datos de un contenedor"""
id: int
doda_sys_id: int
doda_sys_id: int = Field(
validation_alias=AliasChoices("doda_sys_id", "doda_id")
)
container_line: int
container_value: Optional[str] = None
seals: Optional[str] = None
@@ -105,7 +109,9 @@ class DodaAmericanPedimentoResponseDTO(BaseModel):
"""DTO para responder con datos de un pedimento americano"""
id: int
doda_sys_id: int
doda_sys_id: int = Field(
validation_alias=AliasChoices("doda_sys_id", "doda_id")
)
american_pedimento_line: int
american_pedimento_type: Optional[str] = None
american_pedimento_value: Optional[str] = None
@@ -183,7 +189,9 @@ class DodaPedimentoResponseDTO(BaseModel):
"""DTO para responder con datos de un pedimento DODA"""
id: int
doda_sys_id: int
doda_sys_id: int = Field(
validation_alias=AliasChoices("doda_sys_id", "doda_id")
)
pedimento_line: int
authorization_patent: Optional[str] = None
document: Optional[str] = None
@@ -369,6 +377,9 @@ class DodaResponseDTO(BaseModel):
sat_digital_seal: Optional[str] = None
xml_doda_sent_path: Optional[str] = None
xml_doda_response_path: Optional[str] = None
doda_report_pdf_path: Optional[str] = None
doda_report_pdf_generated_at: Optional[datetime] = None
doda_report_source_fingerprint: Optional[str] = None
sat_original_chain: Optional[str] = None
customs_clearance: Optional[int] = None
unique_badge_number: Optional[str] = None
@@ -412,6 +423,9 @@ class DodaDetailResponseDTO(BaseModel):
sat_digital_seal: Optional[str] = None
xml_doda_sent_path: Optional[str] = None
xml_doda_response_path: Optional[str] = None
doda_report_pdf_path: Optional[str] = None
doda_report_pdf_generated_at: Optional[datetime] = None
doda_report_source_fingerprint: Optional[str] = None
sat_original_chain: Optional[str] = None
customs_clearance: Optional[int] = None
unique_badge_number: Optional[str] = None

View File

@@ -0,0 +1,233 @@
"""
Exportación de listado DODA a CSV / TSV (xls) / pipe, alineada al reporte legacy GDoda.
"""
from __future__ import annotations
import csv
import io
from datetime import date, datetime
from enum import Enum
from typing import Any, List, Optional
from sqlalchemy import and_
from sqlalchemy.orm import Session
from .models import Doda
# Encabezados (orden legacy Clarion)
EXPORT_HEADERS: List[str] = [
"SYSID",
"NUM INTEGRACIÓN",
"FECHA",
"HORA",
"ADUANA",
"ADUANA ES",
"PATENTE",
"PEDIMENTOS",
"CAAT",
"IDEN. TRANSPORTE",
"FAST_ID",
"TIPO OPERACIÓN",
"RESPONSABLE",
"TRANSPORTISTA",
"REMESAS",
"TIPO PEDIMENTO",
"CADENA ORIGINIAL",
"NUMERO SERIE",
"FIRMA ELECTRÓNICA",
"NO TRANSACCIÓN",
"ESTATUS",
"LINQSQTQR",
"SAT_CERTIFICADO",
"SELLO DIGITAL",
"PATH XML ENVÍO",
"PATH XML RESPUESTA",
"SAT_CADENA ORIGINAL",
"DESPACHO ADUANERO",
"GAFETE ÚNICO",
"USUARIO",
]
class DodaExportFormat(str, Enum):
csv = "csv"
xls = "xls"
txt = "txt"
def _parse_iso_date(s: str) -> date:
s = (s or "").strip()
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%d-%m-%Y"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
raise ValueError(f"Fecha inválida: {s!r} (use YYYY-MM-DD)")
def _date_to_yyyymmdd(d: date) -> int:
return d.year * 10000 + d.month * 100 + d.day
def _format_doda_date_formatted(doda_date: Optional[int]) -> str:
if doda_date is None:
return ""
s = str(doda_date)
if len(s) == 8 and s.isdigit():
y, m, d = s[:4], s[4:6], s[6:8]
return f"{d}/{m}/{y}"
return s
def _format_doda_time(doda_time: Optional[int]) -> str:
if doda_time is None:
return ""
t = int(doda_time)
s = str(t)
if len(s) <= 2:
return s
if len(s) == 4:
return f"{s[:2]}:{s[2:4]}"
if len(s) == 6:
return f"{s[:2]}:{s[2:4]}:{s[4:6]}"
if len(s) > 6:
return s[:2] + ":" + s[2:4] + ":" + s[4:6]
return s
def _as_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "1" if value else "0"
s = str(value)
s = s.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
return s
def doda_row_values(
row: Doda, *, date_mode: str
) -> List[str]:
"""date_mode: 'raw' | 'formatted' (legacy FechaJul branch)."""
if date_mode == "raw":
fecha = _as_text(row.doda_date)
hora = _as_text(row.doda_time)
else:
fecha = _format_doda_date_formatted(row.doda_date)
hora = _format_doda_time(row.doda_time)
return [
_as_text(row.id),
_as_text(row.integration_number),
fecha,
hora,
_as_text(row.dispatch_customs),
_as_text(row.customs_sections),
_as_text(row.patent),
_as_text(row.pedimentos),
_as_text(row.caat),
_as_text(row.transport_identification),
_as_text(row.fast_id),
_as_text(row.operation_type),
_as_text(row.responsible),
_as_text(row.carrier),
_as_text(row.shipments),
_as_text(row.pedimento_type),
_as_text(row.original_chain),
_as_text(row.serial_number),
_as_text(row.electronic_signature),
_as_text(row.transaction_number),
_as_text(row.status),
_as_text(row.linq_sat_qr),
_as_text(row.sat_certificate),
_as_text(row.sat_digital_seal),
_as_text(row.xml_doda_sent_path),
_as_text(row.xml_doda_response_path),
_as_text(row.sat_original_chain),
_as_text(row.customs_clearance),
_as_text(row.unique_badge_number),
_as_text(row.last_user),
]
def _delimiter_for_format(fmt: DodaExportFormat) -> str:
if fmt == DodaExportFormat.csv:
return ","
if fmt == DodaExportFormat.xls:
return "\t"
if fmt == DodaExportFormat.txt:
return "|"
return ","
def _content_type_and_filename(fmt: DodaExportFormat) -> tuple[str, str]:
if fmt == DodaExportFormat.csv:
return "text/csv; charset=utf-8", "doda_export.csv"
if fmt == DodaExportFormat.xls:
return "application/vnd.ms-excel; charset=utf-8", "doda_export.xls"
return "text/plain; charset=utf-8", "doda_export.txt"
def list_dodas_in_date_range(
db: Session,
*,
tenant_id: int,
company_id: int,
date_start: int,
date_end: int,
) -> List[Doda]:
return (
db.query(Doda)
.filter(
and_(
Doda.tenant_id == tenant_id,
Doda.company_id == company_id,
Doda.doda_date.isnot(None),
Doda.doda_date >= date_start,
Doda.doda_date <= date_end,
)
)
.order_by(Doda.doda_date.asc(), Doda.id.asc())
.all()
)
def build_export_text(
rows: List[Doda],
*,
export_format: DodaExportFormat,
date_mode: str = "formatted",
) -> str:
delim = _delimiter_for_format(export_format)
out = io.StringIO()
w = csv.writer(
out,
delimiter=delim,
quoting=csv.QUOTE_MINIMAL,
lineterminator="\r\n",
)
w.writerow(EXPORT_HEADERS)
for r in rows:
w.writerow(doda_row_values(r, date_mode=date_mode))
return out.getvalue()
def parse_export_params(
date_from: str,
date_to: str,
format_str: str,
date_mode: str,
) -> tuple[int, int, DodaExportFormat, str]:
d0 = _date_to_yyyymmdd(_parse_iso_date(date_from))
d1 = _date_to_yyyymmdd(_parse_iso_date(date_to))
if d0 > d1:
raise ValueError("date_from no puede ser posterior a date_to")
try:
fmt = DodaExportFormat(format_str.lower().strip())
except ValueError:
raise ValueError("format debe ser csv, xls o txt")
mode = (date_mode or "formatted").lower().strip()
if mode not in ("raw", "formatted"):
raise ValueError("date_mode debe ser raw o formatted")
return d0, d1, fmt, mode

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import logging
from typing import Any, Dict
import httpx
from core.config import settings
logger = logging.getLogger(__name__)
class DodaExternalService:
"""
Cliente HTTP para el servicio externo de alta DODA.
Endpoints:
POST {base_url}/api/v1/doda/alta
GET {base_url}/api/v1/doda/alta-status/{task_id}
"""
def __init__(self) -> None:
self.base_url = (settings.DODA_API_BASE_URL or "").strip()
self.verify_ssl = settings.DODA_API_VERIFY_SSL
def post_alta(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
Envía el payload de alta DODA al servicio externo.
Retorna {task_id, status, message}.
"""
if not self.base_url:
raise ValueError(
"DODA_API_BASE_URL no está configurado. "
"Agrega la variable de entorno con la URL del servicio DODA."
)
url = f"{self.base_url.rstrip('/')}/api/v1/doda/alta"
configuracion_vu = payload.get("configuracion_vu") or {}
logger.info(
"Enviando alta DODA: rfc_ciec=%s cer_len=%s key_len=%s clave_fiel_len=%s",
configuracion_vu.get("rfc_ciec"),
len(configuracion_vu.get("archivo_cer_base64") or ""),
len(configuracion_vu.get("archivo_key_base64") or ""),
len(configuracion_vu.get("clave_fiel") or ""),
)
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_status(self, task_id: str) -> Dict[str, Any]:
"""
Consulta el estado de una tarea de alta DODA en el servicio externo.
"""
if not self.base_url:
raise ValueError("DODA_API_BASE_URL no está configurado.")
url = f"{self.base_url.rstrip('/')}/api/v1/doda/alta-status/{task_id}"
logger.debug("Consultando estado tarea DODA: task_id=%s url=%s", task_id, url)
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

@@ -0,0 +1,132 @@
"""
Huella de contenido (fingerprint) para invalidar el PDF de reporte DODA.
"""
from __future__ import annotations
import hashlib
import json
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Dict
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.orm import Session
from .models import (
Doda,
DodaAmericanPedimento,
DodaContainer,
DodaContainerSeal,
DodaPedimento,
)
_DODA_FINGERPRINT_EXCLUDE = frozenset(
{
"doda_report_pdf_path",
"doda_report_pdf_generated_at",
"doda_report_source_fingerprint",
"created_at",
"updated_at",
"deleted_at",
}
)
def _json_default(obj: Any) -> Any:
if isinstance(obj, Decimal):
return str(obj)
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, (bytes, bytearray)):
return obj.hex()
raise TypeError(f"Type {type(obj)} not serializable")
def _instance_payload(instance: object, *, exclude: frozenset[str]) -> Dict[str, Any]:
insp = sa_inspect(instance)
d: Dict[str, Any] = {}
for col in insp.mapper.column_attrs:
name = col.key
if name in exclude or name in _DODA_FINGERPRINT_EXCLUDE:
continue
d[name] = getattr(instance, name)
return d
def build_doda_fingerprint(db: Session, doda_id: int) -> str:
doda = db.get(Doda, doda_id)
if doda is None:
raise ValueError("DODA no encontrado")
doda_block = _instance_payload(doda, exclude=frozenset())
containers_rows = (
db.query(DodaContainer)
.filter(DodaContainer.doda_id == doda_id)
.order_by(DodaContainer.container_line.asc())
.all()
)
container_blocks: list[Dict[str, Any]] = []
for c in containers_rows:
c_block = _instance_payload(
c,
exclude=frozenset(
{
"id",
"doda_id",
}
),
)
seals = (
db.query(DodaContainerSeal)
.filter(DodaContainerSeal.container_id == c.id)
.order_by(DodaContainerSeal.seal_line.asc())
.all()
)
c_block["seals"] = [
_instance_payload(
s,
exclude=frozenset({"id", "container_id", "doda_id"}),
)
for s in seals
]
container_blocks.append(c_block)
american = (
db.query(DodaAmericanPedimento)
.filter(DodaAmericanPedimento.doda_id == doda_id)
.order_by(DodaAmericanPedimento.american_pedimento_line.asc())
.all()
)
american_blocks = [
_instance_payload(
p, exclude=frozenset({"id", "doda_id"})
)
for p in american
]
pedimentos = (
db.query(DodaPedimento)
.filter(DodaPedimento.doda_id == doda_id)
.order_by(DodaPedimento.pedimento_line.asc())
.all()
)
pedimento_blocks = [
_instance_payload(p, exclude=frozenset({"id", "doda_id"})) for p in pedimentos
]
snapshot: Dict[str, Any] = {
"doda": doda_block,
"containers": container_blocks,
"american_pedimentos": american_blocks,
"pedimentos_detail": pedimento_blocks,
}
raw = json.dumps(
snapshot,
sort_keys=True,
ensure_ascii=True,
separators=(",", ":"),
default=_json_default,
)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()

View File

@@ -2,17 +2,18 @@
Modelos ORM para gestión de DODA (Documentos de Operación de Aduana)
"""
from datetime import datetime
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
ForeignKeyConstraint,
DateTime,
Integer,
PrimaryKeyConstraint,
String,
Text,
LargeBinary,
Numeric,
Boolean,
)
@@ -80,6 +81,11 @@ class Doda(Base, TenantScopedMixin, TimestampMixin):
xml_doda_sent_path: Mapped[Optional[str]] = mapped_column(String(1000))
xml_doda_response_path: Mapped[Optional[str]] = mapped_column(String(1000))
# Reporte PDF (S3) + caché por huella de contenido
doda_report_pdf_path: Mapped[Optional[str]] = mapped_column(String(1000))
doda_report_pdf_generated_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
doda_report_source_fingerprint: Mapped[Optional[str]] = mapped_column(String(64))
# SAT original chain
sat_original_chain: Mapped[Optional[Text]] = mapped_column(Text)

View File

@@ -0,0 +1,128 @@
"""
Normaliza valores de negocio hacia el JSON del servicio DODA externo.
Solo cadenas limpias: sin mezclar claves de catálogo (broker_key, vehicle_key) como
sustituto de patente, CAAT o id de transporte cuando deban ser otros datos.
"""
from __future__ import annotations
import re
from typing import Any, Dict, Optional
# Aduana-Patente-Pedimento (3 segmentos; patente 4, pedimento alfanum limpio a dígitos)
_PEDIMENTO_DOC_SPLIT = re.compile(r"^[\s\-–—]*([^\s\-–—]+)[\s\-–—]+([^\s\-–—]+)[\s\-–—]+([^\s\-–—]+)[\s\-–—]*$")
def _digits_only(s: str, max_len: int) -> str:
d = re.sub(r"\D", "", s or "")
if max_len and len(d) > max_len:
return d[-max_len:]
return d
def normalize_aduana_despacho(value: Optional[str]) -> str:
s = (value or "").strip()
if not s:
return ""
return _digits_only(s, 3).zfill(3) if _digits_only(s, 3) else s[:3]
def normalize_aduana_seccion(value: Optional[str]) -> str:
s = (value or "").strip()
if not s:
return ""
d = _digits_only(s, 3)
if d:
return d.zfill(3)
return s[:3]
def normalize_patente(value: Optional[str]) -> str:
s = (re.sub(r"[\s\u00A0]+", " ", (value or "").strip()))
d = re.sub(r"\D", "", s)
if len(d) >= 4:
return d[-4:]
return s[:4] if s else ""
def normalize_caat(value: Optional[str]) -> str:
return (value or "").strip()[:20]
def normalize_id_transporte(value: Optional[str]) -> str:
return (re.sub(r"\s+", " ", (value or "").strip()))[:20]
def normalize_tipo_operacion(value: Optional[str]) -> str:
v = (value or "").strip().upper()[:1]
return v if v in ("I", "E") else v
def normalize_numero_gafete(value: Optional[str]) -> str:
return (value or "").strip()[:250]
def normalize_fast_id(value: Optional[str]) -> str:
return (value or "").strip()[:20]
def _parse_document_pedimento(
document: Optional[str], authorization_patent: Optional[str]
) -> tuple[str, str]:
"""
Retorna (patente_4, número_pedimento) a partir de documento o patente de autorización.
"""
doc = (document or "").strip()
m = _PEDIMENTO_DOC_SPLIT.match(doc)
if m:
b, c = m.group(2), m.group(3)
pat = re.sub(r"\D", "", b)
if len(pat) > 4:
pat = pat[-4:]
else:
pat = pat.zfill(4) if pat else ""
if not pat:
ap = (authorization_patent or "").strip()
pat = re.sub(r"\D", "", ap)[-4:].zfill(4) if ap else ""
ped = re.sub(r"[^\d\w]", "", c) or re.sub(r"\D", "", c)
return (pat[:4], ped)
ped = re.sub(r"[^\d]", "", doc) if doc else ""
ap = (authorization_patent or "").strip()
pat4 = re.sub(r"\D", "", ap)[:4] if ap else ""
if len(pat4) < 4 and ap and ap.isalnum():
pat4 = (re.sub(r"\D", "", ap) + "0000")[:4] if re.sub(r"\D", "", ap) else (ap + "0")[:4]
return (pat4, ped)
def normalize_doda_pedimento_row(
document: Optional[str],
authorization_patent: Optional[str],
shipment: Optional[str],
cove: Optional[str],
umc: Optional[str],
dta_niu: Optional[str],
pedimento_type: Optional[str],
effective: Any,
diff: Any,
) -> Dict[str, Any]:
ap_raw = (authorization_patent or "").strip()
pat, ped = _parse_document_pedimento(document, ap_raw)
if not pat and ap_raw:
d = re.sub(r"\D", "", ap_raw)
pat = d[-4:].zfill(4) if d else ap_raw[:4]
rem = (shipment or "").strip()[:11] if (shipment or "").strip() else ""
if not rem and ped:
rem = ped
return {
"patente": pat,
"pedimento": ped,
"numero_remesa": rem,
"tipo_pedimento": (pedimento_type or "")[:20],
"dta_niu": (dta_niu or "")[:20],
"importe_efectivo_dolares": float(effective or 0) if effective is not None else 0.0,
"importe_diferencia_dolares": float(diff or 0) if diff is not None else 0.0,
"campo_12_apendice_17": 0,
"cove": (cove or "")[:50],
"umc": (umc or "")[:20],
}

View File

@@ -0,0 +1,75 @@
"""
Caché del PDF de reporte DODA (S3 + columnas en a76.doda) e invalidación.
"""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy.orm import Session
from core.config import settings
from core.s3_keys import doda_report_pdf_key
from core import storage_s3
from .models import Doda
logger = logging.getLogger(__name__)
def _delete_stored_s3_key(key: Optional[str]) -> None:
if not key or not settings.use_s3_object_storage:
return
storage_s3.delete_object_if_exists(key)
def clear_doda_report_fields(doda: Doda) -> None:
doda.doda_report_pdf_path = None
doda.doda_report_pdf_generated_at = None
doda.doda_report_source_fingerprint = None
def touch_invalidate_doda_report(
db: Session,
*,
tenant_id: int,
company_id: int,
doda_id: int,
doda: Optional[Doda] = None,
) -> None:
"""
Borra el PDF previo en S3 (si aplica) y limpia columnas de caché en el DODA.
Llamar tras mutaciones de DODA o de tablas hijas.
"""
if doda is None:
doda = (
db.query(Doda)
.filter(
Doda.id == doda_id,
Doda.tenant_id == tenant_id,
Doda.company_id == company_id,
)
.first()
)
if not doda:
return
keys: set[str] = set()
if doda.doda_report_pdf_path:
keys.add(doda.doda_report_pdf_path)
try:
keys.add(doda_report_pdf_key(tenant_id, company_id, doda_id))
except ValueError:
pass
for k in keys:
_delete_stored_s3_key(k)
clear_doda_report_fields(doda)
try:
db.add(doda)
db.commit()
except Exception:
db.rollback()
logger.exception("Error invalidating DODA report cache doda_id=%s", doda_id)

View File

@@ -0,0 +1,205 @@
"""
Generación de PDF de reporte DODA (Jinja2 + pdfkit / wkhtmltopdf).
"""
from __future__ import annotations
import json
import logging
import shutil
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, List, Optional
import pdfkit
from jinja2 import Environment, FileSystemLoader, select_autoescape
from sqlalchemy.orm import Session
from .fingerprint import build_doda_fingerprint
from .models import (
Doda,
DodaAmericanPedimento,
DodaContainer,
DodaContainerSeal,
DodaPedimento,
)
logger = logging.getLogger(__name__)
@dataclass
class _SealView:
seal_line: int
seal_value: str
@dataclass
class _ContainerView:
container_line: int
container_value: str
seals: List[_SealView]
@dataclass
class _AmericanView:
american_pedimento_line: int
american_pedimento_type: str
american_pedimento_value: str
@dataclass
class _PedimentoView:
pedimento_line: int
authorization_patent: str
document: str
shipment: str
cove: str
umc: str
pedimento_type: str
def _s(v: Optional[str]) -> str:
if v is None:
return ""
return str(v).strip()
class DodaReportPdfService:
def __init__(self) -> None:
self.template_dir = Path(__file__).parent / "templates"
self.jinja_env = Environment(
loader=FileSystemLoader(self.template_dir),
autoescape=select_autoescape(["html", "xml"]),
)
self._tpl = self.jinja_env.get_template("doda_report.html")
def _get_wkhtmltopdf_config(self):
for path in (
shutil.which("wkhtmltopdf"),
"/usr/local/bin/wkhtmltopdf",
"/usr/bin/wkhtmltopdf",
):
if path:
return pdfkit.configuration(wkhtmltopdf=path)
raise RuntimeError("wkhtmltopdf binary not found.")
@staticmethod
def _doda_header_block(d: Doda) -> dict[str, str]:
return {
"integration_number": _s(d.integration_number),
"patent": _s(d.patent),
"dispatch_customs": _s(d.dispatch_customs),
"customs_sections": _s(d.customs_sections),
"caat": _s(d.caat),
"transport_identification": _s(d.transport_identification),
"fast_id": _s(d.fast_id),
"operation_type": _s(d.operation_type),
"status": _s(d.status),
"transaction_number": _s(d.transaction_number),
}
def _load_children(self, db: Session, doda_id: int) -> dict[str, Any]:
containers = (
db.query(DodaContainer)
.filter(DodaContainer.doda_id == doda_id)
.order_by(DodaContainer.container_line.asc())
.all()
)
cviews: list[_ContainerView] = []
for c in containers:
seals = (
db.query(DodaContainerSeal)
.filter(DodaContainerSeal.container_id == c.id)
.order_by(DodaContainerSeal.seal_line.asc())
.all()
)
cviews.append(
_ContainerView(
container_line=c.container_line,
container_value=_s(c.container_value),
seals=[
_SealView(
seal_line=s.seal_line, seal_value=_s(s.seal_value)
)
for s in seals
],
)
)
american = (
db.query(DodaAmericanPedimento)
.filter(DodaAmericanPedimento.doda_id == doda_id)
.order_by(DodaAmericanPedimento.american_pedimento_line.asc())
.all()
)
amer_views = [
_AmericanView(
american_pedimento_line=p.american_pedimento_line,
american_pedimento_type=_s(p.american_pedimento_type),
american_pedimento_value=_s(p.american_pedimento_value),
)
for p in american
]
peds = (
db.query(DodaPedimento)
.filter(DodaPedimento.doda_id == doda_id)
.order_by(DodaPedimento.pedimento_line.asc())
.all()
)
ped_views = [
_PedimentoView(
pedimento_line=p.pedimento_line,
authorization_patent=_s(p.authorization_patent),
document=_s(p.document),
shipment=_s(p.shipment),
cove=_s(p.cove),
umc=_s(p.umc),
pedimento_type=_s(p.pedimento_type),
)
for p in peds
]
return {
"containers": [asdict(x) for x in cviews],
"american_pedimentos": [asdict(x) for x in amer_views],
"pedimentos_detail": [asdict(x) for x in ped_views],
}
def build_context(self, db: Session, doda: Doda) -> dict[str, Any]:
fp = build_doda_fingerprint(db, doda.id)
children = self._load_children(db, doda.id)
return {
"doda": self._doda_header_block(doda),
"linq_sat_qr": _s(d.linq_sat_qr),
"sat_chain_preview": _s((d.sat_original_chain or d.original_chain) or "")[:2000],
"sat_digital_seal_preview": _s(d.sat_digital_seal)[:2000],
"fingerprint_sha256": fp,
**children,
}
def render_pdf_bytes(self, context: dict[str, Any]) -> bytes:
html = self._tpl.render(**context)
options = {
"page-size": "A4",
"encoding": "UTF-8",
"margin-top": "12mm",
"margin-bottom": "12mm",
"margin-left": "10mm",
"margin-right": "10mm",
}
return pdfkit.from_string(
html,
False,
options=options,
configuration=self._get_wkhtmltopdf_config(),
)
def build_pdf_for_doda(self, db: Session, doda: Doda) -> bytes:
ctx = self.build_context(db, doda)
return self.render_pdf_bytes(ctx)
@staticmethod
def debug_json_snapshot(context: dict[str, Any]) -> str:
"""Para depuración: snapshot legible (no contiene el sello completo)."""
return json.dumps(context, ensure_ascii=True, indent=2)

View File

@@ -2,12 +2,19 @@
Rutas para gestión de DODA (Documentos de Operación de Aduana)
"""
from typing import List
import io
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from core import storage_s3
from core.config import settings
from core.database import get_core_db
from core.s3_keys import doda_report_pdf_key
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import (
DodaCreateDTO,
@@ -17,6 +24,8 @@ from .dto import (
DodaContainerCreateDTO,
DodaContainerResponseDTO,
DodaContainerUpdateDTO,
DodaContainerSealCreateDTO,
DodaContainerSealResponseDTO,
DodaAmericanPedimentoCreateDTO,
DodaAmericanPedimentoResponseDTO,
DodaAmericanPedimentoUpdateDTO,
@@ -26,25 +35,95 @@ from .dto import (
)
from .models import Doda
from .service import DodaService
from .alta_service import DodaAltaService
from .external_service import DodaExternalService
from .alta_log_dto import (
DodaAltaLogCreateDTO,
DodaAltaLogListResponse,
DodaAltaLogResponseDTO,
DodaAltaLogUpdateDTO,
)
from .alta_log_service import DodaAltaLogService
from .fingerprint import build_doda_fingerprint
from .print_cache import touch_invalidate_doda_report
from .report_service import DodaReportPdfService
from .export_service import (
build_export_text,
list_dodas_in_date_range,
parse_export_params,
_content_type_and_filename,
)
from core.security import get_current_user, validate_access_to_resource
# Create CRUD router
crud_router = TenantCRUDRoutes(
logger = logging.getLogger(__name__)
# Router independiente para rutas literales (deben registrarse antes que /{id})
router = APIRouter(prefix="/doda", tags=["doda"])
# ============ RUTAS LITERALES (antes del CRUD /{id}) ============
@router.get(
"/export",
summary="Exportar DODA por rango de fechas (CSV, TSV como XLS, o TXT con |)",
)
async def export_doda_list(
company_id: int = Query(..., description="Company ID"),
date_from: str = Query(..., description="Fecha inicio (YYYY-MM-DD)"),
date_to: str = Query(..., description="Fecha fin (YYYY-MM-DD)"),
file_format: str = Query("csv", alias="format", description="csv, xls o txt"),
date_mode: str = Query("formatted", description="raw o formatted (fechas/horas)"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Listado al estilo legacy: filtra `doda_date` (YYYYMMDD) entre inicio y fin.
"""
tenant_id = int(validate_access_to_resource(db, company_id, current_user))
try:
d0, d1, fmt, mode = parse_export_params(
date_from, date_to, file_format, date_mode
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
rows = list_dodas_in_date_range(
db, tenant_id=tenant_id, company_id=company_id, date_start=d0, date_end=d1
)
if not rows:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No existen DODA en el rango de fechas seleccionado.",
)
text = build_export_text(rows, export_format=fmt, date_mode=mode)
content_type, default_name = _content_type_and_filename(fmt)
data = ("\ufeff" + text).encode("utf-8")
return StreamingResponse(
io.BytesIO(data),
media_type=content_type,
headers={
"Content-Disposition": f'attachment; filename="{default_name}"',
},
)
# Incluir rutas CRUD (contiene GET /{id}, POST /, PUT /{id}, DELETE /{id}).
# Se registra DESPUÉS de los endpoints literales para que /export, /alta-logs,
# /alta-status no sean capturados por el parámetro /{id}.
_crud_router = TenantCRUDRoutes(
service=DodaService,
create_schema=DodaCreateDTO,
update_schema=DodaUpdateDTO,
response_schema=DodaResponseDTO,
prefix="/doda",
prefix="",
tags=["doda"],
resource_name="DODA",
id_name="doda_id",
enable_list=True,
enable_filters=True,
).router
router = crud_router
# ============ CUSTOM ENDPOINTS ============
router.include_router(_crud_router)
@router.get(
@@ -68,6 +147,86 @@ async def get_doda_detail(
return DodaDetailResponseDTO.model_validate(doda)
@router.get(
"/{doda_id}/print",
summary="Imprimir DODA (PDF)",
responses={422: {"description": "Validación (p. ej. falta sello digital SAT)."}},
)
async def print_doda_pdf(
doda_id: int,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Genera o reutiliza el PDF almacenado en S3 cuando el contenido no ha cambiado
(huella SHA-256 de DODA + hijos).
"""
tenant_id = int(validate_access_to_resource(db, company_id, current_user))
doda = DodaService.get_by_id(db, doda_id, tenant_id, company_id)
if not doda:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="DODA not found",
)
if not (doda.sat_digital_seal and str(doda.sat_digital_seal).strip()):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Falta el sello digital SAT requerido para imprimir el DODA.",
)
content_fp = build_doda_fingerprint(db, doda_id)
expected_key = doda_report_pdf_key(tenant_id, company_id, doda_id)
can_reuse = (
doda.doda_report_source_fingerprint == content_fp
and doda.doda_report_pdf_path == expected_key
and bool(doda.doda_report_pdf_path)
)
if can_reuse and settings.use_s3_object_storage and storage_s3.object_exists(expected_key):
data = storage_s3.get_object_bytes(expected_key)
return StreamingResponse(
io.BytesIO(data),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="doda_{doda_id}.pdf"'},
)
# Re-generar: eliminar caché previa (S3 + columnas) y volver a guardar
if settings.use_s3_object_storage:
touch_invalidate_doda_report(
db,
tenant_id=tenant_id,
company_id=company_id,
doda_id=doda_id,
doda=None,
)
doda_fresh = DodaService.get_by_id(db, doda_id, tenant_id, company_id)
if not doda_fresh:
raise HTTPException(status_code=404, detail="DODA not found")
service = DodaReportPdfService()
pdf = service.build_pdf_for_doda(db, doda_fresh)
if settings.use_s3_object_storage:
storage_s3.put_object_bytes(expected_key, pdf, content_type="application/pdf")
now = datetime.now(timezone.utc)
doda_fresh.doda_report_pdf_path = expected_key if settings.use_s3_object_storage else None
doda_fresh.doda_report_pdf_generated_at = now
doda_fresh.doda_report_source_fingerprint = content_fp
db.add(doda_fresh)
db.commit()
if settings.use_s3_object_storage:
pdf = storage_s3.get_object_bytes(expected_key)
return StreamingResponse(
io.BytesIO(pdf),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="doda_{doda_id}.pdf"'},
)
# ============ CONTAINERS ENDPOINTS ============
@router.get(
"/{doda_id}/containers",
@@ -127,6 +286,80 @@ async def update_container(
return DodaContainerResponseDTO.model_validate(container)
@router.delete(
"/{doda_id}/containers/{container_line}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete container from DODA",
)
async def delete_container(
doda_id: int,
container_line: int,
db: Session = Depends(get_core_db),
):
"""
Elimina un contenedor del DODA.
Devuelve 409 si el contenedor tiene precintos (candados) asignados.
"""
DodaService.delete_container(db, doda_id, container_line)
return None
def _seal_to_response(seal) -> DodaContainerSealResponseDTO:
"""ORM usa doda_id; DTO expone doda_sys_id."""
return DodaContainerSealResponseDTO(
id=seal.id,
doda_sys_id=seal.doda_id,
seal_line=seal.seal_line,
seal_value=seal.seal_value,
)
# ============ CONTAINER SEALS (PRECINTOS) ============
@router.get(
"/{doda_id}/containers/{container_line}/seals",
response_model=List[DodaContainerSealResponseDTO],
summary="Listar precintos de un contenedor",
)
async def get_container_seals(
doda_id: int,
container_line: int,
db: Session = Depends(get_core_db),
):
seals = DodaService.get_seals_for_container(db, doda_id, container_line)
return [_seal_to_response(s) for s in seals]
@router.post(
"/{doda_id}/containers/{container_line}/seals",
response_model=DodaContainerSealResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Agregar precinto a un contenedor",
)
async def add_container_seal(
doda_id: int,
container_line: int,
seal_data: DodaContainerSealCreateDTO,
db: Session = Depends(get_core_db),
):
seal = DodaService.add_seal(db, doda_id, container_line, seal_data)
return _seal_to_response(seal)
@router.delete(
"/{doda_id}/containers/{container_line}/seals/{seal_line}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Eliminar precinto de un contenedor",
)
async def delete_container_seal(
doda_id: int,
container_line: int,
seal_line: int,
db: Session = Depends(get_core_db),
):
DodaService.delete_seal(db, doda_id, container_line, seal_line)
return None
# ============ AMERICAN PEDIMENTOS ENDPOINTS ============
@router.get(
"/{doda_id}/american-pedimentos",
@@ -163,6 +396,22 @@ async def add_american_pedimento(
return DodaAmericanPedimentoResponseDTO.model_validate(pedimento)
# ============ AMERICAN PEDIMENTOS (DELETE) ============
@router.delete(
"/{doda_id}/american-pedimentos/{pedimento_line}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete American pedimento from DODA",
)
async def delete_american_pedimento(
doda_id: int,
pedimento_line: int,
db: Session = Depends(get_core_db),
):
"""Elimina un pedimento americano del DODA."""
DodaService.delete_american_pedimento(db, doda_id, pedimento_line)
return None
# ============ PEDIMENTOS ENDPOINTS ============
@router.get(
"/{doda_id}/pedimentos",
@@ -197,3 +446,279 @@ async def add_pedimento(
detail="DODA not found",
)
return DodaPedimentoResponseDTO.model_validate(pedimento)
@router.delete(
"/{doda_id}/pedimentos/{pedimento_line}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete pedimento from DODA",
)
async def delete_pedimento(
doda_id: int,
pedimento_line: int,
db: Session = Depends(get_core_db),
):
"""Elimina un pedimento del DODA."""
DodaService.delete_pedimento(db, doda_id, pedimento_line)
return None
# ============ ALTA DODA ENDPOINTS ============
@router.get(
"/{doda_id}/alta/elegibilidad",
summary="Verificar elegibilidad para Alta DODA",
tags=["doda-alta"],
)
async def get_doda_elegibilidad(
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]:
"""
Verifica si el DODA cumple los requisitos para enviar el alta al servicio externo.
Porta las validaciones del sistema legacy (campos requeridos, max 4 contenedores,
gafete si DODA, patente vs agente, certificados DODA en VU).
"""
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)
result = service.check_elegibilidad(
doda_id=doda_id,
tenant_id=int(tenant_id),
company_id=company_id,
variant=variant,
user_email=user_email,
)
return {
"can_alta": result.can_alta,
"reasons": [
{"field": r.field, "message": r.message, "solution": r.solution}
for r in result.reasons
],
}
@router.post(
"/{doda_id}/alta",
summary="Enviar Alta DODA al servicio externo (asíncrono)",
tags=["doda-alta"],
)
async def post_doda_alta(
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]:
"""
Verifica elegibilidad, construye el payload desde los datos del DODA y su VU,
y envía el alta al servicio externo. Retorna {task_id, status, message} para polling.
"""
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)
elegibilidad = service.check_elegibilidad(
doda_id=doda_id,
tenant_id=int(tenant_id),
company_id=company_id,
variant=variant,
user_email=user_email,
)
if not elegibilidad.can_alta:
reasons = [
{"field": r.field, "message": r.message, "solution": r.solution}
for r in elegibilidad.reasons
]
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"message": "El DODA no cumple los requisitos para el alta.", "reasons": reasons},
)
try:
payload = service.build_alta_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
try:
ext = DodaExternalService()
result = ext.post_alta(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 alta 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: {exc}",
) from exc
# Persistir el log del alta
doda_record = DodaService.get_by_id(db, doda_id, 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=result,
)
except Exception:
logger.exception("Error persistiendo DodaAltaLog para doda_id=%s", doda_id)
return result
@router.get(
"/alta-status/{task_id}",
summary="Consultar estado de tarea de Alta DODA",
tags=["doda-alta"],
)
async def get_doda_alta_status(
task_id: str,
current_user: dict = Depends(get_current_user),
) -> Any:
"""
Proxy transparente al servicio externo para consultar el estado de una tarea de alta DODA.
"""
try:
ext = DodaExternalService()
return ext.get_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 estado DODA task_id=%s", task_id)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Error al consultar el estado de la tarea DODA: {exc}",
) from exc
# ============ DODA ALTA LOG CRUD ============
@router.get(
"/alta-logs",
response_model=DodaAltaLogListResponse,
summary="Listar registros de alta DODA",
tags=["doda-alta"],
)
async def list_doda_alta_logs(
company_id: int = Query(...),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
doda_id: int = Query(None),
search: str = Query(None),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
return DodaAltaLogService.list(
db, company_id, int(tenant_id), page, page_size, doda_id, search
)
@router.get(
"/alta-logs/{log_id}",
response_model=DodaAltaLogResponseDTO,
summary="Obtener registro de alta DODA",
tags=["doda-alta"],
)
async def get_doda_alta_log(
log_id: int,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id))
if not record:
raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.")
return DodaAltaLogResponseDTO.model_validate(record)
@router.post(
"/alta-logs",
response_model=DodaAltaLogResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Crear registro de alta DODA manualmente",
tags=["doda-alta"],
)
async def create_doda_alta_log(
dto: DodaAltaLogCreateDTO,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = DodaAltaLogService.create(db, dto, company_id, int(tenant_id))
return DodaAltaLogResponseDTO.model_validate(record)
@router.put(
"/alta-logs/{log_id}",
response_model=DodaAltaLogResponseDTO,
summary="Actualizar registro de alta DODA",
tags=["doda-alta"],
)
async def update_doda_alta_log(
log_id: int,
dto: DodaAltaLogUpdateDTO,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id))
if not record:
raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.")
record = DodaAltaLogService.update(db, record, dto)
return DodaAltaLogResponseDTO.model_validate(record)
@router.delete(
"/alta-logs/{log_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Eliminar registro de alta DODA",
tags=["doda-alta"],
)
async def delete_doda_alta_log(
log_id: int,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id))
if not record:
raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.")
DodaAltaLogService.delete(db, record)
return None

View File

@@ -6,6 +6,7 @@ import logging
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -15,6 +16,7 @@ from .dto import (
DodaUpdateDTO,
DodaContainerCreateDTO,
DodaContainerUpdateDTO,
DodaContainerSealCreateDTO,
DodaAmericanPedimentoCreateDTO,
DodaAmericanPedimentoUpdateDTO,
DodaPedimentoCreateDTO,
@@ -27,6 +29,7 @@ from .models import (
DodaAmericanPedimento,
DodaPedimento,
)
from .print_cache import touch_invalidate_doda_report
logger = logging.getLogger(__name__)
@@ -34,6 +37,19 @@ logger = logging.getLogger(__name__)
class DodaService:
"""Servicio para gestión de DODA"""
@staticmethod
def _invalidate_report_after_mutation(
db: Session,
*,
doda_id: int,
tenant_id: int,
company_id: int,
) -> None:
"""Limpia PDF de reporte y objeto S3 previo (patrón artefactos)."""
touch_invalidate_doda_report(
db, tenant_id=tenant_id, company_id=company_id, doda_id=doda_id
)
@staticmethod
def get_all(
db: Session,
@@ -94,6 +110,9 @@ class DodaService:
db.add(db_doda)
db.commit()
db.refresh(db_doda)
DodaService._invalidate_report_after_mutation(
db, doda_id=db_doda.id, tenant_id=tenant_id, company_id=company_id
)
return db_doda
except IntegrityError as e:
db.rollback()
@@ -119,6 +138,9 @@ class DodaService:
db.commit()
db.refresh(db_doda)
DodaService._invalidate_report_after_mutation(
db, doda_id=db_doda.id, tenant_id=tenant_id, company_id=company_id
)
return db_doda
except IntegrityError as e:
db.rollback()
@@ -139,7 +161,16 @@ class DodaService:
return False
try:
db.delete(db_doda)
tid = int(db_doda.tenant_id)
cid = int(db_doda.company_id)
did = int(db_doda.id)
DodaService._invalidate_report_after_mutation(
db, doda_id=did, tenant_id=tid, company_id=cid
)
to_delete = DodaService.get_by_id(db, id, tenant_id, company_id)
if not to_delete:
return True
db.delete(to_delete)
db.commit()
return True
except IntegrityError as e:
@@ -165,6 +196,13 @@ class DodaService:
if not doda:
return None
cv = (container_data.container_value or "").strip()
if not cv:
raise HTTPException(
status_code=400,
detail="El valor del contenedor no puede estar vacío.",
)
# Get max line number
max_line = (
db.query(DodaContainer)
@@ -172,19 +210,30 @@ class DodaService:
.count()
)
dump = container_data.model_dump(exclude_unset=True)
dump.pop("seals_detail", None)
dump["container_value"] = cv
db_container = DodaContainer(
doda_id=doda_id,
container_line=max_line + 1,
**{
k: v
for k, v in container_data.model_dump(exclude_unset=True).items()
if k != "seals_detail"
},
tenant_id=doda.tenant_id,
company_id=doda.company_id,
**{k: v for k, v in dump.items() if k not in ("doda_id", "container_line")},
)
db.add(db_container)
db.commit()
db.refresh(db_container)
DodaService._invalidate_report_after_mutation(
db,
doda_id=doda_id,
tenant_id=int(doda.tenant_id),
company_id=int(doda.company_id),
)
return db_container
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error adding container: {str(e)}")
@@ -216,6 +265,14 @@ class DodaService:
db.commit()
db.refresh(db_container)
doda = db.get(Doda, doda_id)
if doda:
DodaService._invalidate_report_after_mutation(
db,
doda_id=doda_id,
tenant_id=int(doda.tenant_id),
company_id=int(doda.company_id),
)
return db_container
except Exception as e:
db.rollback()
@@ -232,6 +289,211 @@ class DodaService:
.all()
)
@staticmethod
def delete_container(
db: Session, doda_id: int, container_line: int
) -> None:
"""
Delete a container from a DODA.
Raises HTTP 409 if the container has seals assigned (Clarion: validación precintos).
Raises HTTP 404 if not found.
"""
db_container = (
db.query(DodaContainer)
.filter(
DodaContainer.doda_id == doda_id,
DodaContainer.container_line == container_line,
)
.first()
)
if not db_container:
raise HTTPException(status_code=404, detail="Contenedor no encontrado.")
has_seals = bool(db_container.seals_detail)
if not has_seals and db_container.seals:
has_seals = any(s.strip() for s in db_container.seals.split(","))
if has_seals:
raise HTTPException(
status_code=409,
detail=(
"El contenedor tiene uno o más precintos asignados. "
"No se puede borrar el contenedor."
),
)
try:
doda = db.get(Doda, doda_id)
tid = int(doda.tenant_id) if doda else 0
cid = int(doda.company_id) if doda else 0
db.delete(db_container)
db.commit()
if doda:
DodaService._invalidate_report_after_mutation(
db, doda_id=doda_id, tenant_id=tid, company_id=cid, doda=None
)
except Exception as e:
db.rollback()
logger.error(f"Error deleting container doda_id={doda_id} line={container_line}: {e}")
raise HTTPException(status_code=500, detail="Error al eliminar el contenedor.")
# ============ CONTAINER SEALS (PRECINTOS) ============
MAX_SEALS_PER_DODA = 8
@staticmethod
def get_seals_for_container(
db: Session, doda_id: int, container_line: int
) -> List[DodaContainerSeal]:
"""Precintos de un contenedor (por línea de contenedor dentro del DODA)."""
db_container = (
db.query(DodaContainer)
.filter(
DodaContainer.doda_id == doda_id,
DodaContainer.container_line == container_line,
)
.first()
)
if not db_container:
return []
return (
db.query(DodaContainerSeal)
.filter(DodaContainerSeal.container_id == db_container.id)
.order_by(DodaContainerSeal.seal_line.asc())
.all()
)
@staticmethod
def add_seal(
db: Session,
doda_id: int,
container_line: int,
seal_data: DodaContainerSealCreateDTO,
) -> DodaContainerSeal:
"""
Añade un precinto (candado) a un contenedor.
Máximo 8 precintos en total por DODA (legacy Clarion: gDoda_Contenedores_Candados).
"""
doda = db.query(Doda).filter(Doda.id == doda_id).first()
if not doda:
raise HTTPException(status_code=404, detail="DODA no encontrado.")
container = (
db.query(DodaContainer)
.filter(
DodaContainer.doda_id == doda_id,
DodaContainer.container_line == container_line,
)
.first()
)
if not container:
raise HTTPException(status_code=404, detail="Contenedor no encontrado.")
raw_value = (seal_data.seal_value or "").strip()
if not raw_value:
raise HTTPException(
status_code=400,
detail="El campo precinto no puede estar vacío.",
)
total_seals = (
db.query(DodaContainerSeal)
.filter(DodaContainerSeal.doda_id == doda_id)
.count()
)
if total_seals >= DodaService.MAX_SEALS_PER_DODA:
raise HTTPException(
status_code=400,
detail=(
"El DODA supera el máximo de precintos (8). "
"Revise los precintos registrados."
),
)
max_line = (
db.query(func.max(DodaContainerSeal.seal_line))
.filter(DodaContainerSeal.container_id == container.id)
.scalar()
)
next_line = (max_line or 0) + 1
try:
db_seal = DodaContainerSeal(
doda_id=doda_id,
container_id=container.id,
seal_line=next_line,
seal_value=raw_value,
tenant_id=doda.tenant_id,
company_id=doda.company_id,
)
db.add(db_seal)
db.commit()
db.refresh(db_seal)
DodaService._invalidate_report_after_mutation(
db,
doda_id=doda_id,
tenant_id=int(doda.tenant_id),
company_id=int(doda.company_id),
)
return db_seal
except Exception as e:
db.rollback()
logger.error(
"Error adding seal doda_id=%s container_line=%s: %s",
doda_id,
container_line,
e,
)
raise HTTPException(status_code=500, detail="Error al agregar el precinto.")
@staticmethod
def delete_seal(
db: Session, doda_id: int, container_line: int, seal_line: int
) -> None:
"""Elimina un precinto por línea de contenedor y línea de candado."""
container = (
db.query(DodaContainer)
.filter(
DodaContainer.doda_id == doda_id,
DodaContainer.container_line == container_line,
)
.first()
)
if not container:
raise HTTPException(status_code=404, detail="Contenedor no encontrado.")
seal = (
db.query(DodaContainerSeal)
.filter(
DodaContainerSeal.container_id == container.id,
DodaContainerSeal.seal_line == seal_line,
)
.first()
)
if not seal:
raise HTTPException(status_code=404, detail="Precinto no encontrado.")
try:
doda = db.get(Doda, doda_id)
db.delete(seal)
db.commit()
if doda:
DodaService._invalidate_report_after_mutation(
db,
doda_id=doda_id,
tenant_id=int(doda.tenant_id),
company_id=int(doda.company_id),
)
except Exception as e:
db.rollback()
logger.error(
"Error deleting seal doda_id=%s line=%s seal_line=%s: %s",
doda_id,
container_line,
seal_line,
e,
)
raise HTTPException(status_code=500, detail="Error al eliminar el precinto.")
# ============ AMERICAN PEDIMENTOS ============
@staticmethod
def add_american_pedimento(
@@ -243,21 +505,62 @@ class DodaService:
if not doda:
return None
tipo = (pedimento_data.american_pedimento_type or "").strip()
valor = (pedimento_data.american_pedimento_value or "").strip()
# Legacy: IF DODA:DespachoAduanero = '3' (PITA) → sin validación de tipo; web usa customs_clearance=1 para PITA
clearance = getattr(doda, "customs_clearance", None)
if clearance != 1:
if not tipo:
raise HTTPException(
status_code=400,
detail="El tipo de pedimento americano es obligatorio.",
)
op = (doda.operation_type or "").strip().upper()
if op in ("I", "1"):
allowed = {"1", "2", "3", "4", "5"}
elif op in ("E", "2"):
allowed = {"6", "7", "8"}
else:
raise HTTPException(
status_code=400,
detail="El tipo de operación del DODA no permite validar el pedimento americano.",
)
if tipo not in allowed:
raise HTTPException(
status_code=400,
detail="El tipo de pedimento americano no es correcto para el tipo de operación.",
)
max_line = (
db.query(DodaAmericanPedimento)
.filter(DodaAmericanPedimento.doda_id == doda_id)
.count()
)
dump = pedimento_data.model_dump(exclude_unset=True)
if clearance == 1:
dump["american_pedimento_type"] = tipo or None
db_pedimento = DodaAmericanPedimento(
doda_id=doda_id,
american_pedimento_line=max_line + 1,
**pedimento_data.model_dump(exclude_unset=True),
tenant_id=doda.tenant_id,
company_id=doda.company_id,
**dump,
)
db.add(db_pedimento)
db.commit()
db.refresh(db_pedimento)
DodaService._invalidate_report_after_mutation(
db,
doda_id=doda_id,
tenant_id=int(doda.tenant_id),
company_id=int(doda.company_id),
)
return db_pedimento
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error adding American pedimento: {str(e)}")
@@ -274,6 +577,44 @@ class DodaService:
.all()
)
# ============ AMERICAN PEDIMENTOS (DELETE) ============
@staticmethod
def delete_american_pedimento(
db: Session, doda_id: int, pedimento_line: int
) -> None:
"""Delete an American pedimento from a DODA."""
db_pedimento = (
db.query(DodaAmericanPedimento)
.filter(
DodaAmericanPedimento.doda_id == doda_id,
DodaAmericanPedimento.american_pedimento_line == pedimento_line,
)
.first()
)
if not db_pedimento:
raise HTTPException(
status_code=404, detail="Pedimento americano no encontrado."
)
try:
doda = db.get(Doda, doda_id)
db.delete(db_pedimento)
db.commit()
if doda:
DodaService._invalidate_report_after_mutation(
db,
doda_id=doda_id,
tenant_id=int(doda.tenant_id),
company_id=int(doda.company_id),
)
except Exception as e:
db.rollback()
logger.error(
f"Error deleting american pedimento doda_id={doda_id} line={pedimento_line}: {e}"
)
raise HTTPException(
status_code=500, detail="Error al eliminar el pedimento americano."
)
# ============ PEDIMENTOS ============
@staticmethod
def add_pedimento(
@@ -294,12 +635,23 @@ class DodaService:
db_pedimento = DodaPedimento(
doda_id=doda_id,
pedimento_line=max_line + 1,
tenant_id=doda.tenant_id,
company_id=doda.company_id,
**pedimento_data.model_dump(exclude_unset=True),
)
db.add(db_pedimento)
db.commit()
db.refresh(db_pedimento)
DodaService._invalidate_report_after_mutation(
db,
doda_id=doda_id,
tenant_id=int(doda.tenant_id),
company_id=int(doda.company_id),
)
return db_pedimento
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
logger.error(f"Error adding pedimento: {str(e)}")
@@ -313,3 +665,36 @@ class DodaService:
db.query(DodaPedimento).filter(
DodaPedimento.doda_id == doda_id).all()
)
@staticmethod
def delete_pedimento(
db: Session, doda_id: int, pedimento_line: int
) -> None:
"""Delete a pedimento from a DODA."""
db_pedimento = (
db.query(DodaPedimento)
.filter(
DodaPedimento.doda_id == doda_id,
DodaPedimento.pedimento_line == pedimento_line,
)
.first()
)
if not db_pedimento:
raise HTTPException(status_code=404, detail="Pedimento no encontrado.")
try:
doda = db.get(Doda, doda_id)
db.delete(db_pedimento)
db.commit()
if doda:
DodaService._invalidate_report_after_mutation(
db,
doda_id=doda_id,
tenant_id=int(doda.tenant_id),
company_id=int(doda.company_id),
)
except Exception as e:
db.rollback()
logger.error(
f"Error deleting pedimento doda_id={doda_id} line={pedimento_line}: {e}"
)
raise HTTPException(status_code=500, detail="Error al eliminar el pedimento.")

View File

@@ -0,0 +1,160 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Reporte DODA</title>
<style>
body { font-family: DejaVu Sans, Arial, Helvetica, sans-serif; font-size: 10pt; color: #111; }
h1 { font-size: 16pt; margin: 0 0 6px; }
h2 { font-size: 12pt; margin: 16px 0 8px; border-bottom: 1px solid #ccc; }
.muted { color: #555; font-size: 9pt; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ccc; padding: 4px 6px; vertical-align: top; }
th { background: #f3f3f3; text-align: left; }
.no-border td { border: none; padding: 2px 0; }
.mono { font-family: DejaVu Sans Mono, Consolas, monospace; font-size: 8.5pt; white-space: pre-wrap; word-break: break-all; }
.small { font-size: 8.5pt; }
</style>
</head>
<body>
<h1>Documento de operación (DODA)</h1>
<p class="muted">Huella de contenido (SHA-256): <span class="mono">{{ fingerprint_sha256 }}</span></p>
<h2>Datos generales</h2>
<table>
<tr>
<th style="width: 22%;">Folio de integración</th><td>{{ doda.integration_number }}</td>
<th style="width: 22%;">Patente</th><td>{{ doda.patent }}</td>
</tr>
<tr>
<th>Aduana despacho</th><td>{{ doda.dispatch_customs }}</td>
<th>Secciones aduaneras</th><td>{{ doda.customs_sections }}</td>
</tr>
<tr>
<th>Operación</th><td>{{ doda.operation_type }}</td>
<th>Estado</th><td>{{ doda.status }}</td>
</tr>
<tr>
<th>Ident. transporte</th><td>{{ doda.transport_identification }}</td>
<th>ID rápida</th><td>{{ doda.fast_id }}</td>
</tr>
<tr>
<th>CAAT</th><td>{{ doda.caat }}</td>
<th>Transacción / folio</th><td>{{ doda.transaction_number }}</td>
</tr>
</table>
{% if linq_sat_qr %}
<h2>QR (LINQ / SAT)</h2>
<p class="mono small">{{ linq_sat_qr }}</p>
{% endif %}
{% if sat_chain_preview %}
<h2>Cadena original / SAT (extracto)</h2>
<p class="mono small">{{ sat_chain_preview }}</p>
{% endif %}
{% if sat_digital_seal_preview %}
<h2>Sello digital (SAT) — extracto</h2>
<p class="mono small">{{ sat_digital_seal_preview }}</p>
{% endif %}
<h2>Contenedores y precintos</h2>
{% if containers|length == 0 %}
<p class="muted">Sin contenedores registrados.</p>
{% else %}
<table>
<thead>
<tr>
<th style="width:8%;">Línea</th>
<th>Contenedor</th>
<th style="width:38%;">Precintos (línea / valor)</th>
</tr>
</thead>
<tbody>
{% for c in containers %}
<tr>
<td>{{ c.container_line }}</td>
<td class="mono">{{ c.container_value }}</td>
<td>
{% if c.seals|length == 0 %}
<span class="muted"></span>
{% else %}
<table class="no-border">
{% for s in c.seals %}
<tr>
<td class="small" style="width: 18%;">{{ s.seal_line }}</td>
<td class="mono small">{{ s.seal_value }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2>Pedimentos nacionales</h2>
{% if pedimentos_detail|length == 0 %}
<p class="muted">Sin partidas de pedimentos nacionales.</p>
{% else %}
<table>
<thead>
<tr>
<th style="width:8%;">Línea</th>
<th>Patente auth.</th>
<th>Documento / ped.</th>
<th>Embarque</th>
<th>COVE</th>
<th>UMC</th>
<th>Tipo</th>
</tr>
</thead>
<tbody>
{% for p in pedimentos_detail %}
<tr>
<td>{{ p.pedimento_line }}</td>
<td class="mono">{{ p.authorization_patent }}</td>
<td class="mono">{{ p.document }}</td>
<td class="mono">{{ p.shipment }}</td>
<td class="mono">{{ p.cove }}</td>
<td class="mono">{{ p.umc }}</td>
<td class="mono">{{ p.pedimento_type }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2>Pedimentos USA</h2>
{% if american_pedimentos|length == 0 %}
<p class="muted">Sin pedimentos americanos.</p>
{% else %}
<table>
<thead>
<tr>
<th style="width:8%;">Línea</th>
<th>Tipo</th>
<th>Valor</th>
</tr>
</thead>
<tbody>
{% for a in american_pedimentos %}
<tr>
<td>{{ a.american_pedimento_line }}</td>
<td class="mono">{{ a.american_pedimento_type }}</td>
<td class="mono">{{ a.american_pedimento_value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p class="muted" style="margin-top: 18px;">
Documento generado automáticamente. Los extractos de cadena / sello se truncan en este reporte;
los datos de huella (SHA-256) reflejan el contenido completo persistido.
</p>
</body>
</html>