From b12af1a5612cb5d95434883d6dc035799825569c Mon Sep 17 00:00:00 2001 From: Aduanasoft Date: Tue, 14 Jul 2026 16:19:13 -0600 Subject: [PATCH] =?UTF-8?q?feat(crm):=20cat=C3=A1logos=20Clientes/Prospect?= =?UTF-8?q?os=20(enriquecido)=20y=20Proveedores=20+=20direcciones/document?= =?UTF-8?q?os?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../versions/a7b8c9d0e1f2_crm_catalogs.py | 195 +++++++++++++ backend/api/v1/modules/crm/accounts/dto.py | 80 ++++-- backend/api/v1/modules/crm/accounts/models.py | 66 +++-- backend/api/v1/modules/crm/accounts/routes.py | 13 +- .../api/v1/modules/crm/accounts/service.py | 27 +- .../api/v1/modules/crm/addresses/__init__.py | 0 backend/api/v1/modules/crm/addresses/dto.py | 47 +++ .../api/v1/modules/crm/addresses/models.py | 35 +++ .../api/v1/modules/crm/addresses/routes.py | 56 ++++ .../api/v1/modules/crm/addresses/service.py | 96 +++++++ backend/api/v1/modules/crm/contacts/dto.py | 47 +-- backend/api/v1/modules/crm/contacts/models.py | 21 +- backend/api/v1/modules/crm/contacts/routes.py | 5 +- .../api/v1/modules/crm/contacts/service.py | 63 ++-- .../api/v1/modules/crm/documents/__init__.py | 0 backend/api/v1/modules/crm/documents/dto.py | 38 +++ .../api/v1/modules/crm/documents/models.py | 33 +++ .../api/v1/modules/crm/documents/routes.py | 57 ++++ .../api/v1/modules/crm/documents/service.py | 100 +++++++ backend/api/v1/modules/crm/permissions.py | 5 +- backend/api/v1/modules/crm/router.py | 6 + .../api/v1/modules/crm/suppliers/__init__.py | 0 backend/api/v1/modules/crm/suppliers/dto.py | 88 ++++++ .../api/v1/modules/crm/suppliers/models.py | 62 ++++ .../api/v1/modules/crm/suppliers/routes.py | 71 +++++ .../api/v1/modules/crm/suppliers/service.py | 86 ++++++ backend/seed_crm.py | 270 ++++++++++++++++++ backend/tests/conftest.py | 3 + backend/tests/test_addresses.py | 41 +++ backend/tests/test_documents.py | 38 +++ backend/tests/test_suppliers.py | 51 ++++ 31 files changed, 1599 insertions(+), 101 deletions(-) create mode 100644 backend/alembic/versions/a7b8c9d0e1f2_crm_catalogs.py create mode 100644 backend/api/v1/modules/crm/addresses/__init__.py create mode 100644 backend/api/v1/modules/crm/addresses/dto.py create mode 100644 backend/api/v1/modules/crm/addresses/models.py create mode 100644 backend/api/v1/modules/crm/addresses/routes.py create mode 100644 backend/api/v1/modules/crm/addresses/service.py create mode 100644 backend/api/v1/modules/crm/documents/__init__.py create mode 100644 backend/api/v1/modules/crm/documents/dto.py create mode 100644 backend/api/v1/modules/crm/documents/models.py create mode 100644 backend/api/v1/modules/crm/documents/routes.py create mode 100644 backend/api/v1/modules/crm/documents/service.py create mode 100644 backend/api/v1/modules/crm/suppliers/__init__.py create mode 100644 backend/api/v1/modules/crm/suppliers/dto.py create mode 100644 backend/api/v1/modules/crm/suppliers/models.py create mode 100644 backend/api/v1/modules/crm/suppliers/routes.py create mode 100644 backend/api/v1/modules/crm/suppliers/service.py create mode 100644 backend/seed_crm.py create mode 100644 backend/tests/test_addresses.py create mode 100644 backend/tests/test_documents.py create mode 100644 backend/tests/test_suppliers.py diff --git a/backend/alembic/versions/a7b8c9d0e1f2_crm_catalogs.py b/backend/alembic/versions/a7b8c9d0e1f2_crm_catalogs.py new file mode 100644 index 0000000..a38c09d --- /dev/null +++ b/backend/alembic/versions/a7b8c9d0e1f2_crm_catalogs.py @@ -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) diff --git a/backend/api/v1/modules/crm/accounts/dto.py b/backend/api/v1/modules/crm/accounts/dto.py index 771bd9f..b7aa564 100644 --- a/backend/api/v1/modules/crm/accounts/dto.py +++ b/backend/api/v1/modules/crm/accounts/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/accounts/models.py b/backend/api/v1/modules/crm/accounts/models.py index 2f6b0a5..9f23d46 100644 --- a/backend/api/v1/modules/crm/accounts/models.py +++ b/backend/api/v1/modules/crm/accounts/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/accounts/routes.py b/backend/api/v1/modules/crm/accounts/routes.py index 08105ef..9c069c8 100644 --- a/backend/api/v1/modules/crm/accounts/routes.py +++ b/backend/api/v1/modules/crm/accounts/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/accounts/service.py b/backend/api/v1/modules/crm/accounts/service.py index 79efc0e..617edc6 100644 --- a/backend/api/v1/modules/crm/accounts/service.py +++ b/backend/api/v1/modules/crm/accounts/service.py @@ -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() diff --git a/backend/api/v1/modules/crm/addresses/__init__.py b/backend/api/v1/modules/crm/addresses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/addresses/dto.py b/backend/api/v1/modules/crm/addresses/dto.py new file mode 100644 index 0000000..e04ec29 --- /dev/null +++ b/backend/api/v1/modules/crm/addresses/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/addresses/models.py b/backend/api/v1/modules/crm/addresses/models.py new file mode 100644 index 0000000..9cf7537 --- /dev/null +++ b/backend/api/v1/modules/crm/addresses/models.py @@ -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")) diff --git a/backend/api/v1/modules/crm/addresses/routes.py b/backend/api/v1/modules/crm/addresses/routes.py new file mode 100644 index 0000000..06dab8d --- /dev/null +++ b/backend/api/v1/modules/crm/addresses/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/addresses/service.py b/backend/api/v1/modules/crm/addresses/service.py new file mode 100644 index 0000000..3d5c36b --- /dev/null +++ b/backend/api/v1/modules/crm/addresses/service.py @@ -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() diff --git a/backend/api/v1/modules/crm/contacts/dto.py b/backend/api/v1/modules/crm/contacts/dto.py index 9855a8c..8fd15e8 100644 --- a/backend/api/v1/modules/crm/contacts/dto.py +++ b/backend/api/v1/modules/crm/contacts/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/contacts/models.py b/backend/api/v1/modules/crm/contacts/models.py index a04f0cf..972fe6c 100644 --- a/backend/api/v1/modules/crm/contacts/models.py +++ b/backend/api/v1/modules/crm/contacts/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/contacts/routes.py b/backend/api/v1/modules/crm/contacts/routes.py index 7362bc1..8d1cd2d 100644 --- a/backend/api/v1/modules/crm/contacts/routes.py +++ b/backend/api/v1/modules/crm/contacts/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/contacts/service.py b/backend/api/v1/modules/crm/contacts/service.py index 5089fec..9603f7d 100644 --- a/backend/api/v1/modules/crm/contacts/service.py +++ b/backend/api/v1/modules/crm/contacts/service.py @@ -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() diff --git a/backend/api/v1/modules/crm/documents/__init__.py b/backend/api/v1/modules/crm/documents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/documents/dto.py b/backend/api/v1/modules/crm/documents/dto.py new file mode 100644 index 0000000..21ffbb7 --- /dev/null +++ b/backend/api/v1/modules/crm/documents/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/documents/models.py b/backend/api/v1/modules/crm/documents/models.py new file mode 100644 index 0000000..b07b20d --- /dev/null +++ b/backend/api/v1/modules/crm/documents/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/documents/routes.py b/backend/api/v1/modules/crm/documents/routes.py new file mode 100644 index 0000000..cf8c684 --- /dev/null +++ b/backend/api/v1/modules/crm/documents/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/documents/service.py b/backend/api/v1/modules/crm/documents/service.py new file mode 100644 index 0000000..563b9e2 --- /dev/null +++ b/backend/api/v1/modules/crm/documents/service.py @@ -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() diff --git a/backend/api/v1/modules/crm/permissions.py b/backend/api/v1/modules/crm/permissions.py index 4a930df..0825d06 100644 --- a/backend/api/v1/modules/crm/permissions.py +++ b/backend/api/v1/modules/crm/permissions.py @@ -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"), diff --git a/backend/api/v1/modules/crm/router.py b/backend/api/v1/modules/crm/router.py index c4e34e4..1250cf0 100644 --- a/backend/api/v1/modules/crm/router.py +++ b/backend/api/v1/modules/crm/router.py @@ -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) diff --git a/backend/api/v1/modules/crm/suppliers/__init__.py b/backend/api/v1/modules/crm/suppliers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/suppliers/dto.py b/backend/api/v1/modules/crm/suppliers/dto.py new file mode 100644 index 0000000..8a0d9b6 --- /dev/null +++ b/backend/api/v1/modules/crm/suppliers/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/suppliers/models.py b/backend/api/v1/modules/crm/suppliers/models.py new file mode 100644 index 0000000..3108be4 --- /dev/null +++ b/backend/api/v1/modules/crm/suppliers/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/suppliers/routes.py b/backend/api/v1/modules/crm/suppliers/routes.py new file mode 100644 index 0000000..64d5754 --- /dev/null +++ b/backend/api/v1/modules/crm/suppliers/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/suppliers/service.py b/backend/api/v1/modules/crm/suppliers/service.py new file mode 100644 index 0000000..f67124b --- /dev/null +++ b/backend/api/v1/modules/crm/suppliers/service.py @@ -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() diff --git a/backend/seed_crm.py b/backend/seed_crm.py new file mode 100644 index 0000000..2ad374e --- /dev/null +++ b/backend/seed_crm.py @@ -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() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 244f2fc..f2ff212 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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} diff --git a/backend/tests/test_addresses.py b/backend/tests/test_addresses.py new file mode 100644 index 0000000..eadd54d --- /dev/null +++ b/backend/tests/test_addresses.py @@ -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 diff --git a/backend/tests/test_documents.py b/backend/tests/test_documents.py new file mode 100644 index 0000000..c13372d --- /dev/null +++ b/backend/tests/test_documents.py @@ -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 diff --git a/backend/tests/test_suppliers.py b/backend/tests/test_suppliers.py new file mode 100644 index 0000000..aa5d2ee --- /dev/null +++ b/backend/tests/test_suppliers.py @@ -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) == []