feature/digitalizacion-api
This commit is contained in:
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
133
backend/api/v1/modules/a76/expediente_archivos/dto.py
Normal file
133
backend/api/v1/modules/a76/expediente_archivos/dto.py
Normal 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
|
||||
@@ -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()
|
||||
35
backend/api/v1/modules/a76/expediente_archivos/models.py
Normal file
35
backend/api/v1/modules/a76/expediente_archivos/models.py
Normal 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)
|
||||
182
backend/api/v1/modules/a76/expediente_archivos/routes.py
Normal file
182
backend/api/v1/modules/a76/expediente_archivos/routes.py
Normal 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)
|
||||
359
backend/api/v1/modules/a76/expediente_archivos/service.py
Normal file
359
backend/api/v1/modules/a76/expediente_archivos/service.py
Normal 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,
|
||||
}
|
||||
286
backend/api/v1/modules/a76/expediente_archivos/tasks.py
Normal file
286
backend/api/v1/modules/a76/expediente_archivos/tasks.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
151
frontend/src/lib/api/dashboard/a76/expediente-archivos.ts
Normal file
151
frontend/src/lib/api/dashboard/a76/expediente-archivos.ts
Normal 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();
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: "#",
|
||||
|
||||
223
frontend/src/routes/dashboard/digitalizacion/+page.svelte
Normal file
223
frontend/src/routes/dashboard/digitalizacion/+page.svelte
Normal 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}
|
||||
Reference in New Issue
Block a user