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