feature/digitalizacion-api

This commit is contained in:
2026-04-20 15:13:48 -06:00
parent 32f0dcc35f
commit 1cd81ebc03
29 changed files with 3275 additions and 1004 deletions

View File

@@ -1,19 +1,73 @@
"""create expediente_archivo table
"""create expediente_archivo table and seed document types digitization catalog
Revision ID: f1a2b3c4d5e6
Revises: d4e5f6a7b8c9
Create Date: 2026-04-17 12:00:00.000000
Revises: f7a8b9c0d1e2
Create Date: 2026-04-20 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "f1a2b3c4d5e6"
down_revision = "f7a8b9c0d1e2"
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# Seed data
# ---------------------------------------------------------------------------
DOCUMENT_TYPES = [
("168", "Calca o fotografía digital del NIV del vehículo."),
("169", "Aviso."),
("170", "Factura."),
("171", "Documento con el que se acredite la propiedad de la mercancía."),
("172", "Contratos."),
("176", "Documentación relacionada con la garantía otorgada en términos de los artículos 84."),
("177", "Identificación Oficial."),
("179", "Comprobante de domicilio."),
("184", "Documento que ampara el avaluó de las mercancías."),
("185", "Documentos de adjudicación judicial de las mercancías."),
("187", "Solicitud de retiro de mercancías que causaron abandono."),
("189", "Actas."),
("192", "Escritos."),
("420", "Certificado de peso o volumen."),
("421", "Comprobante de la importación temporal de la embarcación debidamente formalizado."),
("422", "Comprobante expedido por donataria."),
("423", "Consulta en la que conste que el vehículo no se encuentra reportado como robado,"),
("424", "Clave Unica del Registro de Población."),
("425", "Declaración de internación o extracción de cantidades en efectivo y/o documentos p"),
("426", "Declaración de operaciones que no confieren origen en países no parte de acuerdo"),
("427", "Declaración en la que se señalen los motivos por los que efectúa la devolución de m"),
("428", "Documentación con información que permita la identificación, análisis y control en tér"),
("429", "Documentación que acredite que acepta y subsana la irregularidad."),
("430", "Documentación que ampare la importación temporal del vehículo de que se trate."),
("431", "Documentación que compruebe que la adquisición de las mercancías fue efectuada "),
("433", "Documento con base en el cual se determine la procedencia y el origen de las merca"),
("434", "Documento con que se acredite el reintegro del IVA, en caso de que el contribuyente "),
("435", "Documentos previstos en la regla 8.7., fracciones I a IV de la Resolución del TLCAN."),
("436", "El Documento que compruebe el cumplimiento de las regulaciones y restricciones no "),
("438", "Guía aérea, conocimiento de embarque o carta de porte."),
("439", "Hoja con los datos de la matrícula y nombre del barco, el lugar donde se localiza y se "),
("440", "Manifiesto de carga."),
("441", "Oficios emitidos por autoridad."),
("442", "Pedimentos."),
("443", "Programa IMMEX."),
("444", "Relación de candados."),
("445", "Relación de certificados de origen."),
]
# ---------------------------------------------------------------------------
# Upgrade / Downgrade
# ---------------------------------------------------------------------------
def upgrade() -> None:
# -- Table -----------------------------------------------------------------
op.create_table(
"expediente_archivo",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
@@ -30,6 +84,10 @@ def upgrade() -> None:
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("envio_xml_path", sa.String(length=500), nullable=True),
sa.Column("respuesta_xml_path", sa.String(length=500), nullable=True),
sa.Column("consulta_envio_xml_path", sa.String(length=500), nullable=True),
sa.Column("consulta_respuesta_xml_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),
@@ -69,10 +127,67 @@ def upgrade() -> None:
schema="a76",
)
# -- Seeds -----------------------------------------------------------------
bind = op.get_bind()
companies = (
bind.execute(sa.text("SELECT id, tenant_id FROM a76.company ORDER BY id"))
.mappings()
.all()
)
for company in companies:
for code, description in DOCUMENT_TYPES:
bind.execute(
sa.text(
"""
INSERT INTO a76.document_types_digitization
(tenant_id, company_id, code, description, active)
VALUES
(:tenant_id, :company_id, :code, :description, TRUE)
ON CONFLICT ON CONSTRAINT document_types_digitization_code_key
DO NOTHING
"""
),
{
"tenant_id": company["tenant_id"],
"company_id": company["id"],
"code": code,
"description": description,
},
)
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")
# -- Remove seeds ----------------------------------------------------------
bind = op.get_bind()
codes = [code for code, _ in DOCUMENT_TYPES]
placeholders = ", ".join(f":c{i}" for i in range(len(codes)))
params = {f"c{i}": code for i, code in enumerate(codes)}
bind.execute(
sa.text(
f"DELETE FROM a76.document_types_digitization WHERE code IN ({placeholders})"
),
params,
)
# -- Drop table ------------------------------------------------------------
op.drop_index(
op.f("ix_a76_expediente_archivo_external_task_id"),
table_name="expediente_archivo",
schema="a76",
)
op.drop_index(
op.f("ix_a76_expediente_archivo_task_id"),
table_name="expediente_archivo",
schema="a76",
)
op.drop_index(
op.f("ix_a76_expediente_archivo_tenant_id"),
table_name="expediente_archivo",
schema="a76",
)
op.drop_index(
op.f("ix_a76_expediente_archivo_company_id"),
table_name="expediente_archivo",
schema="a76",
)
op.drop_table("expediente_archivo", schema="a76")

View File

@@ -1,170 +1,97 @@
from typing import List
from typing import Any, Dict, Optional
from api.v1.modules.a76.doc_types_dig.dto import (
DocumentTypeDigitizationCreate,
DocumentTypeDigitizationResponse,
DocumentTypeDigitizationUpdate,
)
from api.v1.modules.a76.doc_types_dig.models import DocumentTypeDigitization
from core.database import get_core_db
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
router = APIRouter(prefix="/document-types-digitization", tags=["Document Types Digitization"])
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .dto import DocumentTypeDigitizationResponse
from .service import DocumentTypeDigitizationService
router = APIRouter(prefix='/document-types-digitization', tags=['Document Types Digitization'])
@router.get("", response_model=List[DocumentTypeDigitizationResponse])
def get_all_document_types(
active_only: bool = True,
db: Session = Depends(get_core_db),
@router.get('/', response_model=Dict[str, Any])
async def list_document_types(
company_id: int = Query(..., description='Company ID'),
page: int = Query(1, ge=1, description='Page number'),
page_size: int = Query(50, ge=1, le=2000, description='Page size'),
search: Optional[str] = Query(None, description='Search by code or description'),
active_only: bool = Query(False, description='Only active records'),
sort_by: Optional[str] = Query('code', description='Column to sort by'),
sort_order: str = Query('asc', pattern='^(asc|desc)$', description='Sort order'),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtener todos los tipos de documentos para digitalización
Args:
active_only: Si es True, solo devuelve los tipos activos
"""
query = select(DocumentTypeDigitization)
if active_only:
query = query.where(DocumentTypeDigitization.active == True)
query = query.order_by(DocumentTypeDigitization.code)
result = db.execute(query)
document_types = result.scalars().all()
return document_types
tenant_id = validate_access_to_resource(db, company_id, current_user)
skip = (page - 1) * page_size
filters: Dict[str, Any] = {}
if search:
filters['search'] = search
if active_only:
filters['active_only'] = True
items, total = DocumentTypeDigitizationService.get_all(
db,
tenant_id,
company_id,
skip,
page_size,
filters,
sort_by,
sort_order,
)
return {
'items': [DocumentTypeDigitizationResponse.model_validate(item) for item in items],
'total': total,
'page': page,
'page_size': page_size,
}
@router.get("/{document_type_id}", response_model=DocumentTypeDigitizationResponse)
def get_document_type(
document_type_id: int,
db: Session = Depends(get_core_db),
@router.get('/{document_type_id}/', response_model=DocumentTypeDigitizationResponse)
async def get_document_type(
document_type_id: int,
company_id: int = Query(..., description='Company ID'),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Obtener un tipo de documento por ID"""
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.id == document_type_id)
)
document_type = result.scalar_one_or_none()
if not document_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tipo de documento con ID {document_type_id} no encontrado"
)
return document_type
tenant_id = validate_access_to_resource(db, company_id, current_user)
document_type = DocumentTypeDigitizationService.get_by_id(
db,
document_type_id,
tenant_id,
company_id,
)
if not document_type:
raise HTTPException(
status_code=404,
detail=f'Tipo de documento con ID {document_type_id} no encontrado',
)
return document_type
@router.get("/by-code/{code}", response_model=DocumentTypeDigitizationResponse)
def get_document_type_by_code(
code: str,
db: Session = Depends(get_core_db),
@router.get('/by-code/{code}/', response_model=DocumentTypeDigitizationResponse)
async def get_document_type_by_code(
code: str,
company_id: int = Query(..., description='Company ID'),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Obtener un tipo de documento por código"""
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.code == code)
)
document_type = result.scalar_one_or_none()
if not document_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tipo de documento con código {code} no encontrado"
)
return document_type
tenant_id = validate_access_to_resource(db, company_id, current_user)
document_type = DocumentTypeDigitizationService.get_by_code(
db,
code,
tenant_id,
company_id,
)
if not document_type:
raise HTTPException(status_code=404, detail=f'Tipo de documento con codigo {code} no encontrado')
@router.post("", response_model=DocumentTypeDigitizationResponse, status_code=status.HTTP_201_CREATED)
def create_document_type(
document_type_data: DocumentTypeDigitizationCreate,
db: Session = Depends(get_core_db),
):
"""Crear un nuevo tipo de documento"""
# Verificar si el código ya existe
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.code == document_type_data.code)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ya existe un tipo de documento con el código {document_type_data.code}"
)
new_document_type = DocumentTypeDigitization(**document_type_data.model_dump())
db.add(new_document_type)
db.commit()
db.refresh(new_document_type)
return new_document_type
@router.put("/{document_type_id}", response_model=DocumentTypeDigitizationResponse)
def update_document_type(
document_type_id: int,
document_type_data: DocumentTypeDigitizationUpdate,
db: Session = Depends(get_core_db),
):
"""Actualizar un tipo de documento existente"""
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.id == document_type_id)
)
document_type = result.scalar_one_or_none()
if not document_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tipo de documento con ID {document_type_id} no encontrado"
)
# Actualizar solo los campos proporcionados
update_data = document_type_data.model_dump(exclude_unset=True)
# Verificar si el nuevo código ya existe (si se está actualizando)
if "code" in update_data and update_data["code"] != document_type.code:
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.code == update_data["code"])
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Ya existe un tipo de documento con el código {update_data['code']}"
)
for field, value in update_data.items():
setattr(document_type, field, value)
db.commit()
db.refresh(document_type)
return document_type
@router.delete("/{document_type_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_document_type(
document_type_id: int,
db: Session = Depends(get_core_db),
):
"""Eliminar un tipo de documento (soft delete, marca como inactivo)"""
result = db.execute(
select(DocumentTypeDigitization).where(DocumentTypeDigitization.id == document_type_id)
)
document_type = result.scalar_one_or_none()
if not document_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tipo de documento con ID {document_type_id} no encontrado"
)
# Soft delete - solo marcar como inactivo
document_type.active = False
db.commit()
return None
return document_type

View File

@@ -0,0 +1,211 @@
from __future__ import annotations
from typing import Any, Dict, Optional, Tuple
from sqlalchemy import or_
from sqlalchemy.orm import Session
from .dto import DocumentTypeDigitizationCreate, DocumentTypeDigitizationUpdate
from .models import DocumentTypeDigitization
class DocumentTypeDigitizationService:
@staticmethod
def _normalize_code(code: str) -> str:
return (code or '').strip().upper()
@staticmethod
def _normalize_description(description: str) -> str:
return (description or '').strip()
@staticmethod
def _is_truthy_filter(value: Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().lower() in {'1', 'true', 'yes', 'si'}
@staticmethod
def get_all(
db: Session,
tenant_id: int,
company_id: Optional[int],
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
sort_by: Optional[str] = None,
sort_order: str = 'asc',
) -> Tuple[list[DocumentTypeDigitization], int]:
query = db.query(DocumentTypeDigitization).filter(
DocumentTypeDigitization.tenant_id == tenant_id,
)
if company_id is not None:
query = query.filter(DocumentTypeDigitization.company_id == company_id)
filters = filters or {}
active_only = DocumentTypeDigitizationService._is_truthy_filter(
filters.get('active_only'),
default=False,
)
search = (filters.get('search') or '').strip()
if active_only:
query = query.filter(DocumentTypeDigitization.active.is_(True))
if search:
like = f'%{search}%'
query = query.filter(
or_(
DocumentTypeDigitization.code.ilike(like),
DocumentTypeDigitization.description.ilike(like),
)
)
total = query.count()
sort_column = {
'id': DocumentTypeDigitization.id,
'code': DocumentTypeDigitization.code,
'description': DocumentTypeDigitization.description,
'active': DocumentTypeDigitization.active,
}.get(sort_by or 'code', DocumentTypeDigitization.code)
if sort_order == 'desc':
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column.asc())
items = query.offset(skip).limit(limit).all()
return items, total
@staticmethod
def get_by_id(
db: Session,
id: int,
tenant_id: int,
company_id: int,
) -> Optional[DocumentTypeDigitization]:
return (
db.query(DocumentTypeDigitization)
.filter(
DocumentTypeDigitization.id == id,
DocumentTypeDigitization.tenant_id == tenant_id,
DocumentTypeDigitization.company_id == company_id,
)
.first()
)
@staticmethod
def get_by_code(
db: Session,
code: str,
tenant_id: int,
company_id: int,
) -> Optional[DocumentTypeDigitization]:
normalized_code = DocumentTypeDigitizationService._normalize_code(code)
return (
db.query(DocumentTypeDigitization)
.filter(
DocumentTypeDigitization.code == normalized_code,
DocumentTypeDigitization.tenant_id == tenant_id,
DocumentTypeDigitization.company_id == company_id,
)
.first()
)
@staticmethod
def create(
db: Session,
data: DocumentTypeDigitizationCreate,
tenant_id: int,
company_id: int,
) -> DocumentTypeDigitization:
payload = data.model_dump()
payload['code'] = DocumentTypeDigitizationService._normalize_code(payload['code'])
payload['description'] = DocumentTypeDigitizationService._normalize_description(
payload['description']
)
if not payload['code']:
raise ValueError('El codigo es obligatorio')
if not payload['description']:
raise ValueError('La descripcion es obligatoria')
existing = DocumentTypeDigitizationService.get_by_code(
db, payload['code'], tenant_id, company_id
)
if existing:
raise ValueError(
f'Ya existe un tipo de documento con el codigo {payload["code"]}'
)
db_obj = DocumentTypeDigitization(
**payload,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def update(
db: Session,
id: int,
tenant_id: int,
data: DocumentTypeDigitizationUpdate,
company_id: int,
) -> Optional[DocumentTypeDigitization]:
db_obj = DocumentTypeDigitizationService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return None
update_dict = data.model_dump(exclude_unset=True)
if 'code' in update_dict:
update_dict['code'] = DocumentTypeDigitizationService._normalize_code(update_dict['code'])
if not update_dict['code']:
raise ValueError('El codigo es obligatorio')
if update_dict['code'] != db_obj.code:
existing = DocumentTypeDigitizationService.get_by_code(
db,
update_dict['code'],
tenant_id,
company_id,
)
if existing:
raise ValueError(
f'Ya existe un tipo de documento con el codigo {update_dict["code"]}'
)
if 'description' in update_dict:
update_dict['description'] = DocumentTypeDigitizationService._normalize_description(
update_dict['description']
)
if not update_dict['description']:
raise ValueError('La descripcion es obligatoria')
for key, value in update_dict.items():
setattr(db_obj, key, value)
db.commit()
db.refresh(db_obj)
return db_obj
@staticmethod
def delete(
db: Session,
id: int,
tenant_id: int,
company_id: int,
) -> bool:
db_obj = DocumentTypeDigitizationService.get_by_id(db, id, tenant_id, company_id)
if not db_obj:
return False
db_obj.active = False
db.commit()
return True

View File

@@ -49,6 +49,10 @@ class ExpedienteArchivoResponseDTO(BaseModel):
task_id: Optional[str] = None
external_task_id: Optional[str] = None
acuse_pdf_path: Optional[str] = None
envio_xml_path: Optional[str] = None
respuesta_xml_path: Optional[str] = None
consulta_envio_xml_path: Optional[str] = None
consulta_respuesta_xml_path: Optional[str] = None
company_id: int
tenant_id: int
@@ -71,15 +75,15 @@ 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
rfc_consulta: Optional[str] = Field(None, max_length=13)
clave_documento: Optional[str] = Field(None, max_length=10)
nombre_archivo: Optional[str] = Field(None, max_length=255)
archivo_base64: Optional[str] = None # contenido del archivo en base64; opcional si el expediente ya tiene archivo almacenado
class RegistrarDigitalizacionRequest(BaseModel):
"""Digitalizar múltiples expedientes existentes por ID."""
rfc_consulta: str = Field(..., max_length=13)
rfc_consulta: Optional[str] = Field(None, max_length=13)
ids_archivos: List[int]
@@ -120,6 +124,7 @@ class DigitalizacionErrorDetail(BaseModel):
class DigitalizacionTaskDetailResponse(BaseModel):
task_id: str
external_task_id: Optional[str] = None
state: str
status: Optional[str] = None
current_step: Optional[str] = None

View File

@@ -40,9 +40,8 @@ class ExpedienteExternalService:
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:
# connect=10s, read=120s: la subida del PDF puede tomar tiempo en el servidor VU
with httpx.Client(timeout=httpx.Timeout(120.0, connect=10.0), verify=False) as client:
response = client.post(url, json=payload)
response.raise_for_status()
return response.json()
@@ -54,7 +53,9 @@ class ExpedienteExternalService:
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:
# read=None: sin límite de lectura — VU mantiene la conexión abierta mientras procesa.
# El timeout global del polling loop (300 s) actúa como cota máxima real.
with httpx.Client(timeout=httpx.Timeout(None, connect=10.0), verify=False) as client:
response = client.get(url)
response.raise_for_status()
return response.json()

View File

@@ -33,3 +33,7 @@ class ExpedienteArchivo(Base, TenantScopedMixin, TimestampMixin):
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)
envio_xml_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
respuesta_xml_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
consulta_envio_xml_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
consulta_respuesta_xml_path: Mapped[str | None] = mapped_column(String(500), nullable=True)

View File

@@ -1,12 +1,20 @@
from __future__ import annotations
from datetime import datetime
import io
import mimetypes
import os
import zipfile
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
from sqlalchemy.orm import Session
from core.config import settings
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from core.s3_keys import expediente_archivo_document_key
from core.storage_s3 import delete_object_if_exists, get_object_bytes, object_exists, put_object_bytes
from .dto import (
DigitalizacionTaskDetailResponse,
@@ -28,6 +36,20 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/expediente-archivos")
def _remove_stored_document_path(path: str | None) -> None:
raw = (path or "").strip()
if not raw:
return
try:
if settings.use_s3_object_storage and not os.path.isabs(raw):
delete_object_if_exists(raw)
return
if os.path.exists(raw):
os.remove(raw)
except Exception:
logger.warning("No se pudo eliminar archivo previo de expediente: %s", raw, exc_info=True)
# ──────────────────────────────────────────────────────────────────────────── #
# CRUD #
# ──────────────────────────────────────────────────────────────────────────── #
@@ -98,10 +120,191 @@ def delete_expediente_archivo(
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
_remove_stored_document_path(record.archivo_digitalizado_en)
ExpedienteArchivoService.delete(db, record)
return None
_ARTIFACT_TYPE_MAP = {
"acuse": ("acuse_pdf_path", "application/pdf"),
"envio-xml": ("envio_xml_path", "application/xml"),
"respuesta-xml": ("respuesta_xml_path", "application/xml"),
"consulta-envio-xml": ("consulta_envio_xml_path", "application/xml"),
"consulta-respuesta-xml": ("consulta_respuesta_xml_path", "application/xml"),
}
@router.get("/{record_id}/artifacts/{artifact_type}", response_class=Response)
def download_artifact(
record_id: int,
artifact_type: str,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Descarga un artefacto de digitalización (acuse PDF o XMLs) desde S3."""
tenant_id = validate_access_to_resource(db, company_id, current_user)
if artifact_type not in _ARTIFACT_TYPE_MAP:
raise HTTPException(status_code=400, detail=f"Tipo de artefacto no válido: {artifact_type}")
record = ExpedienteArchivoService.get(db, record_id, company_id, tenant_id)
if not record:
raise HTTPException(status_code=404, detail="Expediente archivo no encontrado.")
field_name, content_type = _ARTIFACT_TYPE_MAP[artifact_type]
key = (getattr(record, field_name, None) or "").strip()
if not key or key == "inline":
raise HTTPException(status_code=404, detail="Artefacto no disponible para este expediente.")
if not object_exists(key):
raise HTTPException(status_code=404, detail="El archivo no se encontró en el almacenamiento.")
ext = ".pdf" if content_type == "application/pdf" else ".xml"
filename = f"{artifact_type}_{record.e_document or record_id}{ext}"
return Response(
content=get_object_bytes(key),
media_type=content_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/{record_id}/artifacts-zip", response_class=Response)
def download_artifacts_zip(
record_id: int,
company_id: int = Query(...),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""Descarga todos los artefactos disponibles de un expediente en un archivo ZIP."""
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.")
base_name = record.e_document or str(record_id)
artifact_files = [
("acuse", "acuse_pdf_path", f"acuse_{base_name}.pdf"),
("envio-xml", "envio_xml_path", f"envio_{base_name}.xml"),
("respuesta-xml", "respuesta_xml_path", f"respuesta_{base_name}.xml"),
("consulta-envio-xml", "consulta_envio_xml_path", f"consulta_envio_{base_name}.xml"),
("consulta-respuesta-xml", "consulta_respuesta_xml_path", f"consulta_respuesta_{base_name}.xml"),
]
buf = io.BytesIO()
added = 0
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
for _type, field_name, filename in artifact_files:
key = (getattr(record, field_name, None) or "").strip()
if not key or key == "inline":
continue
if not object_exists(key):
continue
zf.writestr(filename, get_object_bytes(key))
added += 1
if added == 0:
raise HTTPException(status_code=404, detail="No hay artefactos disponibles para este expediente.")
buf.seek(0)
zip_filename = f"expediente_{base_name}.zip"
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
)
@router.post("/{record_id}/upload", response_model=Dict[str, Any])
async def upload_expediente_archivo_file(
record_id: int,
company_id: int = Query(...),
file: UploadFile = File(...),
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.")
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="El archivo está vacío.")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# Borrar artefactos de digitalización previa al subir documento nuevo
_ARTIFACT_FIELDS = [
"acuse_pdf_path",
"envio_xml_path",
"respuesta_xml_path",
"consulta_envio_xml_path",
"consulta_respuesta_xml_path",
]
for _field in _ARTIFACT_FIELDS:
_old_key = (getattr(record, _field, None) or "").strip()
if _old_key and _old_key != "inline":
try:
delete_object_if_exists(_old_key)
except Exception:
logger.warning("No se pudo eliminar artefacto previo %s=%s", _field, _old_key, exc_info=True)
setattr(record, _field, None)
# El documento fuente cambió — la digitalización anterior ya no aplica
record.status = "pending"
record.e_document = None
record.num_operacion = None
record.task_id = None
record.external_task_id = None
try:
if settings.use_s3_object_storage:
key = expediente_archivo_document_key(
tenant_id,
company_id,
record.id,
timestamp,
file.filename or "documento.pdf",
)
ct = file.content_type or mimetypes.guess_type(file.filename or "")[0] or "application/octet-stream"
_remove_stored_document_path(record.archivo_digitalizado_en)
put_object_bytes(key, content, content_type=ct)
stored = key
else:
key = expediente_archivo_document_key(
tenant_id,
company_id,
record.id,
timestamp,
file.filename or "documento.pdf",
)
base = os.path.join("uploads", "expediente_archivos", str(company_id), str(record.id))
os.makedirs(base, exist_ok=True)
filename = key.rsplit("/", 1)[-1]
stored = os.path.join(base, filename)
_remove_stored_document_path(record.archivo_digitalizado_en)
with open(stored, "wb") as destination:
destination.write(content)
record.archivo_digitalizado_en = stored
record.nombre_archivo = file.filename or record.nombre_archivo
db.add(record)
db.commit()
db.refresh(record)
except HTTPException:
db.rollback()
raise
except ValueError as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
db.rollback()
raise HTTPException(status_code=500, detail=f"Error saving file: {str(exc)}") from exc
return {
"message": "Archivo cargado correctamente",
"record_id": record.id,
"path": stored,
"nombre_archivo": record.nombre_archivo,
}
# ──────────────────────────────────────────────────────────────────────────── #
# Digitalización #
# ──────────────────────────────────────────────────────────────────────────── #

View File

@@ -7,10 +7,12 @@ 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 import or_
from sqlalchemy.orm import Session
from core.celery_app import celery_app
from core.config import settings
from core.database import CoreSessionLocal
from core.exceptions import ErrorCollector, ValidationException
from core.storage_s3 import get_object_bytes, object_exists
@@ -37,6 +39,34 @@ logger = logging.getLogger(__name__)
class ExpedienteArchivoService:
@staticmethod
def _get_task_record_metadata(task_id: str) -> dict:
db = CoreSessionLocal()
try:
record = (
db.query(ExpedienteArchivo)
.filter(
ExpedienteArchivo.task_id == task_id,
ExpedienteArchivo.deleted_at.is_(None),
)
.order_by(ExpedienteArchivo.id.desc())
.first()
)
if not record:
return {"external_task_id": None}
return {
"external_task_id": record.external_task_id,
"db_status": record.status,
"e_document": record.e_document,
"num_operacion": record.num_operacion,
"nombre_archivo": record.nombre_archivo,
}
except Exception:
logger.exception("No se pudo obtener metadata del expediente para task_id=%s", task_id)
return {"external_task_id": None}
finally:
db.close()
@staticmethod
def list(
db: Session,
@@ -114,30 +144,90 @@ class ExpedienteArchivoService:
@staticmethod
def get_task_status(task_id: str) -> DigitalizacionTaskDetailResponse:
result = celery_app.AsyncResult(task_id)
state = result.state or "PENDING"
info = result.info or {}
task_metadata = ExpedienteArchivoService._get_task_record_metadata(task_id)
try:
result = celery_app.AsyncResult(task_id)
state = result.state or "PENDING"
info = result.info or {}
except Exception as exc:
logger.exception("No se pudo consultar el estado de la tarea de digitalización task_id=%s", task_id)
return DigitalizacionTaskDetailResponse(
task_id=task_id,
external_task_id=task_metadata.get("external_task_id"),
state="FAILURE",
status="failed",
error="No se pudo consultar el estado de la digitalización.",
error_type=type(exc).__name__,
error_detail=DigitalizacionErrorDetail(
codigo="TASK_STATUS_ERROR",
descripcion=str(exc),
paso="Consulta de estado",
sugerencias=["Cierra el diálogo y vuelve a intentar la digitalización."],
),
)
if state == "SUCCESS":
raw = result.result or {}
return DigitalizacionTaskDetailResponse(
task_id=task_id,
external_task_id=task_metadata.get("external_task_id"),
state="SUCCESS",
status="success",
request_id=raw.get("request_id"),
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
# Fallback: Celery puede tardar en propagar SUCCESS a Redis.
# Si la DB ya tiene status=success, retornamos SUCCESS inmediatamente.
if task_metadata.get("db_status") == "success":
logger.info(
"get_task_status: Celery state=%s but DB status=success — returning SUCCESS from DB task_id=%s",
state, task_id,
)
return DigitalizacionTaskDetailResponse(
task_id=task_id,
external_task_id=task_metadata.get("external_task_id"),
state="SUCCESS",
status="success",
result=DigitalizacionResult(
status="success",
message="Digitalización completada exitosamente.",
e_document=task_metadata.get("e_document"),
numero_operacion=task_metadata.get("num_operacion"),
nombre_archivo=task_metadata.get("nombre_archivo"),
),
progress=100,
total_steps=4,
)
if state in {"FAILURE", "FAILED"}:
err = info if not isinstance(info, dict) else None
error_detail_raw = info.get("error_detail") if isinstance(info, dict) else None
error_text = str(err or info.get("error", "")) if isinstance(info, dict) else str(err or "")
error_type = info.get("error_type") if isinstance(info, dict) else None
if not error_type and isinstance(info, BaseException):
error_type = type(info).__name__
if error_type == "Ignore" and not error_text:
error_text = "La digitalización no pudo completarse."
if not error_text and isinstance(info, BaseException):
error_text = "La digitalización no pudo completarse."
if isinstance(info, BaseException) and not error_detail_raw:
error_detail_raw = {
"codigo": "TASK_FAILED",
"descripcion": "La tarea terminó con error antes de completar la digitalización.",
"paso": "Proceso de digitalización",
"sugerencias": ["Revisa la configuración VU y vuelve a intentarlo."],
}
return DigitalizacionTaskDetailResponse(
task_id=task_id,
external_task_id=task_metadata.get("external_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,
request_id=info.get("request_id") if isinstance(info, dict) else None,
error=error_text,
error_type=error_type,
error_detail=DigitalizacionErrorDetail(**(error_detail_raw or {})) if error_detail_raw else None,
)
@@ -145,14 +235,21 @@ class ExpedienteArchivoService:
if isinstance(info, dict):
return DigitalizacionTaskDetailResponse(
task_id=task_id,
external_task_id=task_metadata.get("external_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,
request_id=info.get("request_id"),
)
return DigitalizacionTaskDetailResponse(task_id=task_id, state=state, status="pending")
return DigitalizacionTaskDetailResponse(
task_id=task_id,
external_task_id=task_metadata.get("external_task_id"),
state=state,
status="pending",
)
# ---------------------------------------------------------------------------
@@ -178,6 +275,94 @@ def _encrypt_fiel(raw_fiel: str) -> str:
return base64.b64encode(encrypted).decode("ascii")
def _resolve_broker_for_vu(
db: Session,
company_id: int,
tenant_id: int,
agente_aduanal_key: str,
) -> Optional[cb_models.CustomsBroker]:
normalized_key = (agente_aduanal_key or "").strip()
if not normalized_key:
return None
brokers = (
db.query(cb_models.CustomsBroker)
.filter(
or_(
cb_models.CustomsBroker.broker_key == normalized_key,
cb_models.CustomsBroker.license == normalized_key,
),
cb_models.CustomsBroker.company_id == company_id,
cb_models.CustomsBroker.tenant_id == tenant_id,
cb_models.CustomsBroker.deleted_at.is_(None),
)
.order_by(cb_models.CustomsBroker.id.desc())
.all()
)
if not brokers:
return None
exact_broker_key = next(
(broker for broker in brokers if (broker.broker_key or "").strip() == normalized_key),
None,
)
if exact_broker_key:
return exact_broker_key
if len(brokers) > 1:
logger.warning(
"Multiple customs brokers matched agente_aduanal=%s; falling back to first license match ids=%s broker_keys=%s",
normalized_key,
[broker.id for broker in brokers],
[broker.broker_key for broker in brokers],
)
return brokers[0]
def resolve_rfc_consulta_value(
db: Session,
company_id: int,
tenant_id: int,
agente_aduanal_key: Optional[str],
request_rfc_consulta: Optional[str],
record_rfc_consulta: Optional[str],
config_vu_rfc: Optional[str],
) -> str:
explicit_rfc = (request_rfc_consulta or "").strip().upper()
if explicit_rfc:
return explicit_rfc
broker_tax_id = ""
if agente_aduanal_key:
broker = _resolve_broker_for_vu(db, company_id, tenant_id, agente_aduanal_key)
broker_tax_id = (getattr(broker, "tax_id", None) or "").strip().upper() if broker else ""
if broker_tax_id:
return broker_tax_id
stored_rfc = (record_rfc_consulta or "").strip().upper()
if stored_rfc:
return stored_rfc
config_rfc = (config_vu_rfc or "").strip().upper()
if config_rfc:
return config_rfc
raise ValidationException(
"RFC Consulta no disponible",
errors=[
{
"field": "rfc_consulta",
"message": "No se pudo resolver el RFC Consulta desde el expediente ni desde el customs broker.",
"code": "MISSING_RFC_CONSULTA",
"solution": [
"Configura el RFC del agente aduanal en el customs broker o captura el RFC directamente en el expediente."
],
}
],
)
def build_configuracion_vu(
db: Session,
company_id: int,
@@ -191,16 +376,7 @@ def build_configuracion_vu(
"""
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()
)
broker = _resolve_broker_for_vu(db, company_id, tenant_id, agente_aduanal_key)
if broker:
vu = broker.vu
else:
@@ -235,17 +411,6 @@ def build_configuracion_vu(
)
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 "")
@@ -257,12 +422,29 @@ def build_configuracion_vu(
)
clave_fiel_value = _encrypt_fiel(str(secret))
if not effective_ws_user:
hardcoded_ws_key = (
"RZGd+CB4R6PfSrstOyN8Is9FXL9AK9NPFisyGGaEWa0vVHoVOl8v2SBcHBoGbt3T/4uHTGcsFQO3b7EonWVfugQjBooywbz74K+jM68j8/Y="
)
vu_ws_key = (getattr(vu, "web_service_access_key", None) or "").strip() if vu else ""
vu_access_key_raw = (getattr(vu, "access_key", None) or "").strip() if vu else ""
vu_access_key_encrypted = _encrypt_fiel(vu_access_key_raw) if vu_access_key_raw else ""
company_ws_key = (getattr(company_vu, "webservice_password", None) or "").strip() if company_vu else ""
if vu_ws_key:
ws_key_source = "web_service_access_key"
elif vu_access_key_encrypted:
ws_key_source = "access_key_encrypted"
elif company_ws_key:
ws_key_source = "company"
else:
ws_key_source = "fallback"
clave_webservice = vu_ws_key or vu_access_key_encrypted or company_ws_key or hardcoded_ws_key
if not clave_webservice:
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",
field="vu.clave_webservice",
message="La clave de web service no está configurada en VU ni en la empresa.",
solution=["Captura la clave de web service en la pestaña VU o completa la configuración VU de la empresa."],
code="MISSING_VU_WS_KEY",
)
if not clave_fiel_value:
@@ -337,17 +519,21 @@ def build_configuracion_vu(
(getattr(company_vu, "query_rfc", None) or "").strip() if company_vu else ""
)
hardcoded_ws_key = (
"RZGd+CB4R6PfSrstOyN8Is9FXL9AK9NPFisyGGaEWa0vVHoVOl8v2SBcHBoGbt3T/4uHTGcsFQO3b7EonWVfugQjBooywbz74K+jM68j8/Y="
email = (
(getattr(vu, "vu_email", None) or "").strip() if vu else ""
) or (
(getattr(company_vu, "email", None) or "").strip() if company_vu else ""
) or (
(getattr(getattr(company, "address", None), "email", None) or "").strip() if company else ""
)
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 ""
if ws_key_source == "fallback":
logger.warning(
"Expediente digitalization is using fallback web service key for agente_aduanal=%s company_id=%s tenant_id=%s",
agente_aduanal_key,
company_id,
tenant_id,
)
return {
"rfc_usuario_vu": rfc_usuario_vu,
@@ -356,4 +542,5 @@ def build_configuracion_vu(
"archivo_key_base64": key_b64 or "",
"clave_fiel": clave_fiel_value,
"email": email,
"_ws_key_source": ws_key_source,
}

View File

@@ -1,17 +1,23 @@
from __future__ import annotations
import base64
import logging
import os
import time
from typing import Any, Dict
from celery import Task
from celery.exceptions import Ignore
import httpx
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.exceptions import ErrorCollector, ValidationException
from core.storage_s3 import delete_object_if_exists, get_object_bytes, object_exists, put_object_bytes
from core.s3_keys import expediente_archivo_artifact_key
from .models import ExpedienteArchivo
from .service import ExpedienteArchivoService, build_configuracion_vu
from .service import ExpedienteArchivoService, build_configuracion_vu, resolve_rfc_consulta_value
from .external_service import ExpedienteExternalService
logger = logging.getLogger(__name__)
@@ -19,6 +25,95 @@ logger = logging.getLogger(__name__)
TOTAL_STEPS = 4
def _fail_task(
task: Task,
*,
error: str,
error_type: str,
codigo: str,
descripcion: str,
paso: str,
sugerencias: list[str] | None = None,
) -> None:
task.update_state(
state="FAILED",
meta={
"error": error,
"error_type": error_type,
"error_detail": {
"codigo": codigo,
"descripcion": descripcion,
"paso": paso,
"sugerencias": sugerencias or [],
},
},
)
raise Ignore()
def _load_record_file_base64(record: ExpedienteArchivo) -> str:
stored_path = (record.archivo_digitalizado_en or "").strip()
if not stored_path:
raise ValidationException(
"El expediente no tiene archivo cargado",
errors=[
{
"field": "archivo_digitalizado_en",
"message": "El expediente no tiene archivo almacenado para digitalizar.",
"code": "MISSING_FILE",
"solution": ["Edita el expediente y vuelve a seleccionar el archivo antes de digitalizar."],
}
],
)
load_started_at = time.perf_counter()
source = "unknown"
try:
if os.path.exists(stored_path):
source = "local"
with open(stored_path, "rb") as file_handle:
raw = file_handle.read()
elif object_exists(stored_path):
source = "s3"
raw = get_object_bytes(stored_path)
else:
raise ValidationException(
"Archivo del expediente no encontrado",
errors=[
{
"field": "archivo_digitalizado_en",
"message": "No se encontró el archivo almacenado del expediente.",
"code": "FILE_NOT_FOUND",
"solution": ["Edita el expediente y vuelve a cargar el documento."],
}
],
)
except ValidationException:
raise
except Exception as exc:
raise ValidationException(
"No se pudo leer el archivo del expediente",
errors=[
{
"field": "archivo_digitalizado_en",
"message": "Ocurrió un error leyendo el archivo almacenado del expediente.",
"code": "FILE_READ_ERROR",
"solution": ["Vuelve a cargar el archivo del expediente e inténtalo nuevamente."],
}
],
) from exc
logger.info(
"Expediente file loaded expediente_id=%s source=%s bytes=%s elapsed_ms=%.1f",
record.id,
source,
len(raw),
(time.perf_counter() - load_started_at) * 1000,
)
return base64.b64encode(raw).decode("ascii")
def _progress(task: Task, current: int, status: str) -> None:
task.update_state(
state="PROGRESS",
@@ -36,40 +131,85 @@ def _poll_external(
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()
start = time.perf_counter()
last_payload: Dict[str, Any] = {}
attempts = 0
while True:
elapsed = time.time() - start
attempts += 1
elapsed = time.perf_counter() - start
if elapsed > timeout_seconds:
logger.error("Timeout en polling externo de digitalización: task_id=%s", external_task_id)
logger.error(
"Timeout en polling externo de digitalización: task_id=%s attempts=%s elapsed_s=%.2f",
external_task_id,
attempts,
elapsed,
)
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")
except httpx.ReadTimeout:
# VU mantiene la conexión abierta mientras procesa; si httpx corta antes,
# lo tratamos como "sigue en proceso" y reintentamos.
logger.warning(
"ReadTimeout consultando estado externo, reintentando task_id=%s attempts=%s elapsed_s=%.2f",
external_task_id,
attempts,
elapsed,
)
time.sleep(5)
continue
except Exception:
logger.exception(
"Error consultando estado externo de digitalización task_id=%s attempts=%s elapsed_s=%.2f",
external_task_id,
attempts,
elapsed,
)
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")
progress_info = status_payload.get("progress")
percent = 0.0
current_step = str(
status_payload.get("current_step")
or status_payload.get("status")
or "Consultando estado en Ventanilla Única..."
)
if isinstance(progress_info, dict):
raw_percent = progress_info.get("progress", progress_info.get("current", 0.0))
try:
percent = float(raw_percent)
except (TypeError, ValueError):
percent = 0.0
current_step = str(
progress_info.get("current_step")
or progress_info.get("status")
or current_step
)
elif isinstance(progress_info, (int, float, str)):
try:
percent = float(progress_info)
except (TypeError, ValueError):
percent = 0.0
_progress(task, int(percent), str(current_step))
if state in {"PENDING", "STARTED", "PROGRESS"} or not state:
time.sleep(5)
continue
logger.info(
"Digitalization external polling finished task_id=%s final_state=%s attempts=%s elapsed_s=%.2f",
external_task_id,
state or "UNKNOWN",
attempts,
elapsed,
)
return last_payload
@@ -91,7 +231,16 @@ def digitalizar_task(
4. Persistir resultado (e_document, num_operacion, acuse_pdf_path) en DB.
"""
db = CoreSessionLocal()
task_started_at = time.perf_counter()
try:
logger.info(
"Digitalization task started task_id=%s expediente_id=%s company_id=%s tenant_id=%s",
self.request.id,
expediente_id,
company_id,
tenant_id,
)
# ------------------------------------------------------------------ #
# Paso 1 cargar registro y construir configuracion_vu #
# ------------------------------------------------------------------ #
@@ -106,29 +255,76 @@ def digitalizar_task(
errors = ErrorCollector()
agente_key = request_data.get("agente_aduanal") or record.agente_aduanal
config_started_at = time.perf_counter()
config_vu = build_configuracion_vu(db, company_id, tenant_id, agente_key, errors)
logger.info(
"Digitalization VU config resolved task_id=%s expediente_id=%s agente_aduanal=%s elapsed_ms=%.1f",
self.request.id,
expediente_id,
agente_key,
(time.perf_counter() - config_started_at) * 1000,
)
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 [],
},
},
_fail_task(
self,
error=first.get("message", "Error de configuración VU"),
error_type="VALIDATION_ERROR",
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
resolved_rfc_consulta = resolve_rfc_consulta_value(
db,
company_id,
tenant_id,
agente_key,
request_data.get("rfc_consulta"),
record.rfc_consulta,
config_vu.get("rfc_usuario_vu"),
)
current_record_rfc = (record.rfc_consulta or "").strip().upper()
config_vu_rfc = (config_vu.get("rfc_usuario_vu") or "").strip().upper()
if not current_record_rfc or current_record_rfc == config_vu_rfc:
record.rfc_consulta = resolved_rfc_consulta
# Limpiar artefactos previos de S3 antes de iniciar nueva digitalización
_ARTIFACT_PATH_FIELDS = [
"acuse_pdf_path",
"envio_xml_path",
"respuesta_xml_path",
"consulta_envio_xml_path",
"consulta_respuesta_xml_path",
]
for _field in _ARTIFACT_PATH_FIELDS:
_old_key = (getattr(record, _field, None) or "").strip()
if _old_key and _old_key != "inline":
try:
delete_object_if_exists(_old_key)
logger.info(
"Digitalization old artifact deleted task_id=%s expediente_id=%s field=%s key=%s",
self.request.id, expediente_id, _field, _old_key,
)
except Exception:
logger.warning(
"Could not delete old artifact task_id=%s expediente_id=%s field=%s key=%s",
self.request.id, expediente_id, _field, _old_key, exc_info=True,
)
record.status = "processing"
record.task_id = self.request.id
record.external_task_id = None
record.e_document = None
record.num_operacion = None
record.acuse_pdf_path = None
record.envio_xml_path = None
record.respuesta_xml_path = None
record.consulta_envio_xml_path = None
record.consulta_respuesta_xml_path = None
db.commit()
# ------------------------------------------------------------------ #
@@ -136,16 +332,38 @@ def digitalizar_task(
# ------------------------------------------------------------------ #
_progress(self, 30, "Enviando documento a Ventanilla Única...")
file_started_at = time.perf_counter()
archivo_base64 = request_data.get("archivo_base64") or _load_record_file_base64(record)
logger.info(
"Digitalization payload document ready task_id=%s expediente_id=%s provided_inline=%s base64_len=%s elapsed_ms=%.1f",
self.request.id,
expediente_id,
bool(request_data.get("archivo_base64")),
len(archivo_base64),
(time.perf_counter() - file_started_at) * 1000,
)
payload = {
"rfc_consulta": request_data.get("rfc_consulta") or record.rfc_consulta or "",
"rfc_consulta": resolved_rfc_consulta,
"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,
"archivo_base64": archivo_base64,
"configuracion_vu": {
key: value for key, value in config_vu.items() if not key.startswith("_")
},
}
external = ExpedienteExternalService()
external_submit_started_at = time.perf_counter()
response = external.digitalizar_archivo_json(payload)
logger.info(
"Digitalization external submission finished task_id=%s expediente_id=%s external_task_id=%s response_state=%s elapsed_ms=%.1f",
self.request.id,
expediente_id,
response.get("task_id") or response.get("id"),
response.get("state") or response.get("status"),
(time.perf_counter() - external_submit_started_at) * 1000,
)
# Chequear si el API externo devolvió un error inmediato
resp_state = str(response.get("state") or response.get("status") or "").upper()
@@ -153,20 +371,15 @@ def digitalizar_task(
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."],
},
},
_fail_task(
self,
error=error_msg,
error_type="EXTERNAL_API_ERROR",
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")
@@ -178,29 +391,60 @@ def digitalizar_task(
_progress(self, 50, "Esperando respuesta de Ventanilla Única...")
record.external_task_id = str(external_task_id)
db.commit()
polling_started_at = time.perf_counter()
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."],
},
},
_fail_task(
self,
error=str(exc),
error_type="TIMEOUT",
codigo="TIMEOUT",
descripcion=str(exc),
paso="Polling Ventanilla Única",
sugerencias=["Vuelve a intentarlo o consulta el estado manualmente."],
)
return {}
logger.info(
"Digitalization external wait completed task_id=%s expediente_id=%s external_task_id=%s elapsed_s=%.2f",
self.request.id,
expediente_id,
external_task_id,
time.perf_counter() - polling_started_at,
)
else:
# El API devolvió resultado directo
final_response = response
final_state = str(final_response.get("state") or final_response.get("status") or "").upper()
if final_state in {"ERROR", "FAILURE", "FAILED"}:
error_detail = final_response.get("error_detail") or {}
suggestions = error_detail.get("sugerencias") or []
if config_vu.get("_ws_key_source") == "fallback":
suggestions = [
"No hay clave real de web service configurada en VU ni en la empresa; se usó la clave fallback del sistema.",
*suggestions,
]
error_msg = (
final_response.get("error")
or final_response.get("message")
or error_detail.get("descripcion")
or final_response.get("status")
or "Error en Ventanilla Única"
)
record.status = "failed"
db.commit()
_fail_task(
self,
error=str(error_msg),
error_type=str(final_response.get("error_type") or "EXTERNAL_API_ERROR"),
codigo=str(error_detail.get("codigo") or "EXTERNAL_TASK_FAILURE"),
descripcion=str(error_detail.get("descripcion") or error_msg),
paso=str(error_detail.get("paso") or "Respuesta final de Ventanilla Única"),
sugerencias=suggestions or ["Revisa el detalle devuelto por Ventanilla Única y vuelve a intentarlo."],
)
# ------------------------------------------------------------------ #
# Paso 4 persistir resultado #
# ------------------------------------------------------------------ #
@@ -209,31 +453,65 @@ def digitalizar_task(
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"
# Guardar todos los artefactos base64 en S3
_ARTIFACT_FIELDS = {
"acuse": ("acuese_digitalizacion_pdf_base64", "application/pdf", "acuse_pdf_path"),
"envio_xml": ("envio_xml_base64", "application/xml", "envio_xml_path"),
"respuesta_xml": ("respuesta_xml_base64", "application/xml", "respuesta_xml_path"),
"consulta_envio_xml": ("consulta_envio_xml_base64", "application/xml", "consulta_envio_xml_path"),
"consulta_respuesta_xml": ("consulta_respuesta_xml_base64", "application/xml", "consulta_respuesta_xml_path"),
}
artifact_ts = time.strftime("%Y%m%d_%H%M%S", time.gmtime())
for artifact_type, (result_field, content_type, record_field) in _ARTIFACT_FIELDS.items():
b64 = result_payload.get(result_field)
if not b64:
continue
try:
key = expediente_archivo_artifact_key(
tenant_id, company_id, expediente_id, artifact_type, artifact_ts
)
put_object_bytes(key, base64.b64decode(b64), content_type=content_type)
setattr(record, record_field, key)
logger.info(
"Digitalization artifact saved task_id=%s expediente_id=%s type=%s key=%s",
self.request.id, expediente_id, artifact_type, key,
)
except Exception:
logger.exception(
"Failed to save artifact %s to S3 task_id=%s expediente_id=%s",
artifact_type, self.request.id, expediente_id,
)
setattr(record, record_field, None)
db.commit()
logger.info(
"Digitalization task finished task_id=%s expediente_id=%s status=success total_elapsed_s=%.2f",
self.request.id,
expediente_id,
time.perf_counter() - task_started_at,
)
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 Ignore:
raise
except ValidationException as exc:
if db:
try:
@@ -245,21 +523,31 @@ def digitalizar_task(
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 [],
},
},
state="PROGRESS",
meta={"current": 0, "status": "Preparando error de validación..."},
)
logger.info(
"Digitalization task failed by validation task_id=%s expediente_id=%s elapsed_s=%.2f",
self.request.id,
expediente_id,
time.perf_counter() - task_started_at,
)
_fail_task(
self,
error=first_error.get("message", str(exc)),
error_type="VALIDATION_ERROR",
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)
logger.exception(
"Error inesperado en digitalizar_task task_id=%s expediente_id=%s elapsed_s=%.2f",
self.request.id,
expediente_id,
time.perf_counter() - task_started_at,
)
if db:
try:
record = db.get(ExpedienteArchivo, expediente_id) # type: ignore
@@ -268,19 +556,14 @@ def digitalizar_task(
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."],
},
},
_fail_task(
self,
error=str(exc),
error_type=type(exc).__name__,
codigo="UNEXPECTED_ERROR",
descripcion=str(exc),
paso="Proceso de digitalización",
sugerencias=["Contacta al soporte técnico."],
)
return {}
finally:
db.close()

View File

@@ -145,21 +145,6 @@ class FacturaCoveDomainService:
)
return None
# Determinar usuario efectivo de WebService:
# - Preferimos el usuario configurado en VU (web_service_user)
# - Si no existe, usamos el de DODA-PITA (doda_web_service_user)
# - Si no existe, usamos la configuración VU de la empresa
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 ""
)
# Determinar clave FIEL efectiva desde la configuración persistida.
# Se envía cifrada con el mismo esquema AES-256-CBC del sistema legado.
clave_fiel_value = ""
@@ -175,17 +160,30 @@ class FacturaCoveDomainService:
)
clave_fiel_value = self._encrypt_fiel(str(company_fiel_secret))
# Validación básica de credenciales VU: para COVE necesitamos al menos
# un usuario de web service (VU o DODA) y una clave FIEL no vacía.
if not effective_ws_user:
# Validación básica de credenciales VU: usamos la clave/token efectiva
# del web service, que es lo que realmente viaja en configuracion_vu.
hardcoded_ws_key = (
"RZGd+CB4R6PfSrstOyN8Is9FXL9AK9NPFisyGGaEWa0vVHoVOl8v2SBcHBoGbt3T/4uHTGcsFQO3b7EonWVfugQjBooywbz74K+jM68j8/Y="
)
vu_ws_key = (getattr(vu, "web_service_access_key", None) or "").strip() if vu else ""
vu_access_key_raw = (getattr(vu, "access_key", None) or "").strip() if vu else ""
vu_access_key_encrypted = self._encrypt_fiel(vu_access_key_raw) if vu_access_key_raw else ""
clave_webservice = (
vu_ws_key
or vu_access_key_encrypted
or (getattr(company_vu, "webservice_password", None) or "").strip()
or hardcoded_ws_key
)
if not clave_webservice:
errors.add_error(
field="vu",
message="Faltan credenciales de web service o clave FIEL en VU",
field="vu.clave_webservice",
message="La clave de web service no está configurada en VU ni en la empresa.",
solution=[
"Captura usuario y clave de web service en la pestaña VU o DODA del agente, "
"o completa la configuración VU de la empresa y su certificado FIEL."
"Captura la clave de web service en la pestaña VU o DODA del agente, "
"o completa la configuración VU de la empresa."
],
code="MISSING_VU_CREDENTIALS",
code="MISSING_VU_WS_KEY",
)
if not clave_fiel_value:
@@ -269,17 +267,9 @@ class FacturaCoveDomainService:
# Clave/token del webservice: usar el valor de VU si existe, o una
# clave fija de pruebas mientras se termina la configuración real.
hardcoded_ws_key = (
"RZGd+CB4R6PfSrstOyN8Is9FXL9AK9NPFisyGGaEWa0vVHoVOl8v2SBcHBoGbt3T/4uHTGcsFQO3b7EonWVfugQjBooywbz74K+jM68j8/Y="
)
return ConfiguracionVU(
rfc_usuario_vu=rfc_usuario_vu,
clave_webservice=(
(getattr(vu, "web_service_access_key", None) or "").strip()
or (getattr(company_vu, "webservice_password", None) or "").strip()
or hardcoded_ws_key
),
clave_webservice=clave_webservice,
archivo_cer_base64=cer_b64 or "",
archivo_key_base64=key_b64 or "",
clave_fiel=clave_fiel_value,

View File

@@ -73,6 +73,7 @@ celery_app.conf.update(
"api.v1.modules.a76.invoices.exports.revert.task",
"api.v1.modules.a76.layouts_csv.common.victor",
"api.v1.modules.a76.factura_cove.tasks",
"api.v1.modules.a76.expediente_archivos.tasks",
] # Ruta al módulo donde están las tareas
)

View File

@@ -288,6 +288,49 @@ def company_certificate_key(
return f"{tenant_company_prefix(tenant_id, company_id)}certificates/{base}"
def expediente_archivo_document_key(
tenant_id: Union[int, str],
company_id: int,
expediente_id: int,
timestamp: str,
original_filename: str,
) -> str:
"""
Archivo del expediente bajo ``.../expediente_archivos/{id}/documents/expediente_{timestamp}_{filename}``.
Extensiones permitidas: .pdf, .xml, .png, .jpg, .jpeg, .json, .txt, .zip
"""
ts = _segment(timestamp, "timestamp")
eid = _segment(expediente_id, "expediente_id")
fn = safe_filename(original_filename)
parts = fn.rsplit(".", 1)
if len(parts) < 2:
raise ValueError("expediente file must have an extension")
ext = "." + parts[1].lower()
allowed = (".pdf", ".xml", ".png", ".jpg", ".jpeg", ".json", ".txt", ".zip")
if ext not in allowed:
raise ValueError(f"expediente file extension not allowed: {ext}")
base = f"expediente_{ts}_{fn}"
return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/documents/{base}"
def expediente_archivo_artifact_key(
tenant_id: Union[int, str],
company_id: int,
expediente_id: int,
artifact_type: str,
timestamp: str,
) -> str:
"""
Artefacto de digitalización bajo ``.../expediente_archivos/{id}/artifacts/{type}_{timestamp}.{ext}``.
artifact_type: acuse | envio_xml | respuesta_xml | consulta_envio_xml | consulta_respuesta_xml
"""
ts = _segment(timestamp, "timestamp")
eid = _segment(expediente_id, "expediente_id")
at = _segment(artifact_type, "artifact_type")
ext = ".pdf" if artifact_type == "acuse" else ".xml"
return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/artifacts/{at}_{ts}{ext}"
def help_asset_key(folder: str, new_filename: str) -> str:
"""
folder: '', 'pdfs', 'videos', 'assets' relativo a system/help/

View File

@@ -1,184 +1,190 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from en!",
"sidebar": {
"reference_data": {
"title": "Fixed Catalogs",
"codes_pedimento_regimen": "Pedimento and Regime Codes",
"containers": "Containers",
"countries": "Countries",
"currency_types": "Currency Types",
"customs_sections": "Customs Sections",
"customs_warehouses": "Customs Warehouses",
"incoterms": "Incoterms",
"invoice_types": "Invoice Types",
"material_types": "Material Types",
"payment_methods": "Payment Methods",
"pedimento_codes": "Pedimento Codes",
"pedimento_regimes": "Pedimento Regimes",
"sectors": "Sectors",
"states": "States",
"transportation_modes": "Transportation Modes",
"transportation_types": "Transportation Types",
"valuation_methods": "Valuation Methods",
"configuracion": "Settings",
"general": "General",
"licencia": "License",
"usuarios": "Users",
"ayuda": "Help"
},
"general_catalogs": {
"title": "General Catalogs",
"company_information": "Company Information",
"packages": "Packages",
"concepts": "Concepts",
"classification": "Classification",
"identifiers": "Identifiers",
"incoterms": "Incoterms",
"inpc": "I.N.P.C",
"fixed_legends": "Fixed Legends",
"seals": "Seals",
"valuation_methods": "Valuation Methods",
"countries": "Countries",
"ports": "Ports",
"unit_measures": "Units of Measure",
"um_customs_mex": "Units of Measure - Mexican Customs",
"um_customs_ame": "Units of Measure - American Customs",
"um_ace": "Units of Measure - ACE",
"um_oma": "Units of Measure - OMA",
"conversions": "Conversions",
"equivalences": "Equivalences",
"exchange_rates": "Exchange Rates",
"currency_types": "Currency Types",
"multi_currency": "Multi Currency",
"invoice_types": "Invoice Types",
"electronic_signatures": "Electronic Signatures",
"billing_errors": "Billing Errors",
"customs_warehouses": "Customs Warehouses",
"locations": "Locations",
"doda": "DODA",
"packing_list": "Packing List",
"prevalidators": "Prevalidators",
"electronic_notices": "Electronic Notices",
"back_flush": "Back Flush",
"crossing_notice": "Crossing Notice"
},
"fractions": {
"title": "Fractions",
"sitar": "Fraction Sitar",
"sitar_seventh_amendment": "Fraction Sitar - Seventh Amendment",
"sitar_us": "Fraction Sitar US",
"american": "Fraction American",
"canadian": "Fraction Canadian",
"historical": "Fraction Historical",
"sectors": "Sectors"
},
"goods": {
"title": "Goods",
"classes": "Classes",
"parts": "Parts",
"fda_codes": "FDA Codes"
},
"pedimentos": {
"title": "Pedimentos",
"pedimento_management": "Pedimento Management",
"pedimento_codes": "Pedimento Codes",
"customs_regimes": "Customs Regimes",
"payment_methods": "Payment Methods",
"customs_sections": "Customs Sections",
"anexo_22_app_31": "Anexo 22 App 3"
},
"import_invoices": {
"title": "Import Invoices",
"temporary": "Temporary",
"definitive": "Definitive",
"mexican_purchases": "Mexican Purchases",
"regime_change": "Regime Change",
"repair": "Repair"
},
"export_invoices": {
"title": "Export Invoices",
"exportation": "Exportation",
"repair": "Repair"
},
"export": {
"title": "Exportation",
"catalog": "Export Catalog",
"repair": "Repair",
"manifest": "Manifest",
"proforma": "Proforma",
"reports": "Reports",
"used_materials": "Used Materials Module",
"destruction": "Destruction",
"special_processes": "Special Processes"
},
"clients_and_providers": "Clients and Providers",
"customs_brokers": "Customs Brokers",
"audit_logs": "Audit Logs",
"audit_logs_title": "Audit Logs",
"audit_logs_description": "Audit trail of operations and background task (Celery) status.",
"audit_logs_tab_bitacora": "Audit trail",
"audit_logs_tab_tasks": "Background tasks",
"audit_logs_tab_files": "File manager",
"audit_logs_files_title": "File manager",
"audit_logs_files_root": "Files root",
"audit_logs_files_refresh": "Refresh",
"audit_logs_files_list_title": "Contents",
"audit_logs_files_error_prefix": "Error:",
"audit_logs_files_col_name": "Name",
"audit_logs_files_col_size": "Size",
"audit_logs_files_col_modified": "Modified",
"audit_logs_files_col_actions": "Actions",
"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",
"both_indicator": "B"
},
"nav_user": {
"profile": "Profile",
"settings": "Settings",
"logout": "Logout"
}
}
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from en!",
"sidebar": {
"reference_data": {
"title": "Fixed Catalogs",
"codes_pedimento_regimen": "Pedimento and Regime Codes",
"containers": "Containers",
"countries": "Countries",
"currency_types": "Currency Types",
"customs_sections": "Customs Sections",
"customs_warehouses": "Customs Warehouses",
"incoterms": "Incoterms",
"document_types_digitization": "Document types for digitization",
"invoice_types": "Invoice Types",
"material_types": "Material Types",
"payment_methods": "Payment Methods",
"pedimento_codes": "Pedimento Codes",
"pedimento_regimes": "Pedimento Regimes",
"sectors": "Sectors",
"states": "States",
"transportation_modes": "Transportation Modes",
"transportation_types": "Transportation Types",
"valuation_methods": "Valuation Methods",
"configuracion": "Settings",
"general": "General",
"licencia": "License",
"usuarios": "Users",
"ayuda": "Help"
},
"general_catalogs": {
"title": "General Catalogs",
"company_information": "Company Information",
"packages": "Packages",
"concepts": "Concepts",
"classification": "Classification",
"identifiers": "Identifiers",
"incoterms": "Incoterms",
"inpc": "I.N.P.C",
"fixed_legends": "Fixed Legends",
"seals": "Seals",
"valuation_methods": "Valuation Methods",
"countries": "Countries",
"ports": "Ports",
"unit_measures": "Units of Measure",
"um_customs_mex": "Units of Measure - Mexican Customs",
"um_customs_ame": "Units of Measure - American Customs",
"um_ace": "Units of Measure - ACE",
"um_oma": "Units of Measure - OMA",
"conversions": "Conversions",
"equivalences": "Equivalences",
"exchange_rates": "Exchange Rates",
"currency_types": "Currency Types",
"multi_currency": "Multi Currency",
"invoice_types": "Invoice Types",
"electronic_signatures": "Electronic Signatures",
"billing_errors": "Billing Errors",
"customs_warehouses": "Customs Warehouses",
"locations": "Locations",
"doda": "DODA",
"packing_list": "Packing List",
"prevalidators": "Prevalidators",
"electronic_notices": "Electronic Notices",
"back_flush": "Back Flush",
"crossing_notice": "Crossing Notice"
},
"fractions": {
"title": "Fractions",
"sitar": "Fraction Sitar",
"sitar_seventh_amendment": "Fraction Sitar - Seventh Amendment",
"sitar_us": "Fraction Sitar US",
"american": "Fraction American",
"canadian": "Fraction Canadian",
"historical": "Fraction Historical",
"sectors": "Sectors"
},
"goods": {
"title": "Goods",
"classes": "Classes",
"parts": "Parts",
"fda_codes": "FDA Codes"
},
"pedimentos": {
"title": "Pedimentos",
"pedimento_management": "Pedimento Management",
"pedimento_codes": "Pedimento Codes",
"customs_regimes": "Customs Regimes",
"payment_methods": "Payment Methods",
"customs_sections": "Customs Sections",
"anexo_22_app_31": "Anexo 22 App 3"
},
"import_invoices": {
"title": "Import Invoices",
"temporary": "Temporary",
"definitive": "Definitive",
"mexican_purchases": "Mexican Purchases",
"regime_change": "Regime Change",
"repair": "Repair"
},
"export_invoices": {
"title": "Export Invoices",
"exportation": "Exportation",
"repair": "Repair"
},
"export": {
"title": "Exportation",
"catalog": "Export Catalog",
"repair": "Repair",
"manifest": "Manifest",
"proforma": "Proforma",
"reports": "Reports",
"used_materials": "Used Materials Module",
"destruction": "Destruction",
"special_processes": "Special Processes"
},
"clients_and_providers": "Clients and Providers",
"customs_brokers": "Customs Brokers",
"audit_logs": "Audit Logs",
"audit_logs_title": "Audit Logs",
"audit_logs_description": "Audit trail of operations and background task (Celery) status.",
"audit_logs_tab_bitacora": "Audit trail",
"audit_logs_tab_tasks": "Background tasks",
"audit_logs_tab_files": "File manager",
"audit_logs_files_title": "File manager",
"audit_logs_files_root": "Files root",
"audit_logs_files_refresh": "Refresh",
"audit_logs_files_list_title": "Contents",
"audit_logs_files_error_prefix": "Error:",
"audit_logs_files_col_name": "Name",
"audit_logs_files_col_size": "Size",
"audit_logs_files_col_modified": "Modified",
"audit_logs_files_col_actions": "Actions",
"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_download_zip": "Download ZIP",
"action_acuse": "Receipt",
"action_envio_xml": "Envío XML",
"action_respuesta_xml": "Respuesta XML",
"action_consulta_envio_xml": "Consulta Envío XML",
"action_consulta_respuesta_xml": "Consulta Respuesta XML",
"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",
"both_indicator": "B"
},
"nav_user": {
"profile": "Profile",
"settings": "Settings",
"logout": "Logout"
}
}
}

View File

@@ -1,183 +1,189 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from es!",
"sidebar": {
"reference_data": {
"title": "Catálogos Fijos",
"codes_pedimento_regimen": "Códigos de Pedimento y Régimen",
"containers": "Contenedores",
"countries": "Países",
"currency_types": "Tipos de moneda",
"customs_sections": "Secciones de aduanas",
"customs_warehouses": "Recintos",
"incoterms": "Incoterms",
"invoice_types": "Tipos de factura",
"material_types": "Tipos de material",
"payment_methods": "Métodos de pago",
"pedimento_codes": "Códigos de pedimento",
"pedimento_regimes": "Regímenes de pedimentos",
"sectors": "Sectores",
"states": "Estados",
"transportation_modes": "Métodos de transporte",
"transportation_types": "Tipos de transporte",
"valuation_methods": "Métodos de valoración",
"configuracion": "Configuración",
"general": "General",
"licencia": "Licencia",
"usuarios": "Usuarios",
"ayuda": "Ayuda"
},
"general_catalogs": {
"title": "Catalogos Generales",
"company_information": "Información de la empresa",
"packages": "Bultos",
"concepts": "Conceptos",
"classification": "Clasificación",
"identifiers": "Identificadores",
"incoterms": "Incoterms",
"inpc": "I.N.P.C",
"fixed_legends": "Leyendas fijas",
"seals": "Precintos",
"valuation_methods": "Metódos de valoración",
"countries": "Países",
"ports": "Puertos",
"unit_measures": "Unidades de medida",
"um_customs_mex": "UM Aduanas MX",
"um_customs_ame": "UM Aduanas USA",
"um_ace": "UM ACE",
"um_oma": "UM OMA",
"conversions": "Conversiones",
"equivalences": "Equivalencias",
"exchange_rates": "Tipos de cambio",
"currency_types": "Tipos de moneda",
"multi_currency": "Multi Moneda",
"invoice_types": "Tipos de factura",
"electronic_signatures": "Firmas electrónicas",
"billing_errors": "Errores de facturación",
"customs_warehouses": "Recintos",
"locations": "Localizaciones",
"doda": "DODA",
"packing_list": "Packing List",
"prevalidators": "Prevalidadores",
"electronic_notices": "Avisos electrónicos",
"back_flush": "Back Flush",
"crossing_notice": "Aviso de cruce"
},
"fractions": {
"title": "Fracciones",
"sitar": "Fracciones Sitar",
"sitar_seventh_amendment": "Fracciones Sitar - 7ma enmienda",
"sitar_us": "Fracciones Sitar US",
"american": "Fracciones Americana",
"canadian": "Fracciones Canadiense",
"historical": "Fracciones Historicas",
"sectors": "Sectores"
},
"goods": {
"title": "Mercancías",
"classes": "Clases",
"parts": "Partes",
"fda_codes": "Códigos F.D.A."
},
"pedimentos": {
"title": "Pedimentos",
"pedimento_management": "Gestión de Pedimentos",
"pedimento_codes": "Claves de Pedimento",
"customs_regimes": "Regímenes Aduaneros",
"payment_methods": "Formas de Pago",
"customs_sections": "Secciones Aduaneras",
"anexo_22_app_31": "Anexo 22 App 3"
},
"import_invoices": {
"title": "Facturas de importación",
"temporary": "Temporal",
"definitive": "Definitiva",
"mexican_purchases": "Compras mexicanas",
"regime_change": "Cambio de régimen",
"repair": "Reparación"
},
"export_invoices": {
"title": "Facturas de exportación",
"exportation": "Exportación",
"repair": "Reparación"
},
"export": {
"title": "Exportación",
"catalog": "Catálogo de exportación",
"repair": "Reparación",
"manifest": "Manifiesto",
"proforma": "Proforma",
"reports": "Reportes",
"used_materials": "Módulo de materiales utilizados",
"destruction": "Destrucción",
"special_processes": "Procesos Especiales"
},
"clients_and_providers": "Clientes y Proveedores",
"customs_brokers": "Agentes Aduanales",
"audit_logs": "Bitácora",
"audit_logs_title": "Bitácora de Movimientos",
"audit_logs_description": "Auditoría de operaciones y seguimiento de tareas en segundo plano (Celery).",
"audit_logs_tab_bitacora": "Bitácora",
"audit_logs_tab_tasks": "Tareas en segundo plano",
"audit_logs_tab_files": "Gestor de archivos",
"audit_logs_files_title": "Gestor de archivos",
"audit_logs_files_root": "Raíz de archivos",
"audit_logs_files_refresh": "Actualizar",
"audit_logs_files_list_title": "Contenido",
"audit_logs_files_error_prefix": "Error:",
"audit_logs_files_col_name": "Nombre",
"audit_logs_files_col_size": "Tamaño",
"audit_logs_files_col_modified": "Modificado",
"audit_logs_files_col_actions": "Acciones",
"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",
"both_indicator": "A"
},
"nav_user": {
"profile": "Perfil",
"settings": "Configuración"
}
}
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from es!",
"sidebar": {
"reference_data": {
"title": "Catálogos Fijos",
"codes_pedimento_regimen": "Códigos de Pedimento y Régimen",
"containers": "Contenedores",
"countries": "Países",
"currency_types": "Tipos de moneda",
"customs_sections": "Secciones de aduanas",
"customs_warehouses": "Recintos",
"incoterms": "Incoterms",
"document_types_digitization": "Tipos de documento para digitalización",
"invoice_types": "Tipos de factura",
"material_types": "Tipos de material",
"payment_methods": "Métodos de pago",
"pedimento_codes": "Códigos de pedimento",
"pedimento_regimes": "Regímenes de pedimentos",
"sectors": "Sectores",
"states": "Estados",
"transportation_modes": "Métodos de transporte",
"transportation_types": "Tipos de transporte",
"valuation_methods": "Métodos de valoración",
"configuracion": "Configuración",
"general": "General",
"licencia": "Licencia",
"usuarios": "Usuarios",
"ayuda": "Ayuda"
},
"general_catalogs": {
"title": "Catalogos Generales",
"company_information": "Información de la empresa",
"packages": "Bultos",
"concepts": "Conceptos",
"classification": "Clasificación",
"identifiers": "Identificadores",
"incoterms": "Incoterms",
"inpc": "I.N.P.C",
"fixed_legends": "Leyendas fijas",
"seals": "Precintos",
"valuation_methods": "Metódos de valoración",
"countries": "Países",
"ports": "Puertos",
"unit_measures": "Unidades de medida",
"um_customs_mex": "UM Aduanas MX",
"um_customs_ame": "UM Aduanas USA",
"um_ace": "UM ACE",
"um_oma": "UM OMA",
"conversions": "Conversiones",
"equivalences": "Equivalencias",
"exchange_rates": "Tipos de cambio",
"currency_types": "Tipos de moneda",
"multi_currency": "Multi Moneda",
"invoice_types": "Tipos de factura",
"electronic_signatures": "Firmas electrónicas",
"billing_errors": "Errores de facturación",
"customs_warehouses": "Recintos",
"locations": "Localizaciones",
"doda": "DODA",
"packing_list": "Packing List",
"prevalidators": "Prevalidadores",
"electronic_notices": "Avisos electrónicos",
"back_flush": "Back Flush",
"crossing_notice": "Aviso de cruce"
},
"fractions": {
"title": "Fracciones",
"sitar": "Fracciones Sitar",
"sitar_seventh_amendment": "Fracciones Sitar - 7ma enmienda",
"sitar_us": "Fracciones Sitar US",
"american": "Fracciones Americana",
"canadian": "Fracciones Canadiense",
"historical": "Fracciones Historicas",
"sectors": "Sectores"
},
"goods": {
"title": "Mercancías",
"classes": "Clases",
"parts": "Partes",
"fda_codes": "Códigos F.D.A."
},
"pedimentos": {
"title": "Pedimentos",
"pedimento_management": "Gestión de Pedimentos",
"pedimento_codes": "Claves de Pedimento",
"customs_regimes": "Regímenes Aduaneros",
"payment_methods": "Formas de Pago",
"customs_sections": "Secciones Aduaneras",
"anexo_22_app_31": "Anexo 22 App 3"
},
"import_invoices": {
"title": "Facturas de importación",
"temporary": "Temporal",
"definitive": "Definitiva",
"mexican_purchases": "Compras mexicanas",
"regime_change": "Cambio de régimen",
"repair": "Reparación"
},
"export_invoices": {
"title": "Facturas de exportación",
"exportation": "Exportación",
"repair": "Reparación"
},
"export": {
"title": "Exportación",
"catalog": "Catálogo de exportación",
"repair": "Reparación",
"manifest": "Manifiesto",
"proforma": "Proforma",
"reports": "Reportes",
"used_materials": "Módulo de materiales utilizados",
"destruction": "Destrucción",
"special_processes": "Procesos Especiales"
},
"clients_and_providers": "Clientes y Proveedores",
"customs_brokers": "Agentes Aduanales",
"audit_logs": "Bitácora",
"audit_logs_title": "Bitácora de Movimientos",
"audit_logs_description": "Auditoría de operaciones y seguimiento de tareas en segundo plano (Celery).",
"audit_logs_tab_bitacora": "Bitácora",
"audit_logs_tab_tasks": "Tareas en segundo plano",
"audit_logs_tab_files": "Gestor de archivos",
"audit_logs_files_title": "Gestor de archivos",
"audit_logs_files_root": "Raíz de archivos",
"audit_logs_files_refresh": "Actualizar",
"audit_logs_files_list_title": "Contenido",
"audit_logs_files_error_prefix": "Error:",
"audit_logs_files_col_name": "Nombre",
"audit_logs_files_col_size": "Tamaño",
"audit_logs_files_col_modified": "Modificado",
"audit_logs_files_col_actions": "Acciones",
"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_download_zip": "Descargar ZIP",
"action_acuse": "Acuse",
"action_envio_xml": "Envío XML",
"action_respuesta_xml": "Respuesta XML",
"action_consulta_envio_xml": "Consulta Envío XML",
"action_consulta_respuesta_xml": "Consulta Respuesta XML",
"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",
"both_indicator": "A"
},
"nav_user": {
"profile": "Perfil",
"settings": "Configuración"
}
}
}

View File

@@ -15,6 +15,10 @@ export interface ExpedienteArchivo {
task_id?: string | null;
external_task_id?: string | null;
acuse_pdf_path?: string | null;
envio_xml_path?: string | null;
respuesta_xml_path?: string | null;
consulta_envio_xml_path?: string | null;
consulta_respuesta_xml_path?: string | null;
company_id: number;
tenant_id: number;
}
@@ -39,10 +43,10 @@ export interface ExpedienteArchivoCreateDTO {
}
export interface DigitalizarRequest {
rfc_consulta: string;
clave_documento: string;
nombre_archivo: string;
archivo_base64: string;
rfc_consulta?: string | null;
clave_documento?: string | null;
nombre_archivo?: string | null;
archivo_base64?: string | null;
}
export interface DigitalizarResponse {
@@ -51,6 +55,13 @@ export interface DigitalizarResponse {
status: string;
}
export interface ExpedienteArchivoUploadResponse {
message: string;
record_id: number;
path: string;
nombre_archivo?: string | null;
}
export interface DigitalizacionResult {
status?: string | null;
message?: string | null;
@@ -76,6 +87,7 @@ export interface DigitalizacionErrorDetail {
export interface DigitalizacionTaskDetailResponse {
task_id: string;
external_task_id?: string | null;
state: string;
status?: string | null;
current_step?: string | null;
@@ -132,6 +144,20 @@ class ExpedienteArchivosApi {
return api.delete<void>(`${this.baseUrl}/${id}?${q}`);
}
async uploadFile(
id: number,
file: File,
companyId: string | number
): Promise<ApiResponse<ExpedienteArchivoUploadResponse>> {
const q = new URLSearchParams({ company_id: companyId.toString() });
const formData = new FormData();
formData.append('file', file);
return api.request<ExpedienteArchivoUploadResponse>(`${this.baseUrl}/${id}/upload?${q}`, {
method: 'POST',
body: formData
});
}
async digitalizar(
id: number,
body: DigitalizarRequest,
@@ -146,6 +172,41 @@ class ExpedienteArchivosApi {
`${this.baseUrl}/status-digitalizacion-task/${taskId}`
);
}
async downloadArtifact(
id: number,
artifactType: 'acuse' | 'envio-xml' | 'respuesta-xml' | 'consulta-envio-xml' | 'consulta-respuesta-xml',
companyId: string | number,
filename?: string
): Promise<void> {
const q = new URLSearchParams({ company_id: companyId.toString() });
const endpoint = `${this.baseUrl}/${id}/artifacts/${artifactType}?${q}`;
const blob = await api.getBlob(endpoint);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `artifact_${id}`;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async downloadAllArtifactsZip(id: number, companyId: string | number, baseName?: string): Promise<void> {
const q = new URLSearchParams({ company_id: companyId.toString() });
const endpoint = `${this.baseUrl}/${id}/artifacts-zip?${q}`;
const blob = await api.getBlob(endpoint);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `expediente_${baseName || id}.zip`;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
export const expedienteArchivosApi = new ExpedienteArchivosApi();

View File

@@ -7,70 +7,52 @@ export interface DocumentTypeDigitization {
active: boolean;
}
export interface DocumentTypeDigitizationCreate {
code: string;
description: string;
active?: boolean;
}
export interface DocumentTypeDigitizationUpdate {
code?: string;
description?: string;
active?: boolean;
export interface DocumentTypeDigitizationListResponse {
items: DocumentTypeDigitization[];
total: number;
page: number;
page_size: number;
}
const BASE_URL = '/v1/a76/document-types-digitization';
/**
* API para Tipos de Documentos de Digitalización
* API de solo lectura para Tipos de Documentos de Digitalización
*/
export const documentTypesDigitizationApi = {
/**
* Obtener todos los tipos de documentos para digitalización
*/
getAll: (activeOnly: boolean = true) => {
// CORRECTO: Al tener BASE_URL con slash, queda "...digitization/?active..."
const url = `${BASE_URL}?active_only=${activeOnly}`;
return api.get<DocumentTypeDigitization[]>(url);
list: (
page = 1,
pageSize = 50,
companyId: number,
search?: string,
activeOnly = false
) => {
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
company_id: companyId.toString()
});
if (search) {
params.append('search', search);
}
if (activeOnly) {
params.append('active_only', 'true');
}
return api.get<DocumentTypeDigitizationListResponse>(`${BASE_URL}/?${params.toString()}`);
},
/**
* Obtener un tipo de documento por ID
*/
getById: (id: number) => {
// CORREGIDO: Añadido slash después del ID
return api.get<DocumentTypeDigitization>(`${BASE_URL}${id}/`);
getAll: (companyId: number, activeOnly = true, search?: string) => {
return documentTypesDigitizationApi.list(1, 2000, companyId, search, activeOnly);
},
/**
* Obtener un tipo de documento por código
*/
getByCode: (code: string) => {
// CORREGIDO: Añadido slash después del código
return api.get<DocumentTypeDigitization>(`${BASE_URL}by-code/${code}/`);
getById: (id: number, companyId: number) => {
return api.get<DocumentTypeDigitization>(`${BASE_URL}/${id}/?company_id=${companyId}`);
},
/**
* Crear un nuevo tipo de documento
*/
create: (data: DocumentTypeDigitizationCreate) => {
// CORRECTO: Usa la BASE_URL que ya termina en /
return api.post<DocumentTypeDigitization>(BASE_URL, data);
},
/**
* Actualizar un tipo de documento existente
*/
update: (id: number, data: DocumentTypeDigitizationUpdate) => {
// CORREGIDO: Añadido slash después del ID
return api.put<DocumentTypeDigitization>(`${BASE_URL}${id}/`, data);
},
/**
* Eliminar (soft delete) un tipo de documento
*/
delete: (id: number) => {
// CORREGIDO: Añadido slash después del ID
return api.delete(`${BASE_URL}${id}/`);
getByCode: (code: string, companyId: number) => {
return api.get<DocumentTypeDigitization>(`${BASE_URL}/by-code/${code}/?company_id=${companyId}`);
}
};

View File

@@ -1,4 +1,4 @@
<script lang="ts" generics="TData, TValue">
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
@@ -11,6 +11,9 @@
hasMore: boolean;
loadMore: () => void;
emptyMessage?: string;
selectedIds?: number[];
onSelectedIdsChange?: (ids: number[]) => void;
onRowClick?: (row: TData) => void;
};
let {
@@ -19,7 +22,10 @@
loading,
hasMore,
loadMore,
emptyMessage = 'No hay resultados.'
emptyMessage = 'No hay resultados.',
selectedIds = [],
onSelectedIdsChange,
onRowClick,
}: InfiniteDataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
@@ -27,7 +33,38 @@
return data;
},
columns,
getCoreRowModel: getCoreRowModel()
getCoreRowModel: getCoreRowModel(),
getRowId: (row: any) => row.id?.toString(),
state: {
get rowSelection() {
const selection: Record<string, boolean> = {};
selectedIds.forEach((id) => {
selection[id.toString()] = true;
});
return selection;
}
},
onStateChange: (updater: any) => {
if (!onSelectedIdsChange) return;
const currentState = table.getState();
const nextState = typeof updater === 'function' ? updater(currentState) : updater;
const rowSelection = nextState?.rowSelection;
if (!rowSelection) {
onSelectedIdsChange([]);
return;
}
const nextSelectedIds = Object.entries(rowSelection)
.filter(([, selected]) => Boolean(selected))
.map(([id]) => Number(id))
.filter((id) => Number.isFinite(id));
onSelectedIdsChange(nextSelectedIds);
},
enableRowSelection: true,
enableMultiRowSelection: true
});
let scrollContainer = $state<HTMLDivElement>();
@@ -117,6 +154,7 @@
<Table.Head
class={[
'catalog-table-head-cell',
colId === 'select' && 'catalog-table-sticky-left z-40 min-w-[2.75rem]',
colId === lastHeaderColId &&
'catalog-table-sticky-right z-30'
]
@@ -141,16 +179,26 @@
{@const lastCellColId = visibleCells[visibleCells.length - 1]?.column.id}
<Table.Row
inTabOrder={false}
data-state={row.getIsSelected() && 'selected'}
class="catalog-table-row"
data-state={row.getIsSelected() ? 'selected' : undefined}
class={[
row.getIsSelected() ? 'catalog-table-row-selected' : 'catalog-table-row',
onRowClick && 'cursor-pointer'
]
.filter(Boolean)
.join(' ')}
onclick={() => onRowClick?.(row.original)}
>
{#each visibleCells as cell (cell.id)}
{@const colId = cell.column.id}
<Table.Cell
class={[
'whitespace-nowrap',
colId === 'select' && 'catalog-table-sticky-left z-30 min-w-[2.75rem]',
colId === lastCellColId &&
'catalog-table-sticky-right z-10'
'catalog-table-sticky-right z-10',
row.getIsSelected()
? 'catalog-table-sticky-row-selected'
: 'catalog-table-sticky-row-hover'
]
.filter(Boolean)
.join(' ')}

View File

@@ -1,17 +1,28 @@
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 { createRawSnippet } from 'svelte';
import { renderComponent, renderSnippet } 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;
const raw = String(dateStr);
const ymd = raw.includes('T') ? raw.split('T')[0] : raw;
const parts = ymd.split('-').map(Number);
if (parts.length === 3 && parts.every((part) => Number.isFinite(part))) {
const [year, month, day] = parts;
return new Date(year, month - 1, day).toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
return new Date(raw).toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
export function createColumns(
@@ -20,6 +31,72 @@ export function createColumns(
onAcuse?: (item: ExpedienteArchivo) => void
): ColumnDef<ExpedienteArchivo>[] {
return [
{
id: 'select',
header: ({ table }) => {
const isAllSelected = table.getIsAllPageRowsSelected();
const isSomeSelected = table.getIsSomePageRowsSelected();
const selectAllSnippet = createRawSnippet<[
{ checked: boolean; indeterminate: boolean; onchange: (event: Event) => void }
]>((getProps) => {
const { checked, indeterminate, onchange } = getProps();
return {
render: () => `<div class="w-4">
<input
type="checkbox"
tabindex="-1"
class="h-4 w-4 cursor-pointer"
${checked ? 'checked' : ''}
${indeterminate ? 'indeterminate="true"' : ''}
/>
</div>`,
setup: (node) => {
const input = node.querySelector('input') as HTMLInputElement | null;
if (!input) return;
input.indeterminate = indeterminate;
input.addEventListener('change', onchange);
}
};
});
return renderSnippet(selectAllSnippet, {
checked: isAllSelected,
indeterminate: isSomeSelected && !isAllSelected,
onchange: (event: Event) => {
table.toggleAllPageRowsSelected(!!(event.target as HTMLInputElement).checked);
}
});
},
cell: ({ row }) => {
const checkboxSnippet = createRawSnippet<[
{ selected: boolean; onchange: (event: Event) => void }
]>((getProps) => {
const { selected, onchange } = getProps();
return {
render: () => `<div class="flex items-center justify-center">
<input type="checkbox" tabindex="-1" class="h-4 w-4 cursor-pointer" ${selected ? 'checked' : ''} />
</div>`,
setup: (node) => {
const input = node.querySelector('input') as HTMLInputElement | null;
if (!input) return;
input.addEventListener('click', (event) => event.stopPropagation());
input.addEventListener('change', onchange);
}
};
});
return renderSnippet(checkboxSnippet, {
selected: row.getIsSelected(),
onchange: (event: Event) => {
event.stopPropagation();
row.toggleSelected(!!(event.target as HTMLInputElement).checked);
}
});
},
enableSorting: false,
enableHiding: false
},
{
accessorKey: 'id',
header: 'Consecutivo',
@@ -48,6 +125,7 @@ export function createColumns(
{
id: 'actions',
header: 'Acciones',
size: 88,
cell: ({ row }) =>
renderComponent(DataTableActions, {
item: row.original,

View File

@@ -5,17 +5,19 @@
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 { LoaderCircle, Search } from 'lucide-svelte';
import {
expedienteArchivosApi,
type ExpedienteArchivo,
type ExpedienteArchivoCreateDTO
} from '$lib/api/dashboard/a76/expediente-archivos';
import type { Pedimento } from '$lib/api/dashboard/a76/pedimentos';
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 PedimentoSelectorDialog from '$lib/components/dashboard/pedimentos/edit/pedimento-selector-dialog.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import * as m from '$lib/paraglide/messages';
@@ -48,8 +50,12 @@
// Buscar en la lista ya cargada primero
const found = brokers.find((b) => b.license === licenseKey);
if (found?.tax_id?.trim()) {
formData.rfc_consulta = found.tax_id.trim().toUpperCase();
return;
}
if (found?.vu?.query_tax_id) {
formData.rfc_consulta = found.vu.query_tax_id;
formData.rfc_consulta = found.vu.query_tax_id.trim().toUpperCase();
return;
}
@@ -57,10 +63,15 @@
const company = companyStore.activeCompany;
if (!company) return;
try {
const res = await customsBrokersApi.get(licenseKey, company.id.toString());
const brokerKey = found?.broker_key || licenseKey;
const res = await customsBrokersApi.get(brokerKey, company.id.toString());
const broker = (res.data || res) as CustomsBroker;
if (broker?.tax_id?.trim()) {
formData.rfc_consulta = broker.tax_id.trim().toUpperCase();
return;
}
if (broker?.vu?.query_tax_id) {
formData.rfc_consulta = broker.vu.query_tax_id;
formData.rfc_consulta = broker.vu.query_tax_id.trim().toUpperCase();
}
} catch {
// VU no disponible, dejar rfc vacío
@@ -82,21 +93,35 @@
let loading = $state(false);
let error = $state<string | null>(null);
let isPedimentoDialogOpen = $state(false);
let selectedFile = $state<File | 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
);
function buildPedimentoLabel(pedimento: Pedimento): string {
return `${pedimento.customs_office?.slice(0, 2) || ''}-${pedimento.license || ''}-${pedimento.pedimento_number || ''}`.replace(
/^-+|-+$/g,
''
);
}
function handlePedimentoSelect(pedimento: Pedimento) {
formData.pedimento = buildPedimentoLabel(pedimento);
}
// ── Efectos ────────────────────────────────────────────────────────────── //
$effect(() => {
if (open && companyStore.activeCompany?.id) {
const companyId = companyStore.activeCompany.id;
// Cargar tipos de documento
if (docTypes.length === 0) {
docTypesLoading = true;
documentTypesDigitizationApi
.getAll(true)
.getAll(companyId, true)
.then((res) => {
docTypes = (res.data as DocumentTypeDigitization[]) || [];
docTypes = res.data?.items || [];
})
.catch(() => (docTypes = []))
.finally(() => (docTypesLoading = false));
@@ -121,6 +146,7 @@
if (!open) {
error = null;
loading = false;
selectedFile = null;
return;
}
if (item) {
@@ -192,6 +218,7 @@
};
let response;
let createdId: number | null = null;
if (isEdit && item) {
response = await expedienteArchivosApi.update(item.id, payload, company.id);
} else {
@@ -204,6 +231,17 @@
throw new Error(response.error);
}
createdId = response.data?.id ?? item?.id ?? null;
if (selectedFile && createdId) {
const uploadResponse = await expedienteArchivosApi.uploadFile(createdId, selectedFile, company.id);
if (uploadResponse.error) {
if (!isEdit) {
await expedienteArchivosApi.delete(createdId, company.id);
}
throw new Error(uploadResponse.error);
}
}
open = false;
onSuccess?.();
} catch (e) {
@@ -225,12 +263,10 @@
{#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.
Captura un nuevo documento digitalizado.
</Dialog.Description>
{/if}
</Dialog.Header>
@@ -288,26 +324,16 @@
<Label for="archivo_digitalizado_en">{m['sidebar.digitalizacion.form_archivo_digitalizado_en']()} *</Label>
<FilePickerInput
id="archivo_digitalizado_en"
value={formData.archivo_digitalizado_en ?? ''}
value={formData.nombre_archivo ?? formData.archivo_digitalizado_en ?? ''}
placeholder="Seleccionar archivo..."
disabled={loading}
onchange={(file) => {
selectedFile = file;
formData.archivo_digitalizado_en = file.name;
if (!formData.nombre_archivo) formData.nombre_archivo = file.name;
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>
@@ -365,26 +391,27 @@
</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 class="flex gap-2">
<Input
id="pedimento"
value={formData.pedimento ?? ''}
placeholder="Selecciona desde el catálogo de pedimentos"
class="flex-1 bg-muted"
readonly
disabled
/>
<Button
type="button"
variant="outline"
size="icon"
onclick={() => (isPedimentoDialogOpen = true)}
disabled={loading}
>
<Search class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
@@ -404,3 +431,5 @@
</form>
</Dialog.Content>
</Dialog.Root>
<PedimentoSelectorDialog bind:open={isPedimentoDialogOpen} onSelect={handlePedimentoSelect} />

View File

@@ -1,7 +1,7 @@
<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 { Ellipsis, FileCheck2, Download, FolderArchive, Pencil, Trash2 } 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';
@@ -10,18 +10,48 @@
let {
item,
onSuccess,
onDigitalizar,
onAcuse
onDigitalizar
}: {
item: ExpedienteArchivo;
onSuccess?: () => void;
onDigitalizar?: (item: ExpedienteArchivo) => void;
onAcuse?: (item: ExpedienteArchivo) => void;
} = $props();
let loading = $state(false);
let editOpen = $state(false);
async function downloadArtifact(
type: 'acuse' | 'envio-xml' | 'respuesta-xml' | 'consulta-envio-xml' | 'consulta-respuesta-xml',
filename: string
) {
if (!companyStore.activeCompany) return;
try {
await expedienteArchivosApi.downloadArtifact(
item.id,
type,
companyStore.activeCompany.id,
filename
);
} catch {
alert('Error al descargar el archivo.');
}
}
const baseName = $derived((item.nombre_archivo || String(item.id)).replace(/\.[^.]+$/, ''));
async function downloadZip() {
if (!companyStore.activeCompany) return;
try {
await expedienteArchivosApi.downloadAllArtifactsZip(
item.id,
companyStore.activeCompany.id,
item.e_document || String(item.id)
);
} catch {
alert('Error al descargar el ZIP.');
}
}
async function handleDelete() {
if (!confirm(m['sidebar.digitalizacion.confirm_delete']())) return;
if (!companyStore.activeCompany) return;
@@ -44,27 +74,70 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" disabled={loading}>
<EllipsisVertical class="h-4 w-4" />
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0" disabled={loading}>
<span class="sr-only">Abrir menú</span>
<Ellipsis class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
<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 onclick={downloadZip}>
<FolderArchive class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_download_zip']()}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => (editOpen = true)}>
{#if item.status === 'success'}
<DropdownMenu.Item onclick={() => downloadArtifact('acuse', `acuse_${baseName}.pdf`)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_acuse']()}
</DropdownMenu.Item>
{/if}
{#if item.envio_xml_path}
<DropdownMenu.Item onclick={() => downloadArtifact('envio-xml', `envio_${baseName}.xml`)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_envio_xml']()}
</DropdownMenu.Item>
{/if}
{#if item.respuesta_xml_path}
<DropdownMenu.Item onclick={() => downloadArtifact('respuesta-xml', `respuesta_${baseName}.xml`)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_respuesta_xml']()}
</DropdownMenu.Item>
{/if}
{#if item.consulta_envio_xml_path}
<DropdownMenu.Item onclick={() => downloadArtifact('consulta-envio-xml', `consulta_envio_${baseName}.xml`)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_consulta_envio_xml']()}
</DropdownMenu.Item>
{/if}
{#if item.consulta_respuesta_xml_path}
<DropdownMenu.Item onclick={() => downloadArtifact('consulta-respuesta-xml', `consulta_respuesta_${baseName}.xml`)}>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_consulta_respuesta_xml']()}
</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}

View File

@@ -14,12 +14,16 @@
open = $bindable(false),
taskId,
nombreArchivo = '',
recordId,
companyId,
onComplete,
onCancel
}: {
open: boolean;
taskId: string;
nombreArchivo?: string;
recordId?: number;
companyId?: string | number;
onComplete?: (result: DigitalizacionResult) => void;
onCancel?: () => void;
} = $props();
@@ -32,12 +36,18 @@
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);
let externalTaskId = $state<string | null>(null);
let requestId = $state<string | null>(null);
let consecutivePollErrors = $state(0);
let pollHandle: ReturnType<typeof setTimeout> | null = null;
let pollInFlight = false;
let pollingActive = false;
let pollingTaskId: string | null = null;
// Start / stop polling based on open + taskId
$effect(() => {
if (open && taskId) {
startPolling();
void startPolling();
} else {
stopPolling();
if (!open) resetState();
@@ -53,58 +63,134 @@
result = null;
errorMsg = null;
errorDetail = null;
externalTaskId = null;
requestId = null;
consecutivePollErrors = 0;
}
function startPolling() {
async function startPolling() {
if (pollingActive && pollingTaskId === taskId) return;
stopPolling();
poll(); // immediate first call
pollHandle = setInterval(poll, 2000);
pollingActive = true;
pollingTaskId = taskId;
await poll();
}
function stopPolling() {
pollingActive = false;
pollingTaskId = null;
if (pollHandle !== null) {
clearInterval(pollHandle);
clearTimeout(pollHandle);
pollHandle = null;
}
}
function scheduleNextPoll(delayMs = 1000) {
if (!pollingActive || !taskId) return;
if (pollHandle !== null) {
clearTimeout(pollHandle);
}
pollHandle = setTimeout(() => {
void poll();
}, delayMs);
}
async function poll() {
if (!taskId) return;
if (!taskId || !pollingActive || pollInFlight) return;
pollInFlight = true;
try {
const res = await expedienteArchivosApi.getStatusTask(taskId);
if (!res.data) return;
const data = res.data;
if (res.error) {
consecutivePollErrors += 1;
if (consecutivePollErrors >= 3 || res.status >= 500) {
state = 'FAILURE';
errorMsg = res.error || 'No se pudo consultar el estado de la digitalización';
errorDetail = {
codigo: 'TASK_STATUS_REQUEST_ERROR',
descripcion: res.error || 'El servidor devolvió un error al consultar el estado.',
paso: 'Consulta de estado',
sugerencias: ['Cierra el diálogo y vuelve a intentar la digitalización.']
};
stopPolling();
} else {
scheduleNextPoll();
}
return;
}
state = (data.state || 'PENDING') as TaskState;
if (!res.data) {
scheduleNextPoll();
return;
}
const data = res.data;
consecutivePollErrors = 0;
externalTaskId = data.external_task_id ?? externalTaskId;
requestId = data.request_id ?? requestId;
state = (data.state === 'FAILED' ? 'FAILURE' : data.state || 'PENDING') as TaskState;
currentStep = data.current_step || 'Procesando...';
progress = data.progress ?? 0;
if (data.state === 'SUCCESS' && data.result) {
result = data.result;
if (data.state === 'SUCCESS') {
result = data.result ?? null;
stopPolling();
onComplete?.(data.result);
} else if (data.state === 'FAILURE') {
onComplete?.(data.result ?? ({} as DigitalizacionResult));
} else if (data.state === 'FAILURE' || data.state === 'FAILED') {
errorMsg = data.error || 'Error en la digitalización';
errorDetail = data.error_detail ?? null;
stopPolling();
} else {
scheduleNextPoll();
}
} catch {
// Ignore transient poll errors
consecutivePollErrors += 1;
if (consecutivePollErrors >= 3) {
state = 'FAILURE';
errorMsg = 'No se pudo consultar el estado de la digitalización';
errorDetail = {
codigo: 'TASK_STATUS_NETWORK_ERROR',
descripcion: 'La consulta de estado falló repetidamente.',
paso: 'Consulta de estado',
sugerencias: ['Verifica la conexión y vuelve a intentar la digitalización.']
};
stopPolling();
} else {
scheduleNextPoll();
}
} finally {
pollInFlight = false;
}
}
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);
async function downloadAcuse() {
const baseName = (nombreArchivo || taskId).replace(/\.[^.]+$/, '');
if (recordId != null && companyId != null) {
try {
await expedienteArchivosApi.downloadArtifact(
recordId,
'acuse',
companyId,
`acuse_${baseName}.pdf`
);
} catch {
alert('Error al descargar el acuse.');
}
} else if (result?.acuese_digitalizacion_pdf_base64) {
// fallback: decode base64 locally (registros legacy)
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_${baseName}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
function handleCancel() {
@@ -148,7 +234,7 @@
{/if}
</dl>
{/if}
{#if result?.acuese_digitalizacion_pdf_base64}
{#if result?.acuese_digitalizacion_pdf_base64 || (recordId != null && companyId != null)}
<Button onclick={downloadAcuse} class="w-full gap-2">
<Download class="h-4 w-4" />
{m['sidebar.digitalizacion.progress_download_acuse']()}
@@ -190,6 +276,21 @@
<p class="text-xs text-right text-muted-foreground">{progress}%</p>
</div>
{/if}
{#if taskId || externalTaskId || requestId}
<div class="rounded-md border bg-muted/30 px-3 py-2 space-y-1">
<p class="text-xs text-muted-foreground">Task App:</p>
<p class="text-xs font-mono break-all">{taskId}</p>
{#if externalTaskId}
<p class="text-xs text-muted-foreground pt-1">Task API:</p>
<p class="text-xs font-mono break-all">{externalTaskId}</p>
{/if}
{#if requestId}
<p class="text-xs text-muted-foreground pt-1">Request ID:</p>
<p class="text-xs font-mono break-all">{requestId}</p>
{/if}
</div>
{/if}
</div>
<Dialog.Footer class="flex justify-end">

View File

@@ -34,11 +34,15 @@
ChevronsRight
} from 'lucide-svelte';
import { documentTypesDigitizationApi, type DocumentTypeDigitization } from '$lib/api/dashboard/reference_data/document_types_digitization';
import type { Pedimento } from '$lib/api/dashboard/a76/pedimentos';
import { companyStore } from '$lib/stores/company.svelte';
import PedimentoSelectorDialog from '$lib/components/dashboard/pedimentos/edit/pedimento-selector-dialog.svelte';
interface Digitalizacion {
id?: number;
linea: number;
tipo_documento: string;
pedimento: string;
ruta_archivo_pdf: string;
observaciones: string;
e_document: string;
@@ -59,27 +63,26 @@
let isDialogOpen = $state(false);
let editingIndex = $state<number | null>(null);
let isTipoDocumentoDialogOpen = $state(false);
let isNuevoTipoDocumentoDialogOpen = $state(false);
let isPedimentoDialogOpen = $state(false);
// Tipos de documentos disponibles (cargados desde el backend)
let tiposDocumentos = $state<DocumentTypeDigitization[]>([]);
let isLoadingTiposDocumentos = $state(false);
let errorLoadingTiposDocumentos = $state<string | null>(null);
// Formulario para nuevo tipo de documento
let nuevoTipoDocumento = $state({
code: '',
description: '',
active: true
});
// Cargar tipos de documentos desde el backend
async function cargarTiposDocumentos() {
try {
isLoadingTiposDocumentos = true;
errorLoadingTiposDocumentos = null;
const response = await documentTypesDigitizationApi.getAll(true);
tiposDocumentos = response.data || [];
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
errorLoadingTiposDocumentos = 'No hay empresa activa seleccionada';
tiposDocumentos = [];
return;
}
const response = await documentTypesDigitizationApi.getAll(companyId, true);
tiposDocumentos = response.data?.items || [];
} catch (error) {
console.error('Error al cargar tipos de documentos:', error);
errorLoadingTiposDocumentos = 'Error al cargar los tipos de documentos';
@@ -111,6 +114,7 @@
let currentDigitalizacion = $state<Digitalizacion>({
linea: 0,
tipo_documento: '',
pedimento: '',
ruta_archivo_pdf: '',
observaciones: '',
e_document: '',
@@ -139,6 +143,7 @@
currentDigitalizacion = {
linea: nextLinea,
tipo_documento: '',
pedimento: '',
ruta_archivo_pdf: '',
observaciones: '',
e_document: '',
@@ -275,31 +280,11 @@
isTipoDocumentoDialogOpen = false;
}
function abrirNuevoTipoDocumento() {
nuevoTipoDocumento = {
code: '',
description: '',
active: true
};
isNuevoTipoDocumentoDialogOpen = true;
}
async function guardarNuevoTipoDocumento() {
try {
const response = await documentTypesDigitizationApi.create(nuevoTipoDocumento);
if (response.data) {
// Agregar el nuevo tipo a la lista
tiposDocumentos = [...tiposDocumentos, response.data];
// Seleccionar el nuevo tipo
currentDigitalizacion.tipo_documento = response.data.code;
// Cerrar ambos dialogs
isNuevoTipoDocumentoDialogOpen = false;
isTipoDocumentoDialogOpen = false;
}
} catch (error) {
console.error('Error al crear tipo de documento:', error);
alert('Error al crear el tipo de documento');
}
function seleccionarPedimento(pedimento: Pedimento) {
currentDigitalizacion.pedimento = `${pedimento.customs_office?.slice(0, 2) || ''}-${pedimento.license || ''}-${pedimento.pedimento_number || ''}`.replace(
/^-+|-+$/g,
''
);
}
</script>
@@ -490,14 +475,36 @@
<div class="flex gap-2">
<Input
id="tipo_documento"
bind:value={currentDigitalizacion.tipo_documento}
placeholder=""
class="flex-1"
value={currentDigitalizacion.tipo_documento}
placeholder="Selecciona desde el catálogo"
class="flex-1 bg-muted"
readonly
disabled
/>
<Button size="icon" variant="outline" onclick={abrirTiposDocumentos}>
<Button size="icon" variant="outline" onclick={abrirTiposDocumentos} type="button">
<FolderOpen class="h-4 w-4" />
</Button>
</div>
<p class="text-xs text-muted-foreground">
Solo lectura. Usa el botón para elegir desde el catálogo de tipos de documento.
</p>
</div>
<div class="space-y-2">
<Label for="pedimento">Pedimento</Label>
<div class="flex gap-2">
<Input
id="pedimento"
value={currentDigitalizacion.pedimento}
placeholder="Selecciona desde el catálogo de pedimentos"
class="flex-1 bg-muted"
readonly
disabled
/>
<Button size="icon" variant="outline" onclick={() => (isPedimentoDialogOpen = true)} type="button">
<Search class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-2">
@@ -555,6 +562,8 @@
</DialogContent>
</Dialog>
<PedimentoSelectorDialog bind:open={isPedimentoDialogOpen} onSelect={seleccionarPedimento} />
<!-- Dialog para Seleccionar Tipo de Documento -->
<Dialog bind:open={isTipoDocumentoDialogOpen}>
<DialogContent class="!max-w-[50vw] !w-[50vw] max-h-[85vh] h-[85vh] flex flex-col p-6">
@@ -563,7 +572,7 @@
</DialogHeader>
<div class="space-y-4 flex-1 min-h-0 flex flex-col overflow-hidden">
<!-- Campo de búsqueda y botón nuevo -->
<!-- Campo de búsqueda -->
<div class="flex items-center gap-2 shrink-0">
<Label for="buscar_tipo" class="whitespace-nowrap min-w-[60px]">Buscar:</Label>
<Input
@@ -572,10 +581,6 @@
placeholder="Buscar por código o descripción..."
class="flex-1"
/>
<Button size="sm" onclick={abrirNuevoTipoDocumento}>
<Plus class="mr-1.5" size={14} />
Nuevo
</Button>
</div>
<!-- Tabla de tipos de documentos -->
@@ -630,62 +635,7 @@
variant="default"
onclick={() => (isTipoDocumentoDialogOpen = false)}
>
Seleccionar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Dialog para Nuevo Tipo de Documento -->
<Dialog bind:open={isNuevoTipoDocumentoDialogOpen}>
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle>Tipos de Documentos para Digitalización</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-4">
<!-- Clave -->
<div class="space-y-2">
<Label for="nuevo_code">Clave:</Label>
<Input
id="nuevo_code"
bind:value={nuevoTipoDocumento.code}
placeholder="Ingrese la clave"
maxlength={10}
/>
</div>
<!-- Documento -->
<div class="space-y-2">
<Label for="nuevo_description">Documento:</Label>
<Input
id="nuevo_description"
bind:value={nuevoTipoDocumento.description}
placeholder="Descripción del documento"
/>
</div>
<!-- Detalle (campo de texto largo) -->
<div class="space-y-2">
<Label for="nuevo_detalle">Detalle:</Label>
<Textarea
id="nuevo_detalle"
placeholder="Información adicional..."
rows={4}
class="resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (isNuevoTipoDocumentoDialogOpen = false)}>
Cancelar
</Button>
<Button
onclick={guardarNuevoTipoDocumento}
disabled={!nuevoTipoDocumento.code || !nuevoTipoDocumento.description}
>
Aceptar
Cerrar
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -0,0 +1,222 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Dialog from '$lib/components/ui/dialog';
import { Search, Loader2, FileText } from 'lucide-svelte';
import { pedimentosApi, type Pedimento } from '$lib/api/dashboard/a76/pedimentos';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
onSelect
}: {
open: boolean;
onSelect: (pedimento: Pedimento) => void;
} = $props();
let pedimentos = $state<Pedimento[]>([]);
let loading = $state(false);
let loadingMore = $state(false);
let searchTerm = $state('');
let loaded = $state(false);
let page = $state(1);
let pageSize = 50;
let totalItems = $state(0);
let hasMore = $state(true);
let observer: IntersectionObserver | null = null;
let bottomSentinel: HTMLElement | null = $state(null);
let scrollContainer: HTMLDivElement | null = $state(null);
function buildPedimentoLabel(pedimento: Pedimento): string {
return `${pedimento.customs_office?.slice(0, 2) || ''}-${pedimento.license || ''}-${pedimento.pedimento_number || ''}`.replace(
/^-+|-+$/g,
''
);
}
let filteredPedimentos = $derived(
pedimentos.filter((pedimento) => {
const label = buildPedimentoLabel(pedimento).toLowerCase();
const code = (pedimento.pedimento_code || '').toLowerCase();
const regime = (pedimento.regime || '').toLowerCase();
const status = (pedimento.status || '').toLowerCase();
const term = searchTerm.toLowerCase();
return (
label.includes(term) ||
code.includes(term) ||
regime.includes(term) ||
status.includes(term) ||
pedimento.id.toString().includes(searchTerm)
);
})
);
$effect(() => {
if (open && !loaded && companyStore.activeCompany?.id) {
void resetAndLoad();
} else if (!open) {
loaded = false;
page = 1;
hasMore = true;
totalItems = 0;
pedimentos = [];
}
});
$effect(() => {
if (bottomSentinel && scrollContainer && hasMore && !loading && !loadingMore && open) {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
void loadMore();
}
},
{
root: scrollContainer,
threshold: 0.1
}
);
observer.observe(bottomSentinel);
}
return () => {
if (observer) observer.disconnect();
};
});
async function resetAndLoad() {
page = 1;
hasMore = true;
totalItems = 0;
pedimentos = [];
await loadPedimentos(true);
}
async function loadMore() {
if (!hasMore || loading || loadingMore) return;
page += 1;
await loadPedimentos(false);
}
async function loadPedimentos(isInitial: boolean) {
if (!companyStore.activeCompany?.id) return;
if (isInitial) loading = true;
else loadingMore = true;
try {
const res = await pedimentosApi.list(page, pageSize, undefined, companyStore.activeCompany.id);
const responseData = (res as any).data || res;
if (responseData?.items) {
const newItems = responseData.items as Pedimento[];
totalItems = responseData.total || 0;
pedimentos = isInitial ? newItems : [...pedimentos, ...newItems];
hasMore = pedimentos.length < totalItems && newItems.length > 0;
loaded = true;
} else {
hasMore = false;
}
} catch (e) {
console.error('Error cargando pedimentos:', e);
hasMore = false;
} finally {
loading = false;
loadingMore = false;
}
}
function handleSelect(pedimento: Pedimento) {
onSelect?.(pedimento);
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[900px] max-h-[80vh] flex flex-col z-[300]">
<Dialog.Header>
<Dialog.Title>Seleccionar Pedimento</Dialog.Title>
<Dialog.Description>
Busca y selecciona el pedimento registrado para asociarlo a la digitalización.
</Dialog.Description>
</Dialog.Header>
<div class="relative w-full my-2">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Buscar por pedimento, clave, régimen o ID..."
class="pl-9"
bind:value={searchTerm}
/>
</div>
<div bind:this={scrollContainer} class="flex-1 overflow-y-auto border rounded-md min-h-[300px]">
{#if loading && pedimentos.length === 0}
<div class="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p>Cargando catálogo...</p>
</div>
{:else if filteredPedimentos.length === 0}
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
<p>No se encontraron pedimentos.</p>
</div>
{:else}
<table class="w-full text-sm">
<thead class="bg-muted/50 backdrop-blur-sm">
<tr class="text-left border-b">
<th class="p-3 font-medium text-muted-foreground w-[60px]">ID</th>
<th class="p-3 font-medium text-muted-foreground w-[220px]">Pedimento</th>
<th class="p-3 font-medium text-muted-foreground w-[120px]">Clave</th>
<th class="p-3 font-medium text-muted-foreground">Régimen</th>
<th class="p-3 font-medium text-muted-foreground w-[110px] text-center">Estado</th>
</tr>
</thead>
<tbody>
{#each filteredPedimentos as pedimento}
<tr
class="border-b hover:bg-accent/50 transition-colors cursor-pointer"
onclick={() => handleSelect(pedimento)}
>
<td class="p-3 font-mono text-xs">{pedimento.id}</td>
<td class="p-3 font-medium">
<div class="flex items-center gap-2">
<FileText class="h-3 w-3 text-blue-500" />
<span class="font-mono text-xs">{buildPedimentoLabel(pedimento)}</span>
</div>
</td>
<td class="p-3 font-mono text-xs">{pedimento.pedimento_code || '-'}</td>
<td class="p-3">{pedimento.regime || '-'}</td>
<td class="p-3 text-center">
{#if pedimento.status}
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
{pedimento.status}
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">
Sin estado
</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
{#if loadingMore}
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
{/if}
</div>
{/if}
</div>
<Dialog.Footer>
<div class="text-xs text-muted-foreground self-center mr-auto">
Mostrando {filteredPedimentos.length} de {totalItems || pedimentos.length} registro(s) cargados
</div>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,59 @@
import type { ColumnDef } from '@tanstack/table-core';
import { renderSnippet } from '$lib/components/ui/data-table/index.js';
import { createRawSnippet } from 'svelte';
import type { DocumentTypeDigitization } from '$lib/api/dashboard/reference_data/document_types_digitization';
export function createColumns(): ColumnDef<DocumentTypeDigitization>[] {
return [
{
accessorKey: 'code',
header: 'Código',
cell: ({ row }) => {
const keySnippet = createRawSnippet<[{ code: string }]>((getCode) => {
const { code } = getCode();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">${code}</code>`
};
});
return renderSnippet(keySnippet, { code: row.original.code });
}
},
{
accessorKey: 'description',
header: 'Descripción',
cell: ({ row }) => {
const descriptionSnippet = createRawSnippet<[{ description: string }]>((getDescription) => {
const { description } = getDescription();
return {
render: () => `<div class="max-w-[720px] whitespace-normal">${description}</div>`
};
});
return renderSnippet(descriptionSnippet, { description: row.original.description });
}
},
{
accessorKey: 'active',
header: 'Estatus',
cell: ({ row }) => {
const activeSnippet = createRawSnippet<[{ active: boolean }]>((getActive) => {
const { active } = getActive();
const className = active
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-50 text-slate-600';
const label = active ? 'Activo' : 'Inactivo';
return {
render: () =>
`<span class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${className}">${label}</span>`
};
});
return renderSnippet(activeSnippet, { active: row.original.active });
}
}
];
}
export const columns = createColumns();

View File

@@ -122,6 +122,10 @@ export function getSidebarData(): SidebarData {
title: m["sidebar.reference_data.incoterms"](),
url: "/dashboard/reference_data/incoterms",
},
{
title: m["sidebar.reference_data.document_types_digitization"](),
url: "/dashboard/reference_data/document_types_digitization",
},
{
title: m["sidebar.reference_data.invoice_types"](),
url: "/dashboard/reference_data/invoice_types",

View File

@@ -0,0 +1,59 @@
import type { ShortcutDef } from '$lib/stores/shortcut-store';
export const obtenerAtajosDigitalizacion = (acciones: {
manejarNuevo: () => void;
manejarActualizar: () => void;
manejarDigitalizar: () => void;
manejarEditar: () => void;
manejarEliminar: () => void;
manejarDescargarZip: () => void;
irATabla: () => void;
irAAcciones: () => void;
}): ShortcutDef[] => [
{
key: 'Alt+Shift+N',
description: 'Nuevo Documento',
action: acciones.manejarNuevo,
},
{
key: 'Alt+Shift+R',
description: 'Actualizar Lista',
action: acciones.manejarActualizar,
},
{
key: 'Alt+Shift+T',
description: 'Ir a tabla',
action: acciones.irATabla,
skipDefaultFocusAfter: true,
},
{
key: 'Alt+Shift+A',
description: 'Ir a acciones (barra inferior)',
action: acciones.irAAcciones,
skipDefaultFocusAfter: true,
},
{
key: 'Alt+Shift+J',
description: 'Digitalizar seleccionado',
action: acciones.manejarDigitalizar,
skipDefaultFocusAfter: true,
},
{
key: 'Alt+Shift+E',
description: 'Editar seleccionado',
action: acciones.manejarEditar,
skipDefaultFocusAfter: true,
},
{
key: 'Alt+Shift+Z',
description: 'Descargar ZIP (todos los artefactos)',
action: acciones.manejarDescargarZip,
skipDefaultFocusAfter: true,
},
{
key: 'Alt+Shift+X',
description: 'Eliminar seleccionados',
action: acciones.manejarEliminar,
skipDefaultFocusAfter: true,
},
];

View File

@@ -1,26 +1,27 @@
<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 { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
import * as m from '$lib/paraglide/messages';
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, FileCheck2, Download, FolderArchive, Pencil, Trash2 } 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';
import InfiniteDataTable from '$lib/components/dashboard/common/infinite-data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/digitalizacion/create-edit-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 { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosDigitalizacion } from '$lib/config/shortcuts/dashboard/a76/digitalizacion/list';
// ── Estado ─────────────────────────────────────────────────────────────── //
let data = $state<ExpedienteArchivo[]>([]);
let totalItems = $state(0);
@@ -32,16 +33,27 @@
let search = $state($page.url.searchParams.get('search') || '');
let searchTimeout: ReturnType<typeof setTimeout>;
// Selección de filas
let selectedIds = $state<number[]>([]);
const selectedItems = $derived(data.filter((item) => selectedIds.includes(item.id)));
const selectedItem = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
const canOpenAcuse = $derived(selectedItem?.status === 'success' && !!selectedItem?.acuse_pdf_path);
const canDownloadEnvioXml = $derived(!!selectedItem?.envio_xml_path);
const canDownloadRespuestaXml = $derived(!!selectedItem?.respuesta_xml_path);
const canDownloadConsultaEnvioXml = $derived(!!selectedItem?.consulta_envio_xml_path);
const canDownloadConsultaRespuestaXml = $derived(!!selectedItem?.consulta_respuesta_xml_path);
const canDownloadZip = $derived(selectedItem?.status === 'success');
// Dialogs
let createDialogOpen = $state(false);
let digitalizarDialogOpen = $state(false);
let editDialogOpen = $state(false);
let progressDialogOpen = $state(false);
let selectedItem = $state<ExpedienteArchivo | null>(null);
let editingItem = $state<ExpedienteArchivo | null>(null);
let digitalizarItem = $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() {
@@ -57,6 +69,7 @@
data = res.data.items;
currentPage = 1;
totalItems = res.data.total;
selectedIds = selectedIds.filter((id) => data.some((item) => item.id === id));
}
} catch (e) {
console.error('Error loading expediente archivos:', e);
@@ -103,46 +116,260 @@
});
// ── Handlers de acciones ───────────────────────────────────────────────── //
function handleDigitalizar(item: ExpedienteArchivo) {
selectedItem = item;
digitalizarDialogOpen = true;
function handleRowClick(item: ExpedienteArchivo) {
if (selectedIds.includes(item.id)) {
selectedIds = selectedIds.filter((id) => id !== item.id);
return;
}
selectedIds = [...selectedIds, item.id];
}
function handleSelectedIdsChange(ids: number[]) {
selectedIds = ids;
}
async function handleDigitalizar(item: ExpedienteArchivo) {
const company = companyStore.activeCompany;
if (!company) {
alert('No hay compañía seleccionada.');
return;
}
if (!item.tipo_documento?.trim()) {
alert('El documento no tiene Clave Documento capturada.');
return;
}
if (!item.nombre_archivo?.trim() || !item.archivo_digitalizado_en?.trim()) {
alert('El documento no tiene archivo cargado. Edita el registro y vuelve a seleccionar el archivo.');
return;
}
digitalizarItem = item;
const response = await expedienteArchivosApi.digitalizar(
item.id,
{
rfc_consulta: item.rfc_consulta?.trim() || undefined,
clave_documento: item.tipo_documento.trim(),
nombre_archivo: item.nombre_archivo.trim(),
archivo_base64: undefined
},
company.id
);
if (response.error) {
const ve = (response as any).validationErrors;
if (ve?.length) {
alert(ve.map((error: any) => error.msg).join(' · '));
return;
}
alert(response.error);
return;
}
const taskId = response.data?.task_id;
if (!taskId) {
alert('No se recibió task_id del servidor');
return;
}
handleDigitalizarSuccess(taskId);
}
async function handleDigitalizarSelected() {
if (!selectedItem) {
alert('Selecciona un documento para digitalizar.');
return;
}
await handleDigitalizar(selectedItem);
}
function handleDigitalizarSuccess(taskId: string) {
currentTaskId = taskId;
currentNombreArchivo = selectedItem?.nombre_archivo ?? '';
currentNombreArchivo = digitalizarItem?.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 };
}
function handleProgressComplete(_result: DigitalizacionResult) {
loadData();
}
function handleAcuse(item: ExpedienteArchivo) {
const b64 = acuseMap[item.id];
if (!b64) {
alert('No hay acuse disponible para este documento en esta sesión.');
async function handleDownloadArtifact(
item: ExpedienteArchivo,
type: 'acuse' | 'envio-xml' | 'respuesta-xml' | 'consulta-envio-xml' | 'consulta-respuesta-xml',
filename: string
) {
if (!companyStore.activeCompany) return;
try {
await expedienteArchivosApi.downloadArtifact(item.id, type, companyStore.activeCompany.id, filename);
} catch {
alert('Error al descargar el archivo.');
}
}
async function handleAcuse(item: ExpedienteArchivo) {
const baseName = (item.nombre_archivo || String(item.id)).replace(/\.[^.]+$/, '');
await handleDownloadArtifact(item, 'acuse', `acuse_${baseName}.pdf`);
}
function handleAcuseSelected() {
if (!selectedItem) {
alert('Selecciona un documento para descargar el acuse.');
return;
}
handleAcuse(selectedItem);
}
function handleDownloadSelected(
type: 'envio-xml' | 'respuesta-xml' | 'consulta-envio-xml' | 'consulta-respuesta-xml',
prefix: string,
ext: string
) {
if (!selectedItem) return;
const baseName = (selectedItem.nombre_archivo || String(selectedItem.id)).replace(/\.[^.]+$/, '');
handleDownloadArtifact(selectedItem, type, `${prefix}_${baseName}.${ext}`);
}
async function handleDownloadZip() {
if (!selectedItem || !companyStore.activeCompany) return;
try {
await expedienteArchivosApi.downloadAllArtifactsZip(
selectedItem.id,
companyStore.activeCompany.id,
selectedItem.e_document || String(selectedItem.id)
);
} catch {
alert('Error al descargar el ZIP.');
}
}
function handleEditSelected() {
if (!selectedItem) {
alert('Selecciona un documento para editar.');
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);
editingItem = selectedItem;
editDialogOpen = true;
}
async function handleFooterDelete() {
if (!companyStore.activeCompany || selectedIds.length === 0) return;
const confirmed =
selectedIds.length === 1
? confirm(m['sidebar.digitalizacion.confirm_delete']())
: confirm(`Se eliminarán ${selectedIds.length} documentos digitalizados. ¿Deseas continuar?`);
if (!confirmed) return;
try {
const results = await Promise.all(
selectedIds.map((id) => expedienteArchivosApi.delete(id, companyStore.activeCompany!.id))
);
const firstError = results.find((result) => result.error)?.error;
if (firstError) {
alert(`Error al eliminar: ${firstError}`);
return;
}
selectedIds = [];
await loadData();
} catch (e) {
alert(`Error: ${e instanceof Error ? e.message : 'Error desconocido'}`);
}
}
const columns = createColumns(loadData, handleDigitalizar, handleAcuse);
// ── Navegación por teclado ─────────────────────────────────────────────── //
function focusFirstTableRow() {
if (!browser) return;
const row = document.querySelector<HTMLElement>(
'[data-digitalizacion-list-table] tbody tr[data-slot="table-row"]'
);
if (row) {
row.focus();
setTimeout(() => row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 50);
return;
}
toast.info('No hay filas en la tabla');
}
function focusFooterActions() {
if (!browser) return;
const footer = document.getElementById('digitalizacion-list-footer');
if (!footer) return;
const candidates = footer.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
);
for (const el of candidates) {
if (el.offsetParent === null && el.getClientRects().length === 0) continue;
el.focus();
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 50);
return;
}
}
function getFooterToolbarButtons(): HTMLButtonElement[] {
const toolbar = document.querySelector('#digitalizacion-list-footer [data-digitalizacion-footer-toolbar]');
if (!toolbar) return [];
return Array.from(toolbar.querySelectorAll<HTMLButtonElement>('[data-footer-action]'));
}
function handleFooterToolbarKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
const target = event.target as HTMLElement | null;
if (!target?.closest('[data-digitalizacion-footer-toolbar]')) return;
if (target.closest('[data-slot="dropdown-menu-content"]')) return;
const buttons = getFooterToolbarButtons().filter((b) => !b.disabled);
if (buttons.length === 0) return;
const active = document.activeElement as HTMLButtonElement | null;
let idx = active ? buttons.indexOf(active) : -1;
if (idx === -1) {
idx = event.key === 'ArrowRight' ? 0 : buttons.length - 1;
} else if (event.key === 'ArrowRight') {
idx = (idx + 1) % buttons.length;
} else {
idx = (idx - 1 + buttons.length) % buttons.length;
}
event.preventDefault();
buttons[idx]?.focus();
}
useShortcuts(
'Digitalizacion List',
obtenerAtajosDigitalizacion({
manejarNuevo: () => (createDialogOpen = true),
manejarActualizar: loadData,
manejarDigitalizar: handleDigitalizarSelected,
manejarEditar: handleEditSelected,
manejarEliminar: () => {
if (selectedIds.length === 0) {
toast.info('Selecciona al menos un documento para eliminar');
return;
}
handleFooterDelete();
},
manejarDescargarZip: () => {
if (!selectedItem || !canDownloadZip) {
toast.info('Selecciona un documento digitalizado para descargar ZIP');
return;
}
handleDownloadZip();
},
irATabla: focusFirstTableRow,
irAAcciones: focusFooterActions,
})
);
</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"
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 pb-[5.5rem]"
>
<!-- Header -->
<div class="flex items-center justify-between">
@@ -157,10 +384,6 @@
<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>
@@ -189,8 +412,17 @@
{m['sidebar.digitalizacion.empty']()}
</div>
{:else}
<div class="rounded-md border bg-background overflow-hidden h-full">
<InfiniteDataTable {data} {columns} {loading} {hasMore} {loadMore} />
<div class="rounded-md border bg-background overflow-hidden h-full" data-digitalizacion-list-table>
<InfiniteDataTable
{data}
{columns}
{loading}
{hasMore}
{loadMore}
{selectedIds}
onSelectedIdsChange={handleSelectedIdsChange}
onRowClick={handleRowClick}
/>
</div>
{/if}
</Card.Content>
@@ -201,15 +433,133 @@
</div>
</div>
<!-- Footer fijo con botones de acción -->
<div
id="digitalizacion-list-footer"
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div
role="toolbar"
aria-label="Acciones de digitalización"
data-digitalizacion-footer-toolbar
class="flex w-full items-center justify-end gap-2"
onkeydown={handleFooterToolbarKeydown}
>
<Button variant="outline" size="sm" data-footer-action="nuevo" onclick={() => (createDialogOpen = true)}>
<Plus class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.new']()}
</Button>
<div class="h-6 w-px bg-border"></div>
<Button
variant="outline"
size="sm"
data-footer-action="digitalizar"
disabled={selectedIds.length !== 1}
onclick={handleDigitalizarSelected}
>
<FileCheck2 class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_digitalizar']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="acuse"
disabled={selectedIds.length !== 1 || !canOpenAcuse}
onclick={handleAcuseSelected}
>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_acuse']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="envio-xml"
disabled={selectedIds.length !== 1 || !canDownloadEnvioXml}
onclick={() => handleDownloadSelected('envio-xml', 'envio', 'xml')}
>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_envio_xml']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="respuesta-xml"
disabled={selectedIds.length !== 1 || !canDownloadRespuestaXml}
onclick={() => handleDownloadSelected('respuesta-xml', 'respuesta', 'xml')}
>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_respuesta_xml']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="consulta-envio-xml"
disabled={selectedIds.length !== 1 || !canDownloadConsultaEnvioXml}
onclick={() => handleDownloadSelected('consulta-envio-xml', 'consulta_envio', 'xml')}
>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_consulta_envio_xml']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="consulta-respuesta-xml"
disabled={selectedIds.length !== 1 || !canDownloadConsultaRespuestaXml}
onclick={() => handleDownloadSelected('consulta-respuesta-xml', 'consulta_respuesta', 'xml')}
>
<Download class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_consulta_respuesta_xml']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="zip"
disabled={selectedIds.length !== 1 || !canDownloadZip}
onclick={handleDownloadZip}
>
<FolderArchive class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_download_zip']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="editar"
disabled={selectedIds.length !== 1}
onclick={handleEditSelected}
>
<Pencil class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_edit']()}
</Button>
<Button
variant="outline"
size="sm"
data-footer-action="eliminar"
disabled={selectedIds.length === 0}
onclick={handleFooterDelete}
>
<Trash2 class="mr-2 h-4 w-4" />
{m['sidebar.digitalizacion.action_delete']()}
</Button>
</div>
</div>
</div>
<!-- Dialogs -->
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
{#if selectedItem && digitalizarDialogOpen}
<DigitalizarDialog
bind:open={digitalizarDialogOpen}
item={selectedItem}
onSuccess={handleDigitalizarSuccess}
/>
{#if editingItem}
<CreateEditDialog bind:open={editDialogOpen} item={editingItem} onSuccess={loadData} />
{/if}
{#if progressDialogOpen && currentTaskId}
@@ -217,6 +567,8 @@
bind:open={progressDialogOpen}
taskId={currentTaskId}
nombreArchivo={currentNombreArchivo}
recordId={digitalizarItem?.id}
companyId={companyStore.activeCompany?.id}
onComplete={handleProgressComplete}
onCancel={() => { progressDialogOpen = false; loadData(); }}
/>

View File

@@ -0,0 +1,88 @@
import type { PageServerLoad } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
const parentData = await parent();
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
return {
error: 'No authenticated',
items: [],
total: 0,
page: 1,
page_size: 50
};
}
try {
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('page_size') || '50');
const search = url.searchParams.get('search') || '';
const cookieCompanyId = cookies.get('active_company_id');
const companyId = cookieCompanyId ? parseInt(cookieCompanyId) : parentData.companies?.[0]?.id;
if (!companyId) {
return {
error: 'No se encontró una compañía seleccionada',
items: [],
total: 0,
page,
page_size: pageSize
};
}
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
company_id: companyId.toString()
});
if (search) {
params.append('search', search);
}
const response = await authenticatedFetch(
`v1/a76/document-types-digitization/?${params.toString()}`,
{},
cookies,
fetch
);
if (!response.ok) {
const errorText = await response.text();
console.error('📊 [Document Types Digitization] API Error:', {
status: response.status,
statusText: response.statusText,
error: errorText
});
return {
error: `Error ${response.status}: ${response.statusText}`,
items: [],
total: 0,
page,
page_size: pageSize
};
}
const data = await response.json();
return {
items: data.items || [],
total: data.total || 0,
page: data.page || page,
page_size: data.page_size || pageSize,
error: null
};
} catch (error) {
console.error('📊 [Document Types Digitization] Load error:', error);
return {
error: 'Error loading data',
items: [],
total: 0,
page: 1,
page_size: 50
};
}
};

View File

@@ -0,0 +1,183 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { page } from '$app/stores';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { RefreshCw } from 'lucide-svelte';
import { companyStore } from '$lib/stores/company.svelte';
import {
documentTypesDigitizationApi,
type DocumentTypeDigitization
} from '$lib/api/dashboard/reference_data/document_types_digitization';
import DataTable from '$lib/components/dashboard/reference_data/invoice_types/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/reference_data/document_types_digitization/columns';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
onMount(() => {
if (browser) {
const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
if (cookieToken && cookieToken !== localToken) localStorage.setItem('access_token', cookieToken);
}
});
let allItems = $state<DocumentTypeDigitization[]>(data.items || []);
let currentPage = $state(data.page || 1);
let pageSize = $state(data.page_size || 50);
let totalItems = $state(data.total || 0);
let loading = $state(false);
let error = $state<string | null>(data.error || null);
let searchQuery = $state($page.url.searchParams.get('search') || '');
let timeout: ReturnType<typeof setTimeout>;
let hasMore = $derived(allItems.length < totalItems);
function getActiveCompanyId(): number | null {
const fromStore = companyStore.activeCompany?.id;
if (fromStore) return fromStore;
if (!browser) return null;
const cookie = document.cookie
.split('; ')
.find((row) => row.startsWith('active_company_id='))
?.split('=')[1];
if (!cookie) return null;
const parsed = Number(cookie);
return Number.isFinite(parsed) ? parsed : null;
}
function handleSearch() {
if (!browser) return;
clearTimeout(timeout);
timeout = setTimeout(async () => {
loading = true;
error = null;
try {
const companyId = getActiveCompanyId();
if (!companyId) {
error = 'No hay empresa activa seleccionada';
return;
}
const response = await documentTypesDigitizationApi.list(1, pageSize, companyId, searchQuery);
if (!response.error && response.data) {
allItems = response.data.items;
currentPage = 1;
totalItems = response.data.total;
}
} catch (err) {
error = 'Error aplicando filtros';
console.error('Error applying filters:', err);
} finally {
loading = false;
}
const url = new URL($page.url);
if (searchQuery) url.searchParams.set('search', searchQuery);
else url.searchParams.delete('search');
history.replaceState(history.state, '', url);
}, 500);
}
async function loadMore() {
if (loading || !hasMore) return;
loading = true;
error = null;
try {
const companyId = getActiveCompanyId();
if (!companyId) {
error = 'No hay empresa activa seleccionada';
return;
}
const response = await documentTypesDigitizationApi.list(
currentPage + 1,
pageSize,
companyId,
searchQuery
);
if (response.error) {
if (response.status === 401 || response.status === 403) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => window.location.reload(), 2000);
} else {
error = response.error;
}
return;
}
if (response.data?.items) {
allItems = [...allItems, ...response.data.items];
currentPage += 1;
totalItems = response.data.total;
}
} catch (err) {
error = 'Error cargando más datos';
console.error('Error loading more document types:', err);
} finally {
loading = false;
}
}
function reloadData() {
window.location.reload();
}
useShortcuts('Tipos de documento para digitalización', [
{ key: 'Alt+Shift+R', description: 'Actualizar Lista', action: reloadData }
]);
const columns = createColumns();
</script>
<div class="flex h-[calc(100svh-4rem)] flex-col gap-6 overflow-hidden p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]">
<div class="flex-none flex items-center justify-between">
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight">Tipos de documento para digitalización</h1>
<p class="text-muted-foreground">
Consulta el catálogo fijo de solo lectura utilizado por digitalización y pedimentos.
</p>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" size="sm" class="h-9" onclick={reloadData}>
<RefreshCw class="mr-2 h-4 w-4" />
Actualizar
</Button>
</div>
</div>
{#if error}
<div class="flex-none rounded-md border border-destructive px-4 py-3 text-sm text-destructive">
{error}
</div>
{/if}
<Card.Root class="border bg-background flex min-h-0 flex-1 flex-col">
<Card.Header>
<div class="flex flex-wrap items-center justify-between gap-3">
<Card.Title>Listado de tipos de documento</Card.Title>
<div class="flex flex-wrap items-center gap-2">
<Input
placeholder="Buscar por código o descripción"
bind:value={searchQuery}
oninput={handleSearch}
class="h-9 w-56 bg-card lg:w-72"
/>
</div>
</div>
</Card.Header>
<Card.Content class="min-h-0 p-0">
<div class="rounded-md border bg-background overflow-hidden flex min-h-0 flex-1 flex-col">
<DataTable data={allItems} {columns} {loading} {hasMore} {loadMore} />
</div>
</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">Mostrando {allItems.length} de {totalItems} registros</div>
</div>