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:
0
backend/api/v1/modules/crm/documents/__init__.py
Normal file
0
backend/api/v1/modules/crm/documents/__init__.py
Normal file
38
backend/api/v1/modules/crm/documents/dto.py
Normal file
38
backend/api/v1/modules/crm/documents/dto.py
Normal 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
|
||||
33
backend/api/v1/modules/crm/documents/models.py
Normal file
33
backend/api/v1/modules/crm/documents/models.py
Normal 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)
|
||||
57
backend/api/v1/modules/crm/documents/routes.py
Normal file
57
backend/api/v1/modules/crm/documents/routes.py
Normal 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)
|
||||
100
backend/api/v1/modules/crm/documents/service.py
Normal file
100
backend/api/v1/modules/crm/documents/service.py
Normal 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()
|
||||
Reference in New Issue
Block a user