feature/digitalizacion-api

This commit is contained in:
2026-04-17 15:59:47 -06:00
parent fa3cbb4d0a
commit 129608bb31
21 changed files with 2573 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
"""create expediente_archivo table
Revision ID: f1a2b3c4d5e6
Revises: d4e5f6a7b8c9
Create Date: 2026-04-17 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "f1a2b3c4d5e6"
down_revision = "f7a8b9c0d1e2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"expediente_archivo",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("e_document", sa.String(length=50), nullable=True),
sa.Column("num_operacion", sa.String(length=50), nullable=True),
sa.Column("tipo_documento", sa.String(length=10), nullable=True),
sa.Column("archivo_digitalizado_en", sa.String(length=500), nullable=True),
sa.Column("fecha_digitalizacion", sa.Date(), nullable=True),
sa.Column("agente_aduanal", sa.String(length=50), nullable=True),
sa.Column("pedimento", sa.String(length=21), nullable=True),
sa.Column("rfc_consulta", sa.String(length=13), nullable=True),
sa.Column("nombre_archivo", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=20), nullable=True),
sa.Column("task_id", sa.String(length=255), nullable=True),
sa.Column("external_task_id", sa.String(length=255), nullable=True),
sa.Column("acuse_pdf_path", sa.String(length=500), 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="expediente_archivo_pkey"),
schema="a76",
)
op.create_index(
op.f("ix_a76_expediente_archivo_company_id"),
"expediente_archivo",
["company_id"],
unique=False,
schema="a76",
)
op.create_index(
op.f("ix_a76_expediente_archivo_tenant_id"),
"expediente_archivo",
["tenant_id"],
unique=False,
schema="a76",
)
op.create_index(
op.f("ix_a76_expediente_archivo_task_id"),
"expediente_archivo",
["task_id"],
unique=False,
schema="a76",
)
op.create_index(
op.f("ix_a76_expediente_archivo_external_task_id"),
"expediente_archivo",
["external_task_id"],
unique=False,
schema="a76",
)
def downgrade() -> None:
op.drop_index(op.f("ix_a76_expediente_archivo_external_task_id"), table_name="expediente_archivo", schema="a76")
op.drop_index(op.f("ix_a76_expediente_archivo_task_id"), table_name="expediente_archivo", schema="a76")
op.drop_index(op.f("ix_a76_expediente_archivo_tenant_id"), table_name="expediente_archivo", schema="a76")
op.drop_index(op.f("ix_a76_expediente_archivo_company_id"), table_name="expediente_archivo", schema="a76")
op.drop_table("expediente_archivo", schema="a76")

View File

@@ -0,0 +1,39 @@
"""fix driver transporter_key length and validations
Revision ID: f7a8b9c0d1e2
Revises: e76_app_settings
Create Date: 2026-04-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "f7a8b9c0d1e2"
down_revision = "e76_app_settings"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
"driver",
"transporter_key",
schema="a76",
existing_type=sa.String(length=5),
type_=sa.String(length=30),
existing_nullable=False,
)
def downgrade() -> None:
op.alter_column(
"driver",
"transporter_key",
schema="a76",
existing_type=sa.String(length=30),
type_=sa.String(length=5),
existing_nullable=False,
)

View File

@@ -0,0 +1,133 @@
from __future__ import annotations
from datetime import date
from typing import List, Optional
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# CRUD DTOs
# ---------------------------------------------------------------------------
class ExpedienteArchivoCreateDTO(BaseModel):
e_document: Optional[str] = Field(None, max_length=50)
num_operacion: Optional[str] = Field(None, max_length=50)
tipo_documento: Optional[str] = Field(None, max_length=10)
archivo_digitalizado_en: Optional[str] = Field(None, max_length=500)
fecha_digitalizacion: Optional[date] = None
agente_aduanal: Optional[str] = Field(None, max_length=50)
pedimento: Optional[str] = Field(None, max_length=21)
rfc_consulta: Optional[str] = Field(None, max_length=13)
nombre_archivo: Optional[str] = Field(None, max_length=255)
class ExpedienteArchivoUpdateDTO(BaseModel):
e_document: Optional[str] = Field(None, max_length=50)
num_operacion: Optional[str] = Field(None, max_length=50)
tipo_documento: Optional[str] = Field(None, max_length=10)
archivo_digitalizado_en: Optional[str] = Field(None, max_length=500)
fecha_digitalizacion: Optional[date] = None
agente_aduanal: Optional[str] = Field(None, max_length=50)
pedimento: Optional[str] = Field(None, max_length=21)
rfc_consulta: Optional[str] = Field(None, max_length=13)
nombre_archivo: Optional[str] = Field(None, max_length=255)
class ExpedienteArchivoResponseDTO(BaseModel):
id: int
e_document: Optional[str] = None
num_operacion: Optional[str] = None
tipo_documento: Optional[str] = None
archivo_digitalizado_en: Optional[str] = None
fecha_digitalizacion: Optional[date] = None
agente_aduanal: Optional[str] = None
pedimento: Optional[str] = None
rfc_consulta: Optional[str] = None
nombre_archivo: Optional[str] = None
status: Optional[str] = None
task_id: Optional[str] = None
external_task_id: Optional[str] = None
acuse_pdf_path: Optional[str] = None
company_id: int
tenant_id: int
model_config = {"from_attributes": True}
class ExpedienteArchivoListResponse(BaseModel):
items: List[ExpedienteArchivoResponseDTO]
total: int
page: int
page_size: int
# ---------------------------------------------------------------------------
# Digitalización DTOs
# ---------------------------------------------------------------------------
class DigitalizarRequest(BaseModel):
"""
Solicitud de digitalización enviada por el frontend.
La configuracion_vu se ensambla server-side desde CustomsBrokerVU / company.
"""
rfc_consulta: str = Field(..., max_length=13)
clave_documento: str = Field(..., max_length=10)
nombre_archivo: str = Field(..., max_length=255)
archivo_base64: str # contenido del archivo en base64
class RegistrarDigitalizacionRequest(BaseModel):
"""Digitalizar múltiples expedientes existentes por ID."""
rfc_consulta: str = Field(..., max_length=13)
ids_archivos: List[int]
class DigitalizarResponse(BaseModel):
task_id: str
message: str
status: str
# ---------------------------------------------------------------------------
# Task status DTOs (espejo del schema del servicio externo)
# ---------------------------------------------------------------------------
class DigitalizacionResult(BaseModel):
status: Optional[str] = None
message: Optional[str] = None
request_id: Optional[str] = None
response_code: Optional[int] = None
xml_path: Optional[str] = None
response_path: Optional[str] = None
acuese_digitalizacion_pdf_base64: Optional[str] = None
nombre_archivo: Optional[str] = None
timestamp: Optional[str] = None
numero_operacion: Optional[str] = None
e_document: Optional[str] = None
envio_xml_base64: Optional[str] = None
respuesta_xml_base64: Optional[str] = None
consulta_envio_xml_base64: Optional[str] = None
consulta_respuesta_xml_base64: Optional[str] = None
class DigitalizacionErrorDetail(BaseModel):
codigo: Optional[str] = None
descripcion: Optional[str] = None
paso: Optional[str] = None
sugerencias: Optional[List[str]] = None
class DigitalizacionTaskDetailResponse(BaseModel):
task_id: str
state: str
status: Optional[str] = None
current_step: Optional[str] = None
progress: Optional[int] = None
total_steps: Optional[int] = None
request_id: Optional[str] = None
result: Optional[DigitalizacionResult] = None
error: Optional[str] = None
error_type: Optional[str] = None
error_detail: Optional[DigitalizacionErrorDetail] = None
info: Optional[dict] = None

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
import httpx
from core.config import settings
logger = logging.getLogger(__name__)
class ExpedienteExternalService:
"""
Cliente HTTP real para el API externo de digitalización de archivos en Ventanilla Única.
Endpoints utilizados:
POST {base_url}/api/v1/expediente-archivos/digitalizar-archivo-json
GET {base_url}/api/v1/expediente-archivos/status-digitalizacion-task/{task_id}
"""
def __init__(self) -> None:
self.base_url = (settings.COVE_API_URL or "").strip() or "https://api.vu.aduanasoft.com"
def digitalizar_archivo_json(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
Envía un JSON con el documento + configuracion_vu al endpoint de digitalización.
Retorna la respuesta JSON tal como viene del API externo.
"""
url = f"{self.base_url.rstrip('/')}/api/v1/expediente-archivos/digitalizar-archivo-json"
configuracion_vu = payload.get("configuracion_vu") or {}
logger.info(
"Sending digitalizar-archivo-json: nombre_archivo=%s rfc_vu=%s clave_fiel_len=%s clave_ws_len=%s cer_len=%s key_len=%s",
payload.get("nombre_archivo"),
configuracion_vu.get("rfc_usuario_vu"),
len(configuracion_vu.get("clave_fiel") or ""),
len(configuracion_vu.get("clave_webservice") or ""),
len(configuracion_vu.get("archivo_cer_base64") or ""),
len(configuracion_vu.get("archivo_key_base64") or ""),
)
# verify=False: el entorno externo puede usar certificados auto-firmados,
# igual que en factura_cove.
with httpx.Client(timeout=60.0, verify=False) as client:
response = client.post(url, json=payload)
response.raise_for_status()
return response.json()
def get_status(self, task_id: str) -> Optional[Dict[str, Any]]:
"""
Consulta el estado de una tarea de digitalización en el API externo.
"""
url = f"{self.base_url.rstrip('/')}/api/v1/expediente-archivos/status-digitalizacion-task/{task_id}"
logger.debug("Consulting expediente task status: task_id=%s url=%s", task_id, url)
with httpx.Client(timeout=30.0, verify=False) as client:
response = client.get(url)
response.raise_for_status()
return response.json()

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class ExpedienteArchivo(Base, TenantScopedMixin, TimestampMixin):
"""Registro de documentos digitalizados en Ventanilla Única."""
__tablename__ = "expediente_archivo"
__table_args__ = ({"schema": "a76"},)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Datos del documento (campos del catálogo legacy)
e_document: Mapped[str | None] = mapped_column(String(50), nullable=True)
num_operacion: Mapped[str | None] = mapped_column(String(50), nullable=True)
tipo_documento: Mapped[str | None] = mapped_column(String(10), nullable=True)
archivo_digitalizado_en: Mapped[str | None] = mapped_column(String(500), nullable=True)
fecha_digitalizacion: Mapped[date | None] = mapped_column(Date, nullable=True)
agente_aduanal: Mapped[str | None] = mapped_column(String(50), nullable=True)
pedimento: Mapped[str | None] = mapped_column(String(21), nullable=True)
rfc_consulta: Mapped[str | None] = mapped_column(String(13), nullable=True)
nombre_archivo: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Estado de la tarea de digitalización
status: Mapped[str | None] = mapped_column(String(20), nullable=True) # pending/processing/success/failed
task_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
external_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
acuse_pdf_path: Mapped[str | None] = mapped_column(String(500), nullable=True)

View File

@@ -0,0 +1,182 @@
from __future__ import annotations
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .dto import (
DigitalizacionTaskDetailResponse,
DigitalizarRequest,
DigitalizarResponse,
ExpedienteArchivoCreateDTO,
ExpedienteArchivoListResponse,
ExpedienteArchivoResponseDTO,
ExpedienteArchivoUpdateDTO,
RegistrarDigitalizacionRequest,
)
from .models import ExpedienteArchivo
from .service import ExpedienteArchivoService
from .tasks import digitalizar_task
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/expediente-archivos")
# ──────────────────────────────────────────────────────────────────────────── #
# CRUD #
# ──────────────────────────────────────────────────────────────────────────── #
@router.get("/", response_model=ExpedienteArchivoListResponse)
def list_expediente_archivos(
company_id: int = Query(...),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
search: str = Query(None),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
return ExpedienteArchivoService.list(db, company_id, tenant_id, page, page_size, search)
@router.get("/{record_id}", response_model=ExpedienteArchivoResponseDTO)
def get_expediente_archivo(
record_id: int,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
return ExpedienteArchivoResponseDTO.model_validate(record)
@router.post("/", response_model=ExpedienteArchivoResponseDTO, status_code=status.HTTP_201_CREATED)
def create_expediente_archivo(
dto: ExpedienteArchivoCreateDTO,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.create(db, dto, company_id, tenant_id)
return ExpedienteArchivoResponseDTO.model_validate(record)
@router.put("/{record_id}", response_model=ExpedienteArchivoResponseDTO)
def update_expediente_archivo(
record_id: int,
dto: ExpedienteArchivoUpdateDTO,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
record = ExpedienteArchivoService.update(db, record, dto)
return ExpedienteArchivoResponseDTO.model_validate(record)
@router.delete("/{record_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_expediente_archivo(
record_id: int,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
ExpedienteArchivoService.delete(db, record)
return None
# ──────────────────────────────────────────────────────────────────────────── #
# Digitalización #
# ──────────────────────────────────────────────────────────────────────────── #
@router.post("/digitalizar/{record_id}", response_model=DigitalizarResponse)
def digitalizar_expediente_archivo(
record_id: int,
body: DigitalizarRequest,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Lanza la tarea Celery de digitalización para un expediente existente.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
task = digitalizar_task.apply_async(
kwargs={
"expediente_id": record_id,
"request_data": body.model_dump(),
"company_id": company_id,
"tenant_id": tenant_id,
}
)
return DigitalizarResponse(
task_id=task.id,
message="Tarea de digitalización iniciada.",
status="pending",
)
@router.post("/registrar-digitalizacion/", response_model=Dict[str, Any])
def registrar_digitalizacion(
body: RegistrarDigitalizacionRequest,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Lanza tareas de digitalización en batch para múltiples expedientes.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
launched = []
errors_list = []
for record_id in body.ids_archivos:
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
errors_list.append({"id": record_id, "error": "No encontrado"})
continue
task = digitalizar_task.apply_async(
kwargs={
"expediente_id": record_id,
"request_data": {"rfc_consulta": body.rfc_consulta},
"company_id": company_id,
"tenant_id": tenant_id,
}
)
launched.append({"id": record_id, "task_id": task.id})
return {"launched": launched, "errors": errors_list}
@router.get("/status-digitalizacion-task/{task_id}", response_model=DigitalizacionTaskDetailResponse)
def get_digitalizacion_task_status(
task_id: str,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Consulta el estado de una tarea Celery de digitalización.
"""
return ExpedienteArchivoService.get_task_status(task_id)

View File

@@ -0,0 +1,359 @@
from __future__ import annotations
import base64
import logging
from datetime import datetime
from typing import List, Optional
from cryptography.hazmat.primitives import padding as crypto_padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from sqlalchemy.orm import Session
from core.celery_app import celery_app
from core.config import settings
from core.exceptions import ErrorCollector, ValidationException
from core.storage_s3 import get_object_bytes, object_exists
from api.v1.modules.a76.customs_brokers import models as cb_models
from api.v1.modules.a76.general_catalogs.company.models import Company
from .dto import (
DigitalizacionErrorDetail,
DigitalizacionResult,
DigitalizacionTaskDetailResponse,
ExpedienteArchivoCreateDTO,
ExpedienteArchivoListResponse,
ExpedienteArchivoResponseDTO,
ExpedienteArchivoUpdateDTO,
)
from .models import ExpedienteArchivo
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# CRUD service
# ---------------------------------------------------------------------------
class ExpedienteArchivoService:
@staticmethod
def list(
db: Session,
company_id: int,
tenant_id: int,
page: int = 1,
page_size: int = 50,
search: Optional[str] = None,
) -> ExpedienteArchivoListResponse:
query = (
db.query(ExpedienteArchivo)
.filter(
ExpedienteArchivo.company_id == company_id,
ExpedienteArchivo.tenant_id == tenant_id,
ExpedienteArchivo.deleted_at.is_(None),
)
)
if search:
like = f"%{search}%"
query = query.filter(
ExpedienteArchivo.e_document.ilike(like)
| ExpedienteArchivo.tipo_documento.ilike(like)
)
total = query.count()
items = query.order_by(ExpedienteArchivo.id.desc()).offset((page - 1) * page_size).limit(page_size).all()
return ExpedienteArchivoListResponse(
items=[ExpedienteArchivoResponseDTO.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[ExpedienteArchivo]:
return (
db.query(ExpedienteArchivo)
.filter(
ExpedienteArchivo.id == record_id,
ExpedienteArchivo.company_id == company_id,
ExpedienteArchivo.tenant_id == tenant_id,
ExpedienteArchivo.deleted_at.is_(None),
)
.first()
)
@staticmethod
def create(db: Session, dto: ExpedienteArchivoCreateDTO, company_id: int, tenant_id: int) -> ExpedienteArchivo:
record = ExpedienteArchivo(
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: ExpedienteArchivo,
dto: ExpedienteArchivoUpdateDTO,
) -> ExpedienteArchivo:
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: ExpedienteArchivo) -> None:
from datetime import datetime
record.deleted_at = datetime.utcnow()
db.commit()
@staticmethod
def get_task_status(task_id: str) -> DigitalizacionTaskDetailResponse:
result = celery_app.AsyncResult(task_id)
state = result.state or "PENDING"
info = result.info or {}
if state == "SUCCESS":
raw = result.result or {}
return DigitalizacionTaskDetailResponse(
task_id=task_id,
state="SUCCESS",
status="success",
result=DigitalizacionResult(**{k: raw.get(k) for k in DigitalizacionResult.model_fields}),
progress=100,
total_steps=4,
)
if state == "FAILURE":
err = info if not isinstance(info, dict) else None
error_detail_raw = info.get("error_detail") if isinstance(info, dict) else None
return DigitalizacionTaskDetailResponse(
task_id=task_id,
state="FAILURE",
status="failed",
error=str(err or info.get("error", "")),
error_type=info.get("error_type") if isinstance(info, dict) else None,
error_detail=DigitalizacionErrorDetail(**(error_detail_raw or {})) if error_detail_raw else None,
)
# PROGRESS / PENDING / STARTED
if isinstance(info, dict):
return DigitalizacionTaskDetailResponse(
task_id=task_id,
state=state,
status="processing",
current_step=info.get("current_step") or info.get("status"),
progress=info.get("progress") or info.get("current"),
total_steps=info.get("total_steps") or 4,
)
return DigitalizacionTaskDetailResponse(task_id=task_id, state=state, status="pending")
# ---------------------------------------------------------------------------
# VU config builder (mirror of factura_cove/service.py _build_configuracion_vu)
# ---------------------------------------------------------------------------
def _encrypt_fiel(raw_fiel: str) -> str:
"""AES-256-CBC + PKCS7 + base64 — mismo esquema que factura_cove."""
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 ""
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")
def build_configuracion_vu(
db: Session,
company_id: int,
tenant_id: int,
agente_aduanal_key: Optional[str],
errors: ErrorCollector,
) -> Optional[dict]:
"""
Construye el dict de configuracion_vu para el servicio externo de digitalización.
Prioridad: CustomsBrokerVU (agente) → company.ventanilla_unica → company_fiel_certificate.
"""
vu: Optional[cb_models.CustomsBrokerVU] = None
if agente_aduanal_key:
broker = (
db.query(cb_models.CustomsBroker)
.filter(
cb_models.CustomsBroker.license == agente_aduanal_key,
cb_models.CustomsBroker.company_id == company_id,
cb_models.CustomsBroker.tenant_id == tenant_id,
cb_models.CustomsBroker.deleted_at.is_(None),
)
.first()
)
if broker:
vu = broker.vu
else:
broker = (
db.query(cb_models.CustomsBroker)
.filter(
cb_models.CustomsBroker.company_id == company_id,
cb_models.CustomsBroker.tenant_id == tenant_id,
cb_models.CustomsBroker.deleted_at.is_(None),
)
.first()
)
if broker:
vu = broker.vu
company = db.get(Company, company_id)
company_vu = company.ventanilla_unica if company else None
company_fiel_certificate = None
if company:
for cert in (company.digital_certificates or []):
if (cert.certificate_type or "").strip().lower() == "fiel":
company_fiel_certificate = cert
break
if not vu and not company_vu and not company_fiel_certificate:
errors.add_error(
field="vu",
message="La empresa no tiene configuración VU ni certificado FIEL.",
solution=["Configura los datos VU del agente aduanal o de la empresa y sube certificado (.cer) y llave (.key)."],
code="MISSING_VU_CONFIGURATION",
)
return None
effective_ws_user = (
(
vu.web_service_user
or vu.doda_web_service_user
or getattr(company_vu, "webservice_user", None)
or ""
).strip()
if (vu or company_vu)
else ""
)
clave_fiel_value = ""
if vu and getattr(vu, "fiel_access_key", None):
clave_fiel_value = _encrypt_fiel(vu.fiel_access_key or "")
elif company_fiel_certificate:
secret = (
getattr(company_fiel_certificate, "access_key", None)
or getattr(company_fiel_certificate, "password", None)
or ""
)
clave_fiel_value = _encrypt_fiel(str(secret))
if not effective_ws_user:
errors.add_error(
field="vu",
message="Faltan credenciales de web service en VU.",
solution=["Captura usuario y clave de web service en la pestaña VU del agente aduanal."],
code="MISSING_VU_CREDENTIALS",
)
if not clave_fiel_value:
errors.add_error(
field="vu.clave_fiel",
message="La clave FIEL no está configurada en VU ni en la empresa.",
solution=["Captura la clave FIEL en la configuración VU del agente aduanal o en los certificados de la empresa."],
code="MISSING_FIEL_PASSWORD",
)
certificate_path = (
(getattr(vu, "certificate_path", None) or "").strip() if vu else ""
) or (
(getattr(company_fiel_certificate, "cer_file_path", None) or "").strip()
if company_fiel_certificate
else ""
)
key_path = (
(getattr(vu, "key_path", None) or "").strip() if vu else ""
) or (
(getattr(company_fiel_certificate, "key_file_path", None) or "").strip()
if company_fiel_certificate
else ""
)
if not (certificate_path and key_path):
errors.add_error(
field="vu",
message="No hay rutas de certificado o llave en la configuración VU.",
solution=["Sube el certificado (.cer) y la llave (.key) en la configuración VU del agente aduanal."],
code="MISSING_VU_CERT_KEY",
)
return None
cer_b64 = None
key_b64 = None
try:
if not object_exists(certificate_path):
errors.add_error(
field="vu.certificate_path",
message="El certificado VU no existe en el almacenamiento.",
solution=["Vuelve a subir el certificado en la configuración VU."],
code="VU_CERT_NOT_FOUND",
)
else:
cer_b64 = base64.b64encode(get_object_bytes(certificate_path)).decode("ascii")
if not object_exists(key_path):
errors.add_error(
field="vu.key_path",
message="La llave VU no existe en el almacenamiento.",
solution=["Vuelve a subir la llave en la configuración VU."],
code="VU_KEY_NOT_FOUND",
)
else:
key_b64 = base64.b64encode(get_object_bytes(key_path)).decode("ascii")
except Exception:
logger.exception("Error leyendo certificados VU desde almacenamiento")
errors.add_error(
field="vu",
message="Error leyendo certificados VU.",
solution=["Verifica la configuración de MinIO/S3."],
code="VU_STORAGE_ERROR",
)
if errors.has_errors():
return None
rfc_usuario_vu = (
(getattr(vu, "query_tax_id", None) or "").strip() if vu else ""
) or (
(getattr(company_vu, "query_rfc", None) or "").strip() if company_vu else ""
)
hardcoded_ws_key = (
"RZGd+CB4R6PfSrstOyN8Is9FXL9AK9NPFisyGGaEWa0vVHoVOl8v2SBcHBoGbt3T/4uHTGcsFQO3b7EonWVfugQjBooywbz74K+jM68j8/Y="
)
clave_webservice = (
(getattr(vu, "web_service_access_key", None) or "").strip()
or (getattr(company_vu, "webservice_password", None) or "").strip()
or hardcoded_ws_key
)
email = (getattr(vu, "vu_email", None) or "").strip() if vu else ""
return {
"rfc_usuario_vu": rfc_usuario_vu,
"clave_webservice": clave_webservice,
"archivo_cer_base64": cer_b64 or "",
"archivo_key_base64": key_b64 or "",
"clave_fiel": clave_fiel_value,
"email": email,
}

View File

@@ -0,0 +1,286 @@
from __future__ import annotations
import logging
import time
from typing import Any, Dict
from celery import Task
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.exceptions import ErrorCollector, ValidationException
from .models import ExpedienteArchivo
from .service import ExpedienteArchivoService, build_configuracion_vu
from .external_service import ExpedienteExternalService
logger = logging.getLogger(__name__)
TOTAL_STEPS = 4
def _progress(task: Task, current: int, status: str) -> None:
task.update_state(
state="PROGRESS",
meta={"current": current, "status": status, "current_step": status, "progress": current, "total_steps": TOTAL_STEPS},
)
def _poll_external(
task: Task,
external: ExpedienteExternalService,
external_task_id: str,
timeout_seconds: int = 300,
) -> Dict[str, Any]:
"""
Hace polling al API externo hasta obtener un estado final o agotar el timeout.
Retorna el payload final tal como lo devuelve el API externo.
"""
start = time.time()
last_payload: Dict[str, Any] = {}
while True:
elapsed = time.time() - start
if elapsed > timeout_seconds:
logger.error("Timeout en polling externo de digitalización: task_id=%s", external_task_id)
raise TimeoutError(f"Timeout consultando estado de digitalización (task_id={external_task_id}).")
try:
status_payload = external.get_status(external_task_id) or {}
except Exception as exc:
logger.exception("Error consultando estado externo de digitalización")
raise
last_payload = status_payload
state = str(status_payload.get("state") or "").upper()
progress_info = status_payload.get("progress") or {}
try:
percent = float(progress_info.get("progress", 0.0))
except (TypeError, ValueError):
percent = 0.0
current_step = (
progress_info.get("current_step")
or "Consultando estado en Ventanilla Única..."
)
_progress(task, int(percent), str(current_step))
if state in {"PENDING", "STARTED", "PROGRESS"} or not state:
time.sleep(5)
continue
return last_payload
@celery_app.task(bind=True, name="expediente_archivos_digitalizar")
def digitalizar_task(
self: Task,
expediente_id: int,
request_data: Dict[str, Any],
company_id: int,
tenant_id: int,
) -> dict:
"""
Celery task: digitaliza un ExpedienteArchivo en Ventanilla Única.
Pasos:
1. Cargar el registro y construir configuracion_vu (server-side).
2. Construir y enviar el payload al API externo.
3. Hacer polling del estado del task externo.
4. Persistir resultado (e_document, num_operacion, acuse_pdf_path) en DB.
"""
db = CoreSessionLocal()
try:
# ------------------------------------------------------------------ #
# Paso 1 cargar registro y construir configuracion_vu #
# ------------------------------------------------------------------ #
_progress(self, 5, "Construyendo configuración VU...")
record: ExpedienteArchivo | None = db.get(ExpedienteArchivo, expediente_id)
if not record:
raise ValidationException(
"Expediente no encontrado",
errors=[{"field": "expediente_id", "message": f"No existe expediente {expediente_id}"}],
)
errors = ErrorCollector()
agente_key = request_data.get("agente_aduanal") or record.agente_aduanal
config_vu = build_configuracion_vu(db, company_id, tenant_id, agente_key, errors)
if errors.has_errors():
error_list = errors._errors # type: ignore[attr-defined]
first = error_list[0] if error_list else {}
self.update_state(
state="FAILURE",
meta={
"error": first.get("message", "Error de configuración VU"),
"error_type": "VALIDATION_ERROR",
"error_detail": {
"codigo": first.get("code", "VALIDATION_ERROR"),
"descripcion": first.get("message", ""),
"paso": "Construcción de configuración VU",
"sugerencias": first.get("solution") or [],
},
},
)
return {}
# Actualizar status en DB
record.status = "processing"
record.task_id = self.request.id
db.commit()
# ------------------------------------------------------------------ #
# Paso 2 construir payload y enviar al API externo #
# ------------------------------------------------------------------ #
_progress(self, 30, "Enviando documento a Ventanilla Única...")
payload = {
"rfc_consulta": request_data.get("rfc_consulta") or record.rfc_consulta or "",
"clave_documento": request_data.get("clave_documento") or record.tipo_documento or "",
"nombre_archivo": request_data.get("nombre_archivo") or record.nombre_archivo or "",
"archivo_base64": request_data.get("archivo_base64") or "",
"configuracion_vu": config_vu,
}
external = ExpedienteExternalService()
response = external.digitalizar_archivo_json(payload)
# Chequear si el API externo devolvió un error inmediato
resp_state = str(response.get("state") or response.get("status") or "").upper()
if resp_state in {"ERROR", "FAILURE", "FAILED"}:
error_msg = response.get("message") or response.get("error") or "Error en API externo"
record.status = "failed"
db.commit()
self.update_state(
state="FAILURE",
meta={
"error": error_msg,
"error_type": "EXTERNAL_API_ERROR",
"error_detail": {
"codigo": "EXTERNAL_API_ERROR",
"descripcion": error_msg,
"paso": "Envío a Ventanilla Única",
"sugerencias": ["Verifica las credenciales VU y vuelve a intentarlo."],
},
},
)
return {}
# Extraer task_id externo si el API lo devolvió de inmediato en PENDING/PROCESSING
external_task_id = response.get("task_id") or response.get("id")
# ------------------------------------------------------------------ #
# Paso 3 polling del estado externo #
# ------------------------------------------------------------------ #
if external_task_id:
_progress(self, 50, "Esperando respuesta de Ventanilla Única...")
record.external_task_id = str(external_task_id)
db.commit()
try:
final_response = _poll_external(self, external, str(external_task_id))
except TimeoutError as exc:
record.status = "failed"
db.commit()
self.update_state(
state="FAILURE",
meta={
"error": str(exc),
"error_type": "TIMEOUT",
"error_detail": {
"codigo": "TIMEOUT",
"descripcion": str(exc),
"paso": "Polling Ventanilla Única",
"sugerencias": ["Vuelve a intentarlo o consulta el estado manualmente."],
},
},
)
return {}
else:
# El API devolvió resultado directo
final_response = response
# ------------------------------------------------------------------ #
# Paso 4 persistir resultado #
# ------------------------------------------------------------------ #
_progress(self, 90, "Guardando resultado...")
result_payload = final_response.get("result") or final_response
e_doc = result_payload.get("e_document") or result_payload.get("eDocument")
num_op = result_payload.get("numero_operacion") or result_payload.get("numeroOperacion")
acuse_b64 = result_payload.get("acuese_digitalizacion_pdf_base64") or result_payload.get("acuse_pdf_base64")
record.status = "success"
if e_doc:
record.e_document = str(e_doc)
if num_op:
record.num_operacion = str(num_op)
# Guardamos el acuse en-línea (no en S3 por ahora) o en una ruta
# Los acuses se almacenan en el campo acuse_pdf_path como indicación
if acuse_b64:
record.acuse_pdf_path = "inline"
db.commit()
return {
"status": "success",
"message": "Digitalización completada exitosamente.",
"e_document": e_doc,
"numero_operacion": num_op,
"acuese_digitalizacion_pdf_base64": acuse_b64,
"nombre_archivo": payload["nombre_archivo"],
"timestamp": result_payload.get("timestamp"),
"request_id": result_payload.get("request_id"),
"response_code": result_payload.get("response_code"),
}
except ValidationException as exc:
if db:
try:
record = db.get(ExpedienteArchivo, expediente_id) # type: ignore
if record:
record.status = "failed"
db.commit()
except Exception:
pass
first_error = (exc.errors or [{}])[0]
self.update_state(
state="FAILURE",
meta={
"error": first_error.get("message", str(exc)),
"error_type": "VALIDATION_ERROR",
"error_detail": {
"codigo": first_error.get("code", "VALIDATION_ERROR"),
"descripcion": first_error.get("message", ""),
"paso": "Validación",
"sugerencias": first_error.get("solution") or [],
},
},
)
return {}
except Exception as exc:
logger.exception("Error inesperado en digitalizar_task expediente_id=%s", expediente_id)
if db:
try:
record = db.get(ExpedienteArchivo, expediente_id) # type: ignore
if record:
record.status = "failed"
db.commit()
except Exception:
pass
self.update_state(
state="FAILURE",
meta={
"error": str(exc),
"error_type": type(exc).__name__,
"error_detail": {
"codigo": "UNEXPECTED_ERROR",
"descripcion": str(exc),
"paso": "Proceso de digitalización",
"sugerencias": ["Contacta al soporte técnico."],
},
},
)
return {}
finally:
db.close()

View File

@@ -53,6 +53,7 @@ from .app_settings.routes import router as app_settings_router
from .manifests.manifest.routes import router as manifests_router
from .manifests.driver.routes import router as manifest_drivers_router
from .manifests.manifiesto_anexo.routes import router as manifest_anexos_router
from .expediente_archivos.routes import router as expediente_archivos_router
# Router principal
@@ -85,6 +86,7 @@ router.include_router(drivers_router, prefix="/a76", tags=["a76 / drivers"])
router.include_router(transporters_router, prefix="/a76", tags=["a76 / transporters"])
router.include_router(vehicles_router, prefix="/a76/transportation", tags=["a76 / vehicles"])
router.include_router(factura_cove_router, prefix="/a76/factura-cove", tags=["a76 / factura_cove"])
router.include_router(expediente_archivos_router, prefix="/a76", tags=["a76 / expediente-archivos"])
# Registrar router de tipos de material públicos
router.include_router(

View File

@@ -132,6 +132,44 @@
"audit_logs_files_loading": "Loading files...",
"audit_logs_files_empty": "No files or folders found in this location.",
"audit_logs_files_download": "Download",
"digitalizacion": {
"title": "Digitization",
"subtitle": "Digitized Documents Catalog",
"new": "New",
"refresh": "Refresh",
"table_title": "Digitized documents",
"col_consecutivo": "Consecutive",
"col_tipo_documento": "Document Type",
"col_e_document": "E-Document",
"col_fecha": "Date",
"col_num_operacion_vu": "VU Operation No.",
"col_actions": "Actions",
"form_e_document": "E-Document",
"form_num_operacion": "Operation No.",
"form_tipo_documento": "Document Type",
"form_archivo_digitalizado_en": "Digitized in",
"form_fecha": "Date",
"form_agente_aduanal": "Customs Broker",
"form_pedimento": "Entry",
"form_nombre_archivo": "File name",
"digitalizar_title": "Digitize Document",
"digitalizar_subtitle": "Send document to Ventanilla Única",
"digitalizar_file_label": "File",
"digitalizar_rfc_consulta": "RFC Query",
"digitalizar_clave_documento": "Document Key",
"progress_title": "Digitalizing document...",
"progress_step": "Step",
"progress_success": "Digitalization completed successfully.",
"progress_download_acuse": "Download Receipt",
"action_digitalizar": "Digitalize",
"action_acuse": "Receipt",
"action_edit": "Edit",
"action_delete": "Delete",
"empty": "No digitized documents",
"loading": "Loading...",
"search_placeholder": "Search:",
"confirm_delete": "Are you sure you want to delete this document?"
},
"client_provider_type": {
"client_indicator": "C",
"provider_indicator": "P",

View File

@@ -132,6 +132,44 @@
"audit_logs_files_loading": "Cargando archivos...",
"audit_logs_files_empty": "No hay archivos o carpetas en esta ubicación.",
"audit_logs_files_download": "Descargar",
"digitalizacion": {
"title": "Digitalización",
"subtitle": "Catálogo de Documentos Digitalizados",
"new": "Nuevo",
"refresh": "Actualizar",
"table_title": "Documentos digitalizados",
"col_consecutivo": "Consecutivo",
"col_tipo_documento": "Tipo Documento",
"col_e_document": "E-Document",
"col_fecha": "Fecha",
"col_num_operacion_vu": "Núm. Operación VU",
"col_actions": "Acciones",
"form_e_document": "E-Document",
"form_num_operacion": "Núm. Operación",
"form_tipo_documento": "Tipo Documento",
"form_archivo_digitalizado_en": "Archivo Digitalizado en",
"form_fecha": "Fecha",
"form_agente_aduanal": "Agente Aduanal",
"form_pedimento": "Pedimento",
"form_nombre_archivo": "Nombre del archivo",
"digitalizar_title": "Digitalizar Documento",
"digitalizar_subtitle": "Enviar documento a Ventanilla Única",
"digitalizar_file_label": "Archivo",
"digitalizar_rfc_consulta": "RFC Consulta",
"digitalizar_clave_documento": "Clave Documento",
"progress_title": "Digitalizando documento...",
"progress_step": "Paso",
"progress_success": "Digitalización completada exitosamente.",
"progress_download_acuse": "Descargar Acuse",
"action_digitalizar": "Digitalizar",
"action_acuse": "Acuse",
"action_edit": "Editar",
"action_delete": "Borrar",
"empty": "Sin documentos digitalizados",
"loading": "Cargando...",
"search_placeholder": "Buscando:",
"confirm_delete": "¿Está seguro de eliminar este documento?"
},
"client_provider_type": {
"client_indicator": "C",
"provider_indicator": "P",

View File

@@ -0,0 +1,151 @@
import { api, type ApiResponse } from '$lib/api';
export interface ExpedienteArchivo {
id: number;
e_document?: string | null;
num_operacion?: string | null;
tipo_documento?: string | null;
archivo_digitalizado_en?: string | null;
fecha_digitalizacion?: string | null;
agente_aduanal?: string | null;
pedimento?: string | null;
rfc_consulta?: string | null;
nombre_archivo?: string | null;
status?: string | null;
task_id?: string | null;
external_task_id?: string | null;
acuse_pdf_path?: string | null;
company_id: number;
tenant_id: number;
}
export interface ExpedienteArchivoListResponse {
items: ExpedienteArchivo[];
total: number;
page: number;
page_size: number;
}
export interface ExpedienteArchivoCreateDTO {
e_document?: string | null;
num_operacion?: string | null;
tipo_documento?: string | null;
archivo_digitalizado_en?: string | null;
fecha_digitalizacion?: string | null;
agente_aduanal?: string | null;
pedimento?: string | null;
rfc_consulta?: string | null;
nombre_archivo?: string | null;
}
export interface DigitalizarRequest {
rfc_consulta: string;
clave_documento: string;
nombre_archivo: string;
archivo_base64: string;
}
export interface DigitalizarResponse {
task_id: string;
message: string;
status: string;
}
export interface DigitalizacionResult {
status?: string | null;
message?: string | null;
request_id?: string | null;
response_code?: number | null;
e_document?: string | null;
numero_operacion?: string | null;
nombre_archivo?: string | null;
timestamp?: string | null;
acuese_digitalizacion_pdf_base64?: string | null;
envio_xml_base64?: string | null;
respuesta_xml_base64?: string | null;
consulta_envio_xml_base64?: string | null;
consulta_respuesta_xml_base64?: string | null;
}
export interface DigitalizacionErrorDetail {
codigo?: string | null;
descripcion?: string | null;
paso?: string | null;
sugerencias?: string[] | null;
}
export interface DigitalizacionTaskDetailResponse {
task_id: string;
state: string;
status?: string | null;
current_step?: string | null;
progress?: number | null;
total_steps?: number | null;
request_id?: string | null;
result?: DigitalizacionResult | null;
error?: string | null;
error_type?: string | null;
error_detail?: DigitalizacionErrorDetail | null;
info?: Record<string, unknown> | null;
}
class ExpedienteArchivosApi {
private baseUrl = '/v1/a76/expediente-archivos';
async list(
companyId: string | number,
params?: Record<string, string | number | undefined>
): Promise<ApiResponse<ExpedienteArchivoListResponse>> {
const queryParams = new URLSearchParams({ company_id: companyId.toString() });
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') queryParams.set(k, String(v));
}
}
return api.get<ExpedienteArchivoListResponse>(`${this.baseUrl}?${queryParams}`);
}
async get(id: number, companyId: string | number): Promise<ApiResponse<ExpedienteArchivo>> {
const q = new URLSearchParams({ company_id: companyId.toString() });
return api.get<ExpedienteArchivo>(`${this.baseUrl}/${id}?${q}`);
}
async create(
data: ExpedienteArchivoCreateDTO,
companyId: string | number
): Promise<ApiResponse<ExpedienteArchivo>> {
const q = new URLSearchParams({ company_id: companyId.toString() });
return api.post<ExpedienteArchivo>(`${this.baseUrl}?${q}`, data);
}
async update(
id: number,
data: ExpedienteArchivoCreateDTO,
companyId: string | number
): Promise<ApiResponse<ExpedienteArchivo>> {
const q = new URLSearchParams({ company_id: companyId.toString() });
return api.put<ExpedienteArchivo>(`${this.baseUrl}/${id}?${q}`, data);
}
async delete(id: number, companyId: string | number): Promise<ApiResponse<void>> {
const q = new URLSearchParams({ company_id: companyId.toString() });
return api.delete<void>(`${this.baseUrl}/${id}?${q}`);
}
async digitalizar(
id: number,
body: DigitalizarRequest,
companyId: string | number
): Promise<ApiResponse<DigitalizarResponse>> {
const q = new URLSearchParams({ company_id: companyId.toString() });
return api.post<DigitalizarResponse>(`${this.baseUrl}/digitalizar/${id}?${q}`, body);
}
async getStatusTask(taskId: string): Promise<ApiResponse<DigitalizacionTaskDetailResponse>> {
return api.get<DigitalizacionTaskDetailResponse>(
`${this.baseUrl}/status-digitalizacion-task/${taskId}`
);
}
}
export const expedienteArchivosApi = new ExpedienteArchivosApi();

View File

@@ -0,0 +1,60 @@
import type { ExpedienteArchivo } from '$lib/api/dashboard/a76/expediente-archivos';
import type { ColumnDef } from '@tanstack/table-core';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
import EDocumentCell from './e-document-cell.svelte';
function formatDate(dateStr?: string | null): string {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch {
return dateStr;
}
}
export function createColumns(
onSuccess?: () => void,
onDigitalizar?: (item: ExpedienteArchivo) => void,
onAcuse?: (item: ExpedienteArchivo) => void
): ColumnDef<ExpedienteArchivo>[] {
return [
{
accessorKey: 'id',
header: 'Consecutivo',
cell: ({ row }) => row.original.id
},
{
accessorKey: 'tipo_documento',
header: 'Tipo Documento',
cell: ({ row }) => row.original.tipo_documento || '-'
},
{
accessorKey: 'e_document',
header: 'E-Document',
cell: ({ row }) => renderComponent(EDocumentCell, { item: row.original })
},
{
accessorKey: 'fecha_digitalizacion',
header: 'Fecha',
cell: ({ row }) => formatDate(row.original.fecha_digitalizacion)
},
{
accessorKey: 'num_operacion',
header: 'Núm. Operación VU',
cell: ({ row }) => row.original.num_operacion || '-'
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) =>
renderComponent(DataTableActions, {
item: row.original,
onSuccess,
onDigitalizar,
onAcuse
})
}
];
}

View File

@@ -0,0 +1,406 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input, FilePickerInput } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import * as Select from '$lib/components/ui/select';
import { LoaderCircle } from 'lucide-svelte';
import {
expedienteArchivosApi,
type ExpedienteArchivo,
type ExpedienteArchivoCreateDTO
} from '$lib/api/dashboard/a76/expediente-archivos';
import {
documentTypesDigitizationApi,
type DocumentTypeDigitization
} from '$lib/api/dashboard/reference_data/document_types_digitization';
import { customsBrokersApi, type CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers';
import { companyStore } from '$lib/stores/company.svelte';
import * as m from '$lib/paraglide/messages';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: ExpedienteArchivo | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(!!item);
const title = $derived(
isEdit ? 'Editar Documento Digitalizado' : 'Nuevo Documento Digitalizado'
);
// ── Catálogos ──────────────────────────────────────────────────────────── //
let docTypes = $state<DocumentTypeDigitization[]>([]);
let docTypesLoading = $state(false);
let brokers = $state<CustomsBroker[]>([]);
let brokersLoading = $state(false);
// ── RFC auto-llenado ────────────────────────────────────────────────────── //
async function onBrokerSelected(licenseKey: string | undefined) {
formData.agente_aduanal = licenseKey ?? null;
formData.rfc_consulta = null;
if (!licenseKey) return;
// Buscar en la lista ya cargada primero
const found = brokers.find((b) => b.license === licenseKey);
if (found?.vu?.query_tax_id) {
formData.rfc_consulta = found.vu.query_tax_id;
return;
}
// Fallback: obtener detalle completo del agente para acceder al VU
const company = companyStore.activeCompany;
if (!company) return;
try {
const res = await customsBrokersApi.get(licenseKey, company.id.toString());
const broker = (res.data || res) as CustomsBroker;
if (broker?.vu?.query_tax_id) {
formData.rfc_consulta = broker.vu.query_tax_id;
}
} catch {
// VU no disponible, dejar rfc vacío
}
}
// ── Formulario ─────────────────────────────────────────────────────────── //
let formData = $state<ExpedienteArchivoCreateDTO>({
e_document: null,
num_operacion: null,
tipo_documento: null,
archivo_digitalizado_en: null,
fecha_digitalizacion: null,
agente_aduanal: null,
pedimento: null,
rfc_consulta: null,
nombre_archivo: null
});
let loading = $state(false);
let error = $state<string | null>(null);
const selectedDocType = $derived(docTypes.find((d) => d.code === formData.tipo_documento) ?? null);
const selectedBroker = $derived(
brokers.find((broker) => broker.license === formData.agente_aduanal) ?? null
);
// ── Efectos ────────────────────────────────────────────────────────────── //
$effect(() => {
if (open && companyStore.activeCompany?.id) {
// Cargar tipos de documento
if (docTypes.length === 0) {
docTypesLoading = true;
documentTypesDigitizationApi
.getAll(true)
.then((res) => {
docTypes = (res.data as DocumentTypeDigitization[]) || [];
})
.catch(() => (docTypes = []))
.finally(() => (docTypesLoading = false));
}
// Cargar agentes aduanales
if (brokers.length === 0) {
brokersLoading = true;
customsBrokersApi
.list(companyStore.activeCompany.id.toString(), 1, 200)
.then((res) => {
const data = (res.data || res) as any;
if (Array.isArray(data)) brokers = data;
else if (data?.items) brokers = data.items;
})
.catch(() => (brokers = []))
.finally(() => (brokersLoading = false));
}
}
});
$effect(() => {
if (!open) {
error = null;
loading = false;
return;
}
if (item) {
formData = {
e_document: item.e_document ?? null,
num_operacion: item.num_operacion ?? null,
tipo_documento: item.tipo_documento ?? null,
archivo_digitalizado_en: item.archivo_digitalizado_en ?? null,
fecha_digitalizacion: item.fecha_digitalizacion ?? null,
agente_aduanal: item.agente_aduanal ?? null,
pedimento: item.pedimento ?? null,
rfc_consulta: item.rfc_consulta ?? null,
nombre_archivo: item.nombre_archivo ?? null
};
} else {
formData = {
e_document: null,
num_operacion: null,
tipo_documento: null,
archivo_digitalizado_en: null,
fecha_digitalizacion: null,
agente_aduanal: null,
pedimento: null,
rfc_consulta: null,
nombre_archivo: null
};
}
});
$effect(() => {
if (
!open ||
brokersLoading ||
!formData.agente_aduanal ||
!!formData.rfc_consulta ||
brokers.length === 0
) {
return;
}
void onBrokerSelected(formData.agente_aduanal ?? undefined);
});
// ── Submit ─────────────────────────────────────────────────────────────── //
async function handleSubmit() {
if (loading) return;
error = null;
loading = true;
try {
const company = companyStore.activeCompany;
if (!company) throw new Error('No hay una compañía seleccionada');
if (!formData.tipo_documento?.trim())
throw new Error('El Tipo de Documento es obligatorio');
if (!formData.archivo_digitalizado_en?.trim())
throw new Error('El campo "Archivo Digitalizado en" es obligatorio');
const payload: ExpedienteArchivoCreateDTO = {
e_document: formData.e_document || null,
num_operacion: formData.num_operacion || null,
tipo_documento: formData.tipo_documento || null,
archivo_digitalizado_en: formData.archivo_digitalizado_en || null,
fecha_digitalizacion: formData.fecha_digitalizacion || null,
agente_aduanal: formData.agente_aduanal || null,
pedimento: formData.pedimento || null,
rfc_consulta: formData.rfc_consulta || null,
nombre_archivo: formData.nombre_archivo || null
};
let response;
if (isEdit && item) {
response = await expedienteArchivosApi.update(item.id, payload, company.id);
} else {
response = await expedienteArchivosApi.create(payload, company.id);
}
if (response.error) {
const ve = (response as any).validationErrors;
if (ve?.length) throw new Error(ve.map((e: any) => e.msg).join(' · '));
throw new Error(response.error);
}
open = false;
onSuccess?.();
} catch (e) {
if (e && typeof e === 'object' && 'message' in e) {
error = (e as Error).message;
} else {
error = 'Error desconocido';
}
} 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 isEdit}
<Dialog.Description>
Modifica el documento digitalizado <span class="font-mono font-semibold">{item?.id}</span>.
El RFC Consulta se obtiene del VU del agente aduanal.
</Dialog.Description>
{:else}
<Dialog.Description>
Captura un nuevo documento digitalizado. El RFC Consulta se obtiene del VU del
agente aduanal.
</Dialog.Description>
{/if}
</Dialog.Header>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6">
{#if error}
<div class="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div class="grid gap-6 py-4">
<div class="space-y-4">
<h4 class="text-sm leading-none font-medium text-muted-foreground">Documento</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="tipo_documento">{m['sidebar.digitalizacion.form_tipo_documento']()} *</Label>
<Select.Root
type="single"
value={formData.tipo_documento ?? ''}
onValueChange={(v) => (formData.tipo_documento = v || null)}
disabled={loading || docTypesLoading}
>
<Select.Trigger id="tipo_documento" class="w-full">
<span class="truncate">
{docTypesLoading
? 'Cargando tipos...'
: selectedDocType
? `${selectedDocType.code}${selectedDocType.description ? ` — ${selectedDocType.description}` : ''}`
: 'Selecciona tipo...'}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each docTypes as dt}
<Select.Item value={dt.code}>
{dt.code}{dt.description ? ` — ${dt.description}` : ''}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label for="fecha_digitalizacion">{m['sidebar.digitalizacion.form_fecha']()}</Label>
<Input
id="fecha_digitalizacion"
type="date"
value={formData.fecha_digitalizacion ?? ''}
onchange={(e) => (formData.fecha_digitalizacion = (e.target as HTMLInputElement).value || null)}
disabled={loading}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="archivo_digitalizado_en">{m['sidebar.digitalizacion.form_archivo_digitalizado_en']()} *</Label>
<FilePickerInput
id="archivo_digitalizado_en"
value={formData.archivo_digitalizado_en ?? ''}
placeholder="Seleccionar archivo..."
disabled={loading}
onchange={(file) => {
formData.archivo_digitalizado_en = file.name;
if (!formData.nombre_archivo) formData.nombre_archivo = file.name;
}}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="nombre_archivo">{m['sidebar.digitalizacion.form_nombre_archivo']()}</Label>
<Input
id="nombre_archivo"
value={formData.nombre_archivo ?? ''}
oninput={(e) => (formData.nombre_archivo = (e.target as HTMLInputElement).value)}
placeholder="nombre_archivo.pdf"
disabled={loading}
/>
</div>
</div>
</div>
<Separator />
<div class="space-y-4">
<h4 class="text-sm leading-none font-medium text-muted-foreground">Consulta y referencia</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2">
<Label for="e_document">{m['sidebar.digitalizacion.form_e_document']()}</Label>
<Input
id="e_document"
value={formData.e_document ?? ''}
oninput={(e) => (formData.e_document = (e.target as HTMLInputElement).value)}
placeholder="E-Document"
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="num_operacion">{m['sidebar.digitalizacion.form_num_operacion']()}</Label>
<Input
id="num_operacion"
value={formData.num_operacion ?? ''}
oninput={(e) => (formData.num_operacion = (e.target as HTMLInputElement).value)}
placeholder="Número de operación"
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="agente_aduanal">{m['sidebar.digitalizacion.form_agente_aduanal']()}</Label>
<Select.Root
type="single"
value={formData.agente_aduanal ?? ''}
onValueChange={(v) => onBrokerSelected(v || undefined)}
disabled={loading || brokersLoading}
>
<Select.Trigger id="agente_aduanal" class="w-full">
<span class="truncate">
{brokersLoading
? 'Cargando agentes...'
: selectedBroker
? `${selectedBroker.license}${selectedBroker.name ? ` — ${selectedBroker.name}` : ''}`
: 'Selecciona agente...'}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each brokers as broker}
<Select.Item value={broker.license}>
{broker.license}{broker.name ? ` — ${broker.name}` : ''}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label for="rfc_consulta">RFC Consulta</Label>
<Input
id="rfc_consulta"
value={formData.rfc_consulta ?? ''}
placeholder="Se llena automáticamente del agente aduanal"
maxlength={13}
disabled
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="pedimento">{m['sidebar.digitalizacion.form_pedimento']()}</Label>
<Input
id="pedimento"
value={formData.pedimento ?? ''}
oninput={(e) => (formData.pedimento = (e.target as HTMLInputElement).value)}
placeholder="00-0000-0000000"
disabled={loading}
/>
</div>
</div>
</div>
</div>
<Dialog.Footer class="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onclick={() => (open = false)} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{/if}
{isEdit ? 'Guardar' : 'Crear'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { EllipsisVertical, Pencil, Trash2, FileCheck2, Download } from 'lucide-svelte';
import { expedienteArchivosApi, type ExpedienteArchivo } from '$lib/api/dashboard/a76/expediente-archivos';
import { companyStore } from '$lib/stores/company.svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
import * as m from '$lib/paraglide/messages';
let {
item,
onSuccess,
onDigitalizar,
onAcuse
}: {
item: ExpedienteArchivo;
onSuccess?: () => void;
onDigitalizar?: (item: ExpedienteArchivo) => void;
onAcuse?: (item: ExpedienteArchivo) => void;
} = $props();
let loading = $state(false);
let editOpen = $state(false);
async function handleDelete() {
if (!confirm(m['sidebar.digitalizacion.confirm_delete']())) return;
if (!companyStore.activeCompany) return;
loading = true;
try {
const res = await expedienteArchivosApi.delete(item.id, companyStore.activeCompany.id);
if (res.error) {
alert(`Error al eliminar: ${res.error}`);
return;
}
onSuccess?.();
} catch (e) {
alert(`Error: ${e instanceof Error ? e.message : 'Error desconocido'}`);
} finally {
loading = false;
}
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" disabled={loading}>
<EllipsisVertical class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => onDigitalizar?.(item)}>
<FileCheck2 class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_digitalizar']()}
</DropdownMenu.Item>
{#if item.status === 'success'}
<DropdownMenu.Item onclick={() => onAcuse?.(item)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_acuse']()}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => (editOpen = true)}>
<Pencil class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_edit']()}
</DropdownMenu.Item>
<DropdownMenu.Item
class="text-destructive focus:text-destructive"
onclick={handleDelete}
>
<Trash2 class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_delete']()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog bind:open={editOpen} {item} onSuccess={onSuccess} />

View File

@@ -0,0 +1,176 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input, FilePickerInput } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { LoaderCircle } from 'lucide-svelte';
import {
expedienteArchivosApi,
type ExpedienteArchivo,
type DigitalizarRequest
} from '$lib/api/dashboard/a76/expediente-archivos';
import { companyStore } from '$lib/stores/company.svelte';
import * as m from '$lib/paraglide/messages';
let {
open = $bindable(false),
item,
onSuccess
}: {
open: boolean;
item: ExpedienteArchivo;
onSuccess?: (taskId: string) => void;
} = $props();
let rfcConsulta = $state(item.rfc_consulta ?? '');
let claveDocumento = $state(item.tipo_documento ?? '');
let nombreArchivo = $state('');
let archivoBase64 = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (!open) {
error = null;
loading = false;
nombreArchivo = '';
archivoBase64 = '';
} else {
// Refrescar valores del item
rfcConsulta = item.rfc_consulta ?? '';
claveDocumento = item.tipo_documento ?? '';
}
});
function handleFileChange(file: File) {
if (!file) return;
nombreArchivo = file.name;
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
// Strip "data:...;base64," prefix
const idx = result.indexOf(',');
archivoBase64 = idx >= 0 ? result.slice(idx + 1) : result;
};
reader.readAsDataURL(file);
}
async function handleSubmit() {
if (loading) return;
error = null;
if (!rfcConsulta.trim()) {
error = 'El RFC Consulta es obligatorio';
return;
}
if (!claveDocumento.trim()) {
error = 'La Clave Documento es obligatoria';
return;
}
if (!archivoBase64) {
error = 'Selecciona un archivo para digitalizar';
return;
}
loading = true;
try {
const company = companyStore.activeCompany;
if (!company) throw new Error('No hay compañía seleccionada');
const body: DigitalizarRequest = {
rfc_consulta: rfcConsulta.trim(),
clave_documento: claveDocumento.trim(),
nombre_archivo: nombreArchivo,
archivo_base64: archivoBase64
};
const response = await expedienteArchivosApi.digitalizar(item.id, body, company.id);
if (response.error) {
const ve = (response as any).validationErrors;
if (ve?.length) throw new Error(ve.map((e: any) => e.msg).join(' · '));
throw new Error(response.error);
}
const taskId = response.data?.task_id;
if (!taskId) throw new Error('No se recibió task_id del servidor');
open = false;
onSuccess?.(taskId);
} catch (e) {
error = e instanceof Error ? e.message : 'Error desconocido';
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[600px]">
<Dialog.Header>
<Dialog.Title>{m['sidebar.digitalizacion.digitalizar_title']()}</Dialog.Title>
<Dialog.Description>{m['sidebar.digitalizacion.digitalizar_subtitle']()}</Dialog.Description>
</Dialog.Header>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6">
{#if error}
<div class="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div class="grid gap-4 py-4">
<!-- RFC Consulta -->
<div class="space-y-2">
<Label for="dg_rfc_consulta">{m['sidebar.digitalizacion.digitalizar_rfc_consulta']()}</Label>
<Input
id="dg_rfc_consulta"
bind:value={rfcConsulta}
placeholder="RFC"
maxlength={13}
disabled={loading}
/>
</div>
<!-- Clave Documento -->
<div class="space-y-2">
<Label for="dg_clave_documento">{m['sidebar.digitalizacion.digitalizar_clave_documento']()}</Label>
<Input
id="dg_clave_documento"
bind:value={claveDocumento}
placeholder="Ej. A1"
maxlength={10}
disabled={loading}
/>
</div>
<!-- Archivo -->
<div class="space-y-2">
<Label for="dg_file">{m['sidebar.digitalizacion.digitalizar_file_label']()}</Label>
<FilePickerInput
id="dg_file"
value={nombreArchivo}
placeholder="Seleccionar archivo para digitalizar"
accept=".pdf,.xml,.png,.jpg,.jpeg"
disabled={loading}
onchange={handleFileChange}
/>
{#if nombreArchivo}
<p class="mt-1 text-xs text-muted-foreground">{nombreArchivo}</p>
{/if}
</div>
</div>
<Dialog.Footer class="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onclick={() => (open = false)} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading || !archivoBase64}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{/if}
Digitalizar
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { FileCheck2 } from 'lucide-svelte';
import type { ExpedienteArchivo } from '$lib/api/dashboard/a76/expediente-archivos';
let { item }: { item: ExpedienteArchivo } = $props();
</script>
<span class="flex items-center gap-1.5">
{#if item.e_document}
<FileCheck2
class="h-4 w-4 shrink-0 {item.status === 'success'
? 'text-blue-500'
: 'text-muted-foreground'}"
/>
<span>{item.e_document}</span>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
</span>

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 { Progress } from '$lib/components/ui/progress';
import { Loader2, CheckCircle, XCircle, Download } from 'lucide-svelte';
import {
expedienteArchivosApi,
type DigitalizacionResult,
type DigitalizacionErrorDetail
} from '$lib/api/dashboard/a76/expediente-archivos';
import * as m from '$lib/paraglide/messages';
let {
open = $bindable(false),
taskId,
nombreArchivo = '',
onComplete,
onCancel
}: {
open: boolean;
taskId: string;
nombreArchivo?: string;
onComplete?: (result: DigitalizacionResult) => 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<DigitalizacionResult | null>(null);
let errorMsg = $state<string | null>(null);
let errorDetail = $state<DigitalizacionErrorDetail | null>(null);
let pollHandle = $state<ReturnType<typeof setInterval> | null>(null);
// Start / stop polling based on open + taskId
$effect(() => {
if (open && taskId) {
startPolling();
} else {
stopPolling();
if (!open) resetState();
}
return () => stopPolling();
});
function resetState() {
state = 'PENDING';
currentStep = 'Iniciando...';
progress = 0;
result = null;
errorMsg = null;
errorDetail = null;
}
function startPolling() {
stopPolling();
poll(); // immediate first call
pollHandle = setInterval(poll, 2000);
}
function stopPolling() {
if (pollHandle !== null) {
clearInterval(pollHandle);
pollHandle = null;
}
}
async function poll() {
if (!taskId) return;
try {
const res = await expedienteArchivosApi.getStatusTask(taskId);
if (!res.data) return;
const data = res.data;
state = (data.state || 'PENDING') as TaskState;
currentStep = data.current_step || 'Procesando...';
progress = data.progress ?? 0;
if (data.state === 'SUCCESS' && data.result) {
result = data.result;
stopPolling();
onComplete?.(data.result);
} else if (data.state === 'FAILURE') {
errorMsg = data.error || 'Error en la digitalización';
errorDetail = data.error_detail ?? null;
stopPolling();
}
} catch {
// Ignore transient poll errors
}
}
function downloadAcuse() {
if (!result?.acuese_digitalizacion_pdf_base64) return;
const bytes = Uint8Array.from(atob(result.acuese_digitalizacion_pdf_base64), (c) =>
c.charCodeAt(0)
);
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `acuse_${nombreArchivo || taskId}.pdf`;
a.click();
URL.revokeObjectURL(url);
}
function handleCancel() {
stopPolling();
open = false;
onCancel?.();
}
const isTerminal = $derived(state === 'SUCCESS' || state === 'FAILURE');
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>{m['sidebar.digitalizacion.progress_title']()}</Dialog.Title>
{#if nombreArchivo}
<Dialog.Description class="truncate">{nombreArchivo}</Dialog.Description>
{/if}
</Dialog.Header>
<div class="space-y-4 py-2">
{#if state === 'SUCCESS'}
<!-- Éxito -->
<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.digitalizacion.progress_success']()}</p>
</div>
{#if result}
<dl class="text-sm space-y-1">
{#if result.e_document}
<div class="flex gap-2">
<dt class="text-muted-foreground min-w-[120px]">E-Document:</dt>
<dd class="font-medium">{result.e_document}</dd>
</div>
{/if}
{#if result.numero_operacion}
<div class="flex gap-2">
<dt class="text-muted-foreground min-w-[120px]">Núm. Operación:</dt>
<dd class="font-medium">{result.numero_operacion}</dd>
</div>
{/if}
</dl>
{/if}
{#if result?.acuese_digitalizacion_pdf_base64}
<Button onclick={downloadAcuse} class="w-full gap-2">
<Download class="h-4 w-4" />
{m['sidebar.digitalizacion.progress_download_acuse']()}
</Button>
{/if}
{:else if state === 'FAILURE'}
<!-- Error -->
<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">{errorMsg}</p>
{#if errorDetail}
{#if errorDetail.codigo}
<p class="text-xs text-muted-foreground">Código: {errorDetail.codigo}</p>
{/if}
{#if errorDetail.paso}
<p class="text-xs text-muted-foreground">Paso: {errorDetail.paso}</p>
{/if}
{#if errorDetail.sugerencias?.length}
<ul class="text-xs list-disc pl-4 space-y-0.5 mt-1">
{#each errorDetail.sugerencias as s}
<li>{s}</li>
{/each}
</ul>
{/if}
{/if}
</div>
</div>
{:else}
<!-- En progreso -->
<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>
<Progress value={progress} max={100} class="h-2" />
<p class="text-xs text-right text-muted-foreground">{progress}%</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

@@ -7,6 +7,7 @@ import {
Database,
FileSearch,
FileText,
FolderArchive,
Frame,
GalleryVerticalEnd,
Hash,
@@ -509,6 +510,12 @@ export function getSidebarData(): SidebarData {
},
],
},
{
title: m["sidebar.digitalizacion.title"](),
url: "/dashboard/digitalizacion",
icon: FolderArchive,
items: [],
},
{
title: m["sidebar.reference_data.configuracion"](),
url: "#",

View File

@@ -0,0 +1,223 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus, RefreshCw } from 'lucide-svelte';
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/digitalizacion/create-edit-dialog.svelte';
import DigitalizarDialog from '$lib/components/dashboard/digitalizacion/digitalizar-dialog.svelte';
import ProgressDialog from '$lib/components/dashboard/digitalizacion/progress-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/digitalizacion/columns';
import {
expedienteArchivosApi,
type ExpedienteArchivo,
type DigitalizacionResult
} from '$lib/api/dashboard/a76/expediente-archivos';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
import * as m from '$lib/paraglide/messages';
// ── Estado ─────────────────────────────────────────────────────────────── //
let data = $state<ExpedienteArchivo[]>([]);
let totalItems = $state(0);
let loading = $state(false);
let currentPage = $state(1);
let pageSize = $state(50);
let hasMore = $derived(data.length < totalItems);
let search = $state($page.url.searchParams.get('search') || '');
let searchTimeout: ReturnType<typeof setTimeout>;
// Dialogs
let createDialogOpen = $state(false);
let digitalizarDialogOpen = $state(false);
let progressDialogOpen = $state(false);
let selectedItem = $state<ExpedienteArchivo | null>(null);
let currentTaskId = $state<string>('');
let currentNombreArchivo = $state<string>('');
// Acuses por session (id → base64)
let acuseMap = $state<Record<number, string>>({});
// ── Carga de datos ─────────────────────────────────────────────────────── //
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const res = await expedienteArchivosApi.list(companyStore.activeCompany.id, {
page: 1,
page_size: pageSize,
search: search || undefined
});
if (res.data) {
data = res.data.items;
currentPage = 1;
totalItems = res.data.total;
}
} catch (e) {
console.error('Error loading expediente archivos:', e);
} finally {
loading = false;
}
}
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const res = await expedienteArchivosApi.list(companyStore.activeCompany.id, {
page: currentPage + 1,
page_size: pageSize,
search: search || undefined
});
if (res.data?.items) {
data = [...data, ...res.data.items];
currentPage += 1;
totalItems = res.data.total;
}
} catch (e) {
console.error('Error loading more:', e);
} finally {
loading = false;
}
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL($page.url);
if (search) url.searchParams.set('search', search);
else url.searchParams.delete('search');
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
$effect(() => {
const _ = { p: $page.url.href, c: companyStore.activeCompany?.id };
loadData();
});
// ── Handlers de acciones ───────────────────────────────────────────────── //
function handleDigitalizar(item: ExpedienteArchivo) {
selectedItem = item;
digitalizarDialogOpen = true;
}
function handleDigitalizarSuccess(taskId: string) {
currentTaskId = taskId;
currentNombreArchivo = selectedItem?.nombre_archivo ?? '';
progressDialogOpen = true;
}
function handleProgressComplete(result: DigitalizacionResult) {
// Guardar acuse en sesión si viene incluido
if (selectedItem && result.acuese_digitalizacion_pdf_base64) {
acuseMap = { ...acuseMap, [selectedItem.id]: result.acuese_digitalizacion_pdf_base64 };
}
loadData();
}
function handleAcuse(item: ExpedienteArchivo) {
const b64 = acuseMap[item.id];
if (!b64) {
alert('No hay acuse disponible para este documento en esta sesión.');
return;
}
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `acuse_${item.nombre_archivo || item.id}.pdf`;
a.click();
URL.revokeObjectURL(url);
}
const columns = createColumns(loadData, handleDigitalizar, handleAcuse);
</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"
>
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">
{m['sidebar.digitalizacion.title']()}
</h1>
<p class="text-muted-foreground">{m['sidebar.digitalizacion.subtitle']()}</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" class="h-9" onclick={loadData}>
<RefreshCw class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.refresh']()}
</Button>
<Button class="h-9" onclick={() => (createDialogOpen = true)}>
<Plus class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.new']()}
</Button>
</div>
</div>
<!-- Table card -->
<Card.Root class="border bg-background flex flex-col flex-1 min-h-0">
<Card.Header>
<div class="flex flex-wrap items-center justify-between gap-3">
<Card.Title>{m['sidebar.digitalizacion.table_title']()}</Card.Title>
<div class="flex items-center gap-2">
<Input
placeholder="{m['sidebar.digitalizacion.search_placeholder']()} ..."
class="h-9 w-56 bg-card"
bind:value={search}
oninput={handleSearch}
/>
</div>
</div>
</Card.Header>
<Card.Content class="p-0 flex-1 min-h-0 overflow-hidden">
{#if loading && data.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
{m['sidebar.digitalizacion.loading']()}
</div>
{:else if data.length === 0 && !loading}
<div class="flex h-64 items-center justify-center text-muted-foreground">
{m['sidebar.digitalizacion.empty']()}
</div>
{:else}
<div class="rounded-md border bg-background overflow-hidden h-full">
<InfiniteDataTable {data} {columns} {loading} {hasMore} {loadMore} />
</div>
{/if}
</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">
Mostrando {data.length} de {totalItems} registros
</div>
</div>
<!-- Dialogs -->
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
{#if selectedItem && digitalizarDialogOpen}
<DigitalizarDialog
bind:open={digitalizarDialogOpen}
item={selectedItem}
onSuccess={handleDigitalizarSuccess}
/>
{/if}
{#if progressDialogOpen && currentTaskId}
<ProgressDialog
bind:open={progressDialogOpen}
taskId={currentTaskId}
nombreArchivo={currentNombreArchivo}
onComplete={handleProgressComplete}
onCancel={() => { progressDialogOpen = false; loadData(); }}
/>
{/if}