Files
CRM_AGENTES_CARGA/backend/api/v1/modules/crm/uploads/routes.py
Aduanasoft 0b12ad5354 feat(fin,ops): Facturación y Cobranza (Diag. 4), bitácora de embarque (Diag. 3) y subida a MinIO
- schema fin: fin.invoices + fin.invoice_items + fin.payments; totales con IVA,
  estados borrador→emitida→enviada→pagada, cobranza (pagos) y saldo automático
- generar-factura-desde-embarque (toma conceptos de venta de la cotización)
- ops.shipment_events: bitácora/hitos del embarque con secuencia por defecto
  según operación (importación/exportación) — cubre Diagrama 3
- subida de documentos a MinIO: POST /crm/uploads (multipart) + URL firmada
- migración c4d5e6f7a8b9, routers/permisos (fin), seed del flujo hasta factura
- 58 tests pytest en verde

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:18:11 -06:00

64 lines
2.3 KiB
Python

"""Subida de archivos a MinIO/S3 para documentos del CRM y Operaciones.
Flujo: el frontend sube el archivo aquí, recibe ``file_key`` (permanente) y lo
guarda en el documento (crm.documents / ops.shipment_documents). Para abrirlo se
pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran).
"""
import re
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from core.security import get_current_user
from core.storage_s3 import presigned_get_url, put_object_bytes
router = APIRouter()
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
def _safe_filename(name: str | None) -> str:
base = (name or "archivo").strip().replace(" ", "_")
base = _SAFE_NAME.sub("", base) or "archivo"
return base[:120]
@router.post("/uploads")
async def upload_file(
file: UploadFile = File(...),
company_id: int = Query(..., description="Company ID"),
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)",
)
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")
return {
"file_key": key,
"file_url": presigned_get_url(key),
"name": file.filename,
"content_type": file.content_type,
"size_bytes": len(content),
}
@router.get("/uploads/url")
def get_upload_url(
key: str = Query(..., description="Object key del archivo en el almacén"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
tenant_id = current_user["tenant_id"]
# Un archivo solo puede consultarse dentro de su propio tenant/company (aislamiento).
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")
return {"url": presigned_get_url(key)}