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

@@ -1,69 +1,93 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class AccountCreate(BaseModel):
class AccountBase(BaseModel):
# Datos generales
name: str = Field(..., min_length=1, max_length=255)
trade_name: str | None = Field(None, max_length=255)
rfc: str | None = Field(None, max_length=13)
account_type: str | None = Field(None, max_length=40)
curp: str | None = Field(None, max_length=18)
record_type: str = Field("cliente", max_length=20) # cliente | prospecto
person_type: str | None = Field(None, max_length=10) # fisica | moral
industry: str | None = Field(None, max_length=120)
account_type: str | None = Field(None, max_length=40)
status: str = Field("active", max_length=20) # active | inactive
# Comercial
commercial_classification: str | None = Field(None, max_length=20)
preferred_contact_method: str | None = Field(None, max_length=20)
language: str | None = Field(None, max_length=40)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
# Fiscal
tax_regime: str | None = Field(None, max_length=120)
cfdi_use: str | None = Field(None, max_length=60)
payment_method: str | None = Field(None, max_length=60)
payment_form: str | None = Field(None, max_length=60)
currency: str | None = Field(None, max_length=3)
credit_limit: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
credit_days: int | None = Field(None, ge=0)
commercial_terms: str | None = None
# Aduanero / ubicación
patente_aduanal: str | None = Field(None, max_length=20)
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
# País por defecto MX (CRM aduanero). Evita depender del server_default,
# que no aplica cuando model_dump envía la columna como NULL explícito.
country: str | None = Field("MX", max_length=2)
patente_aduanal: str | None = Field(None, max_length=20)
status: str = Field("active", max_length=20)
owner_user_id: str | None = Field(None, max_length=64)
# Observaciones
notes: str | None = None
internal_notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class AccountCreate(AccountBase):
pass
class AccountUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
trade_name: str | None = Field(None, max_length=255)
rfc: str | None = Field(None, max_length=13)
account_type: str | None = Field(None, max_length=40)
curp: str | None = Field(None, max_length=18)
record_type: str | None = Field(None, max_length=20)
person_type: str | None = Field(None, max_length=10)
industry: str | None = Field(None, max_length=120)
account_type: str | None = Field(None, max_length=40)
status: str | None = Field(None, max_length=20)
commercial_classification: str | None = Field(None, max_length=20)
preferred_contact_method: str | None = Field(None, max_length=20)
language: str | None = Field(None, max_length=40)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
tax_regime: str | None = Field(None, max_length=120)
cfdi_use: str | None = Field(None, max_length=60)
payment_method: str | None = Field(None, max_length=60)
payment_form: str | None = Field(None, max_length=60)
currency: str | None = Field(None, max_length=3)
credit_limit: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
credit_days: int | None = Field(None, ge=0)
commercial_terms: str | None = None
patente_aduanal: str | None = Field(None, max_length=20)
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
patente_aduanal: str | None = Field(None, max_length=20)
status: str | None = Field(None, max_length=20)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
internal_notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class AccountResponse(BaseModel):
class AccountResponse(AccountBase):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
trade_name: str | None
rfc: str | None
account_type: str | None
industry: str | None
email: str | None
phone: str | None
website: str | None
address: str | None
city: str | None
state: str | None
country: str | None
patente_aduanal: str | None
status: str
owner_user_id: str | None
notes: str | None
tenant_id: int
company_id: int
created_by: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime

View File

@@ -1,4 +1,6 @@
from sqlalchemy import Integer, String, Text, text
from decimal import Decimal
from sqlalchemy import Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -6,35 +8,65 @@ from core.database import Base
class Account(Base, TenantScopedMixin, TimestampMixin):
"""Cuenta CRM: empresa cliente o prospecto.
"""Catálogo de Clientes / Prospectos.
Modela importadores, IMMEX, agencias aduanales, transportistas, etc.
Los campos aduaneros (RFC, patente) son opcionales para no forzar datos
en prospectos que aún no comparten información fiscal.
Un mismo registro puede ser Cliente o Prospecto (``record_type``). Concentra
datos generales, comerciales y fiscales; las direcciones, contactos y
documentos viven en tablas relacionadas (crm.addresses, crm.contacts,
crm.documents).
"""
__tablename__ = "accounts"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# Razón social (nombre legal) y nombre comercial
name: Mapped[str] = mapped_column(String(255), nullable=False)
trade_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# ----- Datos generales -----
name: Mapped[str] = mapped_column(String(255), nullable=False) # razón social
trade_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # nombre comercial
rfc: Mapped[str | None] = mapped_column(String(13), nullable=True, index=True)
# Tipo de cuenta: immex | agencia_aduanal | importador | exportador | transportista | otro
curp: Mapped[str | None] = mapped_column(String(18), nullable=True) # persona física
# Tipo de registro: cliente | prospecto
record_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'cliente'"), index=True)
# Tipo de persona: fisica | moral
person_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
industry: Mapped[str | None] = mapped_column(String(120), nullable=True) # giro / industria
# Tipo operativo (immex | agencia_aduanal | importador | exportador | transportista | otro)
account_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
industry: Mapped[str | None] = mapped_column(String(120), nullable=True)
# Estatus: active | inactive
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
# ----- Información comercial -----
# Clasificación: importador | exportador | ambos
commercial_classification: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Medio de contacto preferido: llamada | correo | videollamada | whatsapp | otro
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
language: Mapped[str | None] = mapped_column(String(40), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
# ----- Información fiscal -----
tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal
cfdi_use: Mapped[str | None] = mapped_column(String(60), nullable=True) # uso de CFDI
payment_method: Mapped[str | None] = mapped_column(String(60), nullable=True) # método de pago
payment_form: Mapped[str | None] = mapped_column(String(60), nullable=True) # forma de pago
currency: Mapped[str | None] = mapped_column(String(3), nullable=True) # moneda
credit_limit: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True)
credit_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
commercial_terms: Mapped[str | None] = mapped_column(Text, nullable=True) # condiciones comerciales
# ----- Datos aduaneros / ubicación rápida -----
patente_aduanal: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Ubicación de referencia (el detalle vive en crm.addresses)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
city: Mapped[str | None] = mapped_column(String(120), nullable=True)
state: Mapped[str | None] = mapped_column(String(120), nullable=True)
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
# Patente del agente aduanal (dato aduanero, no se traduce)
patente_aduanal: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Estado comercial: active | inactive | prospect
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
# Vendedor responsable (id de usuario Keycloak / sub)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# ----- Observaciones y auditoría -----
notes: Mapped[str | None] = mapped_column(Text, nullable=True) # comentarios generales
internal_notes: Mapped[str | None] = mapped_column(Text, nullable=True) # notas internas
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) # vendedor
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)

View File

@@ -10,16 +10,21 @@ from .dto import AccountCreate, AccountResponse, AccountUpdate
router = APIRouter()
def _user_id(current_user: dict) -> str | None:
return current_user.get("sub") or current_user.get("id")
@router.get("/accounts", response_model=list[AccountResponse])
def list_accounts(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None, description="Búsqueda por nombre, nombre comercial o RFC"),
account_status: str | None = Query(None, alias="status", description="Filtrar por estado"),
account_status: str | None = Query(None, alias="status", description="Filtrar por estatus"),
record_type: str | None = Query(None, description="Filtrar por tipo (cliente | prospecto)"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_accounts(db, tenant_id, company_id, search, account_status)
return service.get_accounts(db, tenant_id, company_id, search, account_status, record_type)
@router.get("/accounts/{account_id}", response_model=AccountResponse)
@@ -41,7 +46,7 @@ def create_account(
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.create_account(db, payload, tenant_id, company_id)
return service.create_account(db, payload, tenant_id, company_id, _user_id(current_user))
@router.patch("/accounts/{account_id}", response_model=AccountResponse)
@@ -53,7 +58,7 @@ def update_account(
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.update_account(db, account_id, payload, tenant_id, company_id)
return service.update_account(db, account_id, payload, tenant_id, company_id, _user_id(current_user))
@router.delete("/accounts/{account_id}", status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -13,6 +13,7 @@ def get_accounts(
company_id: int,
search: str | None = None,
account_status: str | None = None,
record_type: str | None = None,
) -> list[Account]:
query = db.query(Account).filter(
Account.tenant_id == tenant_id,
@@ -28,6 +29,8 @@ def get_accounts(
)
if account_status:
query = query.filter(Account.status == account_status)
if record_type:
query = query.filter(Account.record_type == record_type)
return query.order_by(Account.name.asc()).all()
@@ -43,12 +46,20 @@ def get_account(db: Session, account_id: int, tenant_id: int, company_id: int) -
.first()
)
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cuenta no encontrada")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cliente no encontrado")
return account
def create_account(db: Session, payload: AccountCreate, tenant_id: int, company_id: int) -> Account:
account = Account(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
def create_account(
db: Session, payload: AccountCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Account:
account = Account(
**payload.model_dump(),
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(account)
db.commit()
db.refresh(account)
@@ -56,11 +67,17 @@ def create_account(db: Session, payload: AccountCreate, tenant_id: int, company_
def update_account(
db: Session, account_id: int, payload: AccountUpdate, tenant_id: int, company_id: int
db: Session,
account_id: int,
payload: AccountUpdate,
tenant_id: int,
company_id: int,
user_id: str | None = None,
) -> Account:
account = get_account(db, account_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(account, field, value)
account.updated_by = user_id
db.commit()
db.refresh(account)
return account
@@ -68,6 +85,6 @@ def update_account(
def delete_account(db: Session, account_id: int, tenant_id: int, company_id: int) -> None:
account = get_account(db, account_id, tenant_id, company_id)
# Soft delete: conserva el histórico comercial de la cuenta
# Soft delete: conserva el histórico comercial del cliente
account.deleted_at = datetime.now(timezone.utc)
db.commit()

View File

@@ -0,0 +1,47 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class AddressBase(BaseModel):
account_id: int | None = None
supplier_id: int | None = None
address_type: str = Field("fiscal", max_length=20)
street: str | None = Field(None, max_length=255)
ext_number: str | None = Field(None, max_length=30)
int_number: str | None = Field(None, max_length=30)
neighborhood: str | None = Field(None, max_length=120)
postal_code: str | None = Field(None, max_length=10)
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field("MX", max_length=2)
reference_notes: str | None = None
is_primary: bool = False
class AddressCreate(AddressBase):
pass
class AddressUpdate(BaseModel):
address_type: str | None = Field(None, max_length=20)
street: str | None = Field(None, max_length=255)
ext_number: str | None = Field(None, max_length=30)
int_number: str | None = Field(None, max_length=30)
neighborhood: str | None = Field(None, max_length=120)
postal_code: str | None = Field(None, max_length=10)
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
reference_notes: str | None = None
is_primary: bool | None = None
class AddressResponse(AddressBase):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,35 @@
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Address(Base, TenantScopedMixin, TimestampMixin):
"""Dirección de un cliente (``account_id``) o proveedor (``supplier_id``).
Un cliente/proveedor puede tener varias direcciones (fiscal, oficina, etc.).
"""
__tablename__ = "addresses"
__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
)
# fiscal | oficina | sucursal | bodega | patio | terminal | almacen
address_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'fiscal'"))
street: Mapped[str | None] = mapped_column(String(255), nullable=True) # calle
ext_number: Mapped[str | None] = mapped_column(String(30), nullable=True) # número exterior
int_number: Mapped[str | None] = mapped_column(String(30), nullable=True) # número interior
neighborhood: Mapped[str | None] = mapped_column(String(120), nullable=True) # colonia
postal_code: Mapped[str | None] = mapped_column(String(10), nullable=True) # código postal
city: Mapped[str | None] = mapped_column(String(120), nullable=True) # municipio
state: Mapped[str | None] = mapped_column(String(120), nullable=True) # estado
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
reference_notes: Mapped[str | None] = mapped_column(Text, nullable=True) # referencias
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))

View File

@@ -0,0 +1,56 @@
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 AddressCreate, AddressResponse, AddressUpdate
router = APIRouter()
@router.get("/addresses", response_model=list[AddressResponse])
def list_addresses(
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_addresses(db, tenant_id, company_id, account_id, supplier_id)
@router.post("/addresses", response_model=AddressResponse, status_code=status.HTTP_201_CREATED)
def create_address(
payload: AddressCreate,
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.create_address(db, payload, tenant_id, company_id)
@router.patch("/addresses/{address_id}", response_model=AddressResponse)
def update_address(
address_id: int,
payload: AddressUpdate,
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_address(db, address_id, payload, tenant_id, company_id)
@router.delete("/addresses/{address_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_address(
address_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_address(db, address_id, tenant_id, company_id)

View File

@@ -0,0 +1,96 @@
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 AddressCreate, AddressUpdate
from .models import Address
def _validate_owner(db: Session, account_id: int | None, supplier_id: int | None, tenant_id: int, company_id: int) -> None:
"""Una dirección 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="La dirección 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_addresses(
db: Session,
tenant_id: int,
company_id: int,
account_id: int | None = None,
supplier_id: int | None = None,
) -> list[Address]:
query = db.query(Address).filter(
Address.tenant_id == tenant_id,
Address.company_id == company_id,
Address.deleted_at.is_(None),
)
if account_id is not None:
query = query.filter(Address.account_id == account_id)
if supplier_id is not None:
query = query.filter(Address.supplier_id == supplier_id)
return query.order_by(Address.is_primary.desc(), Address.id.asc()).all()
def get_address(db: Session, address_id: int, tenant_id: int, company_id: int) -> Address:
address = (
db.query(Address)
.filter(
Address.id == address_id,
Address.tenant_id == tenant_id,
Address.company_id == company_id,
Address.deleted_at.is_(None),
)
.first()
)
if not address:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dirección no encontrada")
return address
def create_address(db: Session, payload: AddressCreate, tenant_id: int, company_id: int) -> Address:
_validate_owner(db, payload.account_id, payload.supplier_id, tenant_id, company_id)
address = Address(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(address)
db.commit()
db.refresh(address)
return address
def update_address(
db: Session, address_id: int, payload: AddressUpdate, tenant_id: int, company_id: int
) -> Address:
address = get_address(db, address_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(address, field, value)
db.commit()
db.refresh(address)
return address
def delete_address(db: Session, address_id: int, tenant_id: int, company_id: int) -> None:
address = get_address(db, address_id, tenant_id, company_id)
address.deleted_at = datetime.now(timezone.utc)
db.commit()

View File

@@ -3,49 +3,58 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class ContactCreate(BaseModel):
class ContactBase(BaseModel):
account_id: int | None = None
supplier_id: int | None = None
first_name: str = Field(..., min_length=1, max_length=120)
last_name: str | None = Field(None, max_length=120)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
mobile: str | None = Field(None, max_length=40)
job_title: str | None = Field(None, max_length=120)
department: str | None = Field(None, max_length=120)
area: str | None = Field(None, max_length=120)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
extension: str | None = Field(None, max_length=20)
mobile: str | None = Field(None, max_length=40)
whatsapp: str | None = Field(None, max_length=40)
is_primary: bool = False
receives_quotes: bool = False
receives_invoices: bool = False
receives_commercial_info: bool = False
status: str = Field("active", max_length=20)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class ContactCreate(ContactBase):
pass
class ContactUpdate(BaseModel):
account_id: int | None = None
supplier_id: int | None = None
first_name: str | None = Field(None, min_length=1, max_length=120)
last_name: str | None = Field(None, max_length=120)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
mobile: str | None = Field(None, max_length=40)
job_title: str | None = Field(None, max_length=120)
department: str | None = Field(None, max_length=120)
area: str | None = Field(None, max_length=120)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
extension: str | None = Field(None, max_length=20)
mobile: str | None = Field(None, max_length=40)
whatsapp: str | None = Field(None, max_length=40)
is_primary: bool | None = None
receives_quotes: bool | None = None
receives_invoices: bool | None = None
receives_commercial_info: bool | None = None
status: str | None = Field(None, max_length=20)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class ContactResponse(BaseModel):
class ContactResponse(ContactBase):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int | None
first_name: str
last_name: str | None
email: str | None
phone: str | None
mobile: str | None
job_title: str | None
department: str | None
is_primary: bool
owner_user_id: str | None
notes: str | None
tenant_id: int
company_id: int
created_at: datetime

View File

@@ -6,7 +6,7 @@ from core.database import Base
class Contact(Base, TenantScopedMixin, TimestampMixin):
"""Contacto CRM: persona asociada (opcionalmente) a una cuenta."""
"""Contacto CRM asociado a un cliente (``account_id``) o proveedor (``supplier_id``)."""
__tablename__ = "contacts"
__table_args__ = {"schema": "crm"}
@@ -15,13 +15,26 @@ class Contact(Base, TenantScopedMixin, TimestampMixin):
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
)
# Información personal
first_name: Mapped[str] = mapped_column(String(120), nullable=False)
last_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
job_title: Mapped[str | None] = mapped_column(String(120), nullable=True) # puesto
department: Mapped[str | None] = mapped_column(String(120), nullable=True) # departamento
area: Mapped[str | None] = mapped_column(String(120), nullable=True) # ventas, operaciones, cobranza...
# Información de contacto
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
mobile: Mapped[str | None] = mapped_column(String(40), nullable=True)
job_title: Mapped[str | None] = mapped_column(String(120), nullable=True)
department: Mapped[str | None] = mapped_column(String(120), nullable=True)
extension: Mapped[str | None] = mapped_column(String(20), nullable=True)
mobile: Mapped[str | None] = mapped_column(String(40), nullable=True) # celular
whatsapp: Mapped[str | None] = mapped_column(String(40), nullable=True)
# Configuración
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
receives_quotes: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
receives_invoices: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
receives_commercial_info: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -14,12 +14,13 @@ router = APIRouter()
def list_contacts(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None, description="Búsqueda por nombre o email"),
account_id: int | None = Query(None, description="Filtrar por cuenta"),
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_contacts(db, tenant_id, company_id, search, account_id)
return service.get_contacts(db, tenant_id, company_id, search, account_id, supplier_id)
@router.get("/contacts/{contact_id}", response_model=ContactResponse)

View File

@@ -4,29 +4,47 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..suppliers.models import Supplier
from .dto import ContactCreate, ContactUpdate
from .models import Contact
def _validate_account(db: Session, account_id: int | None, tenant_id: int, company_id: int) -> None:
"""Verifica que la cuenta referenciada exista dentro del tenant/company."""
if account_id is None:
return
exists = (
db.query(Account.id)
.filter(
Account.id == account_id,
Account.tenant_id == tenant_id,
Account.company_id == company_id,
Account.deleted_at.is_(None),
def _validate_parent(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
"""Verifica que el cliente/proveedor referenciado exista en el tenant/company."""
account_id = data.get("account_id")
if account_id is not None:
exists = (
db.query(Account.id)
.filter(
Account.id == account_id,
Account.tenant_id == tenant_id,
Account.company_id == company_id,
Account.deleted_at.is_(None),
)
.first()
)
.first()
)
if not exists:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="La cuenta asociada no existe",
if not exists:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="El cliente asociado no existe",
)
supplier_id = data.get("supplier_id")
if supplier_id is not None:
exists = (
db.query(Supplier.id)
.filter(
Supplier.id == supplier_id,
Supplier.tenant_id == tenant_id,
Supplier.company_id == company_id,
Supplier.deleted_at.is_(None),
)
.first()
)
if not exists:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="El proveedor asociado no existe",
)
def get_contacts(
@@ -35,6 +53,7 @@ def get_contacts(
company_id: int,
search: str | None = None,
account_id: int | None = None,
supplier_id: int | None = None,
) -> list[Contact]:
query = db.query(Contact).filter(
Contact.tenant_id == tenant_id,
@@ -43,6 +62,8 @@ def get_contacts(
)
if account_id is not None:
query = query.filter(Contact.account_id == account_id)
if supplier_id is not None:
query = query.filter(Contact.supplier_id == supplier_id)
if search:
pattern = f"%{search}%"
query = query.filter(
@@ -70,8 +91,9 @@ def get_contact(db: Session, contact_id: int, tenant_id: int, company_id: int) -
def create_contact(db: Session, payload: ContactCreate, tenant_id: int, company_id: int) -> Contact:
_validate_account(db, payload.account_id, tenant_id, company_id)
contact = Contact(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
data = payload.model_dump()
_validate_parent(db, data, tenant_id, company_id)
contact = Contact(**data, tenant_id=tenant_id, company_id=company_id)
db.add(contact)
db.commit()
db.refresh(contact)
@@ -83,8 +105,7 @@ def update_contact(
) -> Contact:
contact = get_contact(db, contact_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
if "account_id" in data:
_validate_account(db, data["account_id"], tenant_id, company_id)
_validate_parent(db, data, tenant_id, company_id)
for field, value in data.items():
setattr(contact, field, value)
db.commit()

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()

View File

@@ -11,8 +11,11 @@ MODULE = "crm"
# (entidad, etiqueta legible)
_ENTITIES = [
("account", "cuentas"),
("account", "clientes/prospectos"),
("supplier", "proveedores"),
("contact", "contactos"),
("address", "direcciones"),
("document", "documentos"),
("lead", "prospectos"),
("opportunity", "oportunidades"),
("pipeline", "embudos"),

View File

@@ -10,16 +10,22 @@ from fastapi import APIRouter
from . import permissions # noqa: F401 (side-effect: registra permisos del CRM)
from .accounts.routes import router as accounts_router
from .activities.routes import router as activities_router
from .addresses.routes import router as addresses_router
from .contacts.routes import router as contacts_router
from .documents.routes import router as documents_router
from .leads.routes import router as leads_router
from .metrics.routes import router as metrics_router
from .opportunities.routes import router as opportunities_router
from .pipelines.routes import router as pipelines_router
from .suppliers.routes import router as suppliers_router
router = APIRouter()
router.include_router(accounts_router)
router.include_router(suppliers_router)
router.include_router(contacts_router)
router.include_router(addresses_router)
router.include_router(documents_router)
router.include_router(leads_router)
router.include_router(pipelines_router)
router.include_router(opportunities_router)

View File

@@ -0,0 +1,88 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class SupplierBase(BaseModel):
# Datos generales
name: str = Field(..., min_length=1, max_length=255)
trade_name: str | None = Field(None, max_length=255)
rfc: str | None = Field(None, max_length=13)
curp: str | None = Field(None, max_length=18)
person_type: str | None = Field(None, max_length=10)
status: str = Field("active", max_length=20)
classifications: list[str] = Field(default_factory=list)
# Comercial
services_offered: str | None = None
coverage: str | None = Field(None, max_length=20)
countries: list[str] = Field(default_factory=list)
ports: list[str] = Field(default_factory=list)
airports: list[str] = Field(default_factory=list)
customs: list[str] = Field(default_factory=list)
business_hours: str | None = Field(None, max_length=255)
quote_currency: str | None = Field(None, max_length=3)
avg_response_time: str | None = Field(None, max_length=120)
commercial_notes: str | None = None
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
# Fiscal
tax_regime: str | None = Field(None, max_length=120)
payment_method: str | None = Field(None, max_length=60)
payment_form: str | None = Field(None, max_length=60)
credit_limit: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
credit_days: int | None = Field(None, ge=0)
commercial_terms: str | None = None
# Observaciones
notes: str | None = None
internal_notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class SupplierCreate(SupplierBase):
pass
class SupplierUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
trade_name: str | None = Field(None, max_length=255)
rfc: str | None = Field(None, max_length=13)
curp: str | None = Field(None, max_length=18)
person_type: str | None = Field(None, max_length=10)
status: str | None = Field(None, max_length=20)
classifications: list[str] | None = None
services_offered: str | None = None
coverage: str | None = Field(None, max_length=20)
countries: list[str] | None = None
ports: list[str] | None = None
airports: list[str] | None = None
customs: list[str] | None = None
business_hours: str | None = Field(None, max_length=255)
quote_currency: str | None = Field(None, max_length=3)
avg_response_time: str | None = Field(None, max_length=120)
commercial_notes: str | None = None
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
tax_regime: str | None = Field(None, max_length=120)
payment_method: str | None = Field(None, max_length=60)
payment_form: str | None = Field(None, max_length=60)
credit_limit: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
credit_days: int | None = Field(None, ge=0)
commercial_terms: str | None = None
notes: str | None = None
internal_notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class SupplierResponse(SupplierBase):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
created_by: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,62 @@
from decimal import Decimal
from sqlalchemy import JSON, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Supplier(Base, TenantScopedMixin, TimestampMixin):
"""Catálogo de Proveedores (navieras, aerolíneas, transportistas, agentes, etc.).
Los campos multi-valor (clasificaciones, cobertura por país/puerto/aduana) se
guardan como listas JSON. Direcciones, contactos y documentos viven en tablas
relacionadas (crm.addresses, crm.contacts, crm.documents).
"""
__tablename__ = "suppliers"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# ----- Datos generales -----
name: Mapped[str] = mapped_column(String(255), nullable=False) # razón social
trade_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
rfc: Mapped[str | None] = mapped_column(String(13), nullable=True, index=True)
curp: Mapped[str | None] = mapped_column(String(18), nullable=True)
person_type: Mapped[str | None] = mapped_column(String(10), nullable=True) # fisica | moral
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
# Clasificación (múltiple): naviera, aerolinea, transportista_terrestre, ferrocarril,
# agente_aduanal, agente_carga, agente_corresponsal, almacen, aseguradora, paqueteria, otro
classifications: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list)
# ----- Información comercial -----
services_offered: Mapped[str | None] = mapped_column(Text, nullable=True)
coverage: Mapped[str | None] = mapped_column(String(20), nullable=True) # nacional | internacional | ambos
countries: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list)
ports: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list)
airports: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list)
customs: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list) # aduanas
business_hours: Mapped[str | None] = mapped_column(String(255), nullable=True)
quote_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
avg_response_time: Mapped[str | None] = mapped_column(String(120), nullable=True)
commercial_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
# ----- Información fiscal -----
tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True)
payment_method: Mapped[str | None] = mapped_column(String(60), nullable=True)
payment_form: Mapped[str | None] = mapped_column(String(60), nullable=True)
credit_limit: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True)
credit_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
commercial_terms: Mapped[str | None] = mapped_column(Text, nullable=True)
# ----- Observaciones y auditoría -----
notes: Mapped[str | None] = mapped_column(Text, nullable=True) # comentarios generales
internal_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)

View File

@@ -0,0 +1,71 @@
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 SupplierCreate, SupplierResponse, SupplierUpdate
router = APIRouter()
def _user_id(current_user: dict) -> str | None:
return current_user.get("sub") or current_user.get("id")
@router.get("/suppliers", response_model=list[SupplierResponse])
def list_suppliers(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None, description="Búsqueda por nombre o RFC"),
supplier_status: str | None = Query(None, alias="status", description="Filtrar por estatus"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_suppliers(db, tenant_id, company_id, search, supplier_status)
@router.get("/suppliers/{supplier_id}", response_model=SupplierResponse)
def get_supplier(
supplier_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"]
return service.get_supplier(db, supplier_id, tenant_id, company_id)
@router.post("/suppliers", response_model=SupplierResponse, status_code=status.HTTP_201_CREATED)
def create_supplier(
payload: SupplierCreate,
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.create_supplier(db, payload, tenant_id, company_id, _user_id(current_user))
@router.patch("/suppliers/{supplier_id}", response_model=SupplierResponse)
def update_supplier(
supplier_id: int,
payload: SupplierUpdate,
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_supplier(db, supplier_id, payload, tenant_id, company_id, _user_id(current_user))
@router.delete("/suppliers/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_supplier(
supplier_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_supplier(db, supplier_id, tenant_id, company_id)

View File

@@ -0,0 +1,86 @@
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from .dto import SupplierCreate, SupplierUpdate
from .models import Supplier
def get_suppliers(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
supplier_status: str | None = None,
) -> list[Supplier]:
query = db.query(Supplier).filter(
Supplier.tenant_id == tenant_id,
Supplier.company_id == company_id,
Supplier.deleted_at.is_(None),
)
if search:
pattern = f"%{search}%"
query = query.filter(
Supplier.name.ilike(pattern)
| Supplier.trade_name.ilike(pattern)
| Supplier.rfc.ilike(pattern)
)
if supplier_status:
query = query.filter(Supplier.status == supplier_status)
return query.order_by(Supplier.name.asc()).all()
def get_supplier(db: Session, supplier_id: int, tenant_id: int, company_id: int) -> Supplier:
supplier = (
db.query(Supplier)
.filter(
Supplier.id == supplier_id,
Supplier.tenant_id == tenant_id,
Supplier.company_id == company_id,
Supplier.deleted_at.is_(None),
)
.first()
)
if not supplier:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proveedor no encontrado")
return supplier
def create_supplier(
db: Session, payload: SupplierCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Supplier:
supplier = Supplier(
**payload.model_dump(),
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(supplier)
db.commit()
db.refresh(supplier)
return supplier
def update_supplier(
db: Session,
supplier_id: int,
payload: SupplierUpdate,
tenant_id: int,
company_id: int,
user_id: str | None = None,
) -> Supplier:
supplier = get_supplier(db, supplier_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(supplier, field, value)
supplier.updated_by = user_id
db.commit()
db.refresh(supplier)
return supplier
def delete_supplier(db: Session, supplier_id: int, tenant_id: int, company_id: int) -> None:
supplier = get_supplier(db, supplier_id, tenant_id, company_id)
supplier.deleted_at = datetime.now(timezone.utc)
db.commit()