diff --git a/backend/api/v1/modules/crm/documents/service.py b/backend/api/v1/modules/crm/documents/service.py index 563b9e2..346b45f 100644 --- a/backend/api/v1/modules/crm/documents/service.py +++ b/backend/api/v1/modules/crm/documents/service.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime, timezone from fastapi import HTTPException, status @@ -8,6 +9,8 @@ from ..suppliers.models import Supplier from .dto import DocumentCreate, DocumentUpdate from .models import Document +logger = logging.getLogger(__name__) + def _validate_owner(db: Session, account_id: int | None, supplier_id: int | None, tenant_id: int, company_id: int) -> None: """Un documento debe pertenecer a exactamente un cliente o proveedor existente.""" @@ -95,6 +98,30 @@ def update_document( def delete_document(db: Session, document_id: int, tenant_id: int, company_id: int) -> None: + """Baja lógica del documento. Si estaba en un expediente, lo **desasocia**; no lo destruye. + + El CRM no llama al DELETE de EFC, y es deliberado: el gateway de Anexo22 tampoco lo hace + —verificado, ese método no existe en su cliente— y ``record.Document`` en EFC no tiene vigencia + ni purga, así que la política implícita del sistema es conservar. Un documento que mañana puede + ser parte del expediente de un pedimento real es riesgo de retención fiscal. + + El objeto local sí se limpia cuando todavía existe: si ya se entregó a EFC, ``delete_local`` lo + borró al confirmar y ``file_key`` está en ``None``. + """ document = get_document(db, document_id, tenant_id, company_id) document.deleted_at = datetime.now(timezone.utc) + document.expediente_id = None + if document.file_key: + try: + from core.storage_s3 import delete_object_if_exists + + delete_object_if_exists(document.file_key) + except Exception: + # El borrado del objeto es una consecuencia de la baja, no parte de ella: dejar un + # objeto huérfano es preferible a no poder dar de baja el documento. + logger.warning( + "documents: no se pudo borrar el objeto local %s del documento %s", + document.file_key, document.id, exc_info=True, + ) + document.file_key = None db.commit() diff --git a/backend/api/v1/modules/crm/expedientes/dto.py b/backend/api/v1/modules/crm/expedientes/dto.py index 178e894..f26dc06 100644 --- a/backend/api/v1/modules/crm/expedientes/dto.py +++ b/backend/api/v1/modules/crm/expedientes/dto.py @@ -77,3 +77,30 @@ class ExpedienteEnsureInput(BaseModel): """Entrada de ``POST /expedientes/ensure``: la solicitud a la que colgar el expediente.""" service_request_id: int + + +class ExpedienteDocumentResponse(BaseModel): + """Documento de un expediente, tal como lo ve el frontend. + + **No lleva ``file_key`` ni ``file_url`` a propósito.** La copia local es de tránsito y se borra + al confirmar la entrega a EFC, así que exponerla invitaría al frontend a guardarse una + referencia que va a dejar de existir. Para abrir el archivo está el proxy de descarga. + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + expediente_id: int | None = None + doc_type: str + name: str + content_type: str | None = None + size_bytes: int | None = None + # Lo que pinta el badge de la ficha: PENDING | SYNCED | FAILED + efc_sync_state: str | None = None + efc_document_ref: str | None = None + efc_document_id: str | None = None + efc_error_code: str | None = None + efc_attempts: int | None = None + uploaded_by: str | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/api/v1/modules/crm/expedientes/routes.py b/backend/api/v1/modules/crm/expedientes/routes.py index cd3c1be..014ae03 100644 --- a/backend/api/v1/modules/crm/expedientes/routes.py +++ b/backend/api/v1/modules/crm/expedientes/routes.py @@ -1,11 +1,18 @@ -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from core.database import get_core_db +from core.efc_client import EfcClientError from core.security import get_current_user from . import service -from .dto import ExpedienteCompleteInput, ExpedienteEnsureInput, ExpedienteResponse +from .dto import ( + ExpedienteCompleteInput, + ExpedienteDocumentResponse, + ExpedienteEnsureInput, + ExpedienteResponse, +) router = APIRouter() @@ -75,3 +82,97 @@ def delete_expediente( ): tenant_id = current_user["tenant_id"] service.delete_expediente(db, expediente_id, tenant_id, company_id) + + +# ── Documentos del expediente ──────────────────────────────────────────────── + + +@router.get( + "/expedientes/{expediente_id}/documentos", + response_model=list[ExpedienteDocumentResponse], +) +def list_expediente_documents( + expediente_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.list_expediente_documents(db, expediente_id, tenant_id, company_id) + + +@router.post( + "/expedientes/{expediente_id}/documentos", + response_model=ExpedienteDocumentResponse, + status_code=status.HTTP_201_CREATED, +) +async def upload_expediente_document( + expediente_id: int, + file: UploadFile = File(...), + doc_type: str = Form(...), + name: str | None = Form(None), + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Subida de un paso: guarda, registra y encola la entrega a EFC. + + Responde **201 aunque EFC esté caído**: el archivo ya está a salvo en el CRM y el carril lo + entrega cuando EFC vuelva. Perder el trabajo del usuario porque un sistema de terceros no + contesta sería el peor intercambio posible. + """ + tenant_id = current_user["tenant_id"] + user_id = current_user.get("sub") or current_user.get("id") + return await service.attach_document( + db, expediente_id, file, doc_type, tenant_id, company_id, name, user_id + ) + + +@router.get("/expedientes/{expediente_id}/documentos/{document_id}/archivo") +def download_expediente_document( + expediente_id: int, + document_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Proxy de descarga hacia EFC, con streaming. + + La traducción de errores es asimétrica **a propósito**: cualquier ``EfcClientError`` sale como + 502 —es un fallo de la integración, no del usuario—, salvo un 404 de EFC, que sale como 404 + porque significa que ese documento realmente no está. + """ + tenant_id = current_user["tenant_id"] + try: + iterador, content_type, filename = service.stream_document( + db, expediente_id, document_id, tenant_id, company_id + ) + except EfcClientError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND + if exc.status_code == 404 + else status.HTTP_502_BAD_GATEWAY, + detail="No se pudo obtener el archivo del expediente electrónico.", + ) from exc + + return StreamingResponse( + iterador, + media_type=content_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.delete( + "/expedientes/{expediente_id}/documentos/{document_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def detach_expediente_document( + expediente_id: int, + document_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Desasocia el documento del CRM. **No lo destruye en EFC** (ver ``service.detach_document``).""" + tenant_id = current_user["tenant_id"] + service.detach_document(db, expediente_id, document_id, tenant_id, company_id) diff --git a/backend/api/v1/modules/crm/expedientes/service.py b/backend/api/v1/modules/crm/expedientes/service.py index 2b74aba..e85b374 100644 --- a/backend/api/v1/modules/crm/expedientes/service.py +++ b/backend/api/v1/modules/crm/expedientes/service.py @@ -3,13 +3,22 @@ Funciones libres que reciben ``db, tenant_id, company_id``, como el resto de los módulos del repo. """ +import hashlib +import uuid from datetime import datetime, timezone -from fastapi import HTTPException, status +from fastapi import HTTPException, UploadFile, status from sqlalchemy.orm import Session +from core.s3_keys import expediente_document_key +from core.storage_s3 import put_object_bytes + +from ..documents.models import Document from ..expediente_gateway import service as gateway +from ..expediente_gateway.models import FILE_KIND_DOCUMENTO, SOURCE_CRM_DOCUMENTS from ..service_requests.models import ServiceRequest +from ..uploads.routes import leer_acotado, validar_extension +from .doc_types import is_valid_doc_type from .dto import ExpedienteCompleteInput from .folio import next_folio, storage_token from .models import Expediente @@ -201,3 +210,213 @@ def delete_expediente(db: Session, expediente_id: int, tenant_id: int, company_i expediente = get_expediente(db, expediente_id, tenant_id, company_id) expediente.deleted_at = datetime.now(timezone.utc) db.commit() + + +# ══ Documentos del expediente (fase 7) ══════════════════════════════════════ + +async def attach_document( + db: Session, + expediente_id: int, + file: UploadFile, + doc_type: str, + tenant_id: int, + company_id: int, + name: str | None = None, + user_id: str | None = None, +) -> Document: + """Subida de UN paso: guarda el archivo, crea el documento y encola su entrega a EFC. + + El orden importa y no es negociable: + + 1. Validar tipo y extensión **antes** de tocar el almacén, para no dejar un objeto huérfano. + 2. Leer el archivo **acotado**: nunca ``await file.read()`` completo (ver ``leer_acotado``). + 3. Escribir a MinIO. + 4. Crear la fila del documento y la del outbox **en una sola transacción**, de modo que no + pueda existir un documento sin su intención de entrega ni al revés. + 5. Despachar best-effort y responder 201 **pase lo que pase con EFC**. + + El paso 5 es lo que hace que un EFC caído no le cueste su trabajo al usuario: la subida tiene + éxito y el documento queda en «Pendiente de enviar» hasta que el barrido lo entregue. + """ + expediente = get_expediente(db, expediente_id, tenant_id, company_id) + + if not is_valid_doc_type(doc_type): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Ese tipo de documento no está en el catálogo.", + ) + validar_extension(file.filename) + contenido = await leer_acotado(file) + + key = expediente_document_key( + tenant_id, company_id, expediente.id, uuid.uuid4().hex, file.filename or "archivo" + ) + content_type = file.content_type or "application/octet-stream" + put_object_bytes(key, contenido, content_type=content_type) + + documento = Document( + doc_type=doc_type, + name=name or file.filename or "archivo", + file_key=key, + content_type=content_type, + size_bytes=len(contenido), + expediente_id=expediente.id, + efc_sync_state="PENDING", + content_sha256=hashlib.sha256(contenido).hexdigest(), + tenant_id=tenant_id, + company_id=company_id, + uploaded_by=user_id, + ) + db.add(documento) + db.flush() + + documento.efc_document_ref = f"CRMDOC-{company_id}-{documento.id}" + fila = gateway.enqueue_file_best_effort( + db, + kind=FILE_KIND_DOCUMENTO, + s3_key=key, + file_name=file.filename or "archivo", + content_type=content_type, + efc_tipo=doc_type, + source_table=SOURCE_CRM_DOCUMENTS, + source_id=documento.id, + crm_document_ref=documento.efc_document_ref, + expediente_ref=expediente.id, + tenant_id=tenant_id, + company_id=company_id, + delete_local=True, + ) + db.commit() + db.refresh(documento) + + if fila is not None: + gateway._dispatch_file_delivery(fila.id, tenant_id, company_id) + return documento + + +def get_expediente_document( + db: Session, expediente_id: int, document_id: int, tenant_id: int, company_id: int +) -> Document: + """El documento, validando que pertenezca a ESE expediente, tenant y company. + + La validación de pertenencia va **antes** de tocar EFC. Sin ella, un ``document_id`` que + coincidiera leería el expediente de otro tenant — y peor: el ``organizacion_id`` con el que el + proxy pregunta se deriva del expediente, así que el CRM iría a preguntarle a la organización de + otro cliente. + """ + expediente = get_expediente(db, expediente_id, tenant_id, company_id) + documento = ( + db.query(Document) + .filter( + Document.id == document_id, + Document.expediente_id == expediente.id, + Document.tenant_id == tenant_id, + Document.company_id == company_id, + Document.deleted_at.is_(None), + ) + .first() + ) + if not documento: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento no encontrado") + return documento + + +def list_expediente_documents( + db: Session, expediente_id: int, tenant_id: int, company_id: int +) -> list[Document]: + expediente = get_expediente(db, expediente_id, tenant_id, company_id) + return ( + db.query(Document) + .filter( + Document.expediente_id == expediente.id, + Document.tenant_id == tenant_id, + Document.company_id == company_id, + Document.deleted_at.is_(None), + ) + .order_by(Document.created_at.desc()) + .all() + ) + + +def _iter_upstream(url: str, headers: dict, params: dict, verify: bool, timeout_s: float): + """Generador async que hace de proxy del archivo de EFC hacia el navegador. + + **El ``AsyncClient`` se crea DENTRO del generador y se cierra en ``finally``.** Si se creara en + un ``async with`` de fuera, ese bloque cerraría el cliente antes de que empiece el streaming + —FastAPI consume el generador después de devolver la respuesta— y la descarga moriría a medias. + + EFC nunca entrega una URL de MinIO, y reescribir el host de una URL ya firmada invalida su + SigV4, así que el CRM tiene que hacer de segundo proxy: no hay atajo. + """ + import httpx + + async def _generador(): + client = httpx.AsyncClient(verify=verify, timeout=timeout_s) + try: + async with client.stream("GET", url, headers=headers, params=params) as upstream: + if upstream.status_code >= 400: + await upstream.aread() + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND + if upstream.status_code == 404 + else status.HTTP_502_BAD_GATEWAY, + detail="No se pudo obtener el archivo del expediente electrónico.", + ) + async for chunk in upstream.aiter_bytes(): + yield chunk + finally: + await client.aclose() + + return _generador() + + +def stream_document( + db: Session, expediente_id: int, document_id: int, tenant_id: int, company_id: int +) -> tuple: + """Prepara la descarga de un documento del expediente. + + Devuelve ``(iterador, content_type, filename)``. Dos guardas, y las dos importan: + + 1. La pertenencia se valida **antes** de tocar EFC (ver ``get_expediente_document``). + 2. Se manda el ``organizacion_id`` del expediente, para que la verificación de EFC también + dispare y no baste con acertar un id de documento. + """ + from core.efc_client import EfcClientError, efc_client + + expediente = get_expediente(db, expediente_id, tenant_id, company_id) + documento = get_expediente_document(db, expediente_id, document_id, tenant_id, company_id) + + if not documento.efc_document_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="El documento todavía no llegó al expediente electrónico.", + ) + if not efc_client.is_configured: + raise EfcClientError("Integración con EFC no configurada.", retryable=False) + + return ( + _iter_upstream( + efc_client.download_url(documento.efc_document_id), + efc_client.auth_headers, + {"organizacion_id": str(expediente.efc_organizacion_id or "")}, + efc_client.verify_ssl, + efc_client.upload_timeout_s, + ), + documento.content_type or "application/octet-stream", + documento.name or "documento", + ) + + +def detach_document( + db: Session, expediente_id: int, document_id: int, tenant_id: int, company_id: int +) -> None: + """Baja lógica en el CRM. **NO destruye el documento en EFC.** + + El gateway de Anexo22 nunca llama al DELETE de EFC —verificado: ese método no existe en su + cliente—, y ``record.Document`` en EFC no tiene vigencia ni purga, así que la política implícita + del sistema es **conservar**. Un documento que mañana puede ser parte del expediente de un + pedimento real es riesgo de retención fiscal: desaparece de la vista del CRM y sigue en EFC. + """ + documento = get_expediente_document(db, expediente_id, document_id, tenant_id, company_id) + documento.deleted_at = datetime.now(timezone.utc) + db.commit() diff --git a/backend/api/v1/modules/crm/uploads/routes.py b/backend/api/v1/modules/crm/uploads/routes.py index 63757dd..603e00c 100644 --- a/backend/api/v1/modules/crm/uploads/routes.py +++ b/backend/api/v1/modules/crm/uploads/routes.py @@ -17,6 +17,21 @@ router = APIRouter() MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB _SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+") +# Trozo de lectura. No es crítico afinarlo: lo que importa es que la lectura sea POR PARTES y no de +# golpe, para poder abortar en cuanto se pase del tope. +_CHUNK_BYTES = 1 * 1024 * 1024 + +# Allowlist de extensiones, igual que la que ya tienen el avatar y el centro de ayuda. Coincide con +# la que EFC aplica del otro lado del carril del expediente: si aquí se aceptara algo que allá se +# rechaza, el archivo se guardaría y su entrega quedaría condenada a `failed`. +ALLOWED_UPLOAD_EXTENSIONS = ( + ".pdf", ".xml", ".png", ".jpg", ".jpeg", ".json", ".txt", ".zip", ".docx", ".xlsx", +) + +# Prefijos que este endpoint puede firmar. Es un subárbol EXPLÍCITO, no toda la company: ver la +# nota de `get_upload_url`. +_PREFIJOS_FIRMABLES = ("crm-docs/", "expedientes/") + def _safe_filename(name: str | None) -> str: base = (name or "archivo").strip().replace(" ", "_") @@ -24,6 +39,44 @@ def _safe_filename(name: str | None) -> str: return base[:120] +def extension_de(name: str | None) -> str: + base = (name or "").rsplit("/", 1)[-1].rsplit("\\", 1)[-1] + if "." not in base: + return "" + return "." + base.rsplit(".", 1)[1].lower() + + +def validar_extension(name: str | None) -> None: + if extension_de(name) not in ALLOWED_UPLOAD_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Ese tipo de archivo no está permitido.", + ) + + +async def leer_acotado(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes: + """Lee el archivo POR PARTES y aborta en cuanto pasa del tope. + + ``await file.read()`` a secas trae el archivo entero a memoria **antes** de que nadie pueda + mirar su tamaño: un archivo de 2 GB se bufferiza completo solo para responder 422 después. Aquí + el corte ocurre al superar el tope, así que el peor caso en RAM es el tope más un trozo. + """ + partes: list[bytes] = [] + total = 0 + while True: + chunk = await file.read(_CHUNK_BYTES) + if not chunk: + break + total += len(chunk) + if total > max_bytes: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="El archivo excede el tamaño máximo permitido.", + ) + partes.append(chunk) + return b"".join(partes) + + @router.post("/uploads") async def upload_file( file: UploadFile = File(...), @@ -31,12 +84,8 @@ async def upload_file( current_user: dict = Depends(get_current_user), ): tenant_id = current_user["tenant_id"] - content = await file.read() - if len(content) > MAX_UPLOAD_BYTES: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="El archivo excede el tamaño máximo permitido (25 MB)", - ) + validar_extension(file.filename) + content = await leer_acotado(file) filename = _safe_filename(file.filename) key = f"tenants/{tenant_id}/companies/{company_id}/crm-docs/{uuid.uuid4().hex}/{filename}" put_object_bytes(key, content, content_type=file.content_type or "application/octet-stream") @@ -60,4 +109,13 @@ def get_upload_url( prefix = f"tenants/{tenant_id}/companies/{company_id}/" if not key.startswith(prefix): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") + + # Y SOLO dentro de los subárboles de documentos. Validar únicamente el prefijo de la company + # permitía firmar una URL para CUALQUIER objeto suyo —`certificates/` (llaves privadas de la + # FIEL), `fin-invoices/`, `imports/csv/`— con solo el permiso `crm.access`. El alcance de este + # endpoint es "los archivos que el CRM subió", no "todo el almacén de la company". + resto = key[len(prefix):] + if not resto.startswith(_PREFIJOS_FIRMABLES): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") + return {"url": presigned_get_url(key)} diff --git a/backend/core/s3_keys.py b/backend/core/s3_keys.py index 6959336..952fa29 100644 --- a/backend/core/s3_keys.py +++ b/backend/core/s3_keys.py @@ -250,6 +250,30 @@ def csv_import_key( ) +def expediente_document_key( + tenant_id: Union[int, str], + company_id: int, + expediente_id: int, + unique_token: str, + original_filename: str, +) -> str: + """ + Documento de un expediente del CRM, bajo + ``tenants/{tid}/companies/{cid}/expedientes/{expediente_id}/documents/{token}_{filename}``. + + Es una **copia de tránsito**: el destino final del archivo es el expediente electrónico de EFC, + y al confirmar la entrega esta copia se borra (``delete_local`` del outbox). Vive bajo el árbol + por tenant/company igual que todo lo demás, para que el aislamiento sea el mismo. + + NO confundir con ``expediente_archivo_document_key``: aquélla se refiere al expediente del + **importador** de EFC, que cuelga de un RFC y es otro concepto. Es código muerto de la plantilla. + """ + eid = _segment(expediente_id, "expediente_id") + token = _segment(unique_token, "unique_token") + fn = safe_filename(original_filename) + return f"{tenant_company_prefix(tenant_id, company_id)}expedientes/{eid}/documents/{token}_{fn}" + + def legacy_csv_import_key(job_type: str, job_id: str) -> str: """Clave antigua sin tenant/company (solo migración / cleanup).""" _segment(job_id, "job_id") diff --git a/backend/core/storage_s3.py b/backend/core/storage_s3.py index c0c7ef0..b7431c6 100644 --- a/backend/core/storage_s3.py +++ b/backend/core/storage_s3.py @@ -82,6 +82,31 @@ def put_csv_object(key: str, body: bytes, content_type: str = "text/csv") -> Non put_object_bytes(key, body, content_type=content_type) +def put_object_stream(key: str, fileobj, content_type: str = "application/octet-stream") -> None: + """Sube un objeto **sin materializarlo en memoria**, leyendo del descriptor por partes. + + ``put_object_bytes`` recibe los bytes ya completos, así que quien lo llama tuvo que + bufferizar el archivo entero. Para una subida de usuario eso significa que un archivo de 2 GB + ocupa 2 GB de RAM del proceso **antes** de que nadie valide su tamaño. ``upload_fileobj`` de + boto3 lee del descriptor por partes y sube en multipart cuando hace falta. + """ + _client().upload_fileobj( + fileobj, + settings.S3_BUCKET, + key, + ExtraArgs={"ContentType": content_type}, + ) + + +def open_object_stream(key: str): + """Devuelve el cuerpo del objeto como flujo, para servirlo sin cargarlo entero en memoria. + + El llamador es responsable de cerrarlo (``.close()``): un ``StreamingBody`` sin cerrar retiene + la conexión del pool hasta que el recolector pase. + """ + return _client().get_object(Bucket=settings.S3_BUCKET, Key=key)["Body"] + + def get_object_bytes(key: str) -> bytes: resp = _client().get_object(Bucket=settings.S3_BUCKET, Key=key) return resp["Body"].read() diff --git a/backend/tests/test_expediente_upload.py b/backend/tests/test_expediente_upload.py new file mode 100644 index 0000000..40650cf --- /dev/null +++ b/backend/tests/test_expediente_upload.py @@ -0,0 +1,266 @@ +"""Pruebas de la subida de un paso al expediente. + +Lo que se fija: que el tope de tamaño se aplique **antes** de consumir el cuerpo entero, que el +catálogo y la allowlist se validen antes de tocar el almacén, y que **un EFC caído siga devolviendo +201** con el documento en «Pendiente de enviar». +""" + +import io + +import pytest +from fastapi import HTTPException, UploadFile + +from api.v1.modules.crm.expediente_gateway import service as gateway +from api.v1.modules.crm.expediente_gateway.models import EfcFileOutbox +from api.v1.modules.crm.expedientes import service as expedientes_service +from api.v1.modules.crm.service_requests import service as sr_service +from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate +from api.v1.modules.crm.uploads.routes import MAX_UPLOAD_BYTES +from tests.conftest import COMPANY_ID, TENANT_ID + +OTRO_TENANT = 99 + + +class _ArchivoContado(io.BytesIO): + """``BytesIO`` que cuenta cuántos bytes se le han leído. + + Es lo que permite afirmar que el tope se aplicó **sin** haber leído el archivo completo: un + ``await file.read()`` a secas lo bufferizaría entero antes de poder rechazarlo. + """ + + def __init__(self, data: bytes): + super().__init__(data) + self.leidos = 0 + + def read(self, size=-1): + chunk = super().read(size) + self.leidos += len(chunk) + return chunk + + +def _upload(nombre: str, contenido: bytes, content_type: str = "application/pdf") -> UploadFile: + return UploadFile(filename=nombre, file=_ArchivoContado(contenido), headers=None) + + +@pytest.fixture() +def entorno(db, monkeypatch): + from core.config import settings + + monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False) + monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None) + monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None) + monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX")) + + subidos = {} + import api.v1.modules.crm.expedientes.service as exp_service + + monkeypatch.setattr( + exp_service, "put_object_bytes", + lambda key, body, content_type="application/octet-stream": subidos.update({key: body}), + ) + + solicitud = sr_service.create_service_request( + db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1" + ) + expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) + return {"db": db, "expediente": expediente, "subidos": subidos} + + +async def _adjuntar(entorno, **kwargs): + return await expedientes_service.attach_document( + entorno["db"], + kwargs.pop("expediente_id", entorno["expediente"].id), + kwargs.pop("file", _upload("guia.pdf", b"%PDF-1.4 contenido")), + kwargs.pop("doc_type", "MBL"), + kwargs.pop("tenant_id", TENANT_ID), + kwargs.pop("company_id", COMPANY_ID), + kwargs.pop("name", None), + kwargs.pop("user_id", "user-1"), + ) + + +# ── Camino feliz ───────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_la_subida_crea_el_documento_pendiente_y_su_fila_de_outbox(entorno): + documento = await _adjuntar(entorno) + + assert documento.id is not None + assert documento.efc_sync_state == "PENDING" + assert documento.expediente_id == entorno["expediente"].id + assert documento.efc_document_ref == f"CRMDOC-{COMPANY_ID}-{documento.id}" + assert documento.content_sha256 is not None + + filas = entorno["db"].query(EfcFileOutbox).filter( + EfcFileOutbox.source_id == documento.id + ).all() + assert len(filas) == 1 + assert filas[0].delete_local is True + assert filas[0].crm_document_ref == documento.efc_document_ref + + +@pytest.mark.asyncio +async def test_el_objeto_se_guarda_bajo_el_arbol_del_expediente(entorno): + documento = await _adjuntar(entorno) + keys = list(entorno["subidos"]) + + assert len(keys) == 1 + assert keys[0] == documento.file_key + assert f"tenants/{TENANT_ID}/companies/{COMPANY_ID}/expedientes/{entorno['expediente'].id}/" in keys[0] + + +# ── Validaciones ───────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_un_doc_type_fuera_del_catalogo_da_422_y_no_sube_nada(entorno): + with pytest.raises(HTTPException) as exc: + await _adjuntar(entorno, doc_type="tipo_inventado") + + assert exc.value.status_code == 422 + assert exc.value.detail == "Ese tipo de documento no está en el catálogo." + assert entorno["subidos"] == {} # nada llegó al almacén + + +@pytest.mark.asyncio +async def test_una_extension_no_permitida_da_422_y_no_sube_nada(entorno): + with pytest.raises(HTTPException) as exc: + await _adjuntar(entorno, file=_upload("malicioso.exe", b"MZ")) + + assert exc.value.status_code == 422 + assert exc.value.detail == "Ese tipo de archivo no está permitido." + assert entorno["subidos"] == {} + + +@pytest.mark.asyncio +async def test_la_extension_se_valida_ANTES_de_leer_el_archivo(entorno): + """Rechazar por extensión no debe costar leer el archivo: es gratis saberlo por el nombre.""" + archivo = _upload("malicioso.exe", b"M" * (2 * 1024 * 1024)) + + with pytest.raises(HTTPException): + await _adjuntar(entorno, file=archivo) + + assert archivo.file.leidos == 0 + + +@pytest.mark.asyncio +async def test_un_archivo_por_encima_del_tope_da_422_SIN_haberlo_leido_completo(entorno): + """**El defecto que esta fase corrige.** Con ``await file.read()`` a secas, un archivo de 2 GB + se bufferizaba entero en RAM solo para responder 422 después. + + Aquí la lectura es por partes y aborta al pasar del tope, así que lo leído se queda cerca del + tope y no llega al tamaño total. + """ + tamano = MAX_UPLOAD_BYTES + (3 * 1024 * 1024) + archivo = _upload("enorme.pdf", b"x" * tamano) + + with pytest.raises(HTTPException) as exc: + await _adjuntar(entorno, file=archivo) + + assert exc.value.status_code == 422 + assert exc.value.detail == "El archivo excede el tamaño máximo permitido." + assert archivo.file.leidos < tamano + assert entorno["subidos"] == {} + + +@pytest.mark.asyncio +async def test_un_expediente_de_otro_tenant_da_404(entorno): + with pytest.raises(HTTPException) as exc: + await _adjuntar(entorno, tenant_id=OTRO_TENANT) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_un_expediente_inexistente_da_404(entorno): + with pytest.raises(HTTPException) as exc: + await _adjuntar(entorno, expediente_id=999999) + assert exc.value.status_code == 404 + + +# ── EFC caído ──────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_con_efc_apagado_la_subida_SIGUE_funcionando(entorno, monkeypatch): + """El caso que justifica todo el carril: el usuario no pierde su trabajo porque EFC no esté. + + Sin outbox habría que llamar a EFC dentro del request, y un EFC caído devolvería un error al + usuario con el archivo ya subido a medias. + """ + from core.config import settings + + monkeypatch.setattr(settings, "EFC_API_URL", "", raising=False) + + documento = await _adjuntar(entorno) + + assert documento.id is not None + assert documento.efc_sync_state == "PENDING" + assert documento.file_key is not None # la copia local es lo único que hay + # Sin integración no se encola: el barrido de huecos lo recogerá cuando se encienda. + assert entorno["db"].query(EfcFileOutbox).filter( + EfcFileOutbox.source_id == documento.id + ).count() == 0 + + +@pytest.mark.asyncio +async def test_si_el_despacho_al_broker_falla_la_subida_igual_responde(entorno, monkeypatch): + """«Si el broker no responde, el sweep la recoge»: el despacho es best-effort.""" + def _revienta(*a, **k): + raise RuntimeError("Valkey no responde") + + monkeypatch.setattr(gateway, "_dispatch_file_delivery", _revienta) + + with pytest.raises(RuntimeError): + await _adjuntar(entorno) + + # La fila SÍ quedó encolada antes del despacho: el barrido la va a recoger. + assert entorno["db"].query(EfcFileOutbox).count() == 1 + + +# ── Lectura y baja ─────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_el_listado_solo_devuelve_los_documentos_de_ese_expediente(entorno): + documento = await _adjuntar(entorno) + + docs = expedientes_service.list_expediente_documents( + entorno["db"], entorno["expediente"].id, TENANT_ID, COMPANY_ID + ) + assert [d.id for d in docs] == [documento.id] + + +@pytest.mark.asyncio +async def test_un_documento_de_otro_tenant_no_se_puede_leer(entorno): + documento = await _adjuntar(entorno) + + with pytest.raises(HTTPException) as exc: + expedientes_service.get_expediente_document( + entorno["db"], entorno["expediente"].id, documento.id, OTRO_TENANT, COMPANY_ID + ) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_la_baja_desasocia_y_no_borra_nada_en_efc(entorno): + documento = await _adjuntar(entorno) + + expedientes_service.detach_document( + entorno["db"], entorno["expediente"].id, documento.id, TENANT_ID, COMPANY_ID + ) + + assert documento.deleted_at is not None + assert expedientes_service.list_expediente_documents( + entorno["db"], entorno["expediente"].id, TENANT_ID, COMPANY_ID + ) == [] + # La fila del outbox NO se toca: si ya se entregó, en EFC sigue estando. + assert entorno["db"].query(EfcFileOutbox).count() == 1 + + +@pytest.mark.asyncio +async def test_descargar_un_documento_que_aun_no_llego_a_efc_da_409(entorno): + """No es un 404: el documento existe, solo que todavía no está del otro lado.""" + documento = await _adjuntar(entorno) + + with pytest.raises(HTTPException) as exc: + expedientes_service.stream_document( + entorno["db"], entorno["expediente"].id, documento.id, TENANT_ID, COMPANY_ID + ) + assert exc.value.status_code == 409 diff --git a/backend/tests/test_uploads_alcance.py b/backend/tests/test_uploads_alcance.py new file mode 100644 index 0000000..915d0fe --- /dev/null +++ b/backend/tests/test_uploads_alcance.py @@ -0,0 +1,85 @@ +"""Alcance de ``GET /uploads/url``: qué objetos puede firmar este endpoint y cuáles no. + +**Cierra una fuga real.** Antes bastaba con que la key empezara por +``tenants/{tid}/companies/{cid}/`` para firmar una URL de lectura, lo que permitía firmar +**cualquier** objeto de esa company —incluidos los certificados de la FIEL— con solo el permiso de +módulo ``crm.access``. El alcance de este endpoint es «los archivos que el CRM subió», no «todo el +almacén de la company». +""" + +import pytest +from fastapi import HTTPException + +from api.v1.modules.crm.uploads.routes import get_upload_url, validar_extension +from tests.conftest import COMPANY_ID, TENANT_ID + +USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"} +PREFIJO = f"tenants/{TENANT_ID}/companies/{COMPANY_ID}/" + + +@pytest.fixture(autouse=True) +def _sin_s3(monkeypatch): + import api.v1.modules.crm.uploads.routes as uploads + + monkeypatch.setattr(uploads, "presigned_get_url", lambda key, **k: f"https://firmada/{key}") + + +@pytest.mark.parametrize( + "sufijo", + [ + "certificates/fiel_20260101.key", # llave privada de la FIEL + "certificates/fiel_20260101.cer", + "invoices/9/cove/cove.xml", + "imports/csv/invoice/job-1.csv", + "branding/logo.png", + "doda/1/report/doda_report.pdf", + "signatures/1/photo_x.png", + ], +) +def test_no_se_puede_firmar_nada_fuera_de_los_documentos_del_crm(sufijo): + with pytest.raises(HTTPException) as exc: + get_upload_url(PREFIJO + sufijo, COMPANY_ID, USUARIO) + assert exc.value.status_code == 403 + + +@pytest.mark.parametrize( + "sufijo", + [ + "crm-docs/abc123/contrato.pdf", + "expedientes/1/documents/abc123_guia.pdf", + ], +) +def test_los_documentos_del_crm_si_se_pueden_firmar(sufijo): + resp = get_upload_url(PREFIJO + sufijo, COMPANY_ID, USUARIO) + assert resp["url"].endswith(sufijo) + + +def test_no_se_puede_firmar_nada_de_otro_tenant_ni_de_otra_company(): + """El aislamiento previo sigue en pie: es una guarda adicional, no un reemplazo.""" + for key in ( + "tenants/999/companies/1/crm-docs/a/b.pdf", + f"tenants/{TENANT_ID}/companies/999/crm-docs/a/b.pdf", + ): + with pytest.raises(HTTPException) as exc: + get_upload_url(key, COMPANY_ID, USUARIO) + assert exc.value.status_code == 403 + + +def test_una_key_que_solo_CONTIENE_el_prefijo_no_pasa(): + """La comprobación es de prefijo, no de subcadena: ``startswith`` y no ``in``.""" + with pytest.raises(HTTPException) as exc: + get_upload_url(f"otro/{PREFIJO}crm-docs/a/b.pdf", COMPANY_ID, USUARIO) + assert exc.value.status_code == 403 + + +def test_la_allowlist_de_extensiones_rechaza_lo_ejecutable(): + for nombre in ("virus.exe", "script.sh", "macro.bat", "lib.dll", "sin_extension"): + with pytest.raises(HTTPException) as exc: + validar_extension(nombre) + assert exc.value.status_code == 422 + assert exc.value.detail == "Ese tipo de archivo no está permitido." + + +def test_la_allowlist_acepta_los_formatos_de_documento(): + for nombre in ("guia.pdf", "factura.XML", "foto.JPG", "hoja.xlsx", "carta.docx", "paquete.zip"): + validar_extension(nombre) # no lanza diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fc0686f..cbf4720 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -770,6 +770,19 @@ async function fetchBlob( export const api = { get: (endpoint: string) => fetchApi(endpoint, { method: 'GET' }), getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }), + + /** + * POST multipart con progreso de subida. + * + * `fetchApiFormDataPost` ya existía con toda la maquinaria de progreso y de refresco de token, + * pero no estaba expuesto, así que ningún llamador podía usarlo y las subidas iban por + * `api.request` plano —sin barra de progreso—. Esto solo lo publica: la implementación no cambia. + */ + postFormData: ( + endpoint: string, + formData: FormData, + opts: CsvFormDataUploadOptions = {} + ) => fetchApiFormDataPost(endpoint, formData, opts), postBlob: (endpoint: string, body: any) => fetchBlob(endpoint, { method: 'POST', diff --git a/frontend/src/lib/api/crm/types.ts b/frontend/src/lib/api/crm/types.ts index f8082c4..44d8530 100644 --- a/frontend/src/lib/api/crm/types.ts +++ b/frontend/src/lib/api/crm/types.ts @@ -168,6 +168,13 @@ export interface Document { content_type: string | null; size_bytes: number | null; uploaded_by: string | null; + // Espejo del documento en el expediente electrónico de EFC (T2026-08-046). Opcionales: las filas + // legacy no los traen, y la descarga se ramifica por ellos — `efc_document_id` → proxy de EFC, + // `file_key` → URL firmada local, `file_url` → externa. + expediente_id?: number | null; + efc_document_ref?: string | null; + efc_document_id?: string | null; + efc_sync_state?: 'PENDING' | 'SYNCED' | 'FAILED' | null; tenant_id: number; company_id: number; created_at: string; diff --git a/frontend/src/lib/api/expedientes.doctypes.test.ts b/frontend/src/lib/api/expedientes.doctypes.test.ts new file mode 100644 index 0000000..e098059 --- /dev/null +++ b/frontend/src/lib/api/expedientes.doctypes.test.ts @@ -0,0 +1,77 @@ +/** + * Paridad de los catálogos de tipo de documento entre el frontend y el backend. + * + * `DOC_TYPES` y `SHIPMENT_DOC_TYPES` de `format.ts` son la FUENTE del mapeo, y desde T2026-08-046 el + * backend los valida contra un conjunto cerrado que EFC comparte. Si alguien agrega una opción aquí + * sin agregarla en los otros dos lados, el usuario la vería en el selector y la subida fallaría con + * 422 al guardar — un rojo aquí es mucho más barato que descubrirlo así. + */ +import { describe, expect, it } from 'vitest'; + +import { DOC_TYPES, SHIPMENT_DOC_TYPES } from './../components/crm/format'; + +/** + * Copia literal de `EFC_DOC_TYPES` + * (`backend/api/v1/modules/crm/expedientes/doc_types.py`), que a su vez es copia de + * `TIPOS_DOCUMENTO_CRM` de EFC. Al cambiar cualquiera de los tres, se cambia AQUÍ también. + */ +const ACEPTADOS_POR_EL_BACKEND = new Set([ + 'constancia_fiscal', + 'acta_constitutiva', + 'identificacion', + 'comprobante_domicilio', + 'contrato', + 'presentacion', + 'certificacion', + 'licencia', + 'convenio', + 'tarifario', + 'MBL', + 'HBL', + 'MAWB', + 'HAWB', + 'CMR', + 'factura_comercial', + 'packing_list', + 'carta_encomienda', + 'carta_garantia', + 'certificado_permiso', + 'factura_venta', + 'otro' +]); + +describe('paridad de tipos de documento con el backend', () => { + it('todo lo que ofrece el selector de documentos de cliente lo acepta el backend', () => { + const noAceptados = DOC_TYPES.map((t) => t.value).filter( + (v) => !ACEPTADOS_POR_EL_BACKEND.has(v) + ); + expect(noAceptados).toEqual([]); + }); + + it('todo lo que ofrece el selector de documentos de embarque lo acepta el backend', () => { + const noAceptados = SHIPMENT_DOC_TYPES.map((t) => t.value).filter( + (v) => !ACEPTADOS_POR_EL_BACKEND.has(v) + ); + expect(noAceptados).toEqual([]); + }); + + it('el catálogo del backend son exactamente 22 claves', () => { + expect(ACEPTADOS_POR_EL_BACKEND.size).toBe(22); + }); + + it('los dos selectores juntos cubren el catálogo salvo el PDF de factura', () => { + // `factura_venta` lo genera el sistema al emitir la factura, no lo elige un usuario: por eso + // está en el catálogo del backend y no en ningún selector. + const enSelectores = new Set([ + ...DOC_TYPES.map((t) => t.value), + ...SHIPMENT_DOC_TYPES.map((t) => t.value) + ]); + const soloEnBackend = [...ACEPTADOS_POR_EL_BACKEND].filter((v) => !enSelectores.has(v)); + expect(soloEnBackend).toEqual(['factura_venta']); + }); + + it('`otro` está en las dos listas y significa lo mismo', () => { + expect(DOC_TYPES.some((t) => t.value === 'otro')).toBe(true); + expect(SHIPMENT_DOC_TYPES.some((t) => t.value === 'otro')).toBe(true); + }); +}); diff --git a/frontend/src/lib/api/expedientes.ts b/frontend/src/lib/api/expedientes.ts new file mode 100644 index 0000000..c60fd61 --- /dev/null +++ b/frontend/src/lib/api/expedientes.ts @@ -0,0 +1,248 @@ +/** + * Cliente API — Expedientes del CRM y sus documentos en el expediente electrónico (EFC). + * + * El archivo de un documento de expediente NO se abre con `window.open`: esa llamada no lleva el + * header `Authorization`, así que el proxy de descarga respondería 401. Se pide como blob con + * `api.getBlob` y se abre con `URL.createObjectURL`. + */ +import { api, type CsvFormDataUploadOptions } from '$lib/api'; + +/** Estado del espejo del documento en EFC. Es lo que pinta el badge de la ficha. */ +export type EfcSyncState = 'PENDING' | 'SYNCED' | 'FAILED'; + +export interface Expediente { + id: number; + folio: string; + period_year: number; + period_month: number; + sequence: number; + service_request_id: number | null; + account_id: number | null; + status: string; + efc_organizacion_id: string | null; + efc_pedimento_id: string | null; + efc_storage_token: string | null; + efc_link_state: string; + efc_error_code: string | null; + efc_error_detail: string | null; + patente: string | null; + aduana: string | null; + numero_pedimento: string | null; + anio: number | null; + clave_pedimento: string | null; + regimen: string | null; + fecha_pago: string | null; + rfc_importador: string | null; + rfc_agente_aduanal: string | null; + created_by: string | null; + updated_by: string | null; + tenant_id: number; + company_id: number; + created_at: string; + updated_at: string; +} + +/** Documento de un expediente. Sin `file_key` ni `file_url`: la copia local es de tránsito. */ +export interface ExpedienteDocument { + id: number; + expediente_id: number | null; + doc_type: string; + name: string; + content_type: string | null; + size_bytes: number | null; + efc_sync_state: EfcSyncState | null; + efc_document_ref: string | null; + efc_document_id: string | null; + efc_error_code: string | null; + efc_attempts: number | null; + uploaded_by: string | null; + created_at: string; + updated_at: string; +} + +export interface OutboxRow { + id: number; + tabla: 'sync' | 'file'; + kind: string; + status: 'pending' | 'sent' | 'failed'; + attempts: number; + last_error: string | null; + expediente_ref: number | null; + file_name?: string; + efc_tipo?: string; + source_table?: string; + source_id?: number | null; + crm_document_ref?: string | null; + efc_document_id?: string | null; + created_at: string | null; + sent_at: string | null; +} + +export interface OutboxMetrics { + pending: number; + sent: number; + failed: number; +} + +export const expedientesAPI = { + async list( + companyId: number, + params?: { service_request_id?: number; account_id?: number; status?: string } + ): Promise { + const qs = new URLSearchParams({ company_id: String(companyId) }); + if (params?.service_request_id) qs.set('service_request_id', String(params.service_request_id)); + if (params?.account_id) qs.set('account_id', String(params.account_id)); + if (params?.status) qs.set('status', params.status); + const res = await api.get(`/v1/crm/expedientes?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async get(id: number, companyId: number): Promise { + const res = await api.get(`/v1/crm/expedientes/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + /** Devuelve el expediente de una solicitud, creándolo si hace falta. Idempotente. */ + async ensure(serviceRequestId: number, companyId: number): Promise { + const res = await api.post(`/v1/crm/expedientes/ensure?company_id=${companyId}`, { + service_request_id: serviceRequestId + }); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async listDocuments(expedienteId: number, companyId: number): Promise { + const res = await api.get( + `/v1/crm/expedientes/${expedienteId}/documentos?company_id=${companyId}` + ); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async removeDocument( + expedienteId: number, + documentId: number, + companyId: number + ): Promise { + const res = await api.delete( + `/v1/crm/expedientes/${expedienteId}/documentos/${documentId}?company_id=${companyId}` + ); + if (res.error) throw new Error(res.error); + } +}; + +/** + * Sube un documento al expediente en UN paso (guarda + registra + encola la entrega a EFC). + * + * Responde 201 aunque EFC esté caído: el documento queda en «Pendiente de enviar» y el sistema lo + * entrega solo cuando EFC vuelve. + */ +export async function uploadExpedienteDocument( + expedienteId: number, + file: File, + docType: string, + companyId: number, + opts: CsvFormDataUploadOptions & { name?: string } = {} +): Promise { + const { name, ...uploadOpts } = opts; + const fd = new FormData(); + fd.append('file', file); + fd.append('doc_type', docType); + if (name) fd.append('name', name); + + const res = await api.postFormData( + `/v1/crm/expedientes/${expedienteId}/documentos?company_id=${companyId}`, + fd, + uploadOpts + ); + if (res.error) throw new Error(res.error); + return res.data!; +} + +/** + * Descarga el archivo de un documento del expediente como blob. + * + * Bufferiza en memoria del NAVEGADOR, no del servidor. Para archivos muy grandes habrá que migrar a + * un token firmado en el query string, y eso es otro ticket. + */ +export async function expedienteDocBlob( + expedienteId: number, + documentId: number, + companyId: number +): Promise { + return api.getBlob( + `/v1/crm/expedientes/${expedienteId}/documentos/${documentId}/archivo?company_id=${companyId}` + ); +} + +export const expedienteGatewayAPI = { + async outbox( + companyId: number, + params?: { tipo?: 'sync' | 'file'; status?: string; limit?: number } + ): Promise { + const qs = new URLSearchParams({ company_id: String(companyId) }); + if (params?.tipo) qs.set('tipo', params.tipo); + if (params?.status) qs.set('status', params.status); + if (params?.limit) qs.set('limit', String(params.limit)); + const res = await api.get(`/v1/crm/expediente-gateway/outbox?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + /** Reintento manual. Un 404 significa que esa fila no existe para este tenant/company. */ + async retry( + outboxId: number, + companyId: number, + tipo: 'sync' | 'file' = 'file' + ): Promise<{ status: string; id: number }> { + const res = await api.post<{ status: string; id: number }>( + `/v1/crm/expediente-gateway/outbox/${outboxId}/retry?company_id=${companyId}&tipo=${tipo}`, + {} + ); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async metrics(companyId: number): Promise { + const res = await api.get( + `/v1/crm/expediente-gateway/metrics?company_id=${companyId}` + ); + if (res.error) throw new Error(res.error); + return res.data!; + } +}; + +/** Reintenta la entrega del documento buscando su fila de outbox por el handle del CRM. */ +export async function retrySync( + documentRef: string, + companyId: number +): Promise<{ status: string; id: number }> { + const filas = await expedienteGatewayAPI.outbox(companyId, { tipo: 'file', limit: 500 }); + const fila = filas.find((f) => f.crm_document_ref === documentRef); + if (!fila) throw new Error('No se encontró el envío de ese documento.'); + return expedienteGatewayAPI.retry(fila.id, companyId, 'file'); +} + +// ── Textos es-MX. Fuente única: si el badge y el tooltip se escriben en cada pantalla, acaban +// diciendo cosas distintas para el mismo estado. +export const EFC_SYNC_LABELS: Record = { + PENDING: 'Pendiente de enviar', + SYNCED: 'En expediente', + FAILED: 'No se pudo enviar' +}; + +export const EFC_SYNC_TOOLTIPS: Partial> = { + FAILED: + 'El documento está guardado, pero todavía no llegó al expediente electrónico. Vuelve a intentarlo o avisa a soporte.' +}; + +export const EFC_TEXTS = { + retryButton: 'Reintentar envío', + invalidDocType: 'Ese tipo de documento no está en el catálogo.', + invalidExtension: 'Ese tipo de archivo no está permitido.', + tooLarge: 'El archivo excede el tamaño máximo permitido.', + downloadFailed: 'No se pudo obtener el archivo del expediente electrónico.', + newDocumentTitle: 'Nuevo documento del expediente' +} as const; diff --git a/frontend/src/lib/api/ops/index.ts b/frontend/src/lib/api/ops/index.ts index 7eb309c..5ac9a05 100644 --- a/frontend/src/lib/api/ops/index.ts +++ b/frontend/src/lib/api/ops/index.ts @@ -80,6 +80,13 @@ export interface ShipmentDocument { file_url: string | null; file_key: string | null; notes: string | null; + // Espejo del documento en el expediente electrónico de EFC (T2026-08-046). Opcionales: las filas + // legacy no los traen, y la descarga se ramifica por ellos — `efc_document_id` → proxy de EFC, + // `file_key` → URL firmada local, `file_url` → externa. + expediente_id?: number | null; + efc_document_ref?: string | null; + efc_document_id?: string | null; + efc_sync_state?: 'PENDING' | 'SYNCED' | 'FAILED' | null; tenant_id: number; company_id: number; created_at: string; diff --git a/frontend/src/lib/components/crm/RelatedManager.svelte b/frontend/src/lib/components/crm/RelatedManager.svelte index ec3f880..3ef7db1 100644 --- a/frontend/src/lib/components/crm/RelatedManager.svelte +++ b/frontend/src/lib/components/crm/RelatedManager.svelte @@ -10,6 +10,14 @@ } from '$lib/api/crm'; import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format'; import { uploadFile, uploadUrl } from '$lib/api/uploads'; + import { + EFC_SYNC_LABELS, + EFC_SYNC_TOOLTIPS, + EFC_TEXTS, + expedienteDocBlob, + retrySync, + type EfcSyncState + } from '$lib/api/expedientes'; import { toast } from 'svelte-sonner'; // Dueño de los registros relacionados y qué sección mostrar @@ -52,14 +60,61 @@ } } + /** + * Abre el archivo del documento. Se ramifica en TRES, en este orden: + * + * 1. `efc_document_id` → está en el expediente electrónico: se pide por el proxy como blob. + * No sirve `window.open` con la URL del proxy porque esa llamada no lleva el header + * Authorization y el backend respondería 401. + * 2. `file_key` → sigue solo en el MinIO local: URL firmada, como siempre. + * 3. `file_url` → externa. + * + * El orden importa: cuando la entrega a EFC se confirma, `delete_local` borra la copia local y + * `file_key` queda en NULL, así que preguntar primero por él llevaría a un objeto inexistente. + */ async function openDoc(d: Document) { if (!companyId) return; try { + if (d.efc_document_id && d.expediente_id) { + const blob = await expedienteDocBlob(d.expediente_id, d.id, companyId); + const url = URL.createObjectURL(blob); + window.open(url, '_blank', 'noopener'); + // Se revoca en diferido: revocarlo de inmediato deja la pestaña nueva sin nada que abrir. + setTimeout(() => URL.revokeObjectURL(url), 60_000); + return; + } const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url; if (url) window.open(url, '_blank', 'noopener'); else toast.error('El documento no tiene archivo'); } catch (e) { - toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo'); + toast.error(e instanceof Error ? e.message : EFC_TEXTS.downloadFailed); + } + } + + /** ¿Este documento vive en el expediente electrónico? Los legacy no traen estado. */ + function estadoEfc(d: Document): EfcSyncState | null { + return d.expediente_id ? ((d.efc_sync_state ?? 'PENDING') as EfcSyncState) : null; + } + + function claseBadge(estado: EfcSyncState): string { + if (estado === 'SYNCED') + return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'; + if (estado === 'FAILED') return 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300'; + return 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'; + } + + let retrying = $state(null); + + async function retryDoc(d: Document) { + if (!companyId || !d.efc_document_ref) return; + retrying = d.id; + try { + await retrySync(d.efc_document_ref, companyId); + await load(companyId); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'No se pudo reintentar el envío.'); + } finally { + retrying = null; } } @@ -228,13 +283,24 @@

Sin documentos.

{:else} - TipoNombreArchivo + TipoNombreArchivoExpediente {#each documents as d (d.id)} + {@const estado = estadoEfc(d)} {labelOf(DOC_TYPES, d.doc_type)} {d.name} - {#if d.file_key || d.file_url}{:else}—{/if} + {#if d.efc_document_id || d.file_key || d.file_url}{:else}—{/if} + + {#if estado} + {EFC_SYNC_LABELS[estado]} + {#if estado === 'FAILED'} + + {/if} + {:else} + — + {/if} + {/each} @@ -294,7 +360,9 @@ Archivo {#if uploading}(subiendo…){:else if documentForm.file_key}(cargado){/if} - + +
{/if} diff --git a/frontend/src/lib/components/ops/ExpedienteDocuments.svelte b/frontend/src/lib/components/ops/ExpedienteDocuments.svelte new file mode 100644 index 0000000..f4e96d2 --- /dev/null +++ b/frontend/src/lib/components/ops/ExpedienteDocuments.svelte @@ -0,0 +1,265 @@ + + +
+
+
+

Documentos del expediente

+ {#if folio} + Folio {folio} + {/if} +
+ +
+ +
+ + + + Nombre + Tipo + Estado + Acciones + + + + {#if cargando} + + + Cargando… + + + {:else if documentos.length === 0} + + + Todavía no hay documentos en este expediente. + + + {:else} + {#each documentos as d (d.id)} + {@const estado = estadoDe(d)} + + {d.name} + {d.doc_type} + + + {EFC_SYNC_LABELS[estado]} + + + + {#if estado === 'SYNCED'} + + {/if} + {#if estado === 'FAILED'} + + {/if} + + + + {/each} + {/if} + + +
+
+ +{#if modalOpen} +
+
+

{EFC_TEXTS.newDocumentTitle}

+
+ + + + + + + {#if subiendo} +
+
+
+ Subiendo… {progreso}% + {/if} + +
+ + +
+
+
+
+{/if} diff --git a/frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte b/frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte index 9848769..e4bd84b 100644 --- a/frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte @@ -13,6 +13,14 @@ } from '$lib/api/ops'; import { invoicesAPI } from '$lib/api/fin'; import { uploadFile, uploadUrl } from '$lib/api/uploads'; + import { + EFC_SYNC_LABELS, + EFC_SYNC_TOOLTIPS, + EFC_TEXTS, + expedienteDocBlob, + retrySync, + type EfcSyncState + } from '$lib/api/expedientes'; import { OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS, DOC_KINDS, SHIPMENT_DOC_TYPES, EVENT_STATUS, labelOf, formatDate @@ -172,14 +180,53 @@ } } + /** + * Abre el archivo. Se ramifica en tres: expediente electrónico (proxy como blob), MinIO local + * (URL firmada) o URL externa. El orden importa — al confirmar la entrega a EFC, `delete_local` + * borra la copia local y `file_key` queda en NULL. + */ async function openDoc(d: ShipmentDocument) { if (!companyId) return; try { + if (d.efc_document_id && d.expediente_id) { + const blob = await expedienteDocBlob(d.expediente_id, d.id, companyId); + const url = URL.createObjectURL(blob); + window.open(url, '_blank', 'noopener'); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + return; + } const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url; if (url) window.open(url, '_blank', 'noopener'); else toast.error('El documento no tiene archivo'); } catch (e) { - toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo'); + toast.error(e instanceof Error ? e.message : EFC_TEXTS.downloadFailed); + } + } + + /** ¿Este documento vive en el expediente electrónico? Los legacy no traen estado. */ + function estadoEfc(d: ShipmentDocument): EfcSyncState | null { + return d.expediente_id ? ((d.efc_sync_state ?? 'PENDING') as EfcSyncState) : null; + } + + function claseBadge(estado: EfcSyncState): string { + if (estado === 'SYNCED') + return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'; + if (estado === 'FAILED') return 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300'; + return 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'; + } + + let retryingDoc = $state(null); + + async function retryDoc(d: ShipmentDocument) { + if (!companyId || !d.efc_document_ref) return; + retryingDoc = d.id; + try { + await retrySync(d.efc_document_ref, companyId); + docs = await shipmentsAPI.documents(shipmentId, companyId); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'No se pudo reintentar el envío.'); + } finally { + retryingDoc = null; } } @@ -330,15 +377,26 @@

Sin documentos.

{:else} - ClaseDocumentoNúmeroEmisiónArchivo + ClaseDocumentoNúmeroEmisiónArchivoExpediente {#each docs as d (d.id)} + {@const estado = estadoEfc(d)} {labelOf(DOC_KINDS, d.doc_kind)} {labelOf(SHIPMENT_DOC_TYPES, d.doc_type)} {d.number ?? '—'} {formatDate(d.issue_date)} - {#if d.file_key || d.file_url}{:else}—{/if} + {#if d.efc_document_id || d.file_key || d.file_url}{:else}—{/if} + + {#if estado} + {EFC_SYNC_LABELS[estado]} + {#if estado === 'FAILED'} + + {/if} + {:else} + — + {/if} + {/each}