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

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

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

View File

@@ -0,0 +1,195 @@
"""crm catalogs: suppliers, addresses, documents + enrich accounts/contacts
Revision ID: a7b8c9d0e1f2
Revises: f1a2b3c4d5e6
Create Date: 2026-07-14 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "a7b8c9d0e1f2"
down_revision: Union[str, None] = "f1a2b3c4d5e6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def _scoped_columns() -> list[sa.Column]:
return [
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
]
def _scoped_indexes(table: str) -> None:
op.create_index(f"ix_{SCHEMA}_{table}_id", table, ["id"], schema=SCHEMA)
op.create_index(f"ix_{SCHEMA}_{table}_tenant_id", table, ["tenant_id"], schema=SCHEMA)
op.create_index(f"ix_{SCHEMA}_{table}_company_id", table, ["company_id"], schema=SCHEMA)
# Columnas nuevas de crm.accounts (Clientes/Prospectos enriquecido)
_ACCOUNT_COLUMNS = [
("curp", sa.String(length=18), {}),
("record_type", sa.String(length=20), {"nullable": False, "server_default": sa.text("'cliente'")}),
("person_type", sa.String(length=10), {}),
("commercial_classification", sa.String(length=20), {}),
("preferred_contact_method", sa.String(length=20), {}),
("language", sa.String(length=40), {}),
("tax_regime", sa.String(length=120), {}),
("cfdi_use", sa.String(length=60), {}),
("payment_method", sa.String(length=60), {}),
("payment_form", sa.String(length=60), {}),
("currency", sa.String(length=3), {}),
("credit_limit", sa.Numeric(precision=14, scale=2), {}),
("credit_days", sa.Integer(), {}),
("commercial_terms", sa.Text(), {}),
("internal_notes", sa.Text(), {}),
("created_by", sa.String(length=64), {}),
("updated_by", sa.String(length=64), {}),
]
# Columnas nuevas de crm.contacts (enriquecidas)
_CONTACT_COLUMNS = [
("supplier_id", sa.Integer(), {}),
("extension", sa.String(length=20), {}),
("whatsapp", sa.String(length=40), {}),
("area", sa.String(length=120), {}),
("receives_quotes", sa.Boolean(), {"nullable": False, "server_default": sa.text("false")}),
("receives_invoices", sa.Boolean(), {"nullable": False, "server_default": sa.text("false")}),
("receives_commercial_info", sa.Boolean(), {"nullable": False, "server_default": sa.text("false")}),
("status", sa.String(length=20), {"nullable": False, "server_default": sa.text("'active'")}),
]
def upgrade() -> None:
# ----- suppliers (Proveedores) -----
op.create_table(
"suppliers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("trade_name", sa.String(length=255), nullable=True),
sa.Column("rfc", sa.String(length=13), nullable=True),
sa.Column("curp", sa.String(length=18), nullable=True),
sa.Column("person_type", sa.String(length=10), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'active'")),
sa.Column("classifications", sa.JSON(), nullable=True),
sa.Column("services_offered", sa.Text(), nullable=True),
sa.Column("coverage", sa.String(length=20), nullable=True),
sa.Column("countries", sa.JSON(), nullable=True),
sa.Column("ports", sa.JSON(), nullable=True),
sa.Column("airports", sa.JSON(), nullable=True),
sa.Column("customs", sa.JSON(), nullable=True),
sa.Column("business_hours", sa.String(length=255), nullable=True),
sa.Column("quote_currency", sa.String(length=3), nullable=True),
sa.Column("avg_response_time", sa.String(length=120), nullable=True),
sa.Column("commercial_notes", sa.Text(), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("phone", sa.String(length=40), nullable=True),
sa.Column("website", sa.String(length=255), nullable=True),
sa.Column("tax_regime", sa.String(length=120), nullable=True),
sa.Column("payment_method", sa.String(length=60), nullable=True),
sa.Column("payment_form", sa.String(length=60), nullable=True),
sa.Column("credit_limit", sa.Numeric(precision=14, scale=2), nullable=True),
sa.Column("credit_days", sa.Integer(), nullable=True),
sa.Column("commercial_terms", sa.Text(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("internal_notes", sa.Text(), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
schema=SCHEMA,
)
_scoped_indexes("suppliers")
op.create_index("ix_crm_suppliers_rfc", "suppliers", ["rfc"], schema=SCHEMA)
op.create_index("ix_crm_suppliers_owner_user_id", "suppliers", ["owner_user_id"], schema=SCHEMA)
# ----- enrich accounts -----
for name, coltype, kwargs in _ACCOUNT_COLUMNS:
op.add_column("accounts", sa.Column(name, coltype, **kwargs), schema=SCHEMA)
op.create_index("ix_crm_accounts_record_type", "accounts", ["record_type"], schema=SCHEMA)
# ----- enrich contacts -----
for name, coltype, kwargs in _CONTACT_COLUMNS:
op.add_column("contacts", sa.Column(name, coltype, **kwargs), schema=SCHEMA)
op.create_index("ix_crm_contacts_supplier_id", "contacts", ["supplier_id"], schema=SCHEMA)
op.create_foreign_key(
"contacts_supplier_id_fkey", "contacts", "suppliers",
["supplier_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA,
)
# ----- addresses -----
op.create_table(
"addresses",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("supplier_id", sa.Integer(), nullable=True),
sa.Column("address_type", sa.String(length=20), nullable=False, server_default=sa.text("'fiscal'")),
sa.Column("street", sa.String(length=255), nullable=True),
sa.Column("ext_number", sa.String(length=30), nullable=True),
sa.Column("int_number", sa.String(length=30), nullable=True),
sa.Column("neighborhood", sa.String(length=120), nullable=True),
sa.Column("postal_code", sa.String(length=10), nullable=True),
sa.Column("city", sa.String(length=120), nullable=True),
sa.Column("state", sa.String(length=120), nullable=True),
sa.Column("country", sa.String(length=2), nullable=True, server_default=sa.text("'MX'")),
sa.Column("reference_notes", sa.Text(), nullable=True),
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.text("false")),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["account_id"], [f"{SCHEMA}.accounts.id"]),
sa.ForeignKeyConstraint(["supplier_id"], [f"{SCHEMA}.suppliers.id"]),
schema=SCHEMA,
)
_scoped_indexes("addresses")
op.create_index("ix_crm_addresses_account_id", "addresses", ["account_id"], schema=SCHEMA)
op.create_index("ix_crm_addresses_supplier_id", "addresses", ["supplier_id"], schema=SCHEMA)
# ----- documents -----
op.create_table(
"documents",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("supplier_id", sa.Integer(), nullable=True),
sa.Column("doc_type", sa.String(length=60), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("file_key", sa.String(length=512), nullable=True),
sa.Column("file_url", sa.String(length=1024), nullable=True),
sa.Column("content_type", sa.String(length=120), nullable=True),
sa.Column("size_bytes", sa.Integer(), nullable=True),
sa.Column("uploaded_by", sa.String(length=64), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["account_id"], [f"{SCHEMA}.accounts.id"]),
sa.ForeignKeyConstraint(["supplier_id"], [f"{SCHEMA}.suppliers.id"]),
schema=SCHEMA,
)
_scoped_indexes("documents")
op.create_index("ix_crm_documents_account_id", "documents", ["account_id"], schema=SCHEMA)
op.create_index("ix_crm_documents_supplier_id", "documents", ["supplier_id"], schema=SCHEMA)
def downgrade() -> None:
op.drop_table("documents", schema=SCHEMA)
op.drop_table("addresses", schema=SCHEMA)
op.drop_constraint("contacts_supplier_id_fkey", "contacts", schema=SCHEMA, type_="foreignkey")
for name, _coltype, _kwargs in _CONTACT_COLUMNS:
op.drop_column("contacts", name, schema=SCHEMA)
op.drop_index("ix_crm_accounts_record_type", table_name="accounts", schema=SCHEMA)
for name, _coltype, _kwargs in _ACCOUNT_COLUMNS:
op.drop_column("accounts", name, schema=SCHEMA)
op.drop_table("suppliers", schema=SCHEMA)

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

270
backend/seed_crm.py Normal file
View File

@@ -0,0 +1,270 @@
"""Seed de datos para el CRM en desarrollo.
Idempotente: se puede correr varias veces sin duplicar. Puebla:
1. Tenant dev (core.tenants id=1) — requerido por la FK tenant_id de las tablas crm.
2. Embudo por defecto "Ventas" + 6 etapas (catálogo del pipeline).
3. Datos de ejemplo: cuentas, contactos, prospectos, oportunidades y actividades.
Ejecutar dentro del contenedor backend:
docker compose exec backend python seed_crm.py
Los datos usan valores dummy (RFC XAXX010101000, etc.); no incluir datos reales.
"""
from datetime import datetime, timedelta, timezone
from api.v1.modules.core.tenants.models import Tenant, TenantType
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.activities.models import Activity
from api.v1.modules.crm.addresses.models import Address
from api.v1.modules.crm.contacts.models import Contact
from api.v1.modules.crm.documents.models import Document
from api.v1.modules.crm.leads.models import Lead
from api.v1.modules.crm.opportunities.models import Opportunity
from api.v1.modules.crm.pipelines.models import Pipeline, PipelineStage
from api.v1.modules.crm.suppliers.models import Supplier
from core.database import CoreSessionLocal
# Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID
TENANT_ID = 1
COMPANY_ID = 1
RFC_DUMMY = "XAXX010101000" # RFC genérico dummy (política de datos)
STAGE_DEFS = [
# (nombre, probabilidad, is_won, is_lost)
("Prospecto", 10, False, False),
("Contactado", 25, False, False),
("Propuesta", 50, False, False),
("Negociación", 75, False, False),
("Ganada", 100, True, False),
("Perdida", 0, False, True),
]
def ensure_tenant(db) -> Tenant:
tenant = db.query(Tenant).filter(Tenant.id == TENANT_ID).first()
if tenant:
print(f"• Tenant id={TENANT_ID} ya existe ({tenant.name})")
return tenant
tenant = Tenant(
id=TENANT_ID,
name="Aduanasoft (dev)",
slug="dev",
keycloak_realm="master",
type=TenantType.SHARED,
is_active=True,
)
db.add(tenant)
db.commit()
# Alinear la secuencia para que futuros inserts no colisionen con el id explícito
db.execute(
__import__("sqlalchemy").text(
"SELECT setval('core.tenants_id_seq', (SELECT MAX(id) FROM core.tenants))"
)
)
db.commit()
print(f"✓ Tenant dev creado (id={TENANT_ID})")
return tenant
def ensure_pipeline(db):
pipeline = (
db.query(Pipeline)
.filter(
Pipeline.tenant_id == TENANT_ID,
Pipeline.company_id == COMPANY_ID,
Pipeline.deleted_at.is_(None),
)
.first()
)
if pipeline:
stages = (
db.query(PipelineStage)
.filter(
PipelineStage.pipeline_id == pipeline.id,
PipelineStage.deleted_at.is_(None),
)
.order_by(PipelineStage.position.asc())
.all()
)
print(f"• Embudo '{pipeline.name}' ya existe ({len(stages)} etapas)")
return pipeline, stages
pipeline = Pipeline(name="Ventas", is_default=True, tenant_id=TENANT_ID, company_id=COMPANY_ID)
db.add(pipeline)
db.flush()
stages = []
for position, (name, probability, is_won, is_lost) in enumerate(STAGE_DEFS):
stage = PipelineStage(
pipeline_id=pipeline.id,
name=name,
position=position,
probability=probability,
is_won=is_won,
is_lost=is_lost,
tenant_id=TENANT_ID,
company_id=COMPANY_ID,
)
db.add(stage)
stages.append(stage)
db.commit()
for stage in stages:
db.refresh(stage)
print(f"✓ Embudo 'Ventas' + {len(stages)} etapas creados")
return pipeline, stages
def seed_sample_data(db, pipeline, stages) -> None:
has_accounts = (
db.query(Account)
.filter(
Account.tenant_id == TENANT_ID,
Account.company_id == COMPANY_ID,
Account.deleted_at.is_(None),
)
.first()
)
if has_accounts:
print("• Ya existen cuentas; se omiten los datos de ejemplo")
return
accounts_data = [
dict(name="Maquiladora del Norte SA de CV", trade_name="MaqNorte", rfc=RFC_DUMMY,
account_type="immex", status="active", city="Tijuana", state="Baja California",
email="contacto@ejemplo.mx", phone="6640000000"),
dict(name="Importadora Pacífico SA de CV", trade_name="ImpPacífico", rfc=RFC_DUMMY,
account_type="importador", status="active", city="Manzanillo", state="Colima"),
dict(name="Agencia Aduanal López y Asociados", account_type="agencia_aduanal",
patente_aduanal="0000", status="active", city="Nuevo Laredo", state="Tamaulipas"),
dict(name="Transportes Frontera", account_type="transportista", status="prospect",
city="Ciudad Juárez", state="Chihuahua"),
]
accounts = [Account(**d, tenant_id=TENANT_ID, company_id=COMPANY_ID) for d in accounts_data]
db.add_all(accounts)
db.flush()
contacts_data = [
dict(account=accounts[0], first_name="María", last_name="Pérez",
job_title="Gerente de Comercio Exterior", email="maria@ejemplo.mx",
phone="6640000001", is_primary=True),
dict(account=accounts[1], first_name="Jorge", last_name="Ramírez",
job_title="Director de Logística", email="jorge@ejemplo.mx", is_primary=True),
dict(account=accounts[2], first_name="Luis", last_name="López",
job_title="Agente Aduanal", email="luis@ejemplo.mx", is_primary=True),
]
for d in contacts_data:
account = d.pop("account")
db.add(Contact(**d, account_id=account.id, tenant_id=TENANT_ID, company_id=COMPANY_ID))
leads_data = [
dict(name="Interés en módulo de pedimentos", company_name="Comercializadora Bajío",
contact_name="Ana Torres", email="ana@ejemplo.mx", source="web", status="new",
estimated_value=45000),
dict(name="Demo solicitada Anexo 24", company_name="Ensambles del Golfo",
contact_name="Pedro Gómez", source="evento", status="contacted",
estimated_value=80000),
]
for d in leads_data:
db.add(Lead(**d, tenant_id=TENANT_ID, company_id=COMPANY_ID))
open_stages = [s for s in stages if not s.is_won and not s.is_lost] or stages
opportunities_data = [
dict(name="Licencia Anexo 76 - MaqNorte", account=accounts[0], amount=150000, stage=open_stages[0]),
dict(name="Suite completa - Importadora Pacífico", account=accounts[1], amount=320000, stage=open_stages[min(1, len(open_stages) - 1)]),
dict(name="Módulo de saldos - López y Asociados", account=accounts[2], amount=90000, stage=open_stages[min(2, len(open_stages) - 1)]),
dict(name="Renovación anual - MaqNorte", account=accounts[0], amount=60000, stage=open_stages[min(3, len(open_stages) - 1)]),
]
for d in opportunities_data:
account = d.pop("account")
stage = d.pop("stage")
db.add(Opportunity(
**d, account_id=account.id, pipeline_id=pipeline.id, stage_id=stage.id,
probability=stage.probability, currency="MXN", status="open",
tenant_id=TENANT_ID, company_id=COMPANY_ID,
))
now = datetime.now(timezone.utc)
activities_data = [
dict(activity_type="call", subject="Llamada de seguimiento MaqNorte", status="pending",
due_date=now + timedelta(days=1), account=accounts[0]),
dict(activity_type="meeting", subject="Demo Importadora Pacífico", status="pending",
due_date=now + timedelta(days=3), account=accounts[1]),
dict(activity_type="task", subject="Enviar cotización a López y Asociados",
status="completed", completed_at=now, account=accounts[2]),
]
for d in activities_data:
account = d.pop("account")
db.add(Activity(**d, account_id=account.id, tenant_id=TENANT_ID, company_id=COMPANY_ID))
db.commit()
print("✓ Datos de ejemplo: 4 cuentas, 3 contactos, 2 prospectos, 4 oportunidades, 3 actividades")
def seed_suppliers_and_related(db) -> None:
"""Proveedores + direcciones/documentos/contactos (clientes y proveedores)."""
if db.query(Supplier).filter(Supplier.tenant_id == TENANT_ID, Supplier.company_id == COMPANY_ID, Supplier.deleted_at.is_(None)).first():
print("• Ya existen proveedores; se omite el seed de catálogos")
return
suppliers_data = [
dict(name="Naviera del Golfo SA de CV", trade_name="NavGolfo", rfc=RFC_DUMMY,
classifications=["naviera", "agente_carga"], coverage="internacional",
countries=["MX", "US", "PA"], ports=["Veracruz", "Manzanillo"],
quote_currency="USD", status="active"),
dict(name="Agencia Aduanal Reyes y Asociados", rfc=RFC_DUMMY,
classifications=["agente_aduanal"], coverage="nacional",
customs=["Nuevo Laredo", "Colombia"], status="active"),
]
suppliers = [Supplier(**d, tenant_id=TENANT_ID, company_id=COMPANY_ID) for d in suppliers_data]
db.add_all(suppliers)
db.flush()
first_account = (
db.query(Account)
.filter(Account.tenant_id == TENANT_ID, Account.company_id == COMPANY_ID, Account.deleted_at.is_(None))
.order_by(Account.id.asc())
.first()
)
# Direcciones (múltiples): proveedor + cliente
db.add(Address(supplier_id=suppliers[0].id, address_type="oficina", street="Malecón 100",
neighborhood="Centro", postal_code="91700", city="Veracruz", state="Veracruz",
is_primary=True, tenant_id=TENANT_ID, company_id=COMPANY_ID))
if first_account:
db.add(Address(account_id=first_account.id, address_type="fiscal", street="Blvd. Industrial 500",
neighborhood="Otay", postal_code="22000", city="Tijuana", state="Baja California",
is_primary=True, tenant_id=TENANT_ID, company_id=COMPANY_ID))
db.add(Address(account_id=first_account.id, address_type="bodega", street="Camino a la Presa 12",
city="Tijuana", state="Baja California", tenant_id=TENANT_ID, company_id=COMPANY_ID))
# Documentos
db.add(Document(supplier_id=suppliers[0].id, doc_type="constancia_fiscal",
name="Constancia de Situación Fiscal - NavGolfo", tenant_id=TENANT_ID, company_id=COMPANY_ID))
if first_account:
db.add(Document(account_id=first_account.id, doc_type="acta_constitutiva",
name="Acta Constitutiva - MaqNorte", tenant_id=TENANT_ID, company_id=COMPANY_ID))
# Contacto de proveedor (con área y flags)
db.add(Contact(supplier_id=suppliers[0].id, first_name="Rosa", last_name="Díaz", area="Ventas",
job_title="Ejecutiva de cuenta", email="rosa@ejemplo.mx", phone="2290000000",
is_primary=True, receives_quotes=True, tenant_id=TENANT_ID, company_id=COMPANY_ID))
db.commit()
print("✓ 2 proveedores, 3 direcciones, 2 documentos y 1 contacto de proveedor")
def main() -> None:
db = CoreSessionLocal()
try:
ensure_tenant(db)
pipeline, stages = ensure_pipeline(db)
seed_sample_data(db, pipeline, stages)
seed_suppliers_and_related(db)
print("\nSeed CRM completado.")
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -27,10 +27,13 @@ from core.database import Base # noqa: E402
# Importar los modelos registra sus tablas en Base.metadata
import api.v1.modules.crm.accounts.models # noqa: E402,F401
import api.v1.modules.crm.activities.models # noqa: E402,F401
import api.v1.modules.crm.addresses.models # noqa: E402,F401
import api.v1.modules.crm.contacts.models # noqa: E402,F401
import api.v1.modules.crm.documents.models # noqa: E402,F401
import api.v1.modules.crm.leads.models # noqa: E402,F401
import api.v1.modules.crm.opportunities.models # noqa: E402,F401
import api.v1.modules.crm.pipelines.models # noqa: E402,F401
import api.v1.modules.crm.suppliers.models # noqa: E402,F401
_SCHEMA_MAP = {"crm": None, "core": None}

View File

@@ -0,0 +1,41 @@
import pytest
from fastapi import HTTPException
from api.v1.modules.crm.accounts import service as accounts_service
from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.crm.addresses import service
from api.v1.modules.crm.addresses.dto import AddressCreate
from api.v1.modules.crm.suppliers import service as suppliers_service
from api.v1.modules.crm.suppliers.dto import SupplierCreate
T, C = 1, 1
def test_address_requires_exactly_one_owner(db):
# Ni cliente ni proveedor
with pytest.raises(HTTPException) as exc:
service.create_address(db, AddressCreate(address_type="fiscal"), T, C)
assert exc.value.status_code == 422
def test_address_rejects_both_owners(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cli"), T, C)
sup = suppliers_service.create_supplier(db, SupplierCreate(name="Prov"), T, C)
with pytest.raises(HTTPException) as exc:
service.create_address(db, AddressCreate(account_id=acc.id, supplier_id=sup.id), T, C)
assert exc.value.status_code == 422
def test_create_and_list_addresses_for_account(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cli"), T, C)
service.create_address(db, AddressCreate(account_id=acc.id, address_type="fiscal", street="Av. Reforma", postal_code="06600", is_primary=True), T, C)
service.create_address(db, AddressCreate(account_id=acc.id, address_type="bodega", city="Tijuana"), T, C)
addrs = service.get_addresses(db, T, C, account_id=acc.id)
assert len(addrs) == 2
assert addrs[0].is_primary is True # el principal va primero
def test_address_rejects_unknown_owner(db):
with pytest.raises(HTTPException) as exc:
service.create_address(db, AddressCreate(account_id=999, address_type="fiscal"), T, C)
assert exc.value.status_code == 422

View File

@@ -0,0 +1,38 @@
import pytest
from fastapi import HTTPException
from api.v1.modules.crm.accounts import service as accounts_service
from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.crm.documents import service
from api.v1.modules.crm.documents.dto import DocumentCreate
from api.v1.modules.crm.suppliers import service as suppliers_service
from api.v1.modules.crm.suppliers.dto import SupplierCreate
T, C = 1, 1
def test_create_document_for_supplier(db):
sup = suppliers_service.create_supplier(db, SupplierCreate(name="Prov"), T, C)
doc = service.create_document(
db,
DocumentCreate(supplier_id=sup.id, doc_type="constancia_fiscal",
name="Constancia SF 2026", file_url="https://ejemplo.mx/csf.pdf"),
T, C, user_id="dev",
)
assert doc.id is not None
assert doc.uploaded_by == "dev"
assert service.get_documents(db, T, C, supplier_id=sup.id)[0].name == "Constancia SF 2026"
def test_create_document_for_account(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cli"), T, C)
doc = service.create_document(
db, DocumentCreate(account_id=acc.id, doc_type="acta_constitutiva", name="Acta"), T, C
)
assert doc.account_id == acc.id
def test_document_requires_one_owner(db):
with pytest.raises(HTTPException) as exc:
service.create_document(db, DocumentCreate(doc_type="otro", name="Suelto"), T, C)
assert exc.value.status_code == 422

View File

@@ -0,0 +1,51 @@
import pytest
from fastapi import HTTPException
from api.v1.modules.crm.suppliers import service
from api.v1.modules.crm.suppliers.dto import SupplierCreate, SupplierUpdate
T, C = 1, 1
def test_create_supplier_with_multivalue_fields(db):
supplier = service.create_supplier(
db,
SupplierCreate(
name="Naviera del Golfo SA",
classifications=["naviera", "agente_carga"],
coverage="internacional",
countries=["MX", "US", "PA"],
ports=["Veracruz", "Manzanillo"],
customs=["Veracruz"],
quote_currency="USD",
),
T, C, user_id="dev",
)
assert supplier.id is not None
assert supplier.status == "active"
assert supplier.classifications == ["naviera", "agente_carga"]
assert supplier.countries == ["MX", "US", "PA"]
assert supplier.created_by == "dev"
def test_list_and_search_supplier(db):
service.create_supplier(db, SupplierCreate(name="Aerolínea Carga MX", classifications=["aerolinea"]), T, C)
service.create_supplier(db, SupplierCreate(name="Transportes Terrestres"), T, C)
assert len(service.get_suppliers(db, T, C)) == 2
found = service.get_suppliers(db, T, C, search="aero")
assert len(found) == 1 and found[0].name == "Aerolínea Carga MX"
def test_update_supplier_replaces_lists(db):
s = service.create_supplier(db, SupplierCreate(name="X", classifications=["naviera"]), T, C)
upd = service.update_supplier(db, s.id, SupplierUpdate(classifications=["ferrocarril", "almacen"]), T, C)
assert upd.classifications == ["ferrocarril", "almacen"]
def test_soft_delete_supplier(db):
s = service.create_supplier(db, SupplierCreate(name="Y"), T, C)
service.delete_supplier(db, s.id, T, C)
with pytest.raises(HTTPException) as exc:
service.get_supplier(db, s.id, T, C)
assert exc.value.status_code == 404
assert service.get_suppliers(db, T, C) == []