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:
195
backend/alembic/versions/a7b8c9d0e1f2_crm_catalogs.py
Normal file
195
backend/alembic/versions/a7b8c9d0e1f2_crm_catalogs.py
Normal 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)
|
||||
@@ -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,14 +4,15 @@ 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
|
||||
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(
|
||||
@@ -25,7 +26,24 @@ def _validate_account(db: Session, account_id: int | None, tenant_id: int, compa
|
||||
if not exists:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="La cuenta asociada no existe",
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
270
backend/seed_crm.py
Normal file
270
backend/seed_crm.py
Normal 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()
|
||||
@@ -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}
|
||||
|
||||
|
||||
41
backend/tests/test_addresses.py
Normal file
41
backend/tests/test_addresses.py
Normal 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
|
||||
38
backend/tests/test_documents.py
Normal file
38
backend/tests/test_documents.py
Normal 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
|
||||
51
backend/tests/test_suppliers.py
Normal file
51
backend/tests/test_suppliers.py
Normal 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) == []
|
||||
Reference in New Issue
Block a user