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)
128 lines
4.6 KiB
Python
128 lines
4.6 KiB
Python
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..accounts.models import Account
|
|
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."""
|
|
if (account_id is None) == (supplier_id is None):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="El documento debe asociarse a un cliente O a un proveedor",
|
|
)
|
|
model, _id, msg = (
|
|
(Account, account_id, "El cliente asociado no existe")
|
|
if account_id is not None
|
|
else (Supplier, supplier_id, "El proveedor asociado no existe")
|
|
)
|
|
exists = (
|
|
db.query(model.id)
|
|
.filter(
|
|
model.id == _id,
|
|
model.tenant_id == tenant_id,
|
|
model.company_id == company_id,
|
|
model.deleted_at.is_(None),
|
|
)
|
|
.first()
|
|
)
|
|
if not exists:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
|
|
|
|
|
|
def get_documents(
|
|
db: Session,
|
|
tenant_id: int,
|
|
company_id: int,
|
|
account_id: int | None = None,
|
|
supplier_id: int | None = None,
|
|
) -> list[Document]:
|
|
query = db.query(Document).filter(
|
|
Document.tenant_id == tenant_id,
|
|
Document.company_id == company_id,
|
|
Document.deleted_at.is_(None),
|
|
)
|
|
if account_id is not None:
|
|
query = query.filter(Document.account_id == account_id)
|
|
if supplier_id is not None:
|
|
query = query.filter(Document.supplier_id == supplier_id)
|
|
return query.order_by(Document.created_at.desc()).all()
|
|
|
|
|
|
def get_document(db: Session, document_id: int, tenant_id: int, company_id: int) -> Document:
|
|
document = (
|
|
db.query(Document)
|
|
.filter(
|
|
Document.id == document_id,
|
|
Document.tenant_id == tenant_id,
|
|
Document.company_id == company_id,
|
|
Document.deleted_at.is_(None),
|
|
)
|
|
.first()
|
|
)
|
|
if not document:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento no encontrado")
|
|
return document
|
|
|
|
|
|
def create_document(
|
|
db: Session, payload: DocumentCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
|
) -> Document:
|
|
_validate_owner(db, payload.account_id, payload.supplier_id, tenant_id, company_id)
|
|
document = Document(
|
|
**payload.model_dump(), tenant_id=tenant_id, company_id=company_id, uploaded_by=user_id
|
|
)
|
|
db.add(document)
|
|
db.commit()
|
|
db.refresh(document)
|
|
return document
|
|
|
|
|
|
def update_document(
|
|
db: Session, document_id: int, payload: DocumentUpdate, tenant_id: int, company_id: int
|
|
) -> Document:
|
|
document = get_document(db, document_id, tenant_id, company_id)
|
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
|
setattr(document, field, value)
|
|
db.commit()
|
|
db.refresh(document)
|
|
return 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()
|