chore: resolve second wave of merge conflicts, integrate new DODA despacho module and stabilize catalogues
This commit is contained in:
@@ -4,7 +4,7 @@ Modelo de certificaciones de empresa
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Integer, String, ForeignKey, Boolean, Date
|
||||
from sqlalchemy import String, ForeignKey, Boolean, Date, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from core.database import Base
|
||||
from api.v1.common.base_models import TimestampMixin
|
||||
|
||||
@@ -27,6 +27,14 @@ class ConceptService:
|
||||
Concept.company_id == company_id
|
||||
)
|
||||
|
||||
if filters:
|
||||
if filters.get("code"):
|
||||
query = query.filter(Concept.code.ilike(f"%{filters['code']}%"))
|
||||
if filters.get("description"):
|
||||
query = query.filter(
|
||||
Concept.description.ilike(f"%{filters['description']}%")
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
551
backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py
Normal file
551
backend/api/v1/modules/a76/general_catalogs/doda/alta_service.py
Normal 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 (1–5 importación, 6–8 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 (1–5) o exportación (6–8).",
|
||||
))
|
||||
|
||||
# --- 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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
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, DodaPedimento
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# --- Exportación de pedimentos (líneas) de un DODA específico (legacy / pantalla) ---
|
||||
|
||||
PEDIMENTO_EXPORT_HEADERS: List[str] = [
|
||||
"PATENTE",
|
||||
"DOCUMENTO",
|
||||
"ACUSE_VA",
|
||||
"REMESA",
|
||||
"CANTIDAD",
|
||||
"IMPORTE_USD",
|
||||
"IMPORTE_DIF_USD",
|
||||
"NIU",
|
||||
"ARTICULO",
|
||||
]
|
||||
|
||||
|
||||
def _as_decimal_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return _as_text(value)
|
||||
|
||||
|
||||
def pedimento_row_values(row: DodaPedimento) -> List[str]:
|
||||
"""
|
||||
ACUSE_VA = COVE; CANTIDAD = UMC (captura típica en listado);
|
||||
IMPORTE_USD / IMPORTE_DIF_USD = montos en USD; ARTICULO = art. 7 (0/1).
|
||||
"""
|
||||
return [
|
||||
_as_text(row.authorization_patent),
|
||||
_as_text(row.document),
|
||||
_as_text(row.cove),
|
||||
_as_text(row.shipment),
|
||||
_as_text(row.umc),
|
||||
_as_decimal_text(row.effective_amount_usd),
|
||||
_as_decimal_text(row.difference_amount_usd),
|
||||
_as_text(row.dta_niu),
|
||||
_as_text(row.article_7),
|
||||
]
|
||||
|
||||
|
||||
def list_pedimentos_for_doda_export(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
doda_id: int,
|
||||
) -> List[DodaPedimento]:
|
||||
return (
|
||||
db.query(DodaPedimento)
|
||||
.join(Doda, DodaPedimento.doda_id == Doda.id)
|
||||
.filter(
|
||||
Doda.id == doda_id,
|
||||
Doda.tenant_id == tenant_id,
|
||||
Doda.company_id == company_id,
|
||||
)
|
||||
.order_by(DodaPedimento.pedimento_line.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def build_pedimentos_export_text(
|
||||
rows: List[DodaPedimento],
|
||||
*,
|
||||
export_format: DodaExportFormat,
|
||||
) -> 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(PEDIMENTO_EXPORT_HEADERS)
|
||||
for r in rows:
|
||||
w.writerow(pedimento_row_values(r))
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def parse_pedimento_export_format(format_str: str) -> DodaExportFormat:
|
||||
try:
|
||||
return DodaExportFormat(format_str.lower().strip())
|
||||
except ValueError as e:
|
||||
raise ValueError("format debe ser csv, xls o txt") from e
|
||||
@@ -0,0 +1,63 @@
|
||||
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 (API de Ventanilla Única).
|
||||
|
||||
Endpoints:
|
||||
POST {base_url}/api/v1/doda/alta
|
||||
GET {base_url}/api/v1/doda/alta-status/{task_id}
|
||||
|
||||
Usa COVE_API_URL como URL base (la misma variable que COVE y Expediente).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.COVE_API_URL.strip()
|
||||
self.verify_ssl = settings.COVE_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}.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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()
|
||||
132
backend/api/v1/modules/a76/general_catalogs/doda/fingerprint.py
Normal file
132
backend/api/v1/modules/a76/general_catalogs/doda/fingerprint.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,15 +35,132 @@ 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,
|
||||
build_pedimentos_export_text,
|
||||
list_dodas_in_date_range,
|
||||
list_pedimentos_for_doda_export,
|
||||
parse_export_params,
|
||||
parse_pedimento_export_format,
|
||||
_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}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export/pedimentos/{doda_id}",
|
||||
summary="Exportar líneas de pedimento de un DODA (CSV, TSV como XLS, TXT con |)",
|
||||
)
|
||||
async def export_doda_pedimentos(
|
||||
doda_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
file_format: str = Query("xls", alias="format", description="csv, xls o txt"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Reporte por DODA seleccionado: columnas alineadas al listado de pedimentos (PATENTE, DOCUMENTO, COVE, etc.).
|
||||
Si no hay líneas, se devuelve el archivo solo con encabezados.
|
||||
"""
|
||||
tenant_id = int(validate_access_to_resource(db, company_id, current_user))
|
||||
try:
|
||||
fmt = parse_pedimento_export_format(file_format)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)
|
||||
) from e
|
||||
|
||||
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 no encontrado."
|
||||
)
|
||||
|
||||
rows = list_pedimentos_for_doda_export(
|
||||
db, tenant_id=tenant_id, company_id=company_id, doda_id=doda_id
|
||||
)
|
||||
text = build_pedimentos_export_text(rows, export_format=fmt)
|
||||
content_type, _ = _content_type_and_filename(fmt)
|
||||
fname = f"doda_pedimentos_{doda_id}.{fmt.value}"
|
||||
data = ("\ufeff" + text).encode("utf-8")
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data),
|
||||
media_type=content_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||
)
|
||||
|
||||
|
||||
# Incluir rutas CRUD (contiene GET /{id}, POST /, PUT /{id}, DELETE /{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",
|
||||
@@ -42,8 +168,7 @@ crud_router = TenantCRUDRoutes(
|
||||
enable_filters=True,
|
||||
).router
|
||||
|
||||
router = crud_router
|
||||
|
||||
router.include_router(_crud_router)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -67,6 +192,87 @@ 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",
|
||||
response_model=List[DodaContainerResponseDTO],
|
||||
@@ -125,6 +331,81 @@ 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",
|
||||
response_model=List[DodaAmericanPedimentoResponseDTO],
|
||||
@@ -160,6 +441,23 @@ 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",
|
||||
response_model=List[DodaPedimentoResponseDTO],
|
||||
@@ -193,3 +491,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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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>
|
||||
@@ -22,8 +22,12 @@ class IdentifierService:
|
||||
)
|
||||
|
||||
if filters:
|
||||
# Add filters here if needed
|
||||
pass
|
||||
if filters.get("code"):
|
||||
query = query.filter(Identifier.code.ilike(f"%{filters['code']}%"))
|
||||
if filters.get("description"):
|
||||
query = query.filter(
|
||||
Identifier.description.ilike(f"%{filters['description']}%")
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
|
||||
@@ -27,8 +27,12 @@ class LegendService:
|
||||
)
|
||||
|
||||
if filters:
|
||||
# Add filters here if needed
|
||||
pass
|
||||
if filters.get("code"):
|
||||
query = query.filter(Legend.code.ilike(f"%{filters['code']}%"))
|
||||
if filters.get("description"):
|
||||
query = query.filter(
|
||||
Legend.description.ilike(f"%{filters['description']}%")
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
|
||||
Reference in New Issue
Block a user