forked from sagnik/Project_Velocity
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#41
851 lines
34 KiB
Python
851 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Seed realistic, idempotent iPad investor-demo data into the current operator tenant.
|
|
|
|
The script writes only canonical Velocity domains used by the iPad app:
|
|
crm_*, intel_*, workflow_*, inventory_*, mobile-edge events, and calendar events.
|
|
Rows are tagged with metadata_json/source identifiers so they are auditable and safe
|
|
to re-run without duplication.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
try:
|
|
import asyncpg
|
|
except ModuleNotFoundError: # pragma: no cover - exercised by operator environment
|
|
asyncpg = None # type: ignore[assignment]
|
|
|
|
|
|
SEED_SOURCE = "velocity_ipad_investor_demo_2026_04"
|
|
DEFAULT_OPERATOR_EMAIL = "sayan@desineuron.in"
|
|
NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://desineuron.in/project-velocity/ipad-investor-demo")
|
|
|
|
|
|
def _load_env() -> None:
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
for candidate in (repo_root / "backend" / ".env", repo_root / ".env"):
|
|
if candidate.exists():
|
|
load_dotenv(candidate, override=False)
|
|
load_dotenv(override=False)
|
|
|
|
|
|
def _stable_uuid(tenant_id: str, key: str) -> str:
|
|
return str(uuid.uuid5(NAMESPACE, f"{tenant_id}:{key}"))
|
|
|
|
|
|
def _json(value: Any) -> str:
|
|
return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
|
|
|
|
|
|
def _db_kwargs() -> dict[str, Any]:
|
|
if os.getenv("DATABASE_URL"):
|
|
return {"dsn": os.environ["DATABASE_URL"]}
|
|
required = ["VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD"]
|
|
missing = [key for key in required if not os.getenv(key)]
|
|
if missing:
|
|
raise RuntimeError(
|
|
"Missing database configuration: "
|
|
+ ", ".join(missing)
|
|
+ ". Set DATABASE_URL or VELOCITY_DB_* variables before running the seed."
|
|
)
|
|
return {
|
|
"host": os.getenv("VELOCITY_DB_HOST", "localhost"),
|
|
"port": int(os.getenv("VELOCITY_DB_PORT", "5432")),
|
|
"database": os.environ["VELOCITY_DB_NAME"],
|
|
"user": os.environ["VELOCITY_DB_USER"],
|
|
"password": os.environ["VELOCITY_DB_PASSWORD"],
|
|
}
|
|
|
|
|
|
def _expected_counts() -> dict[str, int]:
|
|
return {
|
|
"people": len(DEMO_CLIENTS),
|
|
"leads": len(DEMO_CLIENTS),
|
|
"projects": len(PROJECTS),
|
|
"properties": len(PROJECTS),
|
|
"interests": len(DEMO_CLIENTS),
|
|
"opportunities": len(DEMO_CLIENTS),
|
|
"scores": len(DEMO_CLIENTS) * 3,
|
|
"interactions": len(DEMO_CLIENTS) * 3,
|
|
"edge_events": len(DEMO_CLIENTS),
|
|
"reminders": len(DEMO_CLIENTS),
|
|
"calendar_events": len(DEMO_CLIENTS),
|
|
"import_batches": 1,
|
|
"import_proposals": 3,
|
|
}
|
|
|
|
|
|
async def _resolve_operator(conn: asyncpg.Connection, email: str, tenant_override: str | None) -> tuple[str, str | None]:
|
|
if tenant_override:
|
|
user_id = await conn.fetchval(
|
|
"SELECT id::text FROM users_and_roles WHERE email = $1 AND tenant_id = $2 LIMIT 1",
|
|
email,
|
|
tenant_override,
|
|
)
|
|
return tenant_override, user_id
|
|
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT id::text AS user_id, tenant_id
|
|
FROM users_and_roles
|
|
WHERE email = $1
|
|
AND is_active = TRUE
|
|
ORDER BY last_login DESC NULLS LAST, created_at DESC
|
|
LIMIT 1
|
|
""",
|
|
email,
|
|
)
|
|
if row:
|
|
return row["tenant_id"], row["user_id"]
|
|
tenant_id = os.getenv("VELOCITY_DEFAULT_TENANT_ID", "tenant_velocity")
|
|
return tenant_id, None
|
|
|
|
|
|
DEMO_CLIENTS = [
|
|
{
|
|
"key": "meera-sen",
|
|
"name": "Meera Sen",
|
|
"email": "meera.sen.investor@demo.desineuron.in",
|
|
"phone": "+91-98745-11820",
|
|
"buyer_type": "hni_end_user",
|
|
"city": "Kolkata",
|
|
"nationality": "Indian",
|
|
"persona": ["family_upgrader", "waterfront_preference"],
|
|
"budget": "12-18 Cr",
|
|
"urgency": "high",
|
|
"status": "qualified",
|
|
"project": "Atri Aqua Sky Residences",
|
|
"configuration": "4BHK Sky Villa",
|
|
"unit": "Tower A / High floor",
|
|
"budget_min": 120000000,
|
|
"budget_max": 180000000,
|
|
"stage": "proposal",
|
|
"value": 154000000,
|
|
"probability": 72,
|
|
"next_action": "Share revised payment schedule and club-deck walkthrough slots.",
|
|
"scores": (0.91, 0.86, 0.88),
|
|
},
|
|
{
|
|
"key": "arjun-malhotra",
|
|
"name": "Arjun Malhotra",
|
|
"email": "arjun.malhotra.nri@demo.desineuron.in",
|
|
"phone": "+91-98102-44591",
|
|
"buyer_type": "nri_investor",
|
|
"city": "Dubai",
|
|
"nationality": "Indian",
|
|
"persona": ["nri_portfolio_buyer", "rental_yield_focused"],
|
|
"budget": "8-12 Cr",
|
|
"urgency": "medium",
|
|
"status": "site_visit_scheduled",
|
|
"project": "Alipore Azure Residences",
|
|
"configuration": "3BHK Signature",
|
|
"unit": "Tower B / 21st floor",
|
|
"budget_min": 80000000,
|
|
"budget_max": 120000000,
|
|
"stage": "site_visit",
|
|
"value": 98000000,
|
|
"probability": 61,
|
|
"next_action": "Coordinate Saturday family video walkthrough and NRI remittance checklist.",
|
|
"scores": (0.82, 0.79, 0.63),
|
|
},
|
|
{
|
|
"key": "devika-roy",
|
|
"name": "Devika Roy",
|
|
"email": "devika.roy.familyoffice@demo.desineuron.in",
|
|
"phone": "+91-99033-28741",
|
|
"buyer_type": "family_office",
|
|
"city": "Mumbai",
|
|
"nationality": "Indian",
|
|
"persona": ["portfolio_allocator", "low_visibility_buyer"],
|
|
"budget": "20-30 Cr",
|
|
"urgency": "critical",
|
|
"status": "negotiation",
|
|
"project": "Victoria Gardens Private Residences",
|
|
"configuration": "Penthouse Duplex",
|
|
"unit": "Private elevator stack",
|
|
"budget_min": 200000000,
|
|
"budget_max": 300000000,
|
|
"stage": "negotiation",
|
|
"value": 265000000,
|
|
"probability": 78,
|
|
"next_action": "Prepare founder-level commercial note before family office review.",
|
|
"scores": (0.95, 0.91, 0.96),
|
|
},
|
|
{
|
|
"key": "rohan-kapoor",
|
|
"name": "Rohan Kapoor",
|
|
"email": "rohan.kapoor.startup@demo.desineuron.in",
|
|
"phone": "+91-98311-73654",
|
|
"buyer_type": "founder_buyer",
|
|
"city": "Bengaluru",
|
|
"nationality": "Indian",
|
|
"persona": ["founder_liquidity_event", "design_led_buyer"],
|
|
"budget": "6-10 Cr",
|
|
"urgency": "high",
|
|
"status": "contacted",
|
|
"project": "Salt Lake Atelier Homes",
|
|
"configuration": "3.5BHK Garden Home",
|
|
"unit": "Podium garden facing",
|
|
"budget_min": 60000000,
|
|
"budget_max": 100000000,
|
|
"stage": "qualified",
|
|
"value": 83500000,
|
|
"probability": 54,
|
|
"next_action": "Send design moodboard and schedule Dream Weaver room concept.",
|
|
"scores": (0.76, 0.83, 0.81),
|
|
},
|
|
{
|
|
"key": "saira-hussain",
|
|
"name": "Saira Hussain",
|
|
"email": "saira.hussain.doctor@demo.desineuron.in",
|
|
"phone": "+91-97482-66019",
|
|
"buyer_type": "end_user",
|
|
"city": "Kolkata",
|
|
"nationality": "Indian",
|
|
"persona": ["quiet_luxury", "school_proximity"],
|
|
"budget": "4-6 Cr",
|
|
"urgency": "medium",
|
|
"status": "site_visited",
|
|
"project": "Ballygunge Meridian",
|
|
"configuration": "3BHK",
|
|
"unit": "South-east corner",
|
|
"budget_min": 40000000,
|
|
"budget_max": 60000000,
|
|
"stage": "proposal",
|
|
"value": 52000000,
|
|
"probability": 66,
|
|
"next_action": "Share school-route comparison and revised parking availability.",
|
|
"scores": (0.74, 0.68, 0.62),
|
|
},
|
|
{
|
|
"key": "vikram-jalan",
|
|
"name": "Vikram Jalan",
|
|
"email": "vikram.jalan.broker@demo.desineuron.in",
|
|
"phone": "+91-98300-91274",
|
|
"buyer_type": "broker_referral",
|
|
"city": "Kolkata",
|
|
"nationality": "Indian",
|
|
"persona": ["broker_network", "bulk_referral_potential"],
|
|
"budget": "15-25 Cr",
|
|
"urgency": "high",
|
|
"status": "booking_initiated",
|
|
"project": "Atri Aqua Sky Residences",
|
|
"configuration": "4BHK River Deck",
|
|
"unit": "Two adjacent units",
|
|
"budget_min": 150000000,
|
|
"budget_max": 250000000,
|
|
"stage": "booking",
|
|
"value": 212000000,
|
|
"probability": 84,
|
|
"next_action": "Confirm booking amount routing and broker mandate documentation.",
|
|
"scores": (0.88, 0.77, 0.89),
|
|
},
|
|
]
|
|
|
|
|
|
PROJECTS = {
|
|
"Atri Aqua Sky Residences": {
|
|
"developer": "Atri Group",
|
|
"micro_market": "Batanagar Riverside",
|
|
"address": "Maheshtala Riverside Corridor, Kolkata",
|
|
"property_type": "apartment",
|
|
"location": {"city": "Kolkata", "district": "Maheshtala", "lat": 22.4981, "lng": 88.2291},
|
|
"price_bands": [
|
|
{"unitType": "3BHK", "minINR": 72000000, "maxINR": 98000000},
|
|
{"unitType": "4BHK Sky Villa", "minINR": 130000000, "maxINR": 190000000},
|
|
],
|
|
"unit_mix": [{"bedrooms": 3, "count": 42, "sizeSqft": 2450}, {"bedrooms": 4, "count": 18, "sizeSqft": 3800}],
|
|
},
|
|
"Alipore Azure Residences": {
|
|
"developer": "Meridian Urban Estates",
|
|
"micro_market": "Alipore",
|
|
"address": "Judges Court Road, Alipore, Kolkata",
|
|
"property_type": "apartment",
|
|
"location": {"city": "Kolkata", "district": "Alipore", "lat": 22.5288, "lng": 88.3309},
|
|
"price_bands": [{"unitType": "3BHK Signature", "minINR": 85000000, "maxINR": 125000000}],
|
|
"unit_mix": [{"bedrooms": 3, "count": 36, "sizeSqft": 2850}, {"bedrooms": 4, "count": 16, "sizeSqft": 4100}],
|
|
},
|
|
"Victoria Gardens Private Residences": {
|
|
"developer": "Heritage Habitat",
|
|
"micro_market": "Maidan",
|
|
"address": "Queen's Way Precinct, Kolkata",
|
|
"property_type": "penthouse",
|
|
"location": {"city": "Kolkata", "district": "Maidan", "lat": 22.5448, "lng": 88.3426},
|
|
"price_bands": [{"unitType": "Penthouse Duplex", "minINR": 220000000, "maxINR": 320000000}],
|
|
"unit_mix": [{"bedrooms": 5, "count": 8, "sizeSqft": 6200}],
|
|
},
|
|
"Salt Lake Atelier Homes": {
|
|
"developer": "Studio Habitat",
|
|
"micro_market": "Salt Lake Sector V",
|
|
"address": "EM Bypass Connector, Salt Lake, Kolkata",
|
|
"property_type": "apartment",
|
|
"location": {"city": "Kolkata", "district": "Salt Lake", "lat": 22.5797, "lng": 88.4353},
|
|
"price_bands": [{"unitType": "3.5BHK Garden Home", "minINR": 68000000, "maxINR": 98000000}],
|
|
"unit_mix": [{"bedrooms": 3, "count": 28, "sizeSqft": 2350}],
|
|
},
|
|
"Ballygunge Meridian": {
|
|
"developer": "Eastern Crest Realty",
|
|
"micro_market": "Ballygunge",
|
|
"address": "Ballygunge Circular Road, Kolkata",
|
|
"property_type": "apartment",
|
|
"location": {"city": "Kolkata", "district": "Ballygunge", "lat": 22.5276, "lng": 88.3651},
|
|
"price_bands": [{"unitType": "3BHK", "minINR": 42000000, "maxINR": 62000000}],
|
|
"unit_mix": [{"bedrooms": 3, "count": 54, "sizeSqft": 1780}],
|
|
},
|
|
}
|
|
|
|
|
|
async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str | None, dry_run: bool = False) -> dict[str, int]:
|
|
now = datetime.now(timezone.utc)
|
|
owner_user_ref = operator_user_id or f"{SEED_SOURCE}:operator"
|
|
counts = {
|
|
"people": 0,
|
|
"leads": 0,
|
|
"projects": 0,
|
|
"properties": 0,
|
|
"interests": 0,
|
|
"opportunities": 0,
|
|
"scores": 0,
|
|
"interactions": 0,
|
|
"edge_events": 0,
|
|
"reminders": 0,
|
|
"calendar_events": 0,
|
|
"import_batches": 0,
|
|
"import_proposals": 0,
|
|
}
|
|
if dry_run:
|
|
return counts
|
|
|
|
project_ids: dict[str, str] = {}
|
|
for name, project in PROJECTS.items():
|
|
project_id = _stable_uuid(tenant_id, f"project:{name}")
|
|
project_ids[name] = project_id
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO inventory_projects (
|
|
project_id, project_name, developer_name, city, micro_market, address,
|
|
total_units, project_status, location_json, amenities_json, metadata_json
|
|
) VALUES (
|
|
$1::uuid, $2, $3, 'Kolkata', $4, $5, $6, 'active',
|
|
$7::jsonb, $8::jsonb, $9::jsonb
|
|
)
|
|
ON CONFLICT (project_name) DO UPDATE SET
|
|
developer_name = EXCLUDED.developer_name,
|
|
micro_market = EXCLUDED.micro_market,
|
|
address = EXCLUDED.address,
|
|
total_units = EXCLUDED.total_units,
|
|
project_status = EXCLUDED.project_status,
|
|
location_json = EXCLUDED.location_json,
|
|
amenities_json = EXCLUDED.amenities_json,
|
|
metadata_json = inventory_projects.metadata_json || EXCLUDED.metadata_json,
|
|
updated_at = NOW()
|
|
""",
|
|
project_id,
|
|
name,
|
|
project["developer"],
|
|
project["micro_market"],
|
|
project["address"],
|
|
sum(item["count"] for item in project["unit_mix"]),
|
|
_json(project["location"]),
|
|
_json(["concierge", "private club", "fitness studio", "visitor lounge", "ev charging"]),
|
|
_json({"seed_source": SEED_SOURCE, "ipad_demo": True}),
|
|
)
|
|
counts["projects"] += 1
|
|
|
|
property_id = _stable_uuid(tenant_id, f"inventory-property:{name}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO inventory_properties (
|
|
property_id, tenant_id, source_id, project_name, developer_name,
|
|
location, property_type, price_bands, unit_mix, amenities,
|
|
status, validation_state, ingested_at, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb, $9::jsonb,
|
|
$10, 'active', $11::jsonb, NOW(), NOW(), NOW()
|
|
)
|
|
ON CONFLICT (property_id) DO UPDATE SET
|
|
project_name = EXCLUDED.project_name,
|
|
developer_name = EXCLUDED.developer_name,
|
|
location = EXCLUDED.location,
|
|
property_type = EXCLUDED.property_type,
|
|
price_bands = EXCLUDED.price_bands,
|
|
unit_mix = EXCLUDED.unit_mix,
|
|
amenities = EXCLUDED.amenities,
|
|
status = 'active',
|
|
validation_state = EXCLUDED.validation_state,
|
|
updated_at = NOW()
|
|
""",
|
|
property_id,
|
|
tenant_id,
|
|
f"{SEED_SOURCE}:{name}",
|
|
name,
|
|
project["developer"],
|
|
_json(project["location"]),
|
|
project["property_type"],
|
|
_json(project["price_bands"]),
|
|
_json(project["unit_mix"]),
|
|
["concierge", "clubhouse", "security", "ev charging", "landscaped deck"],
|
|
_json({"seed_source": SEED_SOURCE, "validated_for_ipad_demo": True}),
|
|
)
|
|
counts["properties"] += 1
|
|
|
|
for index, client in enumerate(DEMO_CLIENTS):
|
|
person_id = _stable_uuid(tenant_id, f"person:{client['key']}")
|
|
lead_id = _stable_uuid(tenant_id, f"lead:{client['key']}")
|
|
opportunity_id = _stable_uuid(tenant_id, f"opportunity:{client['key']}")
|
|
project_id = project_ids[client["project"]]
|
|
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO crm_people (
|
|
person_id, tenant_id, full_name, primary_email, primary_phone, city,
|
|
nationality, buyer_type, persona_labels, source_confidence,
|
|
metadata_json, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, 0.98,
|
|
$10::jsonb, NOW() - ($11::int * INTERVAL '2 days'), NOW()
|
|
)
|
|
ON CONFLICT (person_id) DO UPDATE SET
|
|
full_name = EXCLUDED.full_name,
|
|
primary_email = EXCLUDED.primary_email,
|
|
primary_phone = EXCLUDED.primary_phone,
|
|
city = EXCLUDED.city,
|
|
nationality = EXCLUDED.nationality,
|
|
buyer_type = EXCLUDED.buyer_type,
|
|
persona_labels = EXCLUDED.persona_labels,
|
|
source_confidence = EXCLUDED.source_confidence,
|
|
metadata_json = crm_people.metadata_json || EXCLUDED.metadata_json,
|
|
updated_at = NOW()
|
|
""",
|
|
person_id,
|
|
tenant_id,
|
|
client["name"],
|
|
client["email"],
|
|
client["phone"],
|
|
client["city"],
|
|
client["nationality"],
|
|
client["buyer_type"],
|
|
_json(client["persona"]),
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True, "source_note": "iPad production readiness seed"}),
|
|
index,
|
|
)
|
|
counts["people"] += 1
|
|
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO crm_leads (
|
|
lead_id, tenant_id, person_id, source_system, status, budget_band,
|
|
urgency, financing_posture, timeline_to_decision, objections,
|
|
motivations, assigned_user_id, metadata_json, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, 'ipad_investor_demo',
|
|
$4::crm_lead_status, $5, $6, $7, $8, $9::jsonb, $10::jsonb,
|
|
$11::uuid, $12::jsonb, NOW() - ($13::int * INTERVAL '2 days'), NOW()
|
|
)
|
|
ON CONFLICT (lead_id) DO UPDATE SET
|
|
status = EXCLUDED.status,
|
|
budget_band = EXCLUDED.budget_band,
|
|
urgency = EXCLUDED.urgency,
|
|
financing_posture = EXCLUDED.financing_posture,
|
|
timeline_to_decision = EXCLUDED.timeline_to_decision,
|
|
objections = EXCLUDED.objections,
|
|
motivations = EXCLUDED.motivations,
|
|
assigned_user_id = EXCLUDED.assigned_user_id,
|
|
metadata_json = crm_leads.metadata_json || EXCLUDED.metadata_json,
|
|
updated_at = NOW()
|
|
""",
|
|
lead_id,
|
|
tenant_id,
|
|
person_id,
|
|
client["status"],
|
|
client["budget"],
|
|
client["urgency"],
|
|
"cash_and_structured_payment" if client["budget_min"] >= 100000000 else "bank_loan_preapproved",
|
|
"30_days" if client["urgency"] in {"high", "critical"} else "60_to_90_days",
|
|
_json(["needs_family_alignment"] if client["urgency"] != "critical" else ["price_protection", "privacy"]),
|
|
_json(["upgrade_primary_home", "wealth_preservation", "status_address"]),
|
|
operator_user_id,
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
index,
|
|
)
|
|
counts["leads"] += 1
|
|
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO crm_property_interests (
|
|
interest_id, tenant_id, person_id, lead_id, project_id, project_name,
|
|
unit_preference, configuration, budget_min, budget_max, priority, notes, created_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, 1, $11, NOW()
|
|
)
|
|
ON CONFLICT (interest_id) DO UPDATE SET
|
|
project_name = EXCLUDED.project_name,
|
|
unit_preference = EXCLUDED.unit_preference,
|
|
configuration = EXCLUDED.configuration,
|
|
budget_min = EXCLUDED.budget_min,
|
|
budget_max = EXCLUDED.budget_max,
|
|
notes = EXCLUDED.notes
|
|
""",
|
|
_stable_uuid(tenant_id, f"interest:{client['key']}"),
|
|
tenant_id,
|
|
person_id,
|
|
lead_id,
|
|
project_id,
|
|
client["project"],
|
|
client["unit"],
|
|
client["configuration"],
|
|
client["budget_min"],
|
|
client["budget_max"],
|
|
f"Seeded for iPad investor demo by {SEED_SOURCE}.",
|
|
)
|
|
counts["interests"] += 1
|
|
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO crm_opportunities (
|
|
opportunity_id, tenant_id, lead_id, project_id, stage, value,
|
|
probability, expected_close_date, next_action, notes, metadata_json,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, $4::uuid, $5::crm_opportunity_stage, $6,
|
|
$7, $8::date, $9, $10, $11::jsonb, NOW() - ($12::int * INTERVAL '1 day'), NOW()
|
|
)
|
|
ON CONFLICT (opportunity_id) DO UPDATE SET
|
|
project_id = EXCLUDED.project_id,
|
|
stage = EXCLUDED.stage,
|
|
value = EXCLUDED.value,
|
|
probability = EXCLUDED.probability,
|
|
expected_close_date = EXCLUDED.expected_close_date,
|
|
next_action = EXCLUDED.next_action,
|
|
notes = EXCLUDED.notes,
|
|
metadata_json = crm_opportunities.metadata_json || EXCLUDED.metadata_json,
|
|
updated_at = NOW()
|
|
""",
|
|
opportunity_id,
|
|
tenant_id,
|
|
lead_id,
|
|
project_id,
|
|
client["stage"],
|
|
client["value"],
|
|
client["probability"],
|
|
(now + timedelta(days=21 + index * 4)).date().isoformat(),
|
|
client["next_action"],
|
|
"Investor-demo opportunity with realistic project, value, and next-step context.",
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
index,
|
|
)
|
|
counts["opportunities"] += 1
|
|
|
|
for score_type, value in zip(("intent_score", "engagement_score", "urgency_score"), client["scores"]):
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO intel_qd_scores (
|
|
qd_id, tenant_id, person_id, score_type, current_value,
|
|
computed_at, evidence_refs_json, reasoning, metadata_json
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, $4, $5, NOW(), $6::jsonb, $7, $8::jsonb
|
|
)
|
|
ON CONFLICT (person_id, score_type) DO UPDATE SET
|
|
tenant_id = EXCLUDED.tenant_id,
|
|
current_value = EXCLUDED.current_value,
|
|
computed_at = NOW(),
|
|
evidence_refs_json = EXCLUDED.evidence_refs_json,
|
|
reasoning = EXCLUDED.reasoning,
|
|
metadata_json = intel_qd_scores.metadata_json || EXCLUDED.metadata_json
|
|
""",
|
|
_stable_uuid(tenant_id, f"qd:{client['key']}:{score_type}"),
|
|
tenant_id,
|
|
person_id,
|
|
score_type,
|
|
value,
|
|
_json([f"seed:{client['key']}:interaction"]),
|
|
f"{client['name']} shows {score_type.replace('_', ' ')} from recent budget, project, and follow-up signals.",
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["scores"] += 1
|
|
|
|
interaction_templates = [
|
|
("whatsapp", "message", now - timedelta(hours=6 + index), "Confirmed budget band and asked for a project comparison deck."),
|
|
("phone", "call", now - timedelta(days=1, hours=index), "Discussed decision timeline, family alignment, and next visit window."),
|
|
("site_visit", "visit", now - timedelta(days=3 + index), f"Reviewed {client['project']} and shortlisted {client['configuration']}."),
|
|
]
|
|
for event_index, (channel, interaction_type, happened_at, summary) in enumerate(interaction_templates):
|
|
interaction_id = _stable_uuid(tenant_id, f"interaction:{client['key']}:{event_index}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO intel_interactions (
|
|
interaction_id, tenant_id, person_id, lead_id, channel, interaction_type,
|
|
happened_at, summary, source_ref, metadata_json, created_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, $4::uuid, $5::intel_channel, $6,
|
|
$7, $8, $9, $10::jsonb, NOW()
|
|
)
|
|
ON CONFLICT (interaction_id) DO UPDATE SET
|
|
happened_at = EXCLUDED.happened_at,
|
|
summary = EXCLUDED.summary,
|
|
metadata_json = intel_interactions.metadata_json || EXCLUDED.metadata_json
|
|
""",
|
|
interaction_id,
|
|
tenant_id,
|
|
person_id,
|
|
lead_id,
|
|
channel,
|
|
interaction_type,
|
|
happened_at,
|
|
summary,
|
|
f"{SEED_SOURCE}:{client['key']}:{event_index}",
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["interactions"] += 1
|
|
|
|
edge_event_id = _stable_uuid(tenant_id, f"edge-event:{client['key']}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO edge_communication_events (
|
|
event_id, tenant_id, lead_id, channel, direction, provider,
|
|
capture_mode, consent_state, timestamp, duration_seconds,
|
|
summary, raw_reference, provider_metadata, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3, 'whatsapp_message', 'inbound', 'operator_seed',
|
|
'operator_note', 'granted', $4, NULL, $5, $6, $7::jsonb, NOW(), NOW()
|
|
)
|
|
ON CONFLICT (event_id) DO UPDATE SET
|
|
timestamp = EXCLUDED.timestamp,
|
|
summary = EXCLUDED.summary,
|
|
provider_metadata = EXCLUDED.provider_metadata,
|
|
updated_at = NOW()
|
|
""",
|
|
edge_event_id,
|
|
tenant_id,
|
|
lead_id,
|
|
now - timedelta(minutes=25 + index * 12),
|
|
f"{client['name']} asked the operator to proceed with the next step: {client['next_action']}",
|
|
f"{SEED_SOURCE}:{client['key']}",
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["edge_events"] += 1
|
|
|
|
reminder_id = _stable_uuid(tenant_id, f"reminder:{client['key']}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO intel_reminders (
|
|
reminder_id, tenant_id, person_id, lead_id, opportunity_id, reminder_type,
|
|
title, notes, due_at, status, assigned_to, created_by_type, priority,
|
|
metadata_json, created_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, $4::uuid, $5::uuid, 'follow_up',
|
|
$6, $7, $8, 'pending', $9::uuid, 'human', $10, $11::jsonb, NOW()
|
|
)
|
|
ON CONFLICT (reminder_id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
notes = EXCLUDED.notes,
|
|
due_at = EXCLUDED.due_at,
|
|
status = 'pending',
|
|
assigned_to = EXCLUDED.assigned_to,
|
|
priority = EXCLUDED.priority,
|
|
metadata_json = intel_reminders.metadata_json || EXCLUDED.metadata_json
|
|
""",
|
|
reminder_id,
|
|
tenant_id,
|
|
person_id,
|
|
lead_id,
|
|
opportunity_id,
|
|
f"Follow up with {client['name']} on {client['project']}",
|
|
client["next_action"],
|
|
now + timedelta(hours=3 + index * 4),
|
|
operator_user_id,
|
|
"urgent" if client["urgency"] == "critical" else ("high" if client["urgency"] == "high" else "normal"),
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["reminders"] += 1
|
|
|
|
calendar_id = _stable_uuid(tenant_id, f"calendar:{client['key']}")
|
|
start_at = now + timedelta(days=1 + (index % 3), hours=2 + index)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO user_calendar_events (
|
|
calendar_event_id, tenant_id, owner_user_id, lead_id, source_event_id,
|
|
title, description, start_at, end_at, all_day, status,
|
|
reminder_minutes, created_by, is_nemoclaw_confirmed, location,
|
|
metadata, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3, $4, $5::uuid, $6, $7, $8, $9, FALSE, 'confirmed',
|
|
ARRAY[15, 60], 'user', TRUE, $10, $11::jsonb, NOW(), NOW()
|
|
)
|
|
ON CONFLICT (calendar_event_id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
description = EXCLUDED.description,
|
|
start_at = EXCLUDED.start_at,
|
|
end_at = EXCLUDED.end_at,
|
|
status = 'confirmed',
|
|
reminder_minutes = EXCLUDED.reminder_minutes,
|
|
location = EXCLUDED.location,
|
|
metadata = EXCLUDED.metadata,
|
|
updated_at = NOW()
|
|
""",
|
|
calendar_id,
|
|
tenant_id,
|
|
owner_user_ref,
|
|
lead_id,
|
|
edge_event_id,
|
|
f"{client['name']} - {client['configuration']} review",
|
|
client["next_action"],
|
|
start_at,
|
|
start_at + timedelta(minutes=45),
|
|
client["project"],
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["calendar_events"] += 1
|
|
|
|
batch_id = _stable_uuid(tenant_id, "workflow-import-batch:investor-demo")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO workflow_import_batches (
|
|
batch_id, tenant_id, source_system, uploaded_filename, mime_type, storage_ref,
|
|
row_count, mapped_count, unresolved_count, canonical_count, uploaded_by,
|
|
lifecycle, mapping_manifest, errors_json, metadata_json, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, 'sales_ops_csv', 'investor_demo_priority_clients.csv', 'text/csv',
|
|
$3, 6, 5, 1, 0, $4::uuid, 'proposed',
|
|
$5::jsonb, '[]'::jsonb, $6::jsonb, NOW() - INTERVAL '2 hours', NOW()
|
|
)
|
|
ON CONFLICT (batch_id) DO UPDATE SET
|
|
row_count = EXCLUDED.row_count,
|
|
mapped_count = EXCLUDED.mapped_count,
|
|
unresolved_count = EXCLUDED.unresolved_count,
|
|
lifecycle = EXCLUDED.lifecycle,
|
|
mapping_manifest = EXCLUDED.mapping_manifest,
|
|
metadata_json = workflow_import_batches.metadata_json || EXCLUDED.metadata_json,
|
|
updated_at = NOW()
|
|
""",
|
|
batch_id,
|
|
tenant_id,
|
|
f"s3://velocity-demo/{SEED_SOURCE}/investor_demo_priority_clients.csv",
|
|
operator_user_id,
|
|
_json({"mapped": {"Name": "full_name", "Phone": "primary_phone", "Budget": "budget_band"}}),
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["import_batches"] += 1
|
|
|
|
for row_number, client in enumerate(DEMO_CLIENTS[:3], start=1):
|
|
action_id = _stable_uuid(tenant_id, f"workflow-import-proposal:{client['key']}")
|
|
payload = {
|
|
"batch_id": batch_id,
|
|
"row_number": row_number,
|
|
"canonical_payload": {
|
|
"full_name": client["name"],
|
|
"primary_phone": client["phone"],
|
|
"buyer_type": client["buyer_type"],
|
|
"budget_band": client["budget"],
|
|
"project_name": client["project"],
|
|
},
|
|
"raw_row": {
|
|
"Name": client["name"],
|
|
"Phone": client["phone"],
|
|
"Budget": client["budget"],
|
|
"Project": client["project"],
|
|
},
|
|
"unresolved_fields": [] if row_number < 3 else ["preferred_visit_time"],
|
|
"missing_required": [],
|
|
"confidence": 0.92 - (row_number * 0.04),
|
|
"review_required": True,
|
|
}
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO workflow_actions (
|
|
action_id, tenant_id, action_type, target_domain, target_entity_ref,
|
|
proposal_payload, reasoning_summary, evidence_refs, confidence,
|
|
status, approval_required, created_by_agent, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, 'import_proposal', 'crm', $3,
|
|
$4::jsonb, $5, $6::jsonb, $7, 'pending', TRUE,
|
|
'velocity_ipad_seed', NOW() - INTERVAL '90 minutes', NOW()
|
|
)
|
|
ON CONFLICT (action_id) DO UPDATE SET
|
|
proposal_payload = EXCLUDED.proposal_payload,
|
|
reasoning_summary = EXCLUDED.reasoning_summary,
|
|
evidence_refs = EXCLUDED.evidence_refs,
|
|
confidence = EXCLUDED.confidence,
|
|
status = 'pending',
|
|
approval_required = TRUE,
|
|
updated_at = NOW()
|
|
""",
|
|
action_id,
|
|
tenant_id,
|
|
client["email"],
|
|
_json(payload),
|
|
f"Mapped {client['name']} from realistic investor-demo CRM import.",
|
|
_json([f"batch:{batch_id}", f"seed_source:{SEED_SOURCE}"]),
|
|
payload["confidence"],
|
|
)
|
|
counts["import_proposals"] += 1
|
|
|
|
return counts
|
|
|
|
|
|
async def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--operator-email", default=os.getenv("VELOCITY_DEMO_OPERATOR_EMAIL", DEFAULT_OPERATOR_EMAIL))
|
|
parser.add_argument("--tenant-id", default=os.getenv("VELOCITY_DEMO_TENANT_ID"))
|
|
parser.add_argument("--dry-run", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
_load_env()
|
|
if asyncpg is None:
|
|
raise RuntimeError(
|
|
"asyncpg is not installed. Install backend requirements first: "
|
|
"python3 -m pip install -r backend/requirements.txt"
|
|
)
|
|
if args.dry_run:
|
|
try:
|
|
db_kwargs = _db_kwargs()
|
|
except RuntimeError as exc:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"status": "dry_run_without_db",
|
|
"seed_source": SEED_SOURCE,
|
|
"operator_email": args.operator_email,
|
|
"database_note": str(exc),
|
|
"expected_counts": _expected_counts(),
|
|
},
|
|
indent=2,
|
|
)
|
|
)
|
|
return
|
|
else:
|
|
db_kwargs = _db_kwargs()
|
|
conn = await asyncpg.connect(**db_kwargs)
|
|
try:
|
|
tenant_id, operator_user_id = await _resolve_operator(conn, args.operator_email, args.tenant_id)
|
|
counts = _expected_counts() if args.dry_run else await seed(conn, tenant_id, operator_user_id)
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"status": "dry_run" if args.dry_run else "seeded",
|
|
"seed_source": SEED_SOURCE,
|
|
"tenant_id": tenant_id,
|
|
"operator_email": args.operator_email,
|
|
"operator_user_id": operator_user_id,
|
|
"counts": counts,
|
|
},
|
|
indent=2,
|
|
)
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|