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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
0
backend/api/v1/modules/crm/addresses/__init__.py
Normal file
0
backend/api/v1/modules/crm/addresses/__init__.py
Normal file
47
backend/api/v1/modules/crm/addresses/dto.py
Normal file
47
backend/api/v1/modules/crm/addresses/dto.py
Normal 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
|
||||
35
backend/api/v1/modules/crm/addresses/models.py
Normal file
35
backend/api/v1/modules/crm/addresses/models.py
Normal 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"))
|
||||
56
backend/api/v1/modules/crm/addresses/routes.py
Normal file
56
backend/api/v1/modules/crm/addresses/routes.py
Normal 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)
|
||||
96
backend/api/v1/modules/crm/addresses/service.py
Normal file
96
backend/api/v1/modules/crm/addresses/service.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
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()
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
0
backend/api/v1/modules/crm/suppliers/__init__.py
Normal file
0
backend/api/v1/modules/crm/suppliers/__init__.py
Normal file
88
backend/api/v1/modules/crm/suppliers/dto.py
Normal file
88
backend/api/v1/modules/crm/suppliers/dto.py
Normal 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
|
||||
62
backend/api/v1/modules/crm/suppliers/models.py
Normal file
62
backend/api/v1/modules/crm/suppliers/models.py
Normal 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)
|
||||
71
backend/api/v1/modules/crm/suppliers/routes.py
Normal file
71
backend/api/v1/modules/crm/suppliers/routes.py
Normal 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)
|
||||
86
backend/api/v1/modules/crm/suppliers/service.py
Normal file
86
backend/api/v1/modules/crm/suppliers/service.py
Normal 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()
|
||||
Reference in New Issue
Block a user