feature/api-doda

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

View File

@@ -28,8 +28,15 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# License Service
LICENSE_CHECK_ENABLED=True
# Factura COVE / VUCEM / API Ventanilla Única
# Llave y IV AES-256-CBC para cifrar la clave FIEL (misma que usa VU y DODA).
COVE_FIEL_HASH_KEY=
COVE_FIEL_HASH_IV=
# URL del API de digitalización de expediente (ExpedienteExternalService / CoveExternalService)
COVE_API_URL=https://api.vu.aduanasoft.com
# URL del API externo DODA/PITA (alta, status). Misma red que VU.
DODA_API_BASE_URL=http://192.168.1.66:8008
DODA_API_VERIFY_SSL=False
# Synchronization (Hub & Spoke)
SYNC_SECRET_TOKEN=change-this-sync-token-in-production

View File

@@ -1,7 +1,7 @@
"""add annex30_duration_days to company_certification
Revision ID: 7c8d9e0f1a2b
Revises: c8d9e0f1a2b3
Revises: d1a2b3c4e5f6
Create Date: 2026-04-24 16:50:00.000000
"""
@@ -12,7 +12,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7c8d9e0f1a2b'
down_revision = 'c8d9e0f1a2b3'
down_revision = 'd1a2b3c4e5f6'
branch_labels = None
depends_on = None

View File

@@ -0,0 +1,117 @@
"""create doda_alta_log table
Revision ID: a1b2c3d4e5f6
Revises: 7c8d9e0f1a2b
Create Date: 2026-04-26 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = "7c8d9e0f1a2b"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"doda_alta_log",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("doda_id", sa.Integer(), nullable=True),
sa.Column("variant", sa.String(length=10), nullable=True),
sa.Column("responsible", sa.String(length=20), nullable=True),
sa.Column("patent", sa.String(length=10), nullable=True),
sa.Column("dispatch_customs", sa.String(length=10), nullable=True),
sa.Column("operation_type", sa.String(length=5), nullable=True),
sa.Column("integration_number", sa.String(length=50), nullable=True),
sa.Column("task_id", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=30), nullable=True),
sa.Column("message", sa.String(length=2000), nullable=True),
sa.Column("result_json", sa.Text(), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["company_id"], ["a76.company.id"]),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.PrimaryKeyConstraint("id", name="doda_alta_log_pkey"),
schema="a76",
)
op.create_index(
op.f("ix_a76_doda_alta_log_company_id"),
"doda_alta_log",
["company_id"],
unique=False,
schema="a76",
)
op.create_index(
op.f("ix_a76_doda_alta_log_tenant_id"),
"doda_alta_log",
["tenant_id"],
unique=False,
schema="a76",
)
op.create_index(
op.f("ix_a76_doda_alta_log_doda_id"),
"doda_alta_log",
["doda_id"],
unique=False,
schema="a76",
)
op.create_index(
op.f("ix_a76_doda_alta_log_task_id"),
"doda_alta_log",
["task_id"],
unique=False,
schema="a76",
)
# Reporte PDF almacenado (S3) + invalidación por huella de contenido
op.add_column(
"doda",
sa.Column("doda_report_pdf_path", sa.String(length=1000), nullable=True),
schema="a76",
)
op.add_column(
"doda",
sa.Column("doda_report_pdf_generated_at", sa.DateTime(), nullable=True),
schema="a76",
)
op.add_column(
"doda",
sa.Column("doda_report_source_fingerprint", sa.String(length=64), nullable=True),
schema="a76",
)
def downgrade() -> None:
op.drop_column("doda", "doda_report_source_fingerprint", schema="a76")
op.drop_column("doda", "doda_report_pdf_generated_at", schema="a76")
op.drop_column("doda", "doda_report_pdf_path", schema="a76")
op.drop_index(
op.f("ix_a76_doda_alta_log_task_id"),
table_name="doda_alta_log",
schema="a76",
)
op.drop_index(
op.f("ix_a76_doda_alta_log_doda_id"),
table_name="doda_alta_log",
schema="a76",
)
op.drop_index(
op.f("ix_a76_doda_alta_log_tenant_id"),
table_name="doda_alta_log",
schema="a76",
)
op.drop_index(
op.f("ix_a76_doda_alta_log_company_id"),
table_name="doda_alta_log",
schema="a76",
)
op.drop_table("doda_alta_log", schema="a76")

View File

@@ -27,25 +27,25 @@ class ConfiguracionVU(BaseModel):
class PersonaCove(BaseModel):
tipo_identificador: str = Field(..., max_length=10)
identificacion: str = Field(..., max_length=30)
apellido_paterno: Optional[str] = Field(None, max_length=80)
apellido_materno: Optional[str] = Field(None, max_length=80)
nombre: Optional[str] = Field(None, max_length=80)
calle: Optional[str] = Field(None, max_length=120)
numero_exterior: Optional[str] = Field(None, max_length=20)
numero_interior: Optional[str] = Field(None, max_length=20)
colonia: Optional[str] = Field(None, max_length=120)
localidad: Optional[str] = Field(None, max_length=120)
municipio: Optional[str] = Field(None, max_length=120)
entidad_federativa: Optional[str] = Field(None, max_length=120)
apellido_paterno: str = Field(default="", max_length=80)
apellido_materno: str = Field(default="", max_length=80)
nombre: str = Field(default="", max_length=80)
calle: str = Field(default="", max_length=120)
numero_exterior: str = Field(default="", max_length=20)
numero_interior: str = Field(default="", max_length=20)
colonia: str = Field(default="", max_length=120)
localidad: str = Field(default="", max_length=120)
municipio: str = Field(default="", max_length=120)
entidad_federativa: str = Field(default="", max_length=120)
pais: str = Field(..., max_length=3, description="País en formato ISO o catálogo VU")
codigo_postal: Optional[str] = Field(None, max_length=15)
codigo_postal: str = Field(default="", max_length=15)
class DescripcionEspecifica(BaseModel):
marca: Optional[str] = Field(None, max_length=80)
modelo: Optional[str] = Field(None, max_length=80)
submodelo: Optional[str] = Field(None, max_length=80)
numero_serie: Optional[str] = Field(None, max_length=80)
marca: str = Field(default="", max_length=80)
modelo: str = Field(default="", max_length=80)
submodelo: str = Field(default="", max_length=80)
numero_serie: str = Field(default="", max_length=80)
class MercanciaCove(BaseModel):
@@ -55,7 +55,7 @@ class MercanciaCove(BaseModel):
cantidad: Decimal = Field(..., gt=0)
valor_unitario: Decimal = Field(..., ge=0)
valor_total: Decimal = Field(..., ge=0)
valor_dolares: Optional[Decimal] = Field(None, ge=0)
valor_dolares: Decimal = Field(default=Decimal("0"), ge=0)
descripcion_especifica: List[DescripcionEspecifica] = Field(default_factory=list)

View File

@@ -295,30 +295,28 @@ class FacturaCoveDomainService:
identificacion=identificacion,
apellido_paterno="",
apellido_materno="",
nombre=(cp.name or cp.short_name or "").strip() or None,
calle=(addr.streets or "").strip() if addr and addr.streets else None,
nombre=(cp.name or cp.short_name or "").strip(),
calle=(addr.streets or "").strip() if addr and addr.streets else "",
numero_exterior=(addr.exterior_number or "").strip()
if addr and addr.exterior_number
else None,
# El API de COVE exige texto (no null) para numero_interior y municipio.
# Si no hay valor, enviamos cadena vacía.
else "",
numero_interior=(addr.interior_number or "").strip()
if addr
else "",
colonia=(addr.neighborhood or "").strip()
if addr and addr.neighborhood
else None,
localidad=(addr.city or "").strip() if addr and addr.city else None,
else "",
localidad=(addr.city or "").strip() if addr and addr.city else "",
municipio=(addr.municipality or "").strip()
if addr
else "",
entidad_federativa=(addr.state or "").strip()
if addr and addr.state
else None,
else "",
pais=country_code,
codigo_postal=(addr.postal_code or "").strip()
if addr and addr.postal_code
else None,
else "",
)
def _company_to_persona(self, company: Company) -> PersonaCove:
@@ -343,28 +341,28 @@ class FacturaCoveDomainService:
identificacion=(company.rfc or "").strip().upper(),
apellido_paterno="",
apellido_materno="",
nombre=(company.name or "").strip() or None,
calle=(addr.street or "").strip() if addr and addr.street else None,
nombre=(company.name or "").strip(),
calle=(addr.street or "").strip() if addr and addr.street else "",
numero_exterior=(addr.exterior_number or "").strip()
if addr and addr.exterior_number
else None,
else "",
numero_interior=(addr.interior_number or "").strip()
if addr
else "",
colonia=(addr.neighborhood or "").strip()
if addr and addr.neighborhood
else None,
localidad=(addr.city or "").strip() if addr and addr.city else None,
else "",
localidad=(addr.city or "").strip() if addr and addr.city else "",
municipio=(addr.municipality or "").strip()
if addr
else "",
entidad_federativa=(addr.state or "").strip()
if addr and addr.state
else None,
else "",
pais=country_code,
codigo_postal=(addr.postal_code or "").strip()
if addr and addr.postal_code
else None,
else "",
)
def _build_personas(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,6 +57,8 @@ class Settings(BaseSettings):
COVE_FIEL_HASH_IV: str = ""
SITAR_API_USER: str = ""
SITAR_API_PASSWORD: str = ""
DODA_API_BASE_URL: str = ""
DODA_API_VERIFY_SSL: bool = False
# SMTP Email Configuration
SMTP_HOST: str = "smtp.gmail.com"

View File

@@ -15,6 +15,7 @@ funciones de este módulo (no construir ``tenants/...`` a mano en las rutas HTTP
- ``tenants/{tid}/companies/{company_id}/``
Recursos ligados a una empresa:
- ``.../doda/{doda_id}/report/doda_report.pdf`` — reporte DODA en PDF. ``doda_report_pdf_key``.
- ``.../branding/{filename}`` — logo. ``company_logo_key``.
- ``.../certificates/{tipo}_{timestamp}.{cer|key}`` — CER/KEY FIEL, CFDI, cancelación.
``company_certificate_key``.
@@ -272,6 +273,18 @@ def company_logo_key(
return f"{tenant_company_prefix(tenant_id, company_id)}branding/{fn}"
def doda_report_pdf_key(
tenant_id: Union[int, str],
company_id: int,
doda_id: int,
) -> str:
"""
Reporte DODA en PDF bajo ``.../doda/{doda_id}/report/doda_report.pdf`` (clave estable).
"""
did = _segment(doda_id, "doda_id")
return f"{tenant_company_prefix(tenant_id, company_id)}doda/{did}/report/doda_report.pdf"
def company_certificate_key(
tenant_id: Union[int, str],
company_id: int,

View File

@@ -0,0 +1,72 @@
from types import SimpleNamespace
import pytest
from api.v1.modules.a76.general_catalogs.doda.export_service import (
DodaExportFormat,
build_export_text,
doda_row_values,
parse_export_params,
)
def test_parse_export_params_valid():
d0, d1, fmt, mode = parse_export_params("2024-01-15", "2024-01-20", "csv", "formatted")
assert d0 == 20240115
assert d1 == 20240120
assert fmt == DodaExportFormat.csv
assert mode == "formatted"
def test_parse_export_params_rejects_inverted_range():
with pytest.raises(ValueError, match="date_from"):
parse_export_params("2024-02-01", "2024-01-01", "csv", "formatted")
def _minimal_row():
return SimpleNamespace(
id=1,
integration_number="INT1",
doda_date=20240110,
doda_time=1430,
dispatch_customs="64",
customs_sections=None,
patent="1234",
pedimentos=None,
caat=None,
transport_identification=None,
fast_id=None,
operation_type="I",
responsible=None,
carrier=None,
shipments=None,
pedimento_type=None,
original_chain=None,
serial_number=None,
electronic_signature=None,
transaction_number=None,
status="PENDIENTE",
linq_sat_qr=None,
sat_certificate=None,
sat_digital_seal=None,
xml_doda_sent_path=None,
xml_doda_response_path=None,
sat_original_chain=None,
customs_clearance=None,
unique_badge_number=None,
last_user=None,
)
def test_doda_row_values_length_matches_headers():
row = _minimal_row()
assert len(doda_row_values(row, date_mode="formatted")) == 30
def test_build_export_text_includes_header_and_row():
row = _minimal_row()
text = build_export_text([row], export_format=DodaExportFormat.csv, date_mode="formatted")
lines = text.strip().split("\r\n")
assert "SYSID" in lines[0]
assert "INT1" in lines[1]
assert lines[1].startswith("1,")

View File

@@ -0,0 +1,126 @@
from unittest.mock import patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from api.v1.modules.a76.general_catalogs.doda.fingerprint import build_doda_fingerprint
from api.v1.modules.a76.general_catalogs.doda.models import Doda
from api.v1.modules.a76.general_catalogs.doda import routes as doda_routes
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 core.security import get_current_user
from tests.conftest import allocate_ephemeral_tenant_company_ids, ensure_tenant_company
@pytest.mark.usefixtures("db_session")
def test_doda_fingerprint_changes_when_field_changes(db_session: Session):
tid, cid = allocate_ephemeral_tenant_company_ids(db_session)
ensure_tenant_company(db_session, tenant_id=tid, company_id=cid)
d = Doda(
tenant_id=tid,
company_id=cid,
integration_number="A1",
sat_digital_seal="x",
)
db_session.add(d)
db_session.commit()
fp1 = build_doda_fingerprint(db_session, d.id)
d.patent = "1234"
db_session.commit()
fp2 = build_doda_fingerprint(db_session, d.id)
assert fp1 != fp2
def _print_client(db_session: Session, test_tenant) -> TestClient:
app = FastAPI()
app.include_router(doda_routes.router, prefix="/doda")
def _override_get_db():
yield db_session
async def _user():
return {"sub": "test", "tenant_id": test_tenant.tenant_id}
app.dependency_overrides[get_core_db] = _override_get_db
app.dependency_overrides[get_current_user] = _user
return TestClient(app)
@patch.object(
doda_routes,
"validate_access_to_resource",
lambda db, company_id, current_user: int(current_user["tenant_id"]),
)
def test_print_uses_s3_cache_when_fingerprint_matches(
db_session: Session, test_tenant, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(settings, "S3_FILE_STORAGE", True, raising=False)
monkeypatch.setattr(settings, "CSV_IMPORT_STORAGE", "redis", raising=False)
tid, cid = test_tenant.tenant_id, test_tenant.company_id
ensure_tenant_company(db_session, tenant_id=tid, company_id=cid)
d = Doda(
tenant_id=tid,
company_id=cid,
integration_number="X",
sat_digital_seal="s",
)
db_session.add(d)
db_session.commit()
fp = build_doda_fingerprint(db_session, d.id)
d.doda_report_source_fingerprint = fp
d.doda_report_pdf_path = doda_report_pdf_key(tid, cid, d.id)
db_session.commit()
called = {"render": 0}
class _NoRender:
def build_pdf_for_doda(self, db, doda):
called["render"] += 1
raise AssertionError("should not render when S3 cache hits")
monkeypatch.setattr(doda_routes, "DodaReportPdfService", lambda: _NoRender())
monkeypatch.setattr(storage_s3, "object_exists", lambda key: True)
monkeypatch.setattr(storage_s3, "get_object_bytes", lambda key: b"%PDF-1.4 cached")
client = _print_client(db_session, test_tenant)
resp = client.get(f"/doda/{d.id}/print?company_id={cid}")
assert resp.status_code == 200
assert resp.content == b"%PDF-1.4 cached"
assert called["render"] == 0
@patch.object(
doda_routes,
"validate_access_to_resource",
lambda db, company_id, current_user: int(current_user["tenant_id"]),
)
def test_print_422_without_digital_seal(
db_session: Session, test_tenant, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(settings, "S3_FILE_STORAGE", True, raising=False)
tid, cid = test_tenant.tenant_id, test_tenant.company_id
ensure_tenant_company(db_session, tenant_id=tid, company_id=cid)
d = Doda(
tenant_id=tid, company_id=cid, integration_number="N", sat_digital_seal=" "
)
db_session.add(d)
db_session.commit()
put_calls = []
def _put(key, body, content_type="application/octet-stream"):
put_calls.append((key, body, content_type))
monkeypatch.setattr(storage_s3, "put_object_bytes", _put)
client = _print_client(db_session, test_tenant)
resp = client.get(f"/doda/{d.id}/print?company_id={cid}")
assert resp.status_code == 422
assert put_calls == []

View File

@@ -174,6 +174,9 @@ services:
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
- VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0}
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
@@ -242,6 +245,9 @@ services:
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}
@@ -277,6 +283,9 @@ services:
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}

View File

@@ -179,6 +179,9 @@ services:
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
- VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0}
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
@@ -304,6 +307,9 @@ services:
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}
@@ -343,6 +349,9 @@ services:
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
- COVE_FIEL_HASH_KEY=${COVE_FIEL_HASH_KEY}
- COVE_FIEL_HASH_IV=${COVE_FIEL_HASH_IV}
- COVE_API_URL=${COVE_API_URL:-https://api.vu.aduanasoft.com}
- DODA_API_BASE_URL=${DODA_API_BASE_URL}
- DODA_API_VERIFY_SSL=${DODA_API_VERIFY_SSL:-False}
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}

View File

@@ -135,6 +135,68 @@
"audit_logs_files_loading": "Loading files...",
"audit_logs_files_empty": "No files or folders found in this location.",
"audit_logs_files_download": "Download",
"despacho": {
"title": "Dispatch",
"digitalizacion": "Digitization",
"doda": "DODA"
},
"doda_alta": {
"title": "DODA",
"subtitle": "Customs Clearance Declaration",
"new": "New",
"refresh": "Refresh",
"table_title": "DODAs",
"col_integration_number": "Integration No.",
"col_patent": "Patent",
"col_status": "Status",
"col_dispatch_customs": "Dispatch Customs",
"col_operation_type": "Operation Type",
"col_actions": "Actions",
"action_alta_doda": "DODA Filing",
"action_alta_pita": "PITA Filing",
"action_edit": "Edit",
"action_delete": "Delete",
"action_new": "New DODA",
"progress_title": "Processing DODA filing...",
"progress_success": "DODA filing completed successfully.",
"progress_error": "Error in DODA filing.",
"eligibility_error": "DODA does not meet the requirements for filing.",
"eligibility_checking": "Checking eligibility...",
"empty": "No DODAs",
"loading": "Loading...",
"search_placeholder": "Search:",
"confirm_delete": "Are you sure you want to delete this DODA?",
"delete_success": "DODA deleted successfully",
"delete_error": "Error deleting DODA",
"delete_missing_company": "Select a company",
"delete_select_one": "Select exactly one DODA from the list",
"delete_not_found": "Could not locate the DODA. Select the row again and retry",
"filter_integration_number": "Integration No.",
"filter_patent": "Patent",
"filter_status": "Status",
"filter_operation_type": "Operation Type",
"action_generar": "Submit",
"action_export_excel": "Export to Excel",
"export_excel_title": "Export DODA list",
"export_excel_subtitle": "Filter by DODA date (stored as YYYYMMDD).",
"export_excel_badge": "DODA CATALOG",
"export_report_heading": "General report by date range",
"export_fecha_inicio": "Start date",
"export_fecha_final": "End date",
"export_julian_label": "Use Julian (numeric) date in Excel file.",
"export_report_generar": "Generate",
"export_date_from": "From",
"export_date_to": "To",
"export_format": "File format",
"export_date_mode": "Date/time in file",
"export_date_mode_formatted": "Formatted (DD/MM/YYYY and time)",
"export_date_mode_raw": "Numeric (raw YYYYMMDD)",
"export_download": "Download",
"export_cancel": "Close",
"export_excel_success": "File generated.",
"export_excel_error": "Could not generate the file.",
"export_excel_invalid_dates": "Enter from and to dates."
},
"digitalizacion": {
"title": "Digitization",
"subtitle": "Digitized Documents Catalog",

View File

@@ -135,6 +135,68 @@
"audit_logs_files_loading": "Cargando archivos...",
"audit_logs_files_empty": "No hay archivos o carpetas en esta ubicación.",
"audit_logs_files_download": "Descargar",
"despacho": {
"title": "Despacho",
"digitalizacion": "Digitalización",
"doda": "DODA"
},
"doda_alta": {
"title": "DODA",
"subtitle": "Declaración Operación Despacho Aduanero",
"new": "Nuevo",
"refresh": "Actualizar",
"table_title": "DODAs",
"col_integration_number": "No. Integración",
"col_patent": "Patente",
"col_status": "Estatus",
"col_dispatch_customs": "Aduana Despacho",
"col_operation_type": "Tipo Operación",
"col_actions": "Acciones",
"action_alta_doda": "Alta DODA",
"action_alta_pita": "Alta PITA",
"action_edit": "Editar",
"action_delete": "Borrar",
"action_new": "Nuevo DODA",
"progress_title": "Procesando alta DODA...",
"progress_success": "Alta DODA completada exitosamente.",
"progress_error": "Error en el alta DODA.",
"eligibility_error": "El DODA no cumple los requisitos para el alta.",
"eligibility_checking": "Verificando elegibilidad...",
"empty": "Sin DODAs",
"loading": "Cargando...",
"search_placeholder": "Buscar:",
"confirm_delete": "¿Está seguro de eliminar este DODA?",
"delete_success": "DODA eliminado correctamente",
"delete_error": "Error al eliminar DODA",
"delete_missing_company": "Selecciona una compañía",
"delete_select_one": "Selecciona un solo DODA en el listado",
"delete_not_found": "No se pudo localizar el DODA. Pulsa otra fila e inténtalo de nuevo",
"filter_integration_number": "No. Integración",
"filter_patent": "Patente",
"filter_status": "Estatus",
"filter_operation_type": "Tipo Operación",
"action_generar": "Generar",
"action_export_excel": "Exportar Excel",
"export_excel_title": "Exportar listado DODA",
"export_excel_subtitle": "Filtra por Fecha DODA (en base de datos como AAAAMMDD).",
"export_excel_badge": "CATÁLOGO DODA",
"export_report_heading": "Reporte general por rango de fechas",
"export_fecha_inicio": "Fecha inicio",
"export_fecha_final": "Fecha final",
"export_julian_label": "Imprimir Fecha Juliana en archivo Excel.",
"export_report_generar": "Generar",
"export_date_from": "Desde",
"export_date_to": "Hasta",
"export_format": "Formato de archivo",
"export_date_mode": "Fechas y hora en el archivo",
"export_date_mode_formatted": "Formateado (DD/MM/YYYY y hora)",
"export_date_mode_raw": "Numérico (YYYYMMDD / crudo)",
"export_download": "Descargar",
"export_cancel": "Cerrar",
"export_excel_success": "Archivo generado.",
"export_excel_error": "No se pudo generar el archivo.",
"export_excel_invalid_dates": "Indique fecha desde y hasta."
},
"digitalizacion": {
"title": "Digitalización",
"subtitle": "Catálogo de Documentos Digitalizados",

View File

@@ -0,0 +1,86 @@
import { api, type ApiResponse } from '$lib/api';
export interface DodaAltaLog {
id: number;
doda_id?: number | null;
variant?: string | null;
responsible?: string | null;
patent?: string | null;
dispatch_customs?: string | null;
operation_type?: string | null;
integration_number?: string | null;
task_id?: string | null;
status?: string | null;
message?: string | null;
result_json?: string | null;
company_id: number;
tenant_id: number;
created_at?: string | null;
updated_at?: string | null;
}
export interface DodaAltaLogListResponse {
items: DodaAltaLog[];
total: number;
page: number;
page_size: number;
}
export interface DodaAltaLogCreateDTO {
doda_id?: number | null;
variant?: string | null;
responsible?: string | null;
patent?: string | null;
dispatch_customs?: string | null;
operation_type?: string | null;
integration_number?: string | null;
task_id?: string | null;
status?: string | null;
message?: string | null;
result_json?: string | null;
}
export interface DodaAltaLogUpdateDTO {
status?: string | null;
message?: string | null;
result_json?: string | null;
}
export const dodaAltaLogApi = {
list(
companyId: number,
params?: {
page?: number;
page_size?: number;
doda_id?: number;
search?: string;
}
): Promise<ApiResponse<DodaAltaLogListResponse>> {
const qs = new URLSearchParams({ company_id: companyId.toString() });
if (params?.page) qs.set('page', params.page.toString());
if (params?.page_size) qs.set('page_size', params.page_size.toString());
if (params?.doda_id) qs.set('doda_id', params.doda_id.toString());
if (params?.search) qs.set('search', params.search);
return api.get<DodaAltaLogListResponse>(`/v1/a76/doda/alta-logs?${qs}`);
},
get(id: number, companyId: number): Promise<ApiResponse<DodaAltaLog>> {
return api.get<DodaAltaLog>(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`);
},
create(dto: DodaAltaLogCreateDTO, companyId: number): Promise<ApiResponse<DodaAltaLog>> {
return api.post<DodaAltaLog>(`/v1/a76/doda/alta-logs?company_id=${companyId}`, dto);
},
update(
id: number,
dto: DodaAltaLogUpdateDTO,
companyId: number
): Promise<ApiResponse<DodaAltaLog>> {
return api.put<DodaAltaLog>(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`, dto);
},
delete(id: number, companyId: number): Promise<ApiResponse<void>> {
return api.delete(`/v1/a76/doda/alta-logs/${id}?company_id=${companyId}`);
}
};

View File

@@ -187,20 +187,272 @@ export async function getDoda(id: number, companyId?: number): Promise<Doda> {
if (companyId) {
params.append('company_id', companyId.toString());
}
const response = await api.get(`/v1/a76/doda/${id}/detail?${params.toString()}`);
const response = await api.get<Doda>(`/v1/a76/doda/${id}/detail?${params.toString()}`);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al obtener el DODA');
}
return response.data;
}
export async function createDoda(data: DodaCreate, companyId: number): Promise<Doda> {
const response = await api.post(`/v1/a76/doda/?company_id=${companyId}`, data);
const response = await api.post<Doda>(`/v1/a76/doda/?company_id=${companyId}`, data);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al crear el DODA');
}
return response.data;
}
/** POST /v1/a76/doda/{dodaId}/containers — añade contenedor al DODA existente. */
export async function addDodaContainer(
dodaId: number,
body: DodaContainerCreate,
companyId: number
): Promise<DodaContainer> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.post<DodaContainer>(
`/v1/a76/doda/${dodaId}/containers?${params.toString()}`,
body
);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al agregar el contenedor');
}
return response.data;
}
export async function deleteDodaContainer(
dodaId: number,
containerLine: number,
companyId: number
): Promise<void> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.delete(
`/v1/a76/doda/${dodaId}/containers/${containerLine}?${params.toString()}`
);
if (response.error) {
throw new Error(response.error);
}
}
export async function updateDodaContainer(
dodaId: number,
containerLine: number,
body: DodaContainerCreate,
companyId: number
): Promise<DodaContainer> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.put<DodaContainer>(
`/v1/a76/doda/${dodaId}/containers/${containerLine}?${params.toString()}`,
body
);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al actualizar el contenedor');
}
return response.data;
}
/** POST /v1/a76/doda/{dodaId}/containers/{containerLine}/seals — añade precinto. */
export async function addDodaSeal(
dodaId: number,
containerLine: number,
sealValue: string,
companyId: number
): Promise<DodaContainerSeal> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.post<DodaContainerSeal>(
`/v1/a76/doda/${dodaId}/containers/${containerLine}/seals?${params.toString()}`,
{ seal_value: sealValue }
);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al agregar el precinto');
}
return response.data;
}
export async function deleteDodaSeal(
dodaId: number,
containerLine: number,
sealLine: number,
companyId: number
): Promise<void> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.delete(
`/v1/a76/doda/${dodaId}/containers/${containerLine}/seals/${sealLine}?${params.toString()}`
);
if (response.error) {
throw new Error(response.error);
}
}
/** POST /v1/a76/doda/{dodaId}/pedimentos — añade línea al DODA existente. */
export async function addDodaPedimento(
dodaId: number,
body: DodaPedimentoCreate,
companyId: number
): Promise<DodaPedimento> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.post<DodaPedimento>(
`/v1/a76/doda/${dodaId}/pedimentos?${params.toString()}`,
body
);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al guardar el pedimento');
}
return response.data;
}
/** POST /v1/a76/doda/{dodaId}/american-pedimentos */
export async function addDodaAmericanPedimento(
dodaId: number,
body: DodaAmericanPedimentoCreate,
companyId: number
): Promise<DodaAmericanPedimento> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.post<DodaAmericanPedimento>(
`/v1/a76/doda/${dodaId}/american-pedimentos?${params.toString()}`,
body
);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al guardar el pedimento americano');
}
return response.data;
}
export async function deleteDodaAmericanPedimento(
dodaId: number,
pedimentoLine: number,
companyId: number
): Promise<void> {
const params = new URLSearchParams({ company_id: companyId.toString() });
const response = await api.delete(
`/v1/a76/doda/${dodaId}/american-pedimentos/${pedimentoLine}?${params.toString()}`
);
if (response.error) {
throw new Error(response.error);
}
}
export async function updateDoda(id: number, data: DodaUpdate, companyId: number): Promise<Doda> {
const response = await api.put(`/v1/a76/doda/${id}/?company_id=${companyId}`, data);
const response = await api.put<Doda>(`/v1/a76/doda/${id}/?company_id=${companyId}`, data);
if (response.error || !response.data) {
throw new Error(response.error || 'Error al actualizar el DODA');
}
return response.data;
}
export async function deleteDoda(id: number, companyId: number): Promise<void> {
await api.delete(`/v1/a76/doda/${id}?company_id=${companyId}`);
const response = await api.delete(`/v1/a76/doda/${id}?company_id=${companyId}`);
if (response.error) {
throw new Error(response.error);
}
}
/**
* GET /v1/a76/doda/{id}/print — PDF (requiere sello digital SAT en backend)
*/
export async function printDoda(dodaId: number, companyId: number): Promise<void> {
const params = new URLSearchParams({ company_id: String(companyId) });
const blob = await api.getBlob(`/v1/a76/doda/${dodaId}/print?${params.toString()}`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener';
a.download = `doda_${dodaId}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export type DodaExportFileFormat = 'csv' | 'xls' | 'txt';
export type DodaExportDateMode = 'raw' | 'formatted';
/**
* GET /v1/a76/doda/export — listado por rango (Fecha DODA YYYYMMDD en BD)
*/
export async function exportDodaList(
companyId: number,
opts: {
dateFrom: string;
dateTo: string;
format: DodaExportFileFormat;
dateMode: DodaExportDateMode;
}
): Promise<void> {
const params = new URLSearchParams({
company_id: String(companyId),
date_from: opts.dateFrom,
date_to: opts.dateTo,
format: opts.format,
date_mode: opts.dateMode
});
const ext = opts.format;
const blob = await api.getBlob(`/v1/a76/doda/export?${params.toString()}`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `doda_export_${opts.dateFrom}_${opts.dateTo}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// ── Alta DODA API ─────────────────────────────────────────────────────────── //
export interface DodaAltaResponse {
task_id: string;
status: string;
message: string;
}
export interface DodaAltaStatusResponse {
state?: string;
status?: string;
message?: string;
result?: Record<string, unknown>;
error?: string;
}
export interface DodaElegibilidadReason {
field: string;
message: string;
solution?: string;
}
export interface DodaElegibilidadResponse {
can_alta: boolean;
reasons: DodaElegibilidadReason[];
}
export async function postDodaAlta(
dodaId: number,
companyId: number,
variant: 'doda' | 'pita' = 'doda'
): Promise<ApiResponse<DodaAltaResponse>> {
const params = new URLSearchParams({
company_id: companyId.toString(),
variant,
});
return api.post<DodaAltaResponse>(`/v1/a76/doda/${dodaId}/alta?${params}`, {});
}
export async function getDodaAltaStatus(
taskId: string
): Promise<ApiResponse<DodaAltaStatusResponse>> {
return api.get<DodaAltaStatusResponse>(`/v1/a76/doda/alta-status/${taskId}`);
}
export async function getDodaElegibilidad(
dodaId: number,
companyId: number,
variant: 'doda' | 'pita' = 'doda'
): Promise<ApiResponse<DodaElegibilidadResponse>> {
const params = new URLSearchParams({
company_id: companyId.toString(),
variant,
});
return api.get<DodaElegibilidadResponse>(
`/v1/a76/doda/${dodaId}/alta/elegibilidad?${params}`
);
}

View File

@@ -0,0 +1,119 @@
import type { DodaAltaLog } from '$lib/api/dashboard/a76/doda-alta-log';
import type { ColumnDef } from '@tanstack/table-core';
import { createRawSnippet } from 'svelte';
import { renderSnippet } from '$lib/components/ui/data-table';
function formatDateTime(raw?: string | null): string {
if (!raw) return '-';
try {
return new Date(raw).toLocaleString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return raw;
}
}
const STATUS_CLASSES: Record<string, string> = {
success: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
failure: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
processing: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
started: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
};
function statusBadge(status: string | null | undefined) {
const s = (status || '').toLowerCase();
const cls = STATUS_CLASSES[s] || 'bg-gray-100 text-gray-700';
return createRawSnippet(() => ({
render: () =>
`<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}">${status || '-'}</span>`
}));
}
export function createAltaLogColumns(): ColumnDef<DodaAltaLog>[] {
return [
{
accessorKey: 'id',
header: '#',
size: 60,
cell: ({ row }) => row.original.id
},
{
accessorKey: 'doda_id',
header: 'DODA ID',
size: 80,
cell: ({ row }) => row.original.doda_id ?? '-'
},
{
accessorKey: 'integration_number',
header: 'No. Integración',
cell: ({ row }) => row.original.integration_number || '-'
},
{
accessorKey: 'variant',
header: 'Tipo',
size: 70,
cell: ({ row }) => (row.original.variant || '-').toUpperCase()
},
{
accessorKey: 'patent',
header: 'Patente',
size: 80,
cell: ({ row }) => row.original.patent || '-'
},
{
accessorKey: 'dispatch_customs',
header: 'Aduana',
size: 80,
cell: ({ row }) => row.original.dispatch_customs || '-'
},
{
accessorKey: 'operation_type',
header: 'Operación',
size: 90,
cell: ({ row }) => row.original.operation_type || '-'
},
{
accessorKey: 'status',
header: 'Estatus',
size: 110,
cell: ({ row }) => renderSnippet(statusBadge(row.original.status), {})
},
{
accessorKey: 'task_id',
header: 'Task ID',
cell: ({ row }) => {
const id = row.original.task_id || '';
const snippet = createRawSnippet(() => ({
render: () =>
`<span class="font-mono text-xs truncate max-w-[180px] block" title="${id}">${id || '-'}</span>`
}));
return renderSnippet(snippet, {});
}
},
{
accessorKey: 'message',
header: 'Mensaje',
cell: ({ row }) => {
const msg = row.original.message || '';
const snippet = createRawSnippet(() => ({
render: () =>
`<span class="text-xs truncate max-w-[220px] block text-muted-foreground" title="${msg}">${msg || '-'}</span>`
}));
return renderSnippet(snippet, {});
}
},
{
accessorKey: 'created_at',
header: 'Fecha',
size: 130,
cell: ({ row }) => formatDateTime(row.original.created_at)
}
];
}

View File

@@ -0,0 +1,203 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { LoaderCircle } from 'lucide-svelte';
import {
dodaAltaLogApi,
type DodaAltaLog,
type DodaAltaLogUpdateDTO
} from '$lib/api/dashboard/a76/doda-alta-log';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: DodaAltaLog | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Detalle de Alta DODA' : 'Nuevo Registro de Alta DODA');
let formData = $state<DodaAltaLogUpdateDTO>({
status: null,
message: null,
result_json: null
});
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (!open) {
error = null;
loading = false;
return;
}
if (item) {
formData = {
status: item.status ?? null,
message: item.message ?? null,
result_json: item.result_json ?? null
};
} else {
formData = { status: null, message: null, result_json: null };
}
});
async function handleSubmit() {
if (loading || !item) return;
error = null;
loading = true;
try {
const company = companyStore.activeCompany;
if (!company) throw new Error('No hay una compañía seleccionada');
const res = await dodaAltaLogApi.update(item.id, formData, company.id);
if (res.error) throw new Error(res.error);
open = false;
onSuccess?.();
} catch (e) {
error = e instanceof Error ? e.message : 'Error desconocido';
} finally {
loading = false;
}
}
async function handleDelete() {
if (!item || !confirm('¿Está seguro de eliminar este registro de alta DODA?')) return;
const company = companyStore.activeCompany;
if (!company) return;
loading = true;
try {
await dodaAltaLogApi.delete(item.id, company.id);
open = false;
onSuccess?.();
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[700px]">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
{#if item}
<Dialog.Description>
Registro #{item.id} — DODA {item.doda_id ?? '-'}{(item.variant || 'doda').toUpperCase()}
</Dialog.Description>
{/if}
</Dialog.Header>
<div class="grid gap-6 py-4">
{#if error}
<div class="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<!-- Datos de solo lectura del DODA -->
{#if item}
<div class="grid grid-cols-2 gap-4 rounded-lg border bg-muted/30 p-4 text-sm">
<div>
<p class="text-xs text-muted-foreground">No. Integración</p>
<p class="font-medium">{item.integration_number || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Responsable</p>
<p class="font-medium">{item.responsible || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Patente</p>
<p class="font-medium">{item.patent || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Aduana Despacho</p>
<p class="font-medium">{item.dispatch_customs || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Tipo Operación</p>
<p class="font-medium">{item.operation_type || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Task ID</p>
<p class="font-mono text-xs break-all">{item.task_id || '-'}</p>
</div>
<div>
<p class="text-xs text-muted-foreground">Fecha Alta</p>
<p class="font-medium">
{item.created_at
? new Date(item.created_at).toLocaleString('es-MX')
: '-'}
</p>
</div>
</div>
{/if}
<!-- Campos editables -->
<div class="space-y-4">
<h4 class="text-sm font-medium text-muted-foreground leading-none">Estado</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="status">Estatus</Label>
<Input
id="status"
value={formData.status ?? ''}
oninput={(e) => (formData.status = (e.target as HTMLInputElement).value || null)}
placeholder="pending / success / failed"
disabled={loading}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="message">Mensaje</Label>
<Input
id="message"
value={formData.message ?? ''}
oninput={(e) => (formData.message = (e.target as HTMLInputElement).value || null)}
placeholder="Mensaje del servicio externo"
disabled={loading}
/>
</div>
{#if item?.result_json}
<div class="space-y-2 md:col-span-2">
<Label>Resultado JSON</Label>
<pre class="text-xs rounded-md border bg-muted p-3 overflow-auto max-h-48 whitespace-pre-wrap break-all">{(() => {
try { return JSON.stringify(JSON.parse(item.result_json || '{}'), null, 2); }
catch { return item.result_json || ''; }
})()}</pre>
</div>
{/if}
</div>
</div>
</div>
<Dialog.Footer class="flex justify-between gap-2 pt-2">
<Button
type="button"
variant="destructive"
onclick={handleDelete}
disabled={loading || !item}
>
Eliminar
</Button>
<div class="flex gap-2">
<Button type="button" variant="outline" onclick={() => (open = false)} disabled={loading}>
Cancelar
</Button>
{#if isEdit}
<Button type="button" onclick={handleSubmit} disabled={loading}>
{#if loading}<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />{/if}
Guardar
</Button>
{/if}
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Select from '$lib/components/ui/select';
import { Loader2, FileSpreadsheet } from 'lucide-svelte';
import { m } from '$lib/i18n/messages';
import { exportDodaList, type DodaExportFileFormat, type DodaExportDateMode } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { toast } from 'svelte-sonner';
let {
open = $bindable(false),
companyId
}: {
open: boolean;
companyId: number | undefined;
} = $props();
function isoFirstDayOfMonth(): string {
const d = new Date();
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
return `${y}-${mo}-01`;
}
function isoToday(): string {
const d = new Date();
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${mo}-${day}`;
}
let dateFrom = $state(isoFirstDayOfMonth());
let dateTo = $state(isoToday());
let fileFormat = $state<DodaExportFileFormat>('xls');
/** Alineado al legado: “Imprimir Fecha Juliana” = fechas/hora en archivo numérico (date_mode=raw). */
let printJulian = $state(false);
let busy = $state(false);
$effect(() => {
if (open) {
dateFrom = isoFirstDayOfMonth();
dateTo = isoToday();
fileFormat = 'xls';
printJulian = false;
}
});
function dateModeValue(): DodaExportDateMode {
return printJulian ? 'raw' : 'formatted';
}
async function download() {
if (!companyId) {
toast.error('Seleccione una compañía.');
return;
}
if (!dateFrom || !dateTo) {
toast.error(m['sidebar.doda_alta.export_excel_invalid_dates']());
return;
}
busy = true;
try {
await exportDodaList(companyId, {
dateFrom,
dateTo,
format: fileFormat,
dateMode: dateModeValue()
});
open = false;
toast.success(m['sidebar.doda_alta.export_excel_success']());
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
toast.error(msg || m['sidebar.doda_alta.export_excel_error']());
} finally {
busy = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="gap-0 overflow-hidden p-0 sm:max-w-md" showCloseButton={true}>
<Dialog.Header class="sr-only">
<Dialog.Title>{m['sidebar.doda_alta.export_excel_badge']()}{m['sidebar.doda_alta.export_report_heading']()}</Dialog.Title>
</Dialog.Header>
<div
class="bg-slate-900 py-2.5 pr-10 pl-4 text-center text-sm font-medium tracking-wide text-slate-50 dark:bg-slate-950"
>
{m['sidebar.doda_alta.export_excel_badge']()}
</div>
<div class="space-y-5 px-6 pb-1 pt-5">
<h2
class="text-foreground text-center text-sm font-semibold uppercase leading-snug tracking-wide sm:text-base"
>
{m['sidebar.doda_alta.export_report_heading']()}
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-3">
<div class="space-y-2">
<Label for="doda-exp-from" class="text-foreground/90 font-medium">{m['sidebar.doda_alta.export_fecha_inicio']()}</Label>
<input
id="doda-exp-from"
type="date"
bind:value={dateFrom}
class="border-input bg-background text-foreground flex h-9 w-full rounded-md border px-3 text-sm shadow-sm"
/>
</div>
<div class="space-y-2">
<Label for="doda-exp-to" class="text-foreground/90 font-medium">{m['sidebar.doda_alta.export_fecha_final']()}</Label>
<input
id="doda-exp-to"
type="date"
bind:value={dateTo}
class="border-input bg-background text-foreground flex h-9 w-full rounded-md border px-3 text-sm shadow-sm"
/>
</div>
</div>
<div class="flex flex-col items-center gap-1">
<Button
size="default"
class="w-full min-w-48"
variant="default"
onclick={download}
disabled={busy || !companyId}
>
{#if busy}
<Loader2 class="mr-2 h-4 w-4 shrink-0 animate-spin" />
{:else}
<FileSpreadsheet class="mr-2 h-4 w-4 shrink-0" />
{/if}
{m['sidebar.doda_alta.export_report_generar']()}
</Button>
</div>
<div class="flex items-start gap-2.5">
<Checkbox id="doda-exp-julian" bind:checked={printJulian} class="mt-0.5" />
<Label for="doda-exp-julian" class="text-muted-foreground text-sm font-normal leading-snug">
{m['sidebar.doda_alta.export_julian_label']()}
</Label>
</div>
<div class="border-t pt-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Label for="doda-exp-format" class="text-foreground/80 shrink-0 text-sm"
>{m['sidebar.doda_alta.export_format']()}</Label
>
<Select.Root
type="single"
value={fileFormat}
onValueChange={(v) => {
if (v === 'csv' || v === 'xls' || v === 'txt') fileFormat = v;
}}
>
<Select.Trigger id="doda-exp-format" class="w-full min-w-0 sm:max-w-[12rem]">
.{fileFormat}
</Select.Trigger>
<Select.Content>
<Select.Item value="csv">.csv (coma)</Select.Item>
<Select.Item value="xls">.xls (tabulador, Excel)</Select.Item>
<Select.Item value="txt">.txt (|)</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
</div>
<Dialog.Footer class="border-t px-6 py-4 sm:justify-end">
<Button type="button" variant="outline" class="min-w-24" onclick={() => (open = false)} disabled={busy}>
{m['sidebar.doda_alta.export_cancel']()}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Progress } from '$lib/components/ui/progress';
import { Loader2, CheckCircle, XCircle } from 'lucide-svelte';
import {
getDodaAltaStatus,
type DodaAltaStatusResponse
} from '$lib/api/dashboard/a76/general_catalogs/doda';
import { m } from '$lib/i18n/messages';
let {
open = $bindable(false),
taskId,
dodaId,
variant = 'doda',
onComplete,
onCancel
}: {
open: boolean;
taskId: string;
dodaId?: number;
variant?: 'doda' | 'pita';
onComplete?: (result: DodaAltaStatusResponse) => void;
onCancel?: () => void;
} = $props();
type TaskState = 'PENDING' | 'PROGRESS' | 'SUCCESS' | 'FAILURE' | 'STARTED';
let state = $state<TaskState>('PENDING');
let currentStep = $state<string>('Iniciando...');
let progress = $state(0);
let result = $state<DodaAltaStatusResponse | null>(null);
let errorMsg = $state<string | null>(null);
let consecutivePollErrors = $state(0);
let pollHandle: ReturnType<typeof setTimeout> | null = null;
let pollInFlight = false;
let pollingActive = false;
let pollingTaskId: string | null = null;
$effect(() => {
if (open && taskId) {
void startPolling();
} else {
stopPolling();
if (!open) resetState();
}
return () => stopPolling();
});
function resetState() {
state = 'PENDING';
currentStep = 'Iniciando...';
progress = 0;
result = null;
errorMsg = null;
consecutivePollErrors = 0;
}
async function startPolling() {
if (pollingActive && pollingTaskId === taskId) return;
stopPolling();
pollingActive = true;
pollingTaskId = taskId;
await poll();
}
function stopPolling() {
pollingActive = false;
pollingTaskId = null;
if (pollHandle !== null) {
clearTimeout(pollHandle);
pollHandle = null;
}
}
function scheduleNextPoll(delayMs = 2000) {
if (!pollingActive || !taskId) return;
if (pollHandle !== null) clearTimeout(pollHandle);
pollHandle = setTimeout(() => void poll(), delayMs);
}
async function poll() {
if (!taskId || !pollingActive || pollInFlight) return;
pollInFlight = true;
try {
const res = await getDodaAltaStatus(taskId);
if (res.error) {
consecutivePollErrors += 1;
if (consecutivePollErrors >= 3) {
state = 'FAILURE';
errorMsg = res.error || 'No se pudo consultar el estado del alta DODA';
stopPolling();
} else {
scheduleNextPoll();
}
return;
}
if (!res.data) {
scheduleNextPoll();
return;
}
const data = res.data;
consecutivePollErrors = 0;
const rawState = (data.state || data.status || 'PENDING').toUpperCase();
state = (rawState === 'FAILED' ? 'FAILURE' : rawState) as TaskState;
currentStep = (data.message as string) || 'Procesando...';
if (typeof (data as Record<string, unknown>)['progress'] === 'number') {
progress = (data as Record<string, unknown>)['progress'] as number;
}
if (rawState === 'SUCCESS') {
result = data;
stopPolling();
onComplete?.(data);
} else if (rawState === 'FAILURE' || rawState === 'FAILED') {
errorMsg = data.error || data.message || 'Error en el alta DODA';
stopPolling();
} else {
scheduleNextPoll();
}
} catch {
consecutivePollErrors += 1;
if (consecutivePollErrors >= 3) {
state = 'FAILURE';
errorMsg = 'No se pudo consultar el estado del alta DODA';
stopPolling();
} else {
scheduleNextPoll();
}
} finally {
pollInFlight = false;
}
}
function handleCancel() {
stopPolling();
open = false;
onCancel?.();
}
const isTerminal = $derived(state === 'SUCCESS' || state === 'FAILURE');
const variantLabel = $derived(variant === 'pita' ? 'PITA' : 'DODA');
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>{m['sidebar.doda_alta.progress_title']()}</Dialog.Title>
<Dialog.Description>Alta {variantLabel} — Task ID: {taskId}</Dialog.Description>
</Dialog.Header>
<div class="space-y-4 py-2">
{#if state === 'SUCCESS'}
<div class="flex items-center gap-3 text-green-600">
<CheckCircle class="h-6 w-6 shrink-0" />
<p class="text-sm font-medium">{m['sidebar.doda_alta.progress_success']()}</p>
</div>
{#if result}
<dl class="text-sm space-y-1">
{#each Object.entries(result) as [key, value]}
{#if key !== 'state' && value && typeof value === 'string'}
<div class="flex gap-2">
<dt class="text-muted-foreground min-w-[130px] capitalize">{key.replace(/_/g, ' ')}:</dt>
<dd class="font-medium break-all">{value}</dd>
</div>
{/if}
{/each}
</dl>
{/if}
{:else if state === 'FAILURE'}
<div class="flex items-start gap-3 text-destructive">
<XCircle class="h-6 w-6 shrink-0 mt-0.5" />
<div class="space-y-1">
<p class="text-sm font-medium">{m['sidebar.doda_alta.progress_error']()}</p>
{#if errorMsg}
<p class="text-xs text-muted-foreground">{errorMsg}</p>
{/if}
</div>
</div>
{:else}
<div class="space-y-3">
<div class="flex items-center gap-3">
<Loader2 class="h-5 w-5 animate-spin text-primary shrink-0" />
<p class="text-sm text-muted-foreground">{currentStep}</p>
</div>
{#if progress > 0}
<Progress value={progress} max={100} class="h-2" />
<p class="text-xs text-right text-muted-foreground">{progress}%</p>
{/if}
</div>
{/if}
{#if taskId}
<div class="rounded-md border bg-muted/30 px-3 py-2">
<p class="text-xs text-muted-foreground">Task ID:</p>
<p class="text-xs font-mono break-all">{taskId}</p>
</div>
{/if}
</div>
<Dialog.Footer class="flex justify-end">
{#if isTerminal}
<Button onclick={() => { open = false; }}>Cerrar</Button>
{:else}
<Button variant="outline" onclick={handleCancel}>Cancelar</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { Plus, Pencil, Trash2 } from 'lucide-svelte';
import { Plus, Pencil, Trash2, Inbox } from 'lucide-svelte';
import { cn } from '$lib/utils';
interface Column {
@@ -17,6 +17,10 @@
onAdd,
onEdit,
onDelete,
/** Se dispara al elegir una fila (click / Enter). Útil p. ej. para precintos por contenedor. */
onRowSelect,
/** p. ej. `max-h-48` para limitar altura y scroll interno en tablas amplias */
bodyMaxClass = '',
class: className = ''
}: {
title?: string;
@@ -25,43 +29,93 @@
onAdd?: () => void;
onEdit?: (item: any, index: number) => void;
onDelete?: (item: any, index: number) => void;
onRowSelect?: (item: any, index: number) => void;
bodyMaxClass?: string;
class?: string;
} = $props();
let selectedIndex = $state<number | null>(null);
$effect(() => {
if (data.length === 0) {
selectedIndex = null;
} else if (selectedIndex != null && selectedIndex >= data.length) {
selectedIndex = data.length - 1;
}
});
</script>
<div class={cn('space-y-4 rounded-xl border bg-card p-4 shadow-sm', className)}>
<div class="flex items-center justify-between">
<div
class={cn(
'space-y-0 overflow-hidden rounded-lg border border-border/60 bg-card/80 p-0 shadow-sm',
className
)}
>
<div class="flex items-center border-b border-border/50 bg-muted/25 px-3 py-1.5">
{#if title}
<h3 class="text-sm font-semibold tracking-wider text-muted-foreground uppercase">{title}</h3>
<h3 class="text-xs font-medium text-muted-foreground">{title}</h3>
{/if}
</div>
<div class="relative overflow-hidden rounded-md border bg-background">
<div
class={cn(
'relative overflow-x-auto border-b border-border/40 bg-background/50',
bodyMaxClass
)}
>
<Table.Root>
<Table.Header class="bg-muted/50">
<Table.Row inTabOrder={false}>
<Table.Header class="bg-muted/40">
<Table.Row inTabOrder={false} class="hover:bg-transparent">
{#each columns as col}
<Table.Head class="h-10 px-4 text-xs font-semibold whitespace-nowrap"
>{col.header}</Table.Head
<Table.Head
class="h-7 px-2 py-1.5 text-left text-[0.65rem] font-medium uppercase leading-tight text-muted-foreground sm:px-3 sm:text-[0.7rem]"
>
{col.header}
</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#if data.length === 0}
<Table.Row inTabOrder={false}>
<Table.Row inTabOrder={false} class="hover:bg-transparent">
<Table.Cell
colspan={columns.length}
class="h-24 text-center text-sm text-muted-foreground"
class="h-16 py-2 text-center"
>
No hay registros.
<div
class="flex flex-col items-center justify-center gap-1 text-muted-foreground"
>
<Inbox class="h-5 w-5 opacity-45" />
<span class="text-xs">Sin filas. «Nuevo» para añadir.</span>
</div>
</Table.Cell>
</Table.Row>
{:else}
{#each data as row, i}
<Table.Row inTabOrder={false} class="group transition-colors hover:bg-muted/30">
<Table.Row
inTabOrder={false}
role="button"
tabindex="0"
data-state={selectedIndex === i ? 'selected' : undefined}
class="group transition-colors hover:bg-muted/30 {selectedIndex === i
? 'bg-primary/5 ring-1 ring-inset ring-primary/25'
: ''}"
onclick={() => {
selectedIndex = i;
onRowSelect?.(row, i);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectedIndex = i;
onRowSelect?.(row, i);
}
}}
>
{#each columns as col}
<Table.Cell class="px-4 py-2 text-sm whitespace-nowrap">
<Table.Cell
class="max-w-[12rem] truncate px-2 py-1.5 text-xs sm:max-w-[14rem] sm:px-3 sm:text-sm"
>
{#if col.render}
{col.render(row[col.key])}
{:else}
@@ -76,17 +130,21 @@
</Table.Root>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onclick={onAdd} class="h-8 gap-1 px-3 text-xs font-medium">
<div class="flex flex-wrap items-center justify-end gap-2 border-t border-border/30 bg-muted/20 px-3 py-2 sm:px-4">
<Button variant="outline" size="sm" onclick={onAdd} class="h-8 gap-1.5 px-3 text-xs font-medium">
<Plus class="h-3.5 w-3.5" />
Nuevo
</Button>
<Button
variant="secondary"
size="sm"
onclick={() => {}}
onclick={() => {
if (selectedIndex == null) return;
const row = data[selectedIndex];
if (row) onEdit?.(row, selectedIndex);
}}
class="h-8 gap-1 px-3 text-xs font-medium"
disabled={data.length === 0}
disabled={data.length === 0 || selectedIndex == null}
>
<Pencil class="h-3.5 w-3.5" />
Editar
@@ -94,9 +152,13 @@
<Button
variant="destructive"
size="sm"
onclick={() => {}}
onclick={() => {
if (selectedIndex == null) return;
const row = data[selectedIndex];
if (row) onDelete?.(row, selectedIndex);
}}
class="h-8 gap-1 px-3 text-xs font-medium"
disabled={data.length === 0}
disabled={data.length === 0 || selectedIndex == null}
>
<Trash2 class="h-3.5 w-3.5" />
Borrar

View File

@@ -3,120 +3,153 @@ import type { Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { renderSnippet } from '$lib/components/ui/data-table';
import { createRawSnippet } from 'svelte';
function formatDate(date?: string | null): string {
if (!date) return '-';
// Supposing created_at is an ISO string or similar
/**
* doda_date se almacena como Integer con formato YYYYMMDD (ej. 20180409).
* Lo convertimos a DD/MM/YYYY para mostrar.
*/
function formatDodaDate(val?: number | string | null): string {
if (!val) return '-';
const s = String(val);
if (s.length === 8) {
const y = s.slice(0, 4);
const m = s.slice(4, 6);
const d = s.slice(6, 8);
return `${d}/${m}/${y}`;
}
// Fallback: ISO string
try {
return new Date(date).toLocaleDateString('es-MX', {
year: 'numeric',
return new Date(s).toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
day: '2-digit'
year: 'numeric'
});
} catch (e) {
return date;
} catch {
return s;
}
}
const STATUS_CLASSES: Record<string, string> = {
GENERADO: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
'EN PROCESO':'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
VALIDADO: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
PENDIENTE: 'bg-gray-100 text-gray-700 dark:bg-gray-800/50 dark:text-gray-300',
ELIMINADO: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
};
export function createColumns(): ColumnDef<Doda>[] {
return [
{
accessorKey: 'id',
header: 'Folio',
size: 70,
cell: ({ row }) => {
const numberSnippet = createRawSnippet<[{ number: number }]>((getProps) => {
const { number } = getProps();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${number}</code>`
};
});
return renderSnippet(numberSnippet, { number: row.original.id });
const n = row.original.id;
const s = createRawSnippet(() => ({
render: () =>
`<code class="rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${n}</code>`
}));
return renderSnippet(s, {});
}
},
{
accessorKey: 'created_at',
header: 'Fecha doda',
accessorKey: 'doda_date',
header: 'Fecha Doda',
size: 100,
cell: ({ row }) => {
const dateSnippet = createRawSnippet<[{ date: string }]>((getProps) => {
const { date } = getProps();
return {
render: () =>
`<div class="text-sm text-muted-foreground">${date}</div>`
};
});
return renderSnippet(dateSnippet, { date: formatDate(row.original.created_at) });
const d = formatDodaDate(row.original.doda_date);
const s = createRawSnippet(() => ({
render: () => `<span class="text-sm tabular-nums">${d}</span>`
}));
return renderSnippet(s, {});
}
},
{
accessorKey: 'dispatch_customs',
header: 'Desp',
cell: ({ row }) => row.original.dispatch_customs || 'N/A'
header: 'Desp.',
size: 60,
cell: ({ row }) => row.original.dispatch_customs || '-'
},
{
accessorKey: 'patent',
header: 'Patente',
cell: ({ row }) => row.original.patent || 'N/A'
size: 70,
cell: ({ row }) => row.original.patent || '-'
},
{
accessorKey: 'pedimentos',
header: 'Pedimento(s)',
cell: ({ row }) => row.original.pedimentos || 'N/A'
cell: ({ row }) => {
const v = row.original.pedimentos || '-';
const s = createRawSnippet(() => ({
render: () =>
`<span class="block truncate max-w-[160px]" title="${v}">${v}</span>`
}));
return renderSnippet(s, {});
}
},
{
accessorKey: 'shipments',
header: 'Remesa(s)',
cell: ({ row }) => row.original.shipments || 'N/A'
size: 90,
cell: ({ row }) => row.original.shipments || '-'
},
{
accessorKey: 'integration_number',
header: 'Integracion',
header: 'Integración',
size: 110,
cell: ({ row }) => {
const numberSnippet = createRawSnippet<[{ number?: string | null }]>((getProps) => {
const { number } = getProps();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${number || 'N/A'}</code>`
};
});
return renderSnippet(numberSnippet, { number: row.original.integration_number });
const v = row.original.integration_number;
const s = createRawSnippet(() => ({
render: () =>
v
? `<code class="rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${v}</code>`
: `<span class="text-muted-foreground">-</span>`
}));
return renderSnippet(s, {});
}
},
{
accessorKey: 'transaction_number',
header: 'No transaccion',
cell: ({ row }) => row.original.transaction_number || 'N/A'
header: 'No. Transacción',
cell: ({ row }) => {
const v = row.original.transaction_number || '-';
const s = createRawSnippet(() => ({
render: () =>
`<span class="font-mono text-xs block truncate max-w-[160px]" title="${v}">${v}</span>`
}));
return renderSnippet(s, {});
}
},
{
accessorKey: 'transport_identification',
header: 'Id transporte',
cell: ({ row }) => row.original.transport_identification || 'N/A'
header: 'Id. Transporte',
size: 120,
cell: ({ row }) => row.original.transport_identification || '-'
},
{
accessorKey: 'caat',
header: 'CAAT',
cell: ({ row }) => row.original.caat || 'N/A'
size: 70,
cell: ({ row }) => row.original.caat || '-'
},
{
accessorKey: 'last_user',
header: 'Usuario',
cell: ({ row }) => row.original.last_user || 'N/A'
size: 90,
cell: ({ row }) => row.original.last_user || '-'
},
{
accessorKey: 'status',
header: 'Estatus',
size: 110,
cell: ({ row }) => {
const status = row.original.status;
const statusSnippet = createRawSnippet<[{ status?: string | null }]>((getProps) => {
const { status } = getProps();
const colorClass = status === 'VALIDADO' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
return {
render: () =>
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">
${status || '-'}
</span>`
};
});
return renderSnippet(statusSnippet, { status });
const status = (row.original.status || '').toUpperCase();
const cls = STATUS_CLASSES[status] ?? 'bg-gray-100 text-gray-700 dark:bg-gray-800/50 dark:text-gray-300';
const s = createRawSnippet(() => ({
render: () =>
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap ${cls}">${status || '-'}</span>`
}));
return renderSnippet(s, {});
}
}
];

View File

@@ -1,392 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Switch } from '$lib/components/ui/switch';
import * as Tabs from '$lib/components/ui/tabs';
import { companyStore } from '$lib/stores/company.svelte';
import { createDoda, updateDoda, type Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { obtenerAtajosFormularioDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/edit';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: Doda | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(!!item);
const title = $derived(isEdit ? `Editar DODA ${item?.integration_number || ''}` : 'Nuevo DODA');
// Estado del formulario
let activeTab = $state('general');
let formData = $state({
integration_number: '',
doda_date: undefined as number | undefined,
doda_time: undefined as number | undefined,
dispatch_customs: '',
customs_sections: '',
patent: '',
pedimentos: '',
caat: '',
transport_identification: '',
fast_id: '',
operation_type: '',
selected: false,
user_selected: '',
last_user: '',
responsible: '',
carrier: '',
shipments: '',
pedimento_type: '',
original_chain: '',
serial_number: '',
electronic_signature: '',
transaction_number: '',
status: '',
linq_sat_qr: '',
sat_certificate: '',
sat_digital_seal: '',
xml_doda_sent_path: '',
xml_doda_response_path: '',
sat_original_chain: '',
customs_clearance: undefined as number | undefined,
unique_badge_number: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
// Atajos
// Cargar datos
$effect(() => {
if (open) {
activeTab = 'general';
if (item) {
formData = {
integration_number: item.integration_number || '',
doda_date: item.doda_date,
doda_time: item.doda_time,
dispatch_customs: item.dispatch_customs || '',
customs_sections: item.customs_sections || '',
patent: item.patent || '',
pedimentos: item.pedimentos || '',
caat: item.caat || '',
transport_identification: item.transport_identification || '',
fast_id: item.fast_id || '',
operation_type: item.operation_type || '',
selected: item.selected || false,
user_selected: item.user_selected || '',
last_user: item.last_user || '',
responsible: item.responsible || '',
carrier: item.carrier || '',
shipments: item.shipments || '',
pedimento_type: item.pedimento_type || '',
original_chain: item.original_chain || '',
serial_number: item.serial_number || '',
electronic_signature: item.electronic_signature || '',
transaction_number: item.transaction_number || '',
status: item.status || '',
linq_sat_qr: item.linq_sat_qr || '',
sat_certificate: item.sat_certificate || '',
sat_digital_seal: item.sat_digital_seal || '',
xml_doda_sent_path: item.xml_doda_sent_path || '',
xml_doda_response_path: item.xml_doda_response_path || '',
sat_original_chain: item.sat_original_chain || '',
customs_clearance: item.customs_clearance,
unique_badge_number: item.unique_badge_number || ''
};
} else {
// Reset
formData = {
integration_number: '',
doda_date: undefined,
doda_time: undefined,
dispatch_customs: '',
customs_sections: '',
patent: '',
pedimentos: '',
caat: '',
transport_identification: '',
fast_id: '',
operation_type: '',
selected: false,
user_selected: '',
last_user: '',
responsible: '',
carrier: '',
shipments: '',
pedimento_type: '',
original_chain: '',
serial_number: '',
electronic_signature: '',
transaction_number: '',
status: '',
linq_sat_qr: '',
sat_certificate: '',
sat_digital_seal: '',
xml_doda_sent_path: '',
xml_doda_response_path: '',
sat_original_chain: '',
customs_clearance: undefined,
unique_badge_number: ''
};
}
error = null;
}
});
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'No hay compañía seleccionada';
return;
}
loading = true;
error = null;
try {
const idToUpdate = item?.id;
if (isEdit && idToUpdate) {
await updateDoda(idToUpdate, formData, companyId);
} else {
await createDoda(formData, companyId);
}
open = false;
if (onSuccess) onSuccess();
} catch (e) {
console.error(e);
error = e instanceof Error ? e.message : 'Error al guardar DODA';
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[900px] max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="py-4"
>
{#if error}
<div class="mb-4 rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<Tabs.Root bind:value={activeTab} class="w-full">
<Tabs.List class="grid w-full grid-cols-4">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="transport">Aduana/Transp.</Tabs.Trigger>
<Tabs.Trigger value="sat">SAT / Digital</Tabs.Trigger>
<Tabs.Trigger value="other">Otros</Tabs.Trigger>
</Tabs.List>
<!-- TAB: GENERAL -->
<Tabs.Content value="general" class="space-y-4 py-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="integration_number">No. Integración</Label>
<Input
id="integration_number"
bind:value={formData.integration_number}
maxlength={30}
/>
</div>
<div class="grid gap-2">
<Label for="status">Estatus</Label>
<Input id="status" bind:value={formData.status} maxlength={30} />
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="grid gap-2">
<Label for="doda_date">Fecha (YYYYMMDD)</Label>
<Input type="number" id="doda_date" bind:value={formData.doda_date} />
</div>
<div class="grid gap-2">
<Label for="doda_time">Hora (HHMMSS)</Label>
<Input type="number" id="doda_time" bind:value={formData.doda_time} />
</div>
<div class="grid gap-2">
<Label for="operation_type">Tipo Operación</Label>
<Input id="operation_type" bind:value={formData.operation_type} maxlength={1} />
</div>
</div>
<div class="grid gap-2">
<Label for="pedimentos">Pedimentos</Label>
<Input id="pedimentos" bind:value={formData.pedimentos} maxlength={80} />
</div>
<div class="grid gap-2">
<Label for="pedimento_type">Tipo Pedimento</Label>
<Input id="pedimento_type" bind:value={formData.pedimento_type} maxlength={30} />
</div>
</Tabs.Content>
<!-- TAB: ADUANA / TRANSPORTE -->
<Tabs.Content value="transport" class="space-y-4 py-4">
<div class="grid grid-cols-3 gap-4">
<div class="grid gap-2">
<Label for="patent">Patente</Label>
<Input id="patent" bind:value={formData.patent} maxlength={4} />
</div>
<div class="grid gap-2">
<Label for="dispatch_customs">Aduana Despacho</Label>
<Input id="dispatch_customs" bind:value={formData.dispatch_customs} maxlength={3} />
</div>
<div class="grid gap-2">
<Label for="customs_sections">Sección Aduanera</Label>
<Input id="customs_sections" bind:value={formData.customs_sections} maxlength={3} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="caat">CAAT</Label>
<Input id="caat" bind:value={formData.caat} maxlength={10} />
</div>
<div class="grid gap-2">
<Label for="carrier">Transportista (Carrier)</Label>
<Input id="carrier" bind:value={formData.carrier} maxlength={8} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="transport_identification">Ident. Transporte</Label>
<Input
id="transport_identification"
bind:value={formData.transport_identification}
maxlength={20}
/>
</div>
<div class="grid gap-2">
<Label for="fast_id">FAST ID</Label>
<Input id="fast_id" bind:value={formData.fast_id} maxlength={20} />
</div>
</div>
<div class="grid gap-2">
<Label for="shipments">Embarques (Shipments)</Label>
<Input id="shipments" bind:value={formData.shipments} maxlength={80} />
</div>
<div class="grid gap-2">
<Label for="customs_clearance">Despacho Aduanero (ID)</Label>
<Input type="number" id="customs_clearance" bind:value={formData.customs_clearance} />
</div>
</Tabs.Content>
<!-- TAB: SAT / DIGITAL -->
<Tabs.Content value="sat" class="space-y-4 py-4">
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="serial_number">Número de Serie</Label>
<Input id="serial_number" bind:value={formData.serial_number} maxlength={21} />
</div>
<div class="grid gap-2">
<Label for="transaction_number">No. Transacción</Label>
<Input
id="transaction_number"
bind:value={formData.transaction_number}
maxlength={30}
/>
</div>
</div>
<div class="grid gap-2">
<Label for="unique_badge_number">Número Único de Gafete</Label>
<Input
id="unique_badge_number"
bind:value={formData.unique_badge_number}
maxlength={250}
/>
</div>
<div class="grid gap-2">
<Label for="original_chain">Cadena Original</Label>
<Textarea id="original_chain" bind:value={formData.original_chain} class="h-20" />
</div>
<div class="grid gap-2">
<Label for="electronic_signature">Firma Electrónica</Label>
<Textarea
id="electronic_signature"
bind:value={formData.electronic_signature}
class="h-20"
/>
</div>
<div class="grid gap-2">
<Label for="sat_digital_seal">Sello Digital SAT</Label>
<Textarea id="sat_digital_seal" bind:value={formData.sat_digital_seal} class="h-20" />
</div>
<div class="grid gap-2">
<Label for="sat_original_chain">Cadena Original SAT</Label>
<Textarea
id="sat_original_chain"
bind:value={formData.sat_original_chain}
class="h-20"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="xml_doda_sent_path">Ruta XML Enviado</Label>
<Input id="xml_doda_sent_path" bind:value={formData.xml_doda_sent_path} />
</div>
<div class="grid gap-2">
<Label for="xml_doda_response_path">Ruta XML Respuesta</Label>
<Input id="xml_doda_response_path" bind:value={formData.xml_doda_response_path} />
</div>
</div>
</Tabs.Content>
<!-- TAB: OTROS -->
<Tabs.Content value="other" class="space-y-4 py-4">
<div class="flex items-center space-x-2">
<Switch id="selected" bind:checked={formData.selected} />
<Label for="selected">Seleccionado</Label>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="user_selected">Usuario Selección</Label>
<Input id="user_selected" bind:value={formData.user_selected} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="last_user">Último Usuario</Label>
<Input id="last_user" bind:value={formData.last_user} maxlength={30} />
</div>
</div>
<div class="grid gap-2">
<Label for="responsible">Responsable</Label>
<Input id="responsible" bind:value={formData.responsible} maxlength={14} />
</div>
<div class="grid gap-2">
<Label for="linq_sat_qr">LINQ SAT QR</Label>
<Input id="linq_sat_qr" bind:value={formData.linq_sat_qr} maxlength={1000} />
</div>
<div class="grid gap-2">
<Label for="sat_certificate">Certificado SAT</Label>
<Input id="sat_certificate" bind:value={formData.sat_certificate} maxlength={2001} />
</div>
</Tabs.Content>
</Tabs.Root>
<Dialog.Footer class="mt-6">
<Button type="button" variant="outline" onclick={() => (open = false)}>Cancelar</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : 'Guardar'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -5,8 +5,8 @@
import type { Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { deleteDoda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import { companyStore } from '$lib/stores/company.svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
let {
item,
@@ -17,9 +17,6 @@
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<Doda | null>(null);
async function handleDelete() {
if (!confirm('¿Está seguro de eliminar este registro DODA?')) {
@@ -28,6 +25,7 @@
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
toast.error('Selecciona una compañía');
return;
}
@@ -35,19 +33,15 @@
try {
await deleteDoda(item.id, companyId);
toast.success('DODA eliminado correctamente');
if (onSuccess) onSuccess();
} catch (err: any) {
error = err.message || 'Error al eliminar el registro';
console.error('Error deleting doda:', err);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Error al eliminar DODA';
toast.error(msg);
} finally {
loading = false;
}
}
function handleEdit() {
selectedItem = item;
dialogOpen = true;
}
</script>
<DropdownMenu.Root>
@@ -61,7 +55,7 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Item onclick={() => goto(`/dashboard/general_catalogs/doda/edit/${item.id}`)}>
<DropdownMenu.Item onclick={() => goto(`/dashboard/general_catalogs/doda?doda_id=${item.id}`)}>
<Pencil class="mr-2 h-4 w-4" />
Editar
</DropdownMenu.Item>
@@ -76,8 +70,3 @@
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog
bind:open={dialogOpen}
item={selectedItem}
onSuccess={onSuccess}
/>

View File

@@ -0,0 +1,12 @@
type WithId = { id: number | string };
export function applyOptimisticDelete<T extends WithId>(
items: T[],
total: number,
deletedId: number | string
): { items: T[]; total: number } {
return {
items: items.filter((item) => String(item.id) !== String(deletedId)),
total: Math.max(0, total - 1)
};
}

View File

@@ -0,0 +1,102 @@
import type { Pedimento } from '$lib/api/dashboard/a76/pedimentos';
import type { DodaPedimento, DodaPedimentoCreate } from '$lib/api/dashboard/a76/general_catalogs/doda';
export type PedimentoDetailRow = DodaPedimentoCreate & { id?: number; pedimento_line?: number };
/**
* Misma convención que digitalización: AA-Patente-Pedimento
*/
export function buildPedimentoLabel(pedimento: Pedimento): string {
return `${pedimento.customs_office?.slice(0, 2) || ''}-${pedimento.license || ''}-${pedimento.pedimento_number || ''}`.replace(
/^-+|-+$/g,
''
);
}
export function yyyymmddTodayInt(): number {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return Number(`${y}${m}${day}`);
}
/**
* Sincroniza el campo `shipments` a partir de remesas en detalle (coma-separado).
*/
export function syncShipmentsFromPedimentos(
detail: Array<Pick<DodaPedimento | PedimentoDetailRow, 'shipment'>>
): string {
const parts: string[] = [];
for (const p of detail) {
const s = (p.shipment || '').toString().trim();
if (s && !parts.includes(s)) parts.push(s);
}
return parts.join(', ');
}
/**
* Une etiquetas y números de documento al campo legado `pedimentos`.
*/
export function appendPedimentosString(
current: string | undefined,
segment: string
): string {
const next = (segment || '').trim();
if (!next) return current || '';
const cur = (current || '').trim();
if (!cur) return next;
if (cur.split(/[;,]/).map((s) => s.trim()).includes(next)) return cur;
return `${cur}; ${next}`;
}
export function pedimentoRowFromCatalog(
p: Pedimento,
authorizationPatent: string
): { row: DodaPedimentoCreate; label: string } {
const document =
(p.pedimento_number && String(p.pedimento_number)) ||
buildPedimentoLabel(p) ||
'';
return {
row: {
authorization_patent: (authorizationPatent || p.license || '').trim() || undefined,
document: document
},
label: buildPedimentoLabel(p)
};
}
/** `customs_clearance === 1` = PITA (legacy DespachoAduanero '3'): no aplica catálogo de tipo americano. */
export function isPitaCustomsClearance(customsClearance: number | undefined | null): boolean {
return customsClearance === 1;
}
/**
* Valida `american_pedimento_type` frente a `operation_type` (legacy Clarion).
* Importación: tipos 15. Exportación: tipos 68.
*/
export function validateAmericanPedimentoTipo(
operationType: string | undefined,
tipo: string | undefined
): string | null {
const t = (tipo || '').trim();
if (!t) return 'El tipo de pedimento americano es obligatorio.';
const op = (operationType || '').trim().toUpperCase();
const isImport = op === 'I' || op === '1';
const isExport = op === 'E' || op === '2';
if (isImport) {
if (!['1', '2', '3', '4', '5'].includes(t)) {
return 'El tipo de pedimento americano no es correcto para importación (debe ser 1, 2, 3, 4 o 5).';
}
} else if (isExport) {
if (!['6', '7', '8'].includes(t)) {
return 'El tipo de pedimento americano no es correcto para exportación (debe ser 6, 7 u 8).';
}
} else {
return 'Define el tipo de operación (I/E) antes de validar el pedimento americano.';
}
return null;
}

View File

@@ -13,6 +13,7 @@ import {
Hash,
LayoutDashboard,
Package,
PackageCheck,
Settings2,
Shield,
Ship,
@@ -283,10 +284,6 @@ export function getSidebarData(): SidebarData {
title: m["sidebar.general_catalogs.customs_warehouses"](),
url: "/dashboard/reference_data/customs_warehouses",
},
{
title: m["sidebar.general_catalogs.doda"](),
url: "/dashboard/general_catalogs/doda",
},
{
title: m["sidebar.general_catalogs.prevalidators"](),
url: "/dashboard/general_catalogs/prevalidators",
@@ -519,10 +516,19 @@ export function getSidebarData(): SidebarData {
],
},
{
title: m["sidebar.digitalizacion.title"](),
url: "/dashboard/digitalizacion",
icon: FolderArchive,
items: [],
title: m["sidebar.despacho.title"](),
url: "#",
icon: PackageCheck,
items: [
{
title: m["sidebar.despacho.digitalizacion"](),
url: "/dashboard/digitalizacion",
},
{
title: m["sidebar.despacho.doda"](),
url: "/dashboard/despacho/doda",
},
],
},
{
title: m["sidebar.reference_data.configuracion"](),

View File

@@ -1,22 +1,46 @@
import type { ShortcutDef } from '$lib/stores/shortcut-store';
export const obtenerAtajosFormularioDoda = (acciones: {
cambiarPestana: (pestana: string) => void;
manejarGuardar: () => void;
manejarCerrar: () => void;
cambiarPestana: (pestana: string) => void;
manejarGuardar: () => void;
manejarCerrar: () => void;
}): ShortcutDef[] => [
{ key: 'Alt+Digit1', description: 'Pestaña General', action: () => acciones.cambiarPestana('general') },
{ key: 'Alt+Digit2', description: 'Pestaña Aduana/Transp.', action: () => acciones.cambiarPestana('transport') },
{ key: 'Alt+Digit3', description: 'Pestaña SAT / Digital', action: () => acciones.cambiarPestana('sat') },
{ key: 'Alt+Digit4', description: 'Pestaña Otros', action: () => acciones.cambiarPestana('other') },
{
key: 'Ctrl+S',
description: 'Guardar DODA',
action: () => acciones.manejarGuardar()
},
{
key: 'Escape',
description: 'Cerrar / Cancelar',
action: acciones.manejarCerrar
}
];
{ key: 'Alt+Digit1', description: 'Pestaña General', action: () => acciones.cambiarPestana('general') },
{ key: 'Alt+Digit2', description: 'Pestaña Aduana/Transp.', action: () => acciones.cambiarPestana('transport') },
{ key: 'Alt+Digit3', description: 'Pestaña SAT / Digital', action: () => acciones.cambiarPestana('sat') },
{ key: 'Alt+Digit4', description: 'Pestaña Otros', action: () => acciones.cambiarPestana('other') },
{
key: 'Ctrl+S',
description: 'Guardar DODA',
action: () => acciones.manejarGuardar()
},
{
key: 'Escape',
description: 'Cerrar / Cancelar',
action: acciones.manejarCerrar
}
];
/**
* Atajos específicos para el formulario completo en pantalla
* (modal de la lista con `?doda_id=`), donde solo existen
* las pestañas General y Sellos.
*/
export const obtenerAtajosFormularioDodaPagina = (acciones: {
cambiarPestana: (pestana: string) => void;
manejarGuardar: () => void;
manejarCerrar: () => void;
}): ShortcutDef[] => [
{ key: 'Alt+Digit1', description: 'Pestaña General', action: () => acciones.cambiarPestana('general') },
{ key: 'Alt+Digit2', description: 'Pestaña Sellos', action: () => acciones.cambiarPestana('sellos') },
{
key: 'Ctrl+S',
description: 'Guardar DODA',
action: () => acciones.manejarGuardar()
},
{
key: 'Escape',
description: 'Cerrar / Cancelar',
action: acciones.manejarCerrar
}
];

View File

@@ -0,0 +1,68 @@
import type { PageServerLoad } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
const parentData = await parent();
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
return {
error: 'No authenticated',
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 }
};
}
try {
const page = Number(url.searchParams.get('page')) || 1;
const pageSize = Number(url.searchParams.get('pageSize')) || 50;
const cookieCompanyId = cookies.get('active_company_id');
const companyId = cookieCompanyId
? parseInt(cookieCompanyId)
: parentData.companies?.[0]?.id;
if (!companyId) {
return {
error: 'No company selected',
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 }
};
}
const filters: Record<string, string> = {};
const integrationNumber = url.searchParams.get('integration_number');
const patent = url.searchParams.get('patent');
const status = url.searchParams.get('status');
const operationType = url.searchParams.get('operation_type');
if (integrationNumber) filters.integration_number = integrationNumber;
if (patent) filters.patent = patent;
if (status) filters.status = status;
if (operationType) filters.operation_type = operationType;
const queryParams = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
company_id: companyId.toString(),
...filters
});
const response = await authenticatedFetch(
`v1/a76/doda?${queryParams.toString()}`,
{ method: 'GET' },
cookies,
fetch
);
if (!response.ok) {
return {
error: 'Failed to load',
dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 }
};
}
return { dodas: await response.json() };
} catch (error) {
console.error('Error loading DODAs:', error);
return { error: 'Error loading', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } };
}
};

View File

@@ -0,0 +1,374 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import { m } from '$lib/i18n/messages';
import {
Plus,
RefreshCw,
Pencil,
Trash2,
Search,
RotateCcw,
Send,
Loader2,
FileSpreadsheet
} from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { companyStore } from '$lib/stores/company.svelte';
import { isPitaCustomsClearance } from '$lib/components/dashboard/general_catalogs/doda/doda-form-helpers';
import DataTable from '$lib/components/dashboard/general_catalogs/doda/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/general_catalogs/doda/columns';
import DodaProgressDialog from '$lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte';
import DodaExportExcelDialog from '$lib/components/dashboard/despacho/doda/doda-export-excel-dialog.svelte';
import { applyOptimisticDelete } from '$lib/components/dashboard/general_catalogs/doda/delete-list-state';
import {
getDodas,
deleteDoda,
postDodaAlta,
getDodaElegibilidad,
type Doda
} from '$lib/api/dashboard/a76/general_catalogs/doda';
let { data } = $props();
let allDodas = $state<Doda[]>(data.dodas?.items || []);
let dodaPage = $state(data.dodas?.page || 1);
let dodaPageSize = $state(50);
let dodaTotal = $state(data.dodas?.total || 0);
let dodaLoading = $state(false);
let dodaHasMore = $derived(allDodas.length < dodaTotal);
let filters = $state({
integration_number: $page.url.searchParams.get('integration_number') || '',
patent: $page.url.searchParams.get('patent') || '',
status: $page.url.searchParams.get('status') || '',
operation_type: $page.url.searchParams.get('operation_type') || ''
});
let dodaFilterTimeout: ReturnType<typeof setTimeout>;
let selectedDodaIds = $state<(string | number)[]>([]);
const selectedDoda = $derived(
selectedDodaIds.length === 1
? allDodas.find((item) => String(item.id) === String(selectedDodaIds[0])) ?? null
: null
);
const altaVariant = $derived<'doda' | 'pita'>(
selectedDoda && isPitaCustomsClearance(selectedDoda.customs_clearance) ? 'pita' : 'doda'
);
let progressDialogOpen = $state(false);
let exportDialogOpen = $state(false);
let currentTaskId = $state('');
let currentVariant = $state<'doda' | 'pita'>('doda');
let altaLoading = $state(false);
let deleteLoading = $state(false);
$effect(() => {
if (data.dodas) {
allDodas = data.dodas.items || [];
dodaPage = data.dodas.page || 1;
dodaTotal = data.dodas.total || 0;
}
});
$effect(() => {
const _ = { ...filters };
clearTimeout(dodaFilterTimeout);
dodaFilterTimeout = setTimeout(() => reloadDodas(), 400);
});
async function reloadDodas() {
if (!browser) return;
dodaLoading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
const active = Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== ''));
const res = await getDodas(1, dodaPageSize, active, Number(companyId));
if (res.data) {
allDodas = res.data.items;
dodaPage = 1;
dodaTotal = res.data.total;
selectedDodaIds = [];
}
} catch {
if (allDodas.length > 0) toast.error('Error al recargar DODAs');
} finally {
dodaLoading = false;
}
}
async function loadMoreDodas() {
if (dodaLoading || !dodaHasMore) return;
dodaLoading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
const active = Object.fromEntries(Object.entries(filters).filter(([, v]) => v !== ''));
const res = await getDodas(dodaPage + 1, dodaPageSize, active, Number(companyId));
if (res.data) {
allDodas = [...allDodas, ...res.data.items];
dodaPage++;
dodaTotal = res.data.total;
}
} finally {
dodaLoading = false;
}
}
function handleEdit() {
if (selectedDoda) {
void goto(`/dashboard/general_catalogs/doda?doda_id=${selectedDoda.id}`, { noScroll: true });
}
}
async function handleDelete() {
if (deleteLoading) return;
if (!companyStore.activeCompany) {
toast.error(m['sidebar.doda_alta.delete_missing_company']());
return;
}
if (selectedDodaIds.length !== 1) {
toast.error(m['sidebar.doda_alta.delete_select_one']());
return;
}
if (!selectedDoda) {
toast.error(m['sidebar.doda_alta.delete_not_found']());
return;
}
if (!confirm(m['sidebar.doda_alta.confirm_delete']())) return;
deleteLoading = true;
try {
const deletedId = selectedDoda.id;
await deleteDoda(deletedId, companyStore.activeCompany.id);
toast.success(m['sidebar.doda_alta.delete_success']());
const next = applyOptimisticDelete(allDodas, dodaTotal, deletedId);
allDodas = next.items;
dodaTotal = next.total;
selectedDodaIds = [];
await reloadDodas();
} catch (e) {
const msg = e instanceof Error ? e.message : m['sidebar.doda_alta.delete_error']();
toast.error(msg);
} finally {
deleteLoading = false;
}
}
function clearFilters() {
filters = { integration_number: '', patent: '', status: '', operation_type: '' };
}
async function handleAlta() {
if (!selectedDoda || !companyStore.activeCompany) return;
const companyId = companyStore.activeCompany.id;
const dodaId = selectedDoda.id;
const variant = altaVariant;
altaLoading = true;
try {
const elig = await getDodaElegibilidad(dodaId, companyId, variant);
if (elig.error) {
toast.error(`Error al verificar elegibilidad: ${elig.error}`);
return;
}
if (elig.data && !elig.data.can_alta) {
const msgs = elig.data.reasons.map((r) => `• ${r.message}`).join('\n');
toast.error(msgs || m['sidebar.doda_alta.eligibility_error']());
return;
}
const resp = await postDodaAlta(dodaId, companyId, variant);
if (resp.error) {
toast.error(`Error al enviar alta: ${resp.error}`);
return;
}
currentTaskId = resp.data!.task_id;
currentVariant = variant;
progressDialogOpen = true;
} finally {
altaLoading = false;
}
}
function onAltaComplete() {
progressDialogOpen = false;
reloadDodas();
toast.success(m['sidebar.doda_alta.progress_success']());
}
</script>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
<div class="flex-none flex items-center justify-between">
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight">{m['sidebar.doda_alta.title']()}</h1>
<p class="text-muted-foreground">{m['sidebar.doda_alta.subtitle']()}</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" onclick={() => reloadDodas()} disabled={dodaLoading}>
<RefreshCw class="mr-2 h-4 w-4 {dodaLoading ? 'animate-spin' : ''}" />
{m['sidebar.doda_alta.refresh']()}
</Button>
<Button size="sm" onclick={() => goto('/dashboard/general_catalogs/doda?doda_id=new')}>
<Plus class="mr-2 h-4 w-4" />
{m['sidebar.doda_alta.action_new']()}
</Button>
</div>
</div>
<Card.Root class="flex min-h-0 flex-1 flex-col border bg-background">
<Card.Header>
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<Card.Title>{m['sidebar.doda_alta.table_title']()}</Card.Title>
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-[220px_180px_170px_170px_auto] xl:items-center">
<div class="relative">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={m['sidebar.doda_alta.filter_integration_number']()}
bind:value={filters.integration_number}
class="h-9 bg-card pl-9"
/>
</div>
<Input
placeholder={m['sidebar.doda_alta.filter_patent']()}
bind:value={filters.patent}
class="h-9 bg-card"
/>
<Select.Root
type="single"
value={filters.status}
onValueChange={(v) => (filters.status = v)}
>
<Select.Trigger class="h-9 w-full bg-card">
{filters.status || m['sidebar.doda_alta.filter_status']()}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Todos</Select.Item>
<Select.Item value="PENDIENTE">PENDIENTE</Select.Item>
<Select.Item value="GENERADO">GENERADO</Select.Item>
<Select.Item value="VALIDADO">VALIDADO</Select.Item>
<Select.Item value="ELIMINADO">ELIMINADO</Select.Item>
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={filters.operation_type}
onValueChange={(v) => (filters.operation_type = v)}
>
<Select.Trigger class="h-9 w-full bg-card">
{filters.operation_type === 'I'
? 'Importación'
: filters.operation_type === 'E'
? 'Exportación'
: m['sidebar.doda_alta.filter_operation_type']()}
</Select.Trigger>
<Select.Content>
<Select.Item value="">Todas</Select.Item>
<Select.Item value="I">I - Importación</Select.Item>
<Select.Item value="E">E - Exportación</Select.Item>
</Select.Content>
</Select.Root>
<Button variant="outline" size="sm" class="h-9" onclick={clearFilters}>
<RotateCcw class="mr-2 h-4 w-4" />
Limpiar
</Button>
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 p-0">
<div class="rounded-md border bg-background">
<DataTable
data={allDodas}
columns={createColumns()}
loading={dodaLoading}
hasMore={dodaHasMore}
loadMore={loadMoreDodas}
selectedId={selectedDodaIds.length === 1 ? selectedDodaIds[0] : null}
onRowClick={(row) => {
selectedDodaIds = selectedDodaIds.includes(row.id) ? [] : [row.id];
}}
onRowDoubleClick={(item) =>
goto(`/dashboard/general_catalogs/doda?doda_id=${item.id}`, { noScroll: true })}
/>
</div>
</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground pt-2">
Mostrando {allDodas.length} de {dodaTotal} registros
<span class="ml-2"></span>
<span class="ml-2">Filtros activos: {Object.values(filters).filter((v) => v !== '').length}</span>
</div>
<div class="h-20"></div>
<div
class="fixed right-0 bottom-0 left-0 z-50 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={handleEdit}
disabled={selectedDodaIds.length !== 1}
>
<Pencil size={16} class="mr-2" />
{m['sidebar.doda_alta.action_edit']()}
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={selectedDodaIds.length !== 1 || deleteLoading}
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{#if deleteLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
{m['sidebar.doda_alta.action_delete']()}
</Button>
<Button variant="outline" size="sm" onclick={() => (exportDialogOpen = true)}>
<FileSpreadsheet size={16} class="mr-2" />
{m['sidebar.doda_alta.action_export_excel']()}
</Button>
<Separator orientation="vertical" class="mx-1 h-8 hidden sm:block" />
<Button
size="sm"
onclick={handleAlta}
disabled={selectedDodaIds.length !== 1 || altaLoading}
title={altaVariant === 'pita' ? 'PITA' : 'DODA'}
>
{#if altaLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Send size={16} class="mr-2" />
{/if}
{m['sidebar.doda_alta.action_generar']()}
</Button>
</div>
</div>
</div>
</div>
<DodaExportExcelDialog bind:open={exportDialogOpen} companyId={companyStore.activeCompany?.id} />
{#if progressDialogOpen}
<DodaProgressDialog
bind:open={progressDialogOpen}
taskId={currentTaskId}
dodaId={selectedDoda?.id}
variant={currentVariant}
onComplete={onAltaComplete}
onCancel={() => (progressDialogOpen = false)}
/>
{/if}

View File

@@ -5,8 +5,19 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
const parentData = await parent();
const { accessToken } = getAuthTokens(cookies);
const pUser = parentData.user as
| { preferred_username?: string; name?: string; email?: string }
| undefined
| null;
const defaultLastUser =
(pUser?.preferred_username?.trim() || pUser?.name?.trim() || pUser?.email?.trim() || '') || '';
if (!accessToken) {
return { error: 'No authenticated', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } };
return {
error: 'No authenticated',
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 },
defaultLastUser
};
}
try {
@@ -20,7 +31,11 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
: parentData.companies?.[0]?.id;
if (!companyId) {
return { error: 'No company selected', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } };
return {
error: 'No company selected',
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 },
defaultLastUser
};
}
const filters: Record<string, string> = {};
@@ -37,12 +52,20 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
const response = await authenticatedFetch(`v1/a76/doda?${queryParams.toString()}`, { method: 'GET' }, cookies, fetch);
if (!response.ok) {
return { error: 'Failed to load', dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 } };
return {
error: 'Failed to load',
dodas: { items: [], total: 0, page, page_size: pageSize, pages: 0 },
defaultLastUser
};
}
return { dodas: await response.json() };
return { dodas: await response.json(), defaultLastUser };
} catch (error) {
console.error('Error loading DODAs:', error);
return { error: 'Error loading', dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 } };
return {
error: 'Error loading',
dodas: { items: [], total: 0, page: 1, page_size: 50, pages: 0 },
defaultLastUser
};
}
};

View File

@@ -2,8 +2,17 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { getDodas, deleteDoda, type Doda } from '$lib/api/dashboard/a76/general_catalogs/doda';
import {
getDodas,
deleteDoda,
printDoda,
postDodaAlta,
getDodaElegibilidad,
type Doda
} from '$lib/api/dashboard/a76/general_catalogs/doda';
import { toast } from 'svelte-sonner';
import { m } from '$lib/i18n/messages';
import { isPitaCustomsClearance } from '$lib/components/dashboard/general_catalogs/doda/doda-form-helpers';
import {
Plus,
RefreshCw,
@@ -13,22 +22,50 @@
RotateCcw,
FileText,
LayoutGrid,
Printer
Printer,
Send,
Loader2
} from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import { companyStore } from '$lib/stores/company.svelte';
import DataTable from '$lib/components/dashboard/general_catalogs/doda/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/general_catalogs/doda/columns';
import CreateEditDialog from '$lib/components/dashboard/general_catalogs/doda/create-edit-dialog.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/list';
import DodaProgressDialog from '$lib/components/dashboard/despacho/doda/doda-progress-dialog.svelte';
import DodaFormModal from '$lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte';
import { applyOptimisticDelete } from '$lib/components/dashboard/general_catalogs/doda/delete-list-state';
let { data } = $props();
let dialogOpen = $state(false);
const dodaIdFromUrl = $derived($page.url.searchParams.get('doda_id') ?? '');
function closeDodaForm() {
if (!browser) return;
const u = new URL($page.url);
u.searchParams.delete('doda_id');
const q = u.searchParams.toString();
void goto(u.pathname + (q ? `?${q}` : ''), { replaceState: true, noScroll: true });
}
function setDodaIdQuery(id: number | 'new') {
const u = new URL($page.url);
u.searchParams.set('doda_id', id === 'new' ? 'new' : String(id));
const q = u.searchParams.toString();
void goto(`${u.pathname}?${q}`, { noScroll: true });
}
function onCreatedDodaToEdit(dodaId: number) {
const u = new URL($page.url);
u.searchParams.set('doda_id', String(dodaId));
const q = u.searchParams.toString();
void goto(`${u.pathname}?${q}`, { replaceState: true, noScroll: true });
void reloadData();
}
// Filtros centralizados
let filters = $state({
@@ -52,7 +89,18 @@
// Selection
let selectedIds = $state<(string | number)[]>([]);
const selectedDoda = $derived(
selectedIds.length === 1 ? allItems.find((item) => item.id === selectedIds[0]) ?? null : null
selectedIds.length === 1
? allItems.find((item) => String(item.id) === String(selectedIds[0])) ?? null
: null
);
let altaLoading = $state(false);
let printLoading = $state(false);
let deleteLoading = $state(false);
let progressDialogOpen = $state(false);
let currentTaskId = $state('');
let currentVariant = $state<'doda' | 'pita'>('doda');
const selectedVariant = $derived(
selectedDoda?.customs_clearance === 1 ? ('pita' as const) : ('doda' as const)
);
// Sincronizar con datos del servidor al cargar (primera carga)
@@ -64,7 +112,7 @@
}
});
// Sincronizar filtros con la URL de forma reactiva
// Sincronizar filtros con la URL de forma reactiva (preserva ?doda_id= del modal)
$effect(() => {
if (browser) {
const params = new URLSearchParams();
@@ -73,6 +121,9 @@
if (filters.status) params.set('status', filters.status);
if (filters.operation_type) params.set('operation_type', filters.operation_type);
const modalId = $page.url.searchParams.get('doda_id');
if (modalId) params.set('doda_id', modalId);
const queryString = params.toString();
const newUrl = queryString ? `?${queryString}` : window.location.pathname;
@@ -167,28 +218,99 @@
}
function handleCreateClick() {
goto('/dashboard/general_catalogs/doda/edit');
setDodaIdQuery('new');
}
function handleEdit() {
if (selectedDoda) {
goto(`/dashboard/general_catalogs/doda/edit/${selectedDoda.id}`);
setDodaIdQuery(selectedDoda.id);
}
}
async function handleDelete() {
if (!selectedDoda || !companyStore.activeCompany) return;
if (confirm('¿Estás seguro de eliminar este DODA?')) {
try {
await deleteDoda(selectedDoda.id, companyStore.activeCompany.id);
toast.success('DODA eliminado correctamente');
selectedIds = [];
reloadData();
} catch (e) {
toast.error('Error al eliminar DODA');
}
if (deleteLoading) return;
if (!companyStore.activeCompany) {
toast.error(m['sidebar.doda_alta.delete_missing_company']());
return;
}
if (selectedIds.length !== 1) {
toast.error(m['sidebar.doda_alta.delete_select_one']());
return;
}
if (!selectedDoda) {
toast.error(m['sidebar.doda_alta.delete_not_found']());
return;
}
if (!confirm(m['sidebar.doda_alta.confirm_delete']())) return;
deleteLoading = true;
try {
const deletedId = selectedDoda.id;
await deleteDoda(deletedId, companyStore.activeCompany.id);
toast.success(m['sidebar.doda_alta.delete_success']());
const next = applyOptimisticDelete(allItems, totalItems, deletedId);
allItems = next.items;
totalItems = next.total;
selectedIds = [];
await reloadData();
} catch (e) {
const msg = e instanceof Error ? e.message : m['sidebar.doda_alta.delete_error']();
toast.error(msg);
} finally {
deleteLoading = false;
}
}
async function handleAlta() {
if (!selectedDoda || !companyStore.activeCompany) return;
const companyId = companyStore.activeCompany.id;
const dodaId = selectedDoda.id;
const variant = selectedVariant;
altaLoading = true;
try {
const elig = await getDodaElegibilidad(dodaId, companyId, variant);
if (elig.error) {
toast.error(`Error al verificar elegibilidad: ${elig.error}`);
return;
}
if (elig.data && !elig.data.can_alta) {
const msgs = elig.data.reasons.map((r) => `- ${r.message}`).join('\n');
toast.error(msgs || 'El DODA no cumple con los requisitos de alta.');
return;
}
const resp = await postDodaAlta(dodaId, companyId, variant);
if (resp.error) {
toast.error(`Error al enviar alta DODA: ${resp.error}`);
return;
}
currentTaskId = resp.data!.task_id;
currentVariant = variant;
progressDialogOpen = true;
} finally {
altaLoading = false;
}
}
async function handlePrint() {
if (!selectedDoda || !companyStore.activeCompany) return;
const companyId = companyStore.activeCompany.id;
const dodaId = selectedDoda.id;
printLoading = true;
try {
await printDoda(dodaId, companyId);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Error al generar el PDF del DODA';
toast.error(msg);
} finally {
printLoading = false;
}
}
function onAltaComplete() {
progressDialogOpen = false;
reloadData();
toast.success('Alta DODA completada correctamente');
}
function handleRowClick(row: Doda) {
@@ -276,7 +398,7 @@
{loadMore}
selectedId={selectedIds.length === 1 ? selectedIds[0] : null}
onRowClick={handleRowClick}
onRowDoubleClick={(item) => goto(`/dashboard/general_catalogs/doda/edit/${item.id}`)}
onRowDoubleClick={(item) => setDodaIdQuery(item.id)}
/>
</div>
</Card.Content>
@@ -310,22 +432,66 @@
variant="outline"
size="sm"
onclick={handleDelete}
disabled={selectedIds.length !== 1}
disabled={selectedIds.length !== 1 || deleteLoading}
class="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 size={16} class="mr-2" />
Eliminar
{#if deleteLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
{m['sidebar.doda_alta.action_delete']()}
</Button>
<Separator orientation="vertical" class="mx-1 h-8" />
<Button variant="secondary" size="sm" disabled={selectedIds.length !== 1}>
<Printer size={16} class="mr-2" />
<Button
variant="secondary"
size="sm"
disabled={selectedIds.length !== 1 || printLoading}
onclick={handlePrint}
>
{#if printLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Printer size={16} class="mr-2" />
{/if}
Imprimir
</Button>
<Separator orientation="vertical" class="mx-1 h-8" />
<Button
size="sm"
onclick={handleAlta}
disabled={selectedIds.length !== 1 || altaLoading}
title={isPitaCustomsClearance(selectedDoda?.customs_clearance) ? 'PITA' : 'DODA'}
>
{#if altaLoading}
<Loader2 size={16} class="mr-2 animate-spin" />
{:else}
<Send size={16} class="mr-2" />
{/if}
{m['sidebar.doda_alta.action_generar']()}
</Button>
</div>
</div>
</div>
</div>
{#if dialogOpen}
<CreateEditDialog bind:open={dialogOpen} onSuccess={reloadData} />
{#if progressDialogOpen}
<DodaProgressDialog
bind:open={progressDialogOpen}
taskId={currentTaskId}
dodaId={selectedDoda?.id}
variant={currentVariant}
onComplete={onAltaComplete}
onCancel={() => (progressDialogOpen = false)}
/>
{/if}
{#if dodaIdFromUrl}
<DodaFormModal
dodaIdParam={dodaIdFromUrl}
defaultLastUser={data?.defaultLastUser ?? ''}
onClose={closeDodaForm}
onCreatedNavigateTo={onCreatedDodaToEdit}
/>
{/if}

View File

@@ -1,751 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Switch } from '$lib/components/ui/switch';
import * as Tabs from '$lib/components/ui/tabs';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Separator } from '$lib/components/ui/separator';
import { companyStore } from '$lib/stores/company.svelte';
import {
createDoda,
updateDoda,
getDoda,
type DodaCreate
} from '$lib/api/dashboard/a76/general_catalogs/doda';
import {
ArrowLeft,
Save,
RefreshCw,
FileText,
LayoutGrid,
Printer,
Trash2,
FolderSearch,
ShieldCheck,
LoaderCircle
} from 'lucide-svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosFormularioDoda } from '$lib/config/shortcuts/dashboard/general_catalogs/doda/edit';
import ChildDetailTable from '$lib/components/dashboard/general_catalogs/doda/child-detail-table.svelte';
// Modales de Selección
import BrokerSelectorDialog from '$lib/components/dashboard/export/manifest/modals/broker-selector-dialog.svelte';
import CustomsSectionSelectorDialog from '$lib/components/dashboard/shared/modals/customs-section-selector-dialog.svelte';
import TransporterSelectorDialog from '$lib/components/dashboard/export/manifest/modals/transporter-selector-dialog.svelte';
// 1. Identificación reactiva
let id = $derived($page.params.id);
let isEdit = $derived(!!$page.params.id);
let title = $derived(isEdit ? 'Editar DODA' : 'Nuevo DODA');
let loading = $state(false);
let error = $state<string | null>(null);
let activeTab = $state('general');
// Estados de Modales
let showBrokerSelector = $state(false);
let showAduanaSelector = $state(false);
let showSectionSelector = $state(false);
let showTransporterSelector = $state(false);
// Atajos
useShortcuts(
'Formulario DODA',
obtenerAtajosFormularioDoda({
cambiarPestana: (pestana) => (activeTab = pestana),
manejarGuardar: handleSubmit,
manejarCerrar: () => goto('/dashboard/general_catalogs/doda')
})
);
function getEmptyForm(): DodaCreate & {
pedimentos_detail?: any[];
containers?: any[];
american_pedimentos?: any[];
uuid_carta_porte?: string;
} {
return {
integration_number: '',
doda_date: undefined,
doda_time: undefined,
dispatch_customs: '',
customs_sections: '',
patent: '',
pedimentos: '',
caat: '',
transport_identification: '',
fast_id: '',
operation_type: 'I',
selected: false,
user_selected: '',
last_user: '',
responsible: '',
carrier: '',
shipments: '',
pedimento_type: '',
original_chain: '',
serial_number: '',
electronic_signature: '',
transaction_number: '',
status: 'PENDIENTE',
linq_sat_qr: '',
sat_certificate: '',
sat_digital_seal: '',
xml_doda_sent_path: '',
xml_doda_response_path: '',
sat_original_chain: '',
customs_clearance: 2, // 2 = DODA, 1 = PITA
unique_badge_number: '',
pedimentos_detail: [],
containers: [],
american_pedimentos: [],
uuid_carta_porte: ''
};
}
let formData = $state(getEmptyForm());
$effect(() => {
const currentId = $page.params.id;
const companyId = companyStore.activeCompany?.id;
if (currentId && companyId) {
loadDoda(Number(currentId));
} else if (!currentId) {
formData = getEmptyForm();
error = null;
}
});
async function loadDoda(dodaId: number) {
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
const data = await getDoda(dodaId, companyId);
if (data) {
formData = {
integration_number: data.integration_number || '',
doda_date: data.doda_date,
doda_time: data.doda_time,
dispatch_customs: data.dispatch_customs || '',
customs_sections: data.customs_sections || '',
patent: data.patent || '',
pedimentos: data.pedimentos || '',
caat: data.caat || '',
transport_identification: data.transport_identification || '',
fast_id: data.fast_id || '',
operation_type: data.operation_type || 'I',
selected: data.selected || false,
user_selected: data.user_selected || '',
last_user: data.last_user || '',
responsible: data.responsible || '',
carrier: data.carrier || '',
shipments: data.shipments || '',
pedimento_type: data.pedimento_type || '',
original_chain: data.original_chain || '',
serial_number: data.serial_number || '',
electronic_signature: data.electronic_signature || '',
transaction_number: data.transaction_number || '',
status: data.status || 'PENDIENTE',
linq_sat_qr: data.linq_sat_qr || '',
sat_certificate: data.sat_certificate || '',
sat_digital_seal: data.sat_digital_seal || '',
xml_doda_sent_path: data.xml_doda_sent_path || '',
xml_doda_response_path: data.xml_doda_response_path || '',
sat_original_chain: data.sat_original_chain || '',
customs_clearance: data.customs_clearance || 2,
unique_badge_number: data.unique_badge_number || '',
pedimentos_detail: data.pedimentos_detail || [],
containers: data.containers || [],
american_pedimentos: data.american_pedimentos || [],
uuid_carta_porte: '' // Simulated field
};
}
} catch (e) {
error = 'No se pudo cargar la información del DODA';
} finally {
loading = false;
}
}
async function handleSubmit() {
error = null;
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) throw new Error('Selecciona una compañía');
if (!formData.patent?.trim()) throw new Error('El Agente Aduanal (Patente) es requerido');
const payload: DodaCreate = {
...formData,
integration_number: formData.integration_number?.trim() || '',
doda_date: formData.doda_date || undefined,
doda_time: formData.doda_time || undefined,
customs_clearance: formData.customs_clearance || undefined
};
if (isEdit) {
await updateDoda(Number(id), payload, companyId);
} else {
await createDoda(payload, companyId);
}
goto('/dashboard/general_catalogs/doda');
} catch (e: any) {
error = e.message || 'Error al guardar';
} finally {
loading = false;
}
}
const pedimentosColumns = [
{ header: 'Doda Sysid', key: 'id' },
{ header: 'Línea Pedimento', key: 'pedimento_line' },
{ header: 'Patente Autorización', key: 'authorization_patent' },
{ header: 'Documento', key: 'document' },
{ header: 'Remesa', key: 'shipment' },
{ header: 'Cove', key: 'cove' },
{ header: 'UMC', key: 'umc' },
{ header: 'Importe Efectivo USD', key: 'effective_amount_usd' },
{ header: 'Importe Diferencia USD', key: 'difference_amount_usd' },
{ header: 'DTA NIU', key: 'dta_niu' },
{ header: 'Articulo 7', key: 'article_7', render: (v: any) => (v ? 'Sí' : 'No') }
];
const containersColumns = [
{ header: 'Contenedor', key: 'container_value' },
{ header: 'Percinto', key: 'seals' }
];
const americanPedimentosColumns = [
{ header: 'Tipo', key: 'american_pedimento_type' },
{ header: 'Pedido Americano', key: 'american_pedimento_value' }
];
// Handlers de Selección
function handleBrokerSelect(broker: any) {
formData.responsible = broker.broker_key || '';
formData.patent = broker.license || '';
}
function handleAduanaSelect(section: any) {
formData.dispatch_customs = section.customs_code || '';
}
function handleSectionSelect(section: any) {
formData.customs_sections = section.customs_code || '';
}
function handleTransporterSelect(transporter: any) {
formData.carrier = transporter.name || '';
}
function openOnEnterOrSpace(event: KeyboardEvent, action: () => void) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
action();
}
}
</script>
<div class="space-y-3 p-6 pb-48">
<!-- Header Estilo Facturas -->
<div class="flex items-center justify-between">
<div class="space-y-1">
<div class="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onclick={() => goto('/dashboard/general_catalogs/doda')}
>
<ArrowLeft size={20} />
</Button>
<h1 class="text-2xl font-bold tracking-tight">{title}</h1>
</div>
<p class="text-muted-foreground">Catálogos Generales / Doda</p>
</div>
</div>
<Separator />
<Tabs.Root bind:value={activeTab} class="w-full">
<!-- Contenido Principal con Campos Superiores -->
<div class="space-y-6">
<!-- Fila Compacta de Datos Principales (Estilo InvoiceTopFields) con líneas blancas entre campos -->
<div class="grid grid-cols-12 items-end gap-0 pb-3">
<div class="col-span-3 space-y-1 border-r border-white/20 pr-3">
<Label
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
onclick={() => (showBrokerSelector = true)}
>
Responsable Agentes
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.responsible}
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
maxlength={14}
placeholder="Clave"
onclick={() => (showBrokerSelector = true)}
onkeydown={(event) =>
openOnEnterOrSpace(event, () => (showBrokerSelector = true))}
tabindex="0"
readonly
/>
<Button
variant="secondary"
size="icon"
type="button"
onclick={() => (showBrokerSelector = true)}
class="h-8 w-8 shrink-0 transition-colors hover:bg-background/20"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="col-span-2 space-y-1 border-r border-white/20 px-3">
<Label
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
onclick={() => (showAduanaSelector = true)}
>
Aduana
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.dispatch_customs}
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
maxlength={3}
placeholder="000"
onclick={() => (showAduanaSelector = true)}
onkeydown={(event) =>
openOnEnterOrSpace(event, () => (showAduanaSelector = true))}
tabindex="0"
readonly
/>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showAduanaSelector = true)}
class="h-8 w-8 shrink-0 border-white/10 transition-colors hover:bg-background/20"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="col-span-2 space-y-1 border-r border-white/20 px-3">
<Label
class="cursor-pointer text-xs leading-none text-muted-foreground transition-colors hover:text-primary"
onclick={() => (showSectionSelector = true)}
>
Sección
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.customs_sections}
class="h-8 flex-1 cursor-pointer bg-background/5 text-sm font-medium transition-colors hover:bg-background/10"
maxlength={3}
placeholder="000"
onclick={() => (showSectionSelector = true)}
onkeydown={(event) =>
openOnEnterOrSpace(event, () => (showSectionSelector = true))}
tabindex="0"
readonly
/>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showSectionSelector = true)}
class="h-8 w-8 shrink-0 border-white/10 transition-colors hover:bg-background/20"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="col-span-2 space-y-1 pl-3">
<Label class="text-xs leading-none text-muted-foreground">Operación</Label>
<Select.Root
type="single"
value={formData.operation_type}
onValueChange={(v) => (formData.operation_type = v)}
>
<Select.Trigger class="h-8 border-none bg-background/5 text-sm font-medium shadow-none">
<span class="truncate"
>{formData.operation_type === 'E'
? 'E'
: formData.operation_type === 'I'
? 'I'
: '...'}</span
>
</Select.Trigger>
<Select.Content>
<Select.Item value="I">I - Importación</Select.Item>
<Select.Item value="E">E - Exportación</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
{#if error}
<div
class="animate-in fade-in slide-in-from-top-2 mb-6 flex items-center gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-4 text-sm font-semibold text-destructive"
>
<span class="text-lg">⚠️</span>
{error}
</div>
{/if}
<Tabs.Content value="general" class="animate-in fade-in space-y-6 duration-300 outline-none">
<!-- Grid de Campos Generales Reorganizado -->
<div class="grid grid-cols-12 items-start gap-8">
<!-- Columna 1 (Izquierda): Stack Vertical Principal -->
<div class="col-span-3 space-y-4">
<div class="space-y-1.5">
<Label
class="cursor-pointer text-xs font-semibold text-muted-foreground uppercase transition-colors hover:text-primary"
onclick={() => (showTransporterSelector = true)}
>
Transportista
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.carrier}
placeholder="Transportista"
class="h-9 flex-1 cursor-pointer text-sm font-medium shadow-sm transition-colors hover:bg-background/5"
onclick={() => (showTransporterSelector = true)}
onkeydown={(event) =>
openOnEnterOrSpace(event, () => (showTransporterSelector = true))}
tabindex="0"
readonly
/>
<Button
variant="secondary"
size="icon"
type="button"
onclick={() => (showTransporterSelector = true)}
class="h-9 w-9 shrink-0 shadow-sm transition-all hover:translate-y-[-1px]"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>Identificación</Label
>
<Input
bind:value={formData.transport_identification}
placeholder="Identificación"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>No. de Integración</Label
>
<Input
bind:value={formData.integration_number}
placeholder="Integración"
class="h-9 bg-muted/20 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>Transacción</Label
>
<Input
bind:value={formData.transaction_number}
placeholder="Transacción"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase">Fast ID</Label>
<Input
bind:value={formData.fast_id}
placeholder="Fast ID"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
</div>
<!-- Columna 4 (Alineada con Operación): Stack de Estatus/Patente -->
<div class="col-span-3 col-start-8 space-y-4">
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase">CAAT</Label>
<Input
bind:value={formData.caat}
placeholder="CAAT"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
<div class="space-y-1.5">
<Label
class="cursor-pointer text-xs font-semibold text-muted-foreground uppercase transition-colors hover:text-primary"
onclick={() => (showBrokerSelector = true)}
>
Patente <span class="text-destructive">*</span>
</Label>
<div class="flex gap-2">
<Input
bind:value={formData.patent}
maxlength={4}
placeholder="Patente"
class="h-9 flex-1 cursor-pointer text-sm font-medium shadow-sm transition-colors hover:bg-background/5"
onclick={() => (showBrokerSelector = true)}
onkeydown={(event) =>
openOnEnterOrSpace(event, () => (showBrokerSelector = true))}
tabindex="0"
readonly
/>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showBrokerSelector = true)}
class="h-9 w-9 shrink-0 shadow-sm transition-all hover:translate-y-[-1px]"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase">Estatus</Label>
<Select.Root
type="single"
value={formData.status}
onValueChange={(v) => (formData.status = v)}
>
<Select.Trigger class="h-9 w-full text-sm font-medium shadow-sm">
{formData.status || 'Seleccionar...'}
</Select.Trigger>
<Select.Content>
<Select.Item value="PENDIENTE">PENDIENTE</Select.Item>
<Select.Item value="GENERADO">GENERADO</Select.Item>
<Select.Item value="ELIMINADO">ELIMINADO</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
<!-- Fila Horizontal: Despacho y Gafete (A la derecha de Fast ID) -->
<div class="col-span-12 grid grid-cols-12 items-end gap-8">
<div class="col-span-3">
<!-- Espacio vacío para alinear con la primera columna si es necesario -->
</div>
<!-- Despacho Aduanero (A la derecha de Fast ID en términos lógicos) -->
<div class="col-span-4">
<div
class="flex items-center justify-between rounded-xl border bg-muted/30 p-4 shadow-inner"
>
<Label class="text-xs font-semibold tracking-widest text-muted-foreground uppercase"
>Despacho Aduanero</Label
>
<RadioGroup.Root
value={formData.customs_clearance?.toString()}
onValueChange={(v) => (formData.customs_clearance = parseInt(v))}
class="flex gap-6"
>
<div class="flex cursor-pointer items-center space-x-2">
<RadioGroup.Item value="1" id="pita" class="h-4 w-4 border-primary" />
<Label for="pita" class="cursor-pointer text-xs font-medium uppercase"
>PITA</Label
>
</div>
<div class="flex cursor-pointer items-center space-x-2">
<RadioGroup.Item value="2" id="doda" class="h-4 w-4 border-primary" />
<Label for="doda" class="cursor-pointer text-xs font-medium uppercase"
>DODA</Label
>
</div>
</RadioGroup.Root>
</div>
</div>
<!-- Gafete Único (A la derecha de Despacho) -->
<div class="col-span-4">
<div class="space-y-1.5">
<Label class="text-xs font-semibold text-muted-foreground uppercase"
>Número de gafete único</Label
>
<Input
bind:value={formData.unique_badge_number}
placeholder="Gafete único"
class="h-9 text-sm font-medium shadow-sm"
/>
</div>
</div>
</div>
</div>
<!-- Tabla Principal -->
<div class="pt-4">
<ChildDetailTable
title="Detalle de Pedimentos"
columns={pedimentosColumns}
data={formData.pedimentos_detail || []}
class="border-border bg-card shadow-sm"
/>
</div>
<!-- Tablas Inferiores -->
<div class="grid grid-cols-1 gap-6 pt-4 lg:grid-cols-2">
<ChildDetailTable
title="Contenedores"
columns={containersColumns}
data={formData.containers || []}
/>
<ChildDetailTable
title="Pedimento Americano"
columns={americanPedimentosColumns}
data={formData.american_pedimentos || []}
/>
</div>
</Tabs.Content>
<Tabs.Content value="sellos" class="animate-in fade-in duration-300 outline-none">
<div class="max-w-4xl space-y-8 py-4">
<div class="space-y-1">
<h2 class="text-xl font-bold tracking-tight">Sellos y Firmas</h2>
<p class="text-xs font-semibold text-muted-foreground uppercase">
Validación electrónica ante el SAT
</p>
</div>
<div class="grid grid-cols-1 gap-8">
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Cadena original</Label
>
<Textarea
bind:value={formData.original_chain}
placeholder="Cadena Original..."
class="min-h-[120px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Número de certificado</Label
>
<Input
bind:value={formData.serial_number}
placeholder="Certificado"
class="w-full border-muted/60 text-sm font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Firma electrónica</Label
>
<Textarea
bind:value={formData.electronic_signature}
placeholder="Firma..."
class="min-h-[100px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-primary uppercase"
>UUID Carta Porte</Label
>
<Input
bind:value={formData.uuid_carta_porte}
placeholder="00000000-0000-0000-0000-000000000000"
class="w-full border-muted/60 font-mono text-sm font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Número certificado SAT</Label
>
<Input
bind:value={formData.sat_certificate}
placeholder="Certificado SAT"
class="w-full border-muted/60 text-sm font-medium shadow-none"
/>
</div>
<div class="space-y-2">
<Label class="text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>Firma electrónica SAT (Cadena Original SAT)</Label
>
<Textarea
bind:value={formData.sat_original_chain}
placeholder="Firma SAT..."
class="min-h-[120px] w-full border-muted/60 font-mono text-xs font-medium shadow-none"
/>
</div>
</div>
</div>
</Tabs.Content>
</div>
<!-- Footer fijo con Tabs.List y Botones de Acción -->
<div
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] space-y-4 px-4 py-4">
<div class="w-full overflow-x-auto pb-2">
<Tabs.List class="inline-flex md:grid md:w-full md:grid-cols-2">
<Tabs.Trigger value="general" class="whitespace-nowrap">
<LayoutGrid size={16} class="mr-2" />
General
</Tabs.Trigger>
<Tabs.Trigger value="sellos" class="whitespace-nowrap">
<ShieldCheck size={16} class="mr-2" />
Sellos
</Tabs.Trigger>
</Tabs.List>
</div>
<div class="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onclick={() => goto('/dashboard/general_catalogs/doda')}
disabled={loading}
class="rounded px-8 font-medium"
>
Cancelar
</Button>
<Button
onclick={handleSubmit}
disabled={loading}
class="min-w-[200px] rounded font-bold uppercase shadow-sm"
>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
Guardando...
{:else}
<Save class="mr-2 h-4 w-4" />
{isEdit ? 'Guardar Cambios' : 'Crear DODA'}
{/if}
</Button>
</div>
</div>
</div>
</Tabs.Root>
<!-- Diálogos de Selección de Catálogos -->
<BrokerSelectorDialog bind:open={showBrokerSelector} onSelect={handleBrokerSelect} />
<CustomsSectionSelectorDialog bind:open={showAduanaSelector} onSelect={handleAduanaSelect} />
<CustomsSectionSelectorDialog bind:open={showSectionSelector} onSelect={handleSectionSelect} />
<TransporterSelectorDialog
bind:open={showTransporterSelector}
onSelect={handleTransporterSelect}
/>
</div>

View File

@@ -0,0 +1,15 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
/**
* Mantiene enlaces antiguos /edit o /edit/:id: el formulario vive en la lista con ?doda_id=
*/
export const load: PageLoad = async ({ params }) => {
if (params.id) {
throw redirect(
303,
`/dashboard/general_catalogs/doda?doda_id=${encodeURIComponent(String(params.id))}`
);
}
throw redirect(303, '/dashboard/general_catalogs/doda?doda_id=new');
};