"""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 api.v1.modules.ops.shipments import service as shipments_service from api.v1.modules.fin.invoices.models import Invoice from api.v1.modules.fin.invoices import service as invoices_service from api.v1.modules.fin.invoices.dto import PaymentCreate 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 seed_invoicing_and_events(db) -> None: """Factura desde el embarque (con pago parcial) + hitos de la bitácora.""" from decimal import Decimal if db.query(Invoice).filter( Invoice.tenant_id == TENANT_ID, Invoice.company_id == COMPANY_ID, Invoice.deleted_at.is_(None) ).first(): print("• Ya existe facturación; se omite") return shipment = ( db.query(Shipment) .filter(Shipment.tenant_id == TENANT_ID, Shipment.company_id == COMPANY_ID, Shipment.deleted_at.is_(None)) .order_by(Shipment.id.asc()) .first() ) if not shipment: print("• Sin embarque; se omite facturación") return # Bitácora de hitos (según tipo de operación) shipments_service.seed_default_milestones(db, shipment.id, TENANT_ID, COMPANY_ID) # Factura generada desde el embarque (toma conceptos de la cotización) invoice = invoices_service.generate_from_shipment(db, shipment.id, TENANT_ID, COMPANY_ID) invoices_service.emit_invoice(db, invoice.id, TENANT_ID, COMPANY_ID) invoices_service.create_payment( db, PaymentCreate(invoice_id=invoice.id, amount=Decimal("1000"), method="transferencia", reference="SPEI-001"), TENANT_ID, COMPANY_ID, ) print("✓ Factura emitida desde el embarque con pago parcial + hitos de la bitácora") 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) seed_invoicing_and_events(db) print("\nSeed CRM completado.") finally: db.close() if __name__ == "__main__": main()