feat(crm,ops): subida de un paso al expediente, proxy de descarga y UI del estado

Cierra el carril de cara al usuario: adjunta un documento desde la ficha del
embarque y lo ve aparecer con badge "En expediente". Si EFC esta caido lo ve
como "Pendiente de enviar" en vez de perder su trabajo, y el sistema lo entrega
solo cuando EFC vuelve, sin duplicarlo.

La subida es de UN paso -guarda, registra y encola- y responde 201 PASE LO QUE
PASE con EFC. La alternativa era llamar a EFC dentro del request, y ahi un EFC
caido devuelve un error al usuario con el archivo ya subido a medias.

La descarga es un proxy con streaming, no una redireccion: EFC nunca entrega una
URL de MinIO, y reescribir el host de una URL ya firmada invalida su SigV4. El
AsyncClient se crea DENTRO del generador y se cierra en finally; creado fuera,
en un `async with`, se cerraria antes de que empiece el streaming -FastAPI
consume el generador despues de devolver la respuesta- y la descarga moriria a
la mitad. Dos guardas: la pertenencia se valida ANTES de tocar EFC, y se manda
el organizacion_id del expediente para que la verificacion de EFC tambien
dispare. Sin la primera, un document_id que coincidiera leeria el expediente de
otro tenant, y el organizacion_id se deriva del expediente, asi que el CRM iria
a preguntarle a la organizacion de otro cliente.

TRES DEFECTOS PREEXISTENTES QUE ESTA FASE CORRIGE DE PASO:

1. `POST /uploads` hacia `await file.read()` COMPLETO antes de validar el
   tamano: un archivo de 2 GB se bufferizaba entero en RAM del proceso solo para
   responder 422 despues. Ahora la lectura es por partes y aborta al pasar del
   tope.
2. No habia allowlist de extension, a diferencia del avatar y el centro de
   ayuda, que si la tienen. La de aqui coincide con la de EFC: aceptar algo que
   alla se rechaza guardaria el archivo y condenaria su entrega a `failed`.
3. `GET /uploads/url` validaba SOLO que la key empezara con
   tenants/{tid}/companies/{cid}/, lo que permitia firmar una URL de lectura
   para CUALQUIER objeto de esa company -incluidos los certificados de la FIEL
   bajo certificates/- con solo el permiso de modulo crm.access. Ahora exige
   ademas uno de los dos subarboles de documentos del CRM. Hay test por cada
   prefijo sensible.

El borrado DESASOCIA, no destruye en EFC: el gateway de Anexo22 nunca llama al
DELETE de EFC -verificado, ese metodo no existe en su cliente- y record.Document
alla no tiene vigencia ni purga, asi que la politica implicita es conservar. Un
documento que manana puede ser parte del expediente de un pedimento real es
riesgo de retencion fiscal.

En el frontend, abrir un documento del expediente NO usa window.open sobre la
URL del proxy: esa llamada no lleva el header Authorization y el backend
responderia 401. Va por api.getBlob y URL.createObjectURL. La descarga se
ramifica en tres y el ORDEN importa: efc_document_id primero, porque al
confirmar la entrega `delete_local` borra la copia local y file_key queda en
NULL. `postFormData` solo publica `fetchApiFormDataPost`, que ya existia con la
maquinaria de progreso pero sin exportar, asi que nadie podia usarlo.

Verificacion:
  backend  pytest tests/            EXIT=0  178 passed 1 skipped (baseline 70)
  frontend pnpm run i18n:compile    EXIT=0
           pnpm run check           EXIT=1  38 errores/17 warnings en 20 archivos
                                            IDENTICO al baseline: 55 hallazgos
                                            antes y despues, cero agregados. La
                                            unica diferencia es que el hallazgo
                                            preexistente de RelatedManager se
                                            movio de la linea 251 a la 317 al
                                            insertar codigo arriba.
           pnpm run test:unit       EXIT=1  2 failed 43 passed (baseline 2/38);
                                            los 2 rojos son los preexistentes de
                                            backend.test.ts, que pide un backend
                                            vivo en el host `backend` de docker
           pnpm run build           EXIT=0

El verde se vio fallar primero: reintroduciendo la fuga de `/uploads/url` los
tests de certificates/, doda/ y signatures/ se pusieron rojos; volviendo al
read() completo cayo el test que afirma que el tope corta sin leer el archivo
entero; y agregando un tipo de documento solo en el frontend, el test de paridad
lo nombro: "expected [ 'carta_poder' ] to deeply equal []".

Ticket: T2026-08-046 (fase 7 de 7)
This commit is contained in:
2026-08-07 18:25:02 -06:00
parent 02feb973c9
commit d97cdd2f73
17 changed files with 1591 additions and 16 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)}

View File

@@ -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")

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -770,6 +770,19 @@ async function fetchBlob(
export const api = {
get: <T = any>(endpoint: string) => fetchApi<T>(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: <T = any>(
endpoint: string,
formData: FormData,
opts: CsvFormDataUploadOptions = {}
) => fetchApiFormDataPost<T>(endpoint, formData, opts),
postBlob: (endpoint: string, body: any) =>
fetchBlob(endpoint, {
method: 'POST',

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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<Expediente[]> {
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<Expediente[]>(`/v1/crm/expedientes?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async get(id: number, companyId: number): Promise<Expediente> {
const res = await api.get<Expediente>(`/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<Expediente> {
const res = await api.post<Expediente>(`/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<ExpedienteDocument[]> {
const res = await api.get<ExpedienteDocument[]>(
`/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<void> {
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<ExpedienteDocument> {
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<ExpedienteDocument>(
`/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<Blob> {
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<OutboxRow[]> {
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<OutboxRow[]>(`/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<OutboxMetrics> {
const res = await api.get<OutboxMetrics>(
`/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<EfcSyncState, string> = {
PENDING: 'Pendiente de enviar',
SYNCED: 'En expediente',
FAILED: 'No se pudo enviar'
};
export const EFC_SYNC_TOOLTIPS: Partial<Record<EfcSyncState, string>> = {
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;

View File

@@ -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;

View File

@@ -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<number | null>(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 @@
<p class="text-sm text-muted-foreground">Sin documentos.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Tipo</Table.Head><Table.Head>Nombre</Table.Head><Table.Head>Archivo</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Header><Table.Row><Table.Head>Tipo</Table.Head><Table.Head>Nombre</Table.Head><Table.Head>Archivo</Table.Head><Table.Head>Expediente</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each documents as d (d.id)}
{@const estado = estadoEfc(d)}
<Table.Row>
<Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell>
<Table.Cell class="font-medium">{d.name}</Table.Cell>
<Table.Cell>{#if d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}{/if}</Table.Cell>
<Table.Cell>{#if d.efc_document_id || d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}{/if}</Table.Cell>
<Table.Cell>
{#if estado}
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {claseBadge(estado)}" title={EFC_SYNC_TOOLTIPS[estado] ?? ''}>{EFC_SYNC_LABELS[estado]}</span>
{#if estado === 'FAILED'}
<button type="button" class="text-primary ml-2 text-xs hover:underline disabled:opacity-50" disabled={retrying === d.id} onclick={() => retryDoc(d)}>{retrying === d.id ? 'Reintentando…' : EFC_TEXTS.retryButton}</button>
{/if}
{:else}
{/if}
</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDocument(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
@@ -294,7 +360,9 @@
<span class="font-medium">Archivo {#if uploading}<span class="text-xs text-muted-foreground">(subiendo…)</span>{:else if documentForm.file_key}<span class="text-xs text-emerald-600">(cargado)</span>{/if}</span>
<input type="file" class={inputCls} onchange={onFilePicked} />
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">o URL externa</span><input class={inputCls} bind:value={documentForm.file_url} placeholder="https://…" /></label>
<!-- La URL externa y el expediente son excluyentes: EFC resguarda ARCHIVOS, no enlaces, así
que un documento con solo URL nunca podría entregarse y quedaría eternamente pendiente. -->
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">o URL externa</span><input class={inputCls} bind:value={documentForm.file_url} placeholder="https://…" disabled={!!documentForm.file_key} /></label>
<div class="flex justify-end gap-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form>
{/if}

View File

@@ -0,0 +1,265 @@
<script lang="ts">
/**
* Documentos del expediente electrónico de un embarque.
*
* Subida de UN paso: el usuario elige archivo y tipo, y el sistema guarda, registra y encola la
* entrega a EFC. Si EFC está caído la subida IGUAL tiene éxito y el documento se queda en
* «Pendiente de enviar» hasta que el carril lo entregue — el badge es lo que se lo cuenta.
*/
import { toast } from 'svelte-sonner';
import {
EFC_SYNC_LABELS,
EFC_SYNC_TOOLTIPS,
EFC_TEXTS,
expedienteDocBlob,
expedientesAPI,
retrySync,
uploadExpedienteDocument,
type EfcSyncState,
type ExpedienteDocument
} from '$lib/api/expedientes';
import { SHIPMENT_DOC_TYPES } from '$lib/components/crm/format';
import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table';
let {
expedienteId,
companyId,
folio = ''
}: { expedienteId: number; companyId: number; folio?: string } = $props();
let documentos = $state<ExpedienteDocument[]>([]);
let cargando = $state(false);
let modalOpen = $state(false);
let subiendo = $state(false);
let progreso = $state(0);
let reintentando = $state<number | null>(null);
let archivo = $state<File | null>(null);
let docType = $state<string>(SHIPMENT_DOC_TYPES[0]?.value ?? 'otro');
let nombre = $state('');
async function cargar() {
cargando = true;
try {
documentos = await expedientesAPI.listDocuments(expedienteId, companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los documentos.');
} finally {
cargando = false;
}
}
$effect(() => {
if (expedienteId && companyId) void cargar();
});
function estadoDe(d: ExpedienteDocument): EfcSyncState {
return (d.efc_sync_state ?? 'PENDING') as EfcSyncState;
}
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';
}
function onFilePicked(e: Event) {
const input = e.currentTarget as HTMLInputElement;
archivo = input.files?.[0] ?? null;
if (archivo && !nombre) nombre = archivo.name;
}
async function guardar(e: SubmitEvent) {
e.preventDefault();
if (!archivo) {
toast.error('Elige un archivo.');
return;
}
subiendo = true;
progreso = 0;
try {
await uploadExpedienteDocument(expedienteId, archivo, docType, companyId, {
name: nombre || undefined,
onUploadProgress: (ev) => {
progreso = ev.total ? Math.round((ev.loaded / ev.total) * 100) : 0;
}
});
modalOpen = false;
archivo = null;
nombre = '';
await cargar();
} catch (err) {
toast.error(err instanceof Error ? err.message : EFC_TEXTS.invalidExtension);
} finally {
subiendo = false;
progreso = 0;
}
}
async function abrir(d: ExpedienteDocument) {
try {
const blob = await expedienteDocBlob(expedienteId, 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);
} catch (err) {
toast.error(err instanceof Error ? err.message : EFC_TEXTS.downloadFailed);
}
}
async function reintentar(d: ExpedienteDocument) {
if (!d.efc_document_ref) return;
reintentando = d.id;
try {
await retrySync(d.efc_document_ref, companyId);
await cargar();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo reintentar el envío.');
} finally {
reintentando = null;
}
}
async function quitar(d: ExpedienteDocument) {
try {
await expedientesAPI.removeDocument(expedienteId, d.id, companyId);
await cargar();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo quitar el documento.');
}
}
const inputCls =
'border-input bg-background focus-visible:ring-ring h-9 w-full rounded-md border px-3 py-1 text-sm outline-none focus-visible:ring-[3px]';
</script>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-col">
<h3 class="text-sm font-semibold">Documentos del expediente</h3>
{#if folio}
<span class="text-muted-foreground text-xs">Folio {folio}</span>
{/if}
</div>
<Button type="button" size="sm" onclick={() => (modalOpen = true)}>
{EFC_TEXTS.newDocumentTitle}
</Button>
</div>
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Nombre</Table.Head>
<Table.Head>Tipo</Table.Head>
<Table.Head>Estado</Table.Head>
<Table.Head class="text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if cargando}
<Table.Row>
<Table.Cell colspan={4} class="text-muted-foreground text-center text-sm">
Cargando…
</Table.Cell>
</Table.Row>
{:else if documentos.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="text-muted-foreground text-center text-sm">
Todavía no hay documentos en este expediente.
</Table.Cell>
</Table.Row>
{:else}
{#each documentos as d (d.id)}
{@const estado = estadoDe(d)}
<Table.Row>
<Table.Cell class="font-medium">{d.name}</Table.Cell>
<Table.Cell>{d.doc_type}</Table.Cell>
<Table.Cell>
<span
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {claseBadge(estado)}"
title={EFC_SYNC_TOOLTIPS[estado] ?? ''}
>
{EFC_SYNC_LABELS[estado]}
</span>
</Table.Cell>
<Table.Cell class="flex flex-wrap justify-end gap-2">
{#if estado === 'SYNCED'}
<button
type="button"
class="text-primary text-sm hover:underline"
onclick={() => abrir(d)}
>
Ver
</button>
{/if}
{#if estado === 'FAILED'}
<button
type="button"
class="text-primary text-sm hover:underline disabled:opacity-50"
disabled={reintentando === d.id}
onclick={() => reintentar(d)}
>
{reintentando === d.id ? 'Reintentando…' : EFC_TEXTS.retryButton}
</button>
{/if}
<button
type="button"
class="text-destructive text-sm hover:underline"
onclick={() => quitar(d)}
>
Quitar
</button>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</div>
{#if modalOpen}
<div class="bg-background/80 fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="bg-card w-full max-w-md rounded-lg border p-4 shadow-lg">
<h4 class="mb-3 text-sm font-semibold">{EFC_TEXTS.newDocumentTitle}</h4>
<form class="flex flex-col gap-3" onsubmit={guardar}>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Tipo de documento</span>
<select class={inputCls} bind:value={docType}>
{#each SHIPMENT_DOC_TYPES as t (t.value)}
<option value={t.value}>{t.label}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Nombre</span>
<input class={inputCls} bind:value={nombre} placeholder="Nombre del documento" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Archivo</span>
<input type="file" class={inputCls} onchange={onFilePicked} />
</label>
{#if subiendo}
<div class="bg-muted h-2 w-full overflow-hidden rounded-full">
<div class="bg-primary h-full transition-all" style="width: {progreso}%"></div>
</div>
<span class="text-muted-foreground text-xs">Subiendo… {progreso}%</span>
{/if}
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => (modalOpen = false)}>
Cancelar
</Button>
<Button type="submit" disabled={subiendo}>{subiendo ? 'Subiendo…' : 'Guardar'}</Button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -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<number | null>(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 @@
<p class="text-sm text-muted-foreground">Sin documentos.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Clase</Table.Head><Table.Head>Documento</Table.Head><Table.Head>Número</Table.Head><Table.Head>Emisión</Table.Head><Table.Head>Archivo</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Header><Table.Row><Table.Head>Clase</Table.Head><Table.Head>Documento</Table.Head><Table.Head>Número</Table.Head><Table.Head>Emisión</Table.Head><Table.Head>Archivo</Table.Head><Table.Head>Expediente</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each docs as d (d.id)}
{@const estado = estadoEfc(d)}
<Table.Row>
<Table.Cell>{labelOf(DOC_KINDS, d.doc_kind)}</Table.Cell>
<Table.Cell class="font-medium">{labelOf(SHIPMENT_DOC_TYPES, d.doc_type)}</Table.Cell>
<Table.Cell class="font-mono text-xs">{d.number ?? '—'}</Table.Cell>
<Table.Cell>{formatDate(d.issue_date)}</Table.Cell>
<Table.Cell>{#if d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}{/if}</Table.Cell>
<Table.Cell>{#if d.efc_document_id || d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}{/if}</Table.Cell>
<Table.Cell>
{#if estado}
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {claseBadge(estado)}" title={EFC_SYNC_TOOLTIPS[estado] ?? ''}>{EFC_SYNC_LABELS[estado]}</span>
{#if estado === 'FAILED'}
<button type="button" class="text-primary ml-2 text-xs hover:underline disabled:opacity-50" disabled={retryingDoc === d.id} onclick={() => retryDoc(d)}>{retryingDoc === d.id ? 'Reintentando…' : EFC_TEXTS.retryButton}</button>
{/if}
{:else}
{/if}
</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDoc(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}