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

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

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

270
backend/seed_crm.py Normal file
View File

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