Diagrama 1 (CRM comercial) y Diagrama 2 (Operaciones) del spec de agente de carga: - crm.service_requests (RFQ) + crm.rate_requests (solicitud de tarifas a proveedores) - crm.quotes + crm.quote_items: conceptos costo/venta/margen, totales automáticos, estados borrador→enviada→aceptada/rechazada - schema ops: ops.shipments (booking, Cut Off, ETD/ETA, naviera/agente aduanal/destino) y ops.shipment_documents (MBL/HBL, MAWB/HAWB, CMR…) - liberar-a-operaciones: crea el embarque desde la cotización aceptada - migración b3c4d5e6f7a8, routers/permisos, seed del flujo completo - 52 tests pytest en verde Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
341 lines
14 KiB
Python
341 lines
14 KiB
Python
"""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.quotes.models import Quote, QuoteItem
|
|
from api.v1.modules.crm.service_requests.models import ServiceRequest
|
|
from api.v1.modules.crm.suppliers.models import Supplier
|
|
from api.v1.modules.ops.shipments.models import Shipment, ShipmentDocument
|
|
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 seed_commercial_and_ops(db) -> None:
|
|
"""Demo del flujo comercial: Solicitud → Cotización (aceptada) → Embarque liberado."""
|
|
if db.query(ServiceRequest).filter(
|
|
ServiceRequest.tenant_id == TENANT_ID, ServiceRequest.company_id == COMPANY_ID,
|
|
ServiceRequest.deleted_at.is_(None),
|
|
).first():
|
|
print("• Ya existe flujo comercial; se omite")
|
|
return
|
|
|
|
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()
|
|
)
|
|
account_id = account.id if account else None
|
|
|
|
# 1. Solicitud de servicio (RFQ)
|
|
sr = ServiceRequest(
|
|
reference="SOL-0001", account_id=account_id, operation_type="exportacion",
|
|
transport_mode="maritimo", service_type="puerta_puerta", incoterm="FOB",
|
|
origin="Manzanillo, MX", destination="Long Beach, US", cargo_type="Carga general",
|
|
load_type="FCL", container_equipment="1x40'HC", status="cotizada",
|
|
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
|
)
|
|
db.add(sr)
|
|
db.flush()
|
|
|
|
# 2. Cotización aceptada con conceptos
|
|
quote = Quote(
|
|
reference="COT-0001", service_request_id=sr.id, account_id=account_id, currency="USD",
|
|
status="aceptada", tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
|
)
|
|
db.add(quote)
|
|
db.flush()
|
|
items = [
|
|
("flete_internacional", 1, 1800, 2200),
|
|
("transporte_terrestre", 1, 350, 500),
|
|
("despacho_aduanal", 1, 200, 320),
|
|
]
|
|
total_cost = total_sale = 0
|
|
for concept, qty, cost, sale in items:
|
|
db.add(QuoteItem(quote_id=quote.id, concept=concept, quantity=qty, unit_cost=cost,
|
|
unit_sale=sale, currency="USD", tenant_id=TENANT_ID, company_id=COMPANY_ID))
|
|
total_cost += qty * cost
|
|
total_sale += qty * sale
|
|
quote.total_cost = total_cost
|
|
quote.total_sale = total_sale
|
|
sr.status = "liberada"
|
|
|
|
# 3. Embarque liberado a Operaciones
|
|
shipment = Shipment(
|
|
reference="EMB-0001", quote_id=quote.id, service_request_id=sr.id, account_id=account_id,
|
|
operation_type=sr.operation_type, transport_mode=sr.transport_mode, service_type=sr.service_type,
|
|
incoterm=sr.incoterm, origin=sr.origin, destination=sr.destination,
|
|
status="booking", booking_number="BKG-778812", tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
|
)
|
|
db.add(shipment)
|
|
db.flush()
|
|
db.add(ShipmentDocument(shipment_id=shipment.id, doc_kind="master", doc_type="MBL",
|
|
number="MBLU12345678", tenant_id=TENANT_ID, company_id=COMPANY_ID))
|
|
|
|
db.commit()
|
|
print("✓ Flujo comercial: 1 solicitud, 1 cotización aceptada (3 conceptos), 1 embarque + documento MBL")
|
|
|
|
|
|
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)
|
|
seed_commercial_and_ops(db)
|
|
print("\nSeed CRM completado.")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|