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>
This commit is contained in:
0
backend/api/v1/modules/crm/uploads/__init__.py
Normal file
0
backend/api/v1/modules/crm/uploads/__init__.py
Normal file
63
backend/api/v1/modules/crm/uploads/routes.py
Normal file
63
backend/api/v1/modules/crm/uploads/routes.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""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)}
|
||||
Reference in New Issue
Block a user