feature/digitalizacion-api
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
211
backend/api/v1/modules/a76/doc_types_dig/service.py
Normal file
211
backend/api/v1/modules/a76/doc_types_dig/service.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 #
|
||||
# ──────────────────────────────────────────────────────────────────────────── #
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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(' ')}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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(); }}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user