feat(crm): catálogos Clientes/Prospectos (enriquecido) y Proveedores + direcciones/documentos

Alinea el dominio al spec de catálogos:
- accounts (Clientes/Prospectos): tipo registro/persona, CURP, clasificación
  comercial, bloque fiscal (régimen, CFDI, pago, crédito), auditoría, notas internas
- suppliers (Proveedores): clasificación múltiple, cobertura, países/puertos/
  aeropuertos/aduanas (JSON), fiscal
- addresses y documents: tablas compartidas con FK a cliente o proveedor
- contacts enriquecidos (extensión, whatsapp, área, flags "recibe…", supplier_id)
- migración a7b8c9d0e1f2 (ALTER + CREATE) con downgrade completo
- permisos supplier/address/document; seed de datos actualizado
- 39 tests pytest en verde

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-14 16:19:13 -06:00
parent 2ed6247f1e
commit b12af1a561
31 changed files with 1599 additions and 101 deletions

View File

@@ -0,0 +1,38 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class DocumentBase(BaseModel):
account_id: int | None = None
supplier_id: int | None = None
doc_type: str = Field(..., max_length=60)
name: str = Field(..., min_length=1, max_length=255)
file_key: str | None = Field(None, max_length=512)
file_url: str | None = Field(None, max_length=1024)
content_type: str | None = Field(None, max_length=120)
size_bytes: int | None = Field(None, ge=0)
class DocumentCreate(DocumentBase):
pass
class DocumentUpdate(BaseModel):
doc_type: str | None = Field(None, max_length=60)
name: str | None = Field(None, min_length=1, max_length=255)
file_key: str | None = Field(None, max_length=512)
file_url: str | None = Field(None, max_length=1024)
content_type: str | None = Field(None, max_length=120)
size_bytes: int | None = Field(None, ge=0)
class DocumentResponse(DocumentBase):
model_config = ConfigDict(from_attributes=True)
id: int
uploaded_by: str | None = None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,33 @@
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Document(Base, TenantScopedMixin, TimestampMixin):
"""Documento de un cliente (``account_id``) o proveedor (``supplier_id``).
Guarda los metadatos y una referencia al archivo (``file_key`` en MinIO/S3 o
``file_url`` externa). La subida binaria se hace vía la capa de storage.
"""
__tablename__ = "documents"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True
)
# constancia_fiscal | acta_constitutiva | identificacion | comprobante_domicilio |
# contrato | presentacion | certificacion | licencia | convenio | tarifario | otro
doc_type: Mapped[str] = mapped_column(String(60), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
file_key: Mapped[str | None] = mapped_column(String(512), nullable=True) # objeto en MinIO/S3
file_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) # o URL externa
content_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
uploaded_by: Mapped[str | None] = mapped_column(String(64), nullable=True)

View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import DocumentCreate, DocumentResponse, DocumentUpdate
router = APIRouter()
@router.get("/documents", response_model=list[DocumentResponse])
def list_documents(
company_id: int = Query(..., description="Company ID"),
account_id: int | None = Query(None, description="Filtrar por cliente"),
supplier_id: int | None = Query(None, description="Filtrar por proveedor"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_documents(db, tenant_id, company_id, account_id, supplier_id)
@router.post("/documents", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
def create_document(
payload: DocumentCreate,
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"]
user_id = current_user.get("sub") or current_user.get("id")
return service.create_document(db, payload, tenant_id, company_id, user_id)
@router.patch("/documents/{document_id}", response_model=DocumentResponse)
def update_document(
document_id: int,
payload: DocumentUpdate,
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.update_document(db, document_id, payload, tenant_id, company_id)
@router.delete("/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_document(
document_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"]
service.delete_document(db, document_id, tenant_id, company_id)

View File

@@ -0,0 +1,100 @@
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
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:
document = get_document(db, document_id, tenant_id, company_id)
document.deleted_at = datetime.now(timezone.utc)
db.commit()