Spaces:
Running
Running
multiple structure changes
Browse files- alembic/versions/0013_desk_and_call_picked_by.py +36 -0
- alembic/versions/0014_hidden_and_callback.py +43 -0
- app/api/leads.py +47 -1
- app/constants.py +2 -1
- app/crud/leads.py +124 -7
- app/models.py +25 -1
- app/schemas/lead.py +40 -1
- app/services/agent_core.py +3 -2
- app/services/lead_engine.py +5 -3
- scripts/check_duplicate_leads.py +70 -0
- scripts/check_structure_auto_desk.py +62 -0
- scripts/check_structure_phase1.py +82 -0
- scripts/check_structure_phase2.py +73 -0
- scripts/check_structure_phase3.py +124 -0
- scripts/check_structure_phase5.py +73 -0
- scripts/check_structure_phase8.py +75 -0
- scripts/delete_lead_by_id.py +57 -0
- scripts/delete_user.py +85 -0
- scripts/revert_last_csv_import.py +87 -0
- scripts/review_duplicate_leads.py +154 -0
- scripts/seed_leads.py +9 -5
- scripts/smoke_test.py +1 -1
alembic/versions/0013_desk_and_call_picked_by.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""leads.sent_to_desk_at, leads.call_picked_by
|
| 2 |
+
|
| 3 |
+
structure-plan.md Phase 1 -- Contacts becomes the intake hub; a lead only appears on the Cold Call Desk
|
| 4 |
+
once a rep explicitly sends it there (sent_to_desk_at). "Call picked by" (Receptionist / Decision Maker /
|
| 5 |
+
Team Member) is a tag-only mark on the card, never a stage change (call_picked_by).
|
| 6 |
+
|
| 7 |
+
Both nullable, no default: existing leads get NULL for both -- NOT auto-populated onto the desk. The next
|
| 8 |
+
phase (Cold Call Desk query change) decides how NULL is treated for leads that predate this column; until
|
| 9 |
+
then this migration only adds the columns.
|
| 10 |
+
|
| 11 |
+
Revision ID: 0013_desk_and_call_picked_by
|
| 12 |
+
Revises: 0012_user_password
|
| 13 |
+
Create Date: 2026-09-22
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from typing import Sequence, Union
|
| 17 |
+
|
| 18 |
+
import sqlalchemy as sa
|
| 19 |
+
from alembic import op
|
| 20 |
+
|
| 21 |
+
revision: str = "0013_desk_and_call_picked_by"
|
| 22 |
+
down_revision: Union[str, None] = "0012_user_password"
|
| 23 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 24 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def upgrade() -> None:
|
| 28 |
+
op.add_column("leads", sa.Column("sent_to_desk_at", sa.DateTime(), nullable=True))
|
| 29 |
+
op.add_column("leads", sa.Column("call_picked_by", sa.String(length=16), nullable=True))
|
| 30 |
+
op.create_index("idx_leads_sent_to_desk", "leads", ["sent_to_desk_at"])
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def downgrade() -> None:
|
| 34 |
+
op.drop_index("idx_leads_sent_to_desk", table_name="leads")
|
| 35 |
+
op.drop_column("leads", "call_picked_by")
|
| 36 |
+
op.drop_column("leads", "sent_to_desk_at")
|
alembic/versions/0014_hidden_and_callback.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""leads.hidden_at, leads.callback_at
|
| 2 |
+
|
| 3 |
+
structure-plan.md Phase 3 -- Cold Call Desk card redesign.
|
| 4 |
+
|
| 5 |
+
hidden_at Dead Line / Not Interested (cold call) mark a lead invisible everywhere -- recorded in the
|
| 6 |
+
database, shown on no screen. crud.list_leads() (Phase 3) excludes hidden_at IS NOT NULL
|
| 7 |
+
unconditionally, so every list consumer (Contacts, Pipeline, Cold Call Desk) is covered by
|
| 8 |
+
one change, with nothing to remember per page. A future phase's audit view will need its
|
| 9 |
+
own explicit override; none exists yet.
|
| 10 |
+
callback_at Callback Scheduled's date/time, asked the same way Meeting Booked asks for meeting_at --
|
| 11 |
+
deliberately a SEPARATE column, so a callback can never be confused with, or accidentally
|
| 12 |
+
show on, the Meetings calendar (which reads meeting_at).
|
| 13 |
+
|
| 14 |
+
Both nullable, no default, no backfill: every existing lead is unaffected (not hidden, no callback set).
|
| 15 |
+
|
| 16 |
+
Revision ID: 0014_hidden_and_callback
|
| 17 |
+
Revises: 0013_desk_and_call_picked_by
|
| 18 |
+
Create Date: 2026-09-22
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from typing import Sequence, Union
|
| 22 |
+
|
| 23 |
+
import sqlalchemy as sa
|
| 24 |
+
from alembic import op
|
| 25 |
+
|
| 26 |
+
revision: str = "0014_hidden_and_callback"
|
| 27 |
+
down_revision: Union[str, None] = "0013_desk_and_call_picked_by"
|
| 28 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 29 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def upgrade() -> None:
|
| 33 |
+
op.add_column("leads", sa.Column("hidden_at", sa.DateTime(), nullable=True))
|
| 34 |
+
op.add_column("leads", sa.Column("callback_at", sa.DateTime(), nullable=True))
|
| 35 |
+
op.create_index("idx_leads_hidden", "leads", ["hidden_at"])
|
| 36 |
+
op.create_index("idx_leads_callback", "leads", ["callback_at"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def downgrade() -> None:
|
| 40 |
+
op.drop_index("idx_leads_callback", table_name="leads")
|
| 41 |
+
op.drop_index("idx_leads_hidden", table_name="leads")
|
| 42 |
+
op.drop_column("leads", "callback_at")
|
| 43 |
+
op.drop_column("leads", "hidden_at")
|
app/api/leads.py
CHANGED
|
@@ -24,10 +24,12 @@ from app.models import _utcnow as _now
|
|
| 24 |
from app.schemas.lead import (
|
| 25 |
AddNoteInput,
|
| 26 |
CallOutcomeInput,
|
|
|
|
| 27 |
CreateLeadInput,
|
| 28 |
FindPhoneInput,
|
| 29 |
LeadContactOut,
|
| 30 |
LeadOut,
|
|
|
|
| 31 |
RevealInput,
|
| 32 |
UpdateLeadInput,
|
| 33 |
)
|
|
@@ -158,13 +160,48 @@ def record_outcome(
|
|
| 158 |
user: User | None = Depends(get_current_user),
|
| 159 |
):
|
| 160 |
lead = _require(db, lead_id)
|
| 161 |
-
updated = crud.apply_call_outcome(
|
|
|
|
|
|
|
| 162 |
crud_notify.notify_mentions(
|
| 163 |
db, user, payload.mentions, payload.mention_team, text=payload.rep_notes, lead_id=lead_id
|
| 164 |
)
|
| 165 |
return LeadOut.from_model(updated)
|
| 166 |
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
@router.post("/leads/{lead_id}/notes", response_model=LeadOut)
|
| 169 |
def add_note(
|
| 170 |
lead_id: int,
|
|
@@ -181,6 +218,15 @@ def add_note(
|
|
| 181 |
return LeadOut.from_model(updated)
|
| 182 |
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
@router.post("/leads/{lead_id}/email-sent", status_code=status.HTTP_204_NO_CONTENT)
|
| 185 |
def mark_email_sent(
|
| 186 |
lead_id: int, db: Session = Depends(get_db), user: User | None = Depends(get_current_user)
|
|
|
|
| 24 |
from app.schemas.lead import (
|
| 25 |
AddNoteInput,
|
| 26 |
CallOutcomeInput,
|
| 27 |
+
CallPickedByInput,
|
| 28 |
CreateLeadInput,
|
| 29 |
FindPhoneInput,
|
| 30 |
LeadContactOut,
|
| 31 |
LeadOut,
|
| 32 |
+
MeetingOutcomeInput,
|
| 33 |
RevealInput,
|
| 34 |
UpdateLeadInput,
|
| 35 |
)
|
|
|
|
| 160 |
user: User | None = Depends(get_current_user),
|
| 161 |
):
|
| 162 |
lead = _require(db, lead_id)
|
| 163 |
+
updated = crud.apply_call_outcome(
|
| 164 |
+
db, lead, payload.outcome, payload.rep_notes, payload.meeting_at, user, payload.callback_at
|
| 165 |
+
)
|
| 166 |
crud_notify.notify_mentions(
|
| 167 |
db, user, payload.mentions, payload.mention_team, text=payload.rep_notes, lead_id=lead_id
|
| 168 |
)
|
| 169 |
return LeadOut.from_model(updated)
|
| 170 |
|
| 171 |
|
| 172 |
+
@router.post("/leads/{lead_id}/call-picked-by", response_model=LeadOut)
|
| 173 |
+
def call_picked_by(
|
| 174 |
+
lead_id: int,
|
| 175 |
+
payload: CallPickedByInput,
|
| 176 |
+
db: Session = Depends(get_db),
|
| 177 |
+
user: User | None = Depends(get_current_user),
|
| 178 |
+
):
|
| 179 |
+
""""Call picked by" row -- tag only, see crud.set_call_picked_by(). structure-plan.md Phase 3."""
|
| 180 |
+
lead = _require(db, lead_id)
|
| 181 |
+
return LeadOut.from_model(crud.set_call_picked_by(db, lead, payload.value, user))
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
@router.post("/leads/{lead_id}/needs-email", response_model=LeadOut)
|
| 185 |
+
def needs_email(
|
| 186 |
+
lead_id: int, db: Session = Depends(get_db), user: User | None = Depends(get_current_user)
|
| 187 |
+
):
|
| 188 |
+
""""Email Send" button -- see crud.mark_needs_email(). structure-plan.md Phase 3/4."""
|
| 189 |
+
lead = _require(db, lead_id)
|
| 190 |
+
return LeadOut.from_model(crud.mark_needs_email(db, lead, user))
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
@router.post("/leads/{lead_id}/meeting-outcome", response_model=LeadOut)
|
| 194 |
+
def meeting_outcome(
|
| 195 |
+
lead_id: int,
|
| 196 |
+
payload: MeetingOutcomeInput,
|
| 197 |
+
db: Session = Depends(get_db),
|
| 198 |
+
user: User | None = Depends(get_current_user),
|
| 199 |
+
):
|
| 200 |
+
"""Meetings detail popup's 4-way picker -- see crud.set_meeting_outcome(). structure-plan.md Phase 5."""
|
| 201 |
+
lead = _require(db, lead_id)
|
| 202 |
+
return LeadOut.from_model(crud.set_meeting_outcome(db, lead, payload.stage, user))
|
| 203 |
+
|
| 204 |
+
|
| 205 |
@router.post("/leads/{lead_id}/notes", response_model=LeadOut)
|
| 206 |
def add_note(
|
| 207 |
lead_id: int,
|
|
|
|
| 218 |
return LeadOut.from_model(updated)
|
| 219 |
|
| 220 |
|
| 221 |
+
@router.post("/leads/{lead_id}/send-to-desk", response_model=LeadOut)
|
| 222 |
+
def send_to_desk(
|
| 223 |
+
lead_id: int, db: Session = Depends(get_db), user: User | None = Depends(get_current_user)
|
| 224 |
+
):
|
| 225 |
+
""""Send to Cold Call Desk" on Contacts -- see crud.send_to_desk(). structure-plan.md Phase 2."""
|
| 226 |
+
lead = _require(db, lead_id)
|
| 227 |
+
return LeadOut.from_model(crud.send_to_desk(db, lead, user))
|
| 228 |
+
|
| 229 |
+
|
| 230 |
@router.post("/leads/{lead_id}/email-sent", status_code=status.HTTP_204_NO_CONTENT)
|
| 231 |
def mark_email_sent(
|
| 232 |
lead_id: int, db: Session = Depends(get_db), user: User | None = Depends(get_current_user)
|
app/constants.py
CHANGED
|
@@ -25,7 +25,8 @@ ACTIVE_STAGES = {
|
|
| 25 |
"proposal_sent",
|
| 26 |
}
|
| 27 |
|
| 28 |
-
|
|
|
|
| 29 |
|
| 30 |
PHONE_STATUSES = [
|
| 31 |
"missing",
|
|
|
|
| 25 |
"proposal_sent",
|
| 26 |
}
|
| 27 |
|
| 28 |
+
# Retired by structure-plan.md Phase 3: Cold Call Desk membership is now sent_to_desk_at, not stage.
|
| 29 |
+
# Kept only as a comment for anyone grepping history -- CALL_READY_STAGES = {"draft_ready", "followup_due"}
|
| 30 |
|
| 31 |
PHONE_STATUSES = [
|
| 32 |
"missing",
|
app/crud/leads.py
CHANGED
|
@@ -19,7 +19,7 @@ from typing import Any, Iterable, Sequence
|
|
| 19 |
from sqlalchemy import Select, and_, case, func, or_, select
|
| 20 |
from sqlalchemy.orm import Session
|
| 21 |
|
| 22 |
-
from app.constants import ACTIVE_STAGES,
|
| 23 |
from app.models import Lead, SalesProblem
|
| 24 |
from app.models import _utcnow as _now
|
| 25 |
from app.schemas.lead import CreateLeadInput, UpdateLeadInput, to_decimal
|
|
@@ -38,6 +38,11 @@ def list_leads(
|
|
| 38 |
) -> Sequence[Lead]:
|
| 39 |
stmt: Select = select(Lead)
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
if stage and stage != "all":
|
| 42 |
stmt = stmt.where(Lead.pipeline_stage == stage)
|
| 43 |
|
|
@@ -176,6 +181,12 @@ def create_lead(db: Session, data: CreateLeadInput, user=None) -> Lead:
|
|
| 176 |
screenshot_path="",
|
| 177 |
enrichment_source="",
|
| 178 |
enriched_at=None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
)
|
| 180 |
db.add(lead)
|
| 181 |
db.commit()
|
|
@@ -200,7 +211,9 @@ def lead_from_dict(payload: dict) -> Lead:
|
|
| 200 |
contact_linkedin=(payload.get("contact_linkedin") or "").strip(),
|
| 201 |
country=(payload.get("country") or "").strip().upper()[:2],
|
| 202 |
industry_tag=(payload.get("industry_tag") or DEFAULT_CATEGORY).strip(),
|
| 203 |
-
|
|
|
|
|
|
|
| 204 |
pipeline_stage=payload.get("pipeline_stage") or "draft_ready",
|
| 205 |
status="new",
|
| 206 |
reason=(payload.get("reason") or "").strip(),
|
|
@@ -240,6 +253,11 @@ def lead_from_dict(payload: dict) -> Lead:
|
|
| 240 |
phone_country=(payload.get("phone_country") or "").strip(),
|
| 241 |
signalhire_uid=(payload.get("signalhire_uid") or "").strip(),
|
| 242 |
discovery_citations=payload.get("discovery_citations") or None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
)
|
| 244 |
|
| 245 |
|
|
@@ -370,8 +388,23 @@ def _author(user) -> str:
|
|
| 370 |
return f"{name} · " if name else ""
|
| 371 |
|
| 372 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
def apply_call_outcome(
|
| 374 |
-
db: Session,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
) -> Lead:
|
| 376 |
"""Port of db.update_lead_call_outcome, including the appended note format."""
|
| 377 |
stamp = datetime.now().strftime("%b %d, %H:%M")
|
|
@@ -386,6 +419,12 @@ def apply_call_outcome(
|
|
| 386 |
lead.notes = f"{lead.notes}\n{entry}".strip() if lead.notes else entry
|
| 387 |
if meeting_at is not None:
|
| 388 |
lead.meeting_at = meeting_at
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
lead.updated_at = _now()
|
| 390 |
if user is not None:
|
| 391 |
lead.last_touched_by_user_id = user.id
|
|
@@ -436,6 +475,83 @@ def add_note(db: Session, lead: Lead, text: str, user=None) -> Lead:
|
|
| 436 |
return lead
|
| 437 |
|
| 438 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
def delete_lead(db: Session, lead: Lead) -> None:
|
| 440 |
db.delete(lead)
|
| 441 |
db.commit()
|
|
@@ -487,12 +603,13 @@ def get_crm_metrics(db: Session) -> CrmMetrics:
|
|
| 487 |
)
|
| 488 |
).scalar_one()
|
| 489 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
call_ready = db.execute(
|
| 491 |
select(func.count()).where(
|
| 492 |
-
and_(
|
| 493 |
-
Lead.pipeline_stage.in_(CALL_READY_STAGES),
|
| 494 |
-
Lead.phone_status != "dead_disconnected",
|
| 495 |
-
)
|
| 496 |
)
|
| 497 |
).scalar_one()
|
| 498 |
|
|
|
|
| 19 |
from sqlalchemy import Select, and_, case, func, or_, select
|
| 20 |
from sqlalchemy.orm import Session
|
| 21 |
|
| 22 |
+
from app.constants import ACTIVE_STAGES, DEFAULT_CATEGORY, STAGE_IDS
|
| 23 |
from app.models import Lead, SalesProblem
|
| 24 |
from app.models import _utcnow as _now
|
| 25 |
from app.schemas.lead import CreateLeadInput, UpdateLeadInput, to_decimal
|
|
|
|
| 38 |
) -> Sequence[Lead]:
|
| 39 |
stmt: Select = select(Lead)
|
| 40 |
|
| 41 |
+
# Dead Line / Not Interested -- recorded, shown nowhere. Unconditional: every list consumer
|
| 42 |
+
# (Contacts, Pipeline, Cold Call Desk) is covered here, nothing to remember per page. A single lead
|
| 43 |
+
# fetched by id (get_lead()) is NOT filtered -- only this list.
|
| 44 |
+
stmt = stmt.where(Lead.hidden_at.is_(None))
|
| 45 |
+
|
| 46 |
if stage and stage != "all":
|
| 47 |
stmt = stmt.where(Lead.pipeline_stage == stage)
|
| 48 |
|
|
|
|
| 181 |
screenshot_path="",
|
| 182 |
enrichment_source="",
|
| 183 |
enriched_at=None,
|
| 184 |
+
# Reverses structure-plan.md Phase 2/3's "a lead reaches Cold Call Desk only once Contacts
|
| 185 |
+
# explicitly sends it" (user, 2026-09-22: "hum ab chahty hein woh leads coldcalldesk me bhi jati
|
| 186 |
+
# rhyn... jo abhi abhi ati jayengi wo upar coldcalldesk me ati rhyngi") -- now every new lead lands
|
| 187 |
+
# there immediately, same moment it's created. "Send to Cold Call Desk"/"Resend" stays for a lead
|
| 188 |
+
# that later left the desk (e.g. Wrong Number) and needs to go back.
|
| 189 |
+
sent_to_desk_at=now,
|
| 190 |
)
|
| 191 |
db.add(lead)
|
| 192 |
db.commit()
|
|
|
|
| 211 |
contact_linkedin=(payload.get("contact_linkedin") or "").strip(),
|
| 212 |
country=(payload.get("country") or "").strip().upper()[:2],
|
| 213 |
industry_tag=(payload.get("industry_tag") or DEFAULT_CATEGORY).strip(),
|
| 214 |
+
# structure-plan.md Phase 8: default deal value is 10000 everywhere (was 15000 here, 20000 in
|
| 215 |
+
# lead_engine.py's CSV import, 18000 in the frontend's Add Lead form).
|
| 216 |
+
deal_value=to_decimal(payload.get("deal_value") or 10000.0),
|
| 217 |
pipeline_stage=payload.get("pipeline_stage") or "draft_ready",
|
| 218 |
status="new",
|
| 219 |
reason=(payload.get("reason") or "").strip(),
|
|
|
|
| 253 |
phone_country=(payload.get("phone_country") or "").strip(),
|
| 254 |
signalhire_uid=(payload.get("signalhire_uid") or "").strip(),
|
| 255 |
discovery_citations=payload.get("discovery_citations") or None,
|
| 256 |
+
# Reverses structure-plan.md Phase 2/3's "a lead reaches Cold Call Desk only once Contacts
|
| 257 |
+
# explicitly sends it" (user, 2026-09-22) -- every new lead lands there immediately now,
|
| 258 |
+
# regardless of source (AI discovery, CSV import, Google Maps, Apollo -- all go through this same
|
| 259 |
+
# function). See create_lead()'s matching comment above.
|
| 260 |
+
sent_to_desk_at=now,
|
| 261 |
)
|
| 262 |
|
| 263 |
|
|
|
|
| 388 |
return f"{name} · " if name else ""
|
| 389 |
|
| 390 |
|
| 391 |
+
# Outcomes that leave the Cold Call Desk -- structure-plan.md Phase 3. Dead Line/Not Interested also go
|
| 392 |
+
# further (hidden everywhere, below); Wrong Number/Callback/Meeting Booked/Closed just leave THIS desk,
|
| 393 |
+
# each landing somewhere else (Contacts, Pipeline, Pipeline+Meetings, Projects respectively).
|
| 394 |
+
_LEAVES_DESK = ("dead_number", "not_interested", "wrong_number", "callback_scheduled", "meeting_booked", "closed")
|
| 395 |
+
# Hidden everywhere (Contacts, Pipeline, Cold Call Desk) -- recorded, shown on no screen. See
|
| 396 |
+
# crud.list_leads()'s unconditional hidden_at filter.
|
| 397 |
+
_HIDES_LEAD = ("dead_number", "not_interested")
|
| 398 |
+
|
| 399 |
+
|
| 400 |
def apply_call_outcome(
|
| 401 |
+
db: Session,
|
| 402 |
+
lead: Lead,
|
| 403 |
+
outcome: str,
|
| 404 |
+
rep_notes: str = "",
|
| 405 |
+
meeting_at: datetime | None = None,
|
| 406 |
+
user=None,
|
| 407 |
+
callback_at: datetime | None = None,
|
| 408 |
) -> Lead:
|
| 409 |
"""Port of db.update_lead_call_outcome, including the appended note format."""
|
| 410 |
stamp = datetime.now().strftime("%b %d, %H:%M")
|
|
|
|
| 419 |
lead.notes = f"{lead.notes}\n{entry}".strip() if lead.notes else entry
|
| 420 |
if meeting_at is not None:
|
| 421 |
lead.meeting_at = meeting_at
|
| 422 |
+
if outcome == "callback_scheduled" and callback_at is not None:
|
| 423 |
+
lead.callback_at = callback_at
|
| 424 |
+
if outcome in _LEAVES_DESK:
|
| 425 |
+
lead.sent_to_desk_at = None
|
| 426 |
+
if outcome in _HIDES_LEAD:
|
| 427 |
+
lead.hidden_at = _now()
|
| 428 |
lead.updated_at = _now()
|
| 429 |
if user is not None:
|
| 430 |
lead.last_touched_by_user_id = user.id
|
|
|
|
| 475 |
return lead
|
| 476 |
|
| 477 |
|
| 478 |
+
def send_to_desk(db: Session, lead: Lead, user=None) -> Lead:
|
| 479 |
+
"""
|
| 480 |
+
"Send to Cold Call Desk" on Contacts -- structure-plan.md Phase 1/2. Always allowed (no
|
| 481 |
+
phone/email gate, per the user's decision): the rep decides when a lead is ready to call.
|
| 482 |
+
Idempotent -- pressing it again on a lead already on the desk just refreshes the timestamp,
|
| 483 |
+
same as re-sending.
|
| 484 |
+
"""
|
| 485 |
+
lead.sent_to_desk_at = _now()
|
| 486 |
+
lead.updated_at = _now()
|
| 487 |
+
if user is not None:
|
| 488 |
+
lead.last_touched_by_user_id = user.id
|
| 489 |
+
db.commit()
|
| 490 |
+
db.refresh(lead)
|
| 491 |
+
from app.crud import call_events
|
| 492 |
+
|
| 493 |
+
call_events.record_event(db, lead.id, "sent_to_desk", None, getattr(user, "id", None))
|
| 494 |
+
return lead
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def set_call_picked_by(db: Session, lead: Lead, value: str, user=None) -> Lead:
|
| 498 |
+
"""
|
| 499 |
+
"Call picked by" row (Receptionist / Decision Maker / Team Member) -- structure-plan.md Phase 3.
|
| 500 |
+
Tag only: no stage change, no queue movement, not logged as a call_events row (it is not itself an
|
| 501 |
+
action -- the rep still picks a real disposition button separately, same call).
|
| 502 |
+
"""
|
| 503 |
+
lead.call_picked_by = value
|
| 504 |
+
lead.updated_at = _now()
|
| 505 |
+
if user is not None:
|
| 506 |
+
lead.last_touched_by_user_id = user.id
|
| 507 |
+
db.commit()
|
| 508 |
+
db.refresh(lead)
|
| 509 |
+
return lead
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def mark_needs_email(db: Session, lead: Lead, user=None) -> Lead:
|
| 513 |
+
"""
|
| 514 |
+
"Email Send" button -- structure-plan.md Phase 3/4. A forward-looking flag ("this lead now needs an
|
| 515 |
+
email"), NOT a record that one was sent (that is still Mark Email Sent / record_email_sent,
|
| 516 |
+
unchanged, and does not touch this). Moves the lead into Pipeline and off the Cold Call Desk; the
|
| 517 |
+
stage id stays "contacted" (PIPELINE_STAGES' display label is what changed, "Outreach Sent" ->
|
| 518 |
+
"Email Send" -- see outreach-frontend/lib/constants.ts).
|
| 519 |
+
"""
|
| 520 |
+
lead.pipeline_stage = "contacted"
|
| 521 |
+
lead.sent_to_desk_at = None
|
| 522 |
+
lead.updated_at = _now()
|
| 523 |
+
if user is not None:
|
| 524 |
+
lead.last_touched_by_user_id = user.id
|
| 525 |
+
db.commit()
|
| 526 |
+
db.refresh(lead)
|
| 527 |
+
from app.crud import call_events
|
| 528 |
+
|
| 529 |
+
call_events.record_event(db, lead.id, "stage_change", "contacted", getattr(user, "id", None))
|
| 530 |
+
return lead
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def set_meeting_outcome(db: Session, lead: Lead, stage: str, user=None) -> Lead:
|
| 534 |
+
"""
|
| 535 |
+
The Meetings detail popup's 4-way picker -- structure-plan.md Phase 5. Was a plain PATCH
|
| 536 |
+
pipeline_stage before; a dedicated function now because "lost" (Not Interested) must ALSO hide
|
| 537 |
+
the lead everywhere (same rule as Dead Line/Not Interested on Cold Call Desk, crud.apply_call_outcome()),
|
| 538 |
+
which the generic UpdateLeadInput deliberately cannot do -- hidden_at is not client-settable through
|
| 539 |
+
that path, so a manual Pipeline/Contacts stage change to Closed Lost does NOT hide a lead, only this does.
|
| 540 |
+
"""
|
| 541 |
+
lead.pipeline_stage = stage
|
| 542 |
+
if stage == "lost":
|
| 543 |
+
lead.hidden_at = _now()
|
| 544 |
+
lead.updated_at = _now()
|
| 545 |
+
if user is not None:
|
| 546 |
+
lead.last_touched_by_user_id = user.id
|
| 547 |
+
db.commit()
|
| 548 |
+
db.refresh(lead)
|
| 549 |
+
from app.crud import call_events
|
| 550 |
+
|
| 551 |
+
call_events.record_event(db, lead.id, "stage_change", stage, getattr(user, "id", None))
|
| 552 |
+
return lead
|
| 553 |
+
|
| 554 |
+
|
| 555 |
def delete_lead(db: Session, lead: Lead) -> None:
|
| 556 |
db.delete(lead)
|
| 557 |
db.commit()
|
|
|
|
| 603 |
)
|
| 604 |
).scalar_one()
|
| 605 |
|
| 606 |
+
# Cold Call Desk's own membership rule, structure-plan.md Phase 3 -- a lead is on the desk once a
|
| 607 |
+
# rep has explicitly sent it there (sent_to_desk_at), full stop; hidden_at is redundant here (every
|
| 608 |
+
# outcome that hides a lead also clears sent_to_desk_at, see apply_call_outcome()) but kept explicit
|
| 609 |
+
# so this count can never drift from crud.list_leads()'s own rule if that ever changes independently.
|
| 610 |
call_ready = db.execute(
|
| 611 |
select(func.count()).where(
|
| 612 |
+
and_(Lead.sent_to_desk_at.is_not(None), Lead.hidden_at.is_(None))
|
|
|
|
|
|
|
|
|
|
| 613 |
)
|
| 614 |
).scalar_one()
|
| 615 |
|
app/models.py
CHANGED
|
@@ -70,6 +70,9 @@ class Lead(Base):
|
|
| 70 |
Index("idx_leads_industry", "industry_tag"),
|
| 71 |
Index("idx_leads_company", "company_name"),
|
| 72 |
Index("idx_leads_created", "created_at"),
|
|
|
|
|
|
|
|
|
|
| 73 |
)
|
| 74 |
|
| 75 |
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
@@ -86,7 +89,11 @@ class Lead(Base):
|
|
| 86 |
contact_phone: Mapped[str] = mapped_column(String(500), default="")
|
| 87 |
contact_linkedin: Mapped[str] = mapped_column(String(500), default="")
|
| 88 |
industry_tag: Mapped[str] = mapped_column(String(190), default="⚡ Tech & Commercial Products")
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
pipeline_stage: Mapped[str] = mapped_column(String(32), nullable=False, default="draft_ready")
|
| 91 |
# Vestigial in the Streamlit app: everything writes "new" and reads pipeline_stage.
|
| 92 |
status: Mapped[str] = mapped_column(String(32), nullable=False, default="new")
|
|
@@ -175,6 +182,23 @@ class Lead(Base):
|
|
| 175 |
# Follow-ups page filter. "" until a lead has ever been called.
|
| 176 |
last_call_outcome: Mapped[str] = mapped_column(String(32), default="")
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
contacts: Mapped[list["LeadContact"]] = relationship(
|
| 179 |
back_populates="lead", cascade="all, delete-orphan", passive_deletes=True
|
| 180 |
)
|
|
|
|
| 70 |
Index("idx_leads_industry", "industry_tag"),
|
| 71 |
Index("idx_leads_company", "company_name"),
|
| 72 |
Index("idx_leads_created", "created_at"),
|
| 73 |
+
Index("idx_leads_sent_to_desk", "sent_to_desk_at"),
|
| 74 |
+
Index("idx_leads_hidden", "hidden_at"),
|
| 75 |
+
Index("idx_leads_callback", "callback_at"),
|
| 76 |
)
|
| 77 |
|
| 78 |
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
| 89 |
contact_phone: Mapped[str] = mapped_column(String(500), default="")
|
| 90 |
contact_linkedin: Mapped[str] = mapped_column(String(500), default="")
|
| 91 |
industry_tag: Mapped[str] = mapped_column(String(190), default="⚡ Tech & Commercial Products")
|
| 92 |
+
# structure-plan.md Phase 8: default deal value is 10000 everywhere (was 15000/18000/20000 in three
|
| 93 |
+
# different places -- create_lead(), lead_from_dict() and this column default all disagreed). This
|
| 94 |
+
# column default is not actually reachable today (both places that build a Lead() always pass
|
| 95 |
+
# deal_value explicitly), kept in sync anyway so nothing here still says a different number.
|
| 96 |
+
deal_value: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("10000.00"))
|
| 97 |
pipeline_stage: Mapped[str] = mapped_column(String(32), nullable=False, default="draft_ready")
|
| 98 |
# Vestigial in the Streamlit app: everything writes "new" and reads pipeline_stage.
|
| 99 |
status: Mapped[str] = mapped_column(String(32), nullable=False, default="new")
|
|
|
|
| 182 |
# Follow-ups page filter. "" until a lead has ever been called.
|
| 183 |
last_call_outcome: Mapped[str] = mapped_column(String(32), default="")
|
| 184 |
|
| 185 |
+
# --- structure restructure (revision 0013, structure-plan.md) ---
|
| 186 |
+
# Set the moment a rep presses "Send to Cold Call Desk" on Contacts; cleared when the lead leaves
|
| 187 |
+
# the desk on its own (Wrong Number goes back to Contacts and must be re-sent) or advances past it
|
| 188 |
+
# (Pipeline/Projects). Cold Call Desk's own query is "sent_to_desk_at is not null" -- this REPLACES
|
| 189 |
+
# "every lead not yet dispositioned" as the desk's membership rule.
|
| 190 |
+
sent_to_desk_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 191 |
+
# Who picked up the call, from the "Call picked by" row -- tag only, never changes pipeline_stage or
|
| 192 |
+
# queue position. NULL until set; "" is never written (unlike several other free-text columns above).
|
| 193 |
+
call_picked_by: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
| 194 |
+
# Set by Dead Line / Not Interested (cold call), and later Meeting's Not Interested -- the lead is
|
| 195 |
+
# recorded but shown NOWHERE (crud.list_leads() excludes it unconditionally; a single-lead GET by id
|
| 196 |
+
# still works). A later phase adds an explicit audit view; until then this is genuinely invisible.
|
| 197 |
+
hidden_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 198 |
+
# Callback Scheduled's date/time (separate from meeting_at -- a callback is not a meeting and must
|
| 199 |
+
# never appear on the Meetings calendar). Pipeline shows/filters by this.
|
| 200 |
+
callback_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 201 |
+
|
| 202 |
contacts: Mapped[list["LeadContact"]] = relationship(
|
| 203 |
back_populates="lead", cascade="all, delete-orphan", passive_deletes=True
|
| 204 |
)
|
app/schemas/lead.py
CHANGED
|
@@ -162,6 +162,17 @@ class LeadOut(BaseModel):
|
|
| 162 |
# None = before login existed.
|
| 163 |
created_by_user_id: int | None = None
|
| 164 |
last_touched_by_user_id: int | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
@classmethod
|
| 167 |
def from_model(cls, m: Any) -> "LeadOut":
|
|
@@ -219,6 +230,10 @@ class LeadOut(BaseModel):
|
|
| 219 |
last_call_outcome=_s(getattr(m, "last_call_outcome", "")),
|
| 220 |
created_by_user_id=getattr(m, "created_by_user_id", None),
|
| 221 |
last_touched_by_user_id=getattr(m, "last_touched_by_user_id", None),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
call_tz=call_timezone(
|
| 223 |
_s(m.contact_phone),
|
| 224 |
_s(getattr(m, "phone_country", "")) or _s(getattr(m, "country", "")),
|
|
@@ -317,7 +332,8 @@ class CreateLeadInput(BaseModel):
|
|
| 317 |
contact_email: str = Field(default="unknown", max_length=320)
|
| 318 |
contact_phone: str = Field(default="", max_length=500)
|
| 319 |
country: str = Field(default="", max_length=2)
|
| 320 |
-
|
|
|
|
| 321 |
industry_tag: str = Field(default=DEFAULT_CATEGORY, max_length=120)
|
| 322 |
pipeline_stage: PipelineStage = "draft_ready"
|
| 323 |
reason: str = Field(default="", max_length=5000)
|
|
@@ -392,6 +408,29 @@ class CallOutcomeInput(BaseModel):
|
|
| 392 |
# prompt shown when a rep clicks "🎯 Booked!" (see battlecard.tsx). Other
|
| 393 |
# outcomes just omit it.
|
| 394 |
meeting_at: datetime | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
|
| 396 |
|
| 397 |
class AddNoteInput(BaseModel):
|
|
|
|
| 162 |
# None = before login existed.
|
| 163 |
created_by_user_id: int | None = None
|
| 164 |
last_touched_by_user_id: int | None = None
|
| 165 |
+
# --- structure restructure (schema revision 0013, structure-plan.md) ---
|
| 166 |
+
# "" = not on the Cold Call Desk (still in Contacts, or sent back there e.g. by Wrong Number) --
|
| 167 |
+
# same "" -> unset convention as every other timestamp/string field above, not None.
|
| 168 |
+
sent_to_desk_at: str = ""
|
| 169 |
+
# "receptionist" | "decision_maker" | "team_member" | "" -- tag only, see models.Lead.
|
| 170 |
+
call_picked_by: str = ""
|
| 171 |
+
# "" = not hidden. Present on LeadOut for completeness; crud.list_leads() already excludes a hidden
|
| 172 |
+
# lead from every LIST response, so this is only ever seen via GET /leads/{id} directly.
|
| 173 |
+
hidden_at: str = ""
|
| 174 |
+
# "" = no callback scheduled. Pipeline shows/filters leads by this.
|
| 175 |
+
callback_at: str = ""
|
| 176 |
|
| 177 |
@classmethod
|
| 178 |
def from_model(cls, m: Any) -> "LeadOut":
|
|
|
|
| 230 |
last_call_outcome=_s(getattr(m, "last_call_outcome", "")),
|
| 231 |
created_by_user_id=getattr(m, "created_by_user_id", None),
|
| 232 |
last_touched_by_user_id=getattr(m, "last_touched_by_user_id", None),
|
| 233 |
+
sent_to_desk_at=_dt_utc(getattr(m, "sent_to_desk_at", None)),
|
| 234 |
+
call_picked_by=_s(getattr(m, "call_picked_by", "")),
|
| 235 |
+
hidden_at=_dt_utc(getattr(m, "hidden_at", None)),
|
| 236 |
+
callback_at=_dt_utc(getattr(m, "callback_at", None)),
|
| 237 |
call_tz=call_timezone(
|
| 238 |
_s(m.contact_phone),
|
| 239 |
_s(getattr(m, "phone_country", "")) or _s(getattr(m, "country", "")),
|
|
|
|
| 332 |
contact_email: str = Field(default="unknown", max_length=320)
|
| 333 |
contact_phone: str = Field(default="", max_length=500)
|
| 334 |
country: str = Field(default="", max_length=2)
|
| 335 |
+
# structure-plan.md Phase 8: default deal value is 10000 everywhere (was 18000 here).
|
| 336 |
+
deal_value: float = Field(default=10000.0, ge=0, le=100_000_000)
|
| 337 |
industry_tag: str = Field(default=DEFAULT_CATEGORY, max_length=120)
|
| 338 |
pipeline_stage: PipelineStage = "draft_ready"
|
| 339 |
reason: str = Field(default="", max_length=5000)
|
|
|
|
| 408 |
# prompt shown when a rep clicks "🎯 Booked!" (see battlecard.tsx). Other
|
| 409 |
# outcomes just omit it.
|
| 410 |
meeting_at: datetime | None = None
|
| 411 |
+
# Only meaningful for outcome="callback_scheduled" -- same date/time prompt style as meeting_at
|
| 412 |
+
# above, but a SEPARATE column (callback_at) so it never shows on the Meetings calendar.
|
| 413 |
+
callback_at: datetime | None = None
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
class CallPickedByInput(BaseModel):
|
| 417 |
+
"""POST /leads/{id}/call-picked-by -- the "Call picked by" row. Tag only, see crud.set_call_picked_by()."""
|
| 418 |
+
|
| 419 |
+
model_config = ConfigDict(extra="forbid")
|
| 420 |
+
|
| 421 |
+
value: Literal["receptionist", "decision_maker", "team_member"]
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
class MeetingOutcomeInput(BaseModel):
|
| 425 |
+
"""
|
| 426 |
+
POST /leads/{id}/meeting-outcome -- the 4-way picker in the Meetings detail popup
|
| 427 |
+
(Client Closed / Send Proposal / Needs Follow-up / Not Interested). See
|
| 428 |
+
crud.set_meeting_outcome(). structure-plan.md Phase 5.
|
| 429 |
+
"""
|
| 430 |
+
|
| 431 |
+
model_config = ConfigDict(extra="forbid")
|
| 432 |
+
|
| 433 |
+
stage: Literal["won", "proposal_sent", "followup_due", "lost"]
|
| 434 |
|
| 435 |
|
| 436 |
class AddNoteInput(BaseModel):
|
app/services/agent_core.py
CHANGED
|
@@ -106,7 +106,7 @@ Return a valid JSON array of objects:
|
|
| 106 |
"contact_linkedin": "Direct personal LinkedIn profile URL (https://www.linkedin.com/in/...) or company LinkedIn page (https://www.linkedin.com/company/...)",
|
| 107 |
"industry_tag": "Must be one of: '⛳ Custom Golf Carts', '🏎️ Custom Automotive & Mobility', '🛋️ Luxury Furniture & Interiors', '🏗️ Real Estate & Megaprojects', '⛵ Superyachts & Marine', '⚡ Tech & Commercial Products'",
|
| 108 |
"country": "ISO 3166-1 alpha-2 code of where this company is actually based, e.g. US, CA, AE, GB",
|
| 109 |
-
"deal_value":
|
| 110 |
"reason_no_configurator": "Precise reason why Elipse Studio adds massive value"
|
| 111 |
}
|
| 112 |
]
|
|
@@ -1278,7 +1278,8 @@ Identify {max_leads} real commercial businesses/brands/exhibitors matching this
|
|
| 1278 |
# slice (covers an already-correct code the dict doesn't translate).
|
| 1279 |
raw_country = (c.get("country") or "").strip()
|
| 1280 |
country = countries.name_to_iso2(raw_country) or raw_country.upper()[:2]
|
| 1281 |
-
|
|
|
|
| 1282 |
# No email draft is generated at discovery any more (it cost output
|
| 1283 |
# tokens for every lead, most of which never reach follow-up). A draft
|
| 1284 |
# is written only when a lead is in follow-ups -- see the Follow-ups
|
|
|
|
| 106 |
"contact_linkedin": "Direct personal LinkedIn profile URL (https://www.linkedin.com/in/...) or company LinkedIn page (https://www.linkedin.com/company/...)",
|
| 107 |
"industry_tag": "Must be one of: '⛳ Custom Golf Carts', '🏎️ Custom Automotive & Mobility', '🛋️ Luxury Furniture & Interiors', '🏗️ Real Estate & Megaprojects', '⛵ Superyachts & Marine', '⚡ Tech & Commercial Products'",
|
| 108 |
"country": "ISO 3166-1 alpha-2 code of where this company is actually based, e.g. US, CA, AE, GB",
|
| 109 |
+
"deal_value": 10000,
|
| 110 |
"reason_no_configurator": "Precise reason why Elipse Studio adds massive value"
|
| 111 |
}
|
| 112 |
]
|
|
|
|
| 1278 |
# slice (covers an already-correct code the dict doesn't translate).
|
| 1279 |
raw_country = (c.get("country") or "").strip()
|
| 1280 |
country = countries.name_to_iso2(raw_country) or raw_country.upper()[:2]
|
| 1281 |
+
# structure-plan.md Phase 8: default deal value is 10000 everywhere (was 25000 here).
|
| 1282 |
+
deal_val = float(c.get("deal_value") or 10000.0)
|
| 1283 |
# No email draft is generated at discovery any more (it cost output
|
| 1284 |
# tokens for every lead, most of which never reach follow-up). A draft
|
| 1285 |
# is written only when a lead is in follow-ups -- see the Follow-ups
|
app/services/lead_engine.py
CHANGED
|
@@ -155,7 +155,8 @@ def query_apollo_leads(icp_prompt: str, limit: int = 50, api_key: str = None) ->
|
|
| 155 |
"contact_phone": scrubbed_phone["phone"],
|
| 156 |
"contact_linkedin": linkedin,
|
| 157 |
"industry_tag": clean_cat,
|
| 158 |
-
|
|
|
|
| 159 |
"phone_status": scrubbed_phone["status"],
|
| 160 |
"source_prompt": f"Apollo ICP: {icp_prompt}",
|
| 161 |
})
|
|
@@ -289,7 +290,8 @@ def parse_and_enrich_csv(
|
|
| 289 |
website = ""
|
| 290 |
industry_val = ""
|
| 291 |
country_val = ""
|
| 292 |
-
|
|
|
|
| 293 |
deal_found = False
|
| 294 |
for row in rows:
|
| 295 |
if not website and c_website and row.get(c_website, "").strip():
|
|
@@ -301,7 +303,7 @@ def parse_and_enrich_csv(
|
|
| 301 |
if not deal_found and c_deal and row.get(c_deal):
|
| 302 |
try:
|
| 303 |
v = float(re.sub(r"[^\d.]", "", str(row[c_deal])))
|
| 304 |
-
deal_val = v if v >= 5000 else
|
| 305 |
deal_found = True
|
| 306 |
except Exception:
|
| 307 |
pass
|
|
|
|
| 155 |
"contact_phone": scrubbed_phone["phone"],
|
| 156 |
"contact_linkedin": linkedin,
|
| 157 |
"industry_tag": clean_cat,
|
| 158 |
+
# structure-plan.md Phase 8: default deal value is 10000 everywhere (was 25000 here).
|
| 159 |
+
"deal_value": 10000.0,
|
| 160 |
"phone_status": scrubbed_phone["status"],
|
| 161 |
"source_prompt": f"Apollo ICP: {icp_prompt}",
|
| 162 |
})
|
|
|
|
| 290 |
website = ""
|
| 291 |
industry_val = ""
|
| 292 |
country_val = ""
|
| 293 |
+
# structure-plan.md Phase 8: default deal value is 10000 everywhere.
|
| 294 |
+
deal_val = 10000.0
|
| 295 |
deal_found = False
|
| 296 |
for row in rows:
|
| 297 |
if not website and c_website and row.get(c_website, "").strip():
|
|
|
|
| 303 |
if not deal_found and c_deal and row.get(c_deal):
|
| 304 |
try:
|
| 305 |
v = float(re.sub(r"[^\d.]", "", str(row[c_deal])))
|
| 306 |
+
deal_val = v if v >= 5000 else 10000.0
|
| 307 |
deal_found = True
|
| 308 |
except Exception:
|
| 309 |
pass
|
scripts/check_duplicate_leads.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
check_duplicate_leads.py -- read-only: lists every company_name that has more than one Lead row.
|
| 3 |
+
|
| 4 |
+
Investigating a user report (2026-09-22): "Key West Boats" showing twice on the Pipeline tab. Confirmed:
|
| 5 |
+
every duplicate found so far is source="Manual entry", created 2026-09-15/17 -- crud.is_duplicate() (the
|
| 6 |
+
case-insensitive company_name check) wasn't wired into the "Add a Lead" popover's endpoint until it went
|
| 7 |
+
live 2026-09-17 (see app/api/leads.py create_lead()'s own docstring), so a rep could accidentally submit
|
| 8 |
+
the same company twice before that fix landed. It's already wired into every creation path now (manual,
|
| 9 |
+
AI discovery, CSV import, Google Maps) -- these groups are historical leftovers from just before the fix,
|
| 10 |
+
not an ongoing bug.
|
| 11 |
+
|
| 12 |
+
uv run python scripts/check_duplicate_leads.py
|
| 13 |
+
|
| 14 |
+
Read-only -- prints what it finds, changes nothing. Prints enough per row (deal value, category, meeting/
|
| 15 |
+
callback dates) to tell which one is the real record before deciding which to delete.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import sys
|
| 21 |
+
from collections import defaultdict
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 25 |
+
|
| 26 |
+
from sqlalchemy import select # noqa: E402
|
| 27 |
+
|
| 28 |
+
from app.database import SessionLocal # noqa: E402
|
| 29 |
+
from app.models import Lead # noqa: E402
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main() -> int:
|
| 33 |
+
db = SessionLocal()
|
| 34 |
+
try:
|
| 35 |
+
leads = list(db.scalars(select(Lead).order_by(Lead.company_name, Lead.created_at)).all())
|
| 36 |
+
groups: dict[str, list[Lead]] = defaultdict(list)
|
| 37 |
+
for lead in leads:
|
| 38 |
+
key = (lead.company_name or "").strip().lower()
|
| 39 |
+
if key:
|
| 40 |
+
groups[key].append(lead)
|
| 41 |
+
|
| 42 |
+
dupes = {k: v for k, v in groups.items() if len(v) > 1}
|
| 43 |
+
if not dupes:
|
| 44 |
+
print("No duplicate company names found.")
|
| 45 |
+
return 0
|
| 46 |
+
|
| 47 |
+
print(f"{len(dupes)} company name(s) with more than one lead row:\n")
|
| 48 |
+
for key, rows in sorted(dupes.items(), key=lambda kv: -len(kv[1])):
|
| 49 |
+
print(f"'{rows[0].company_name}' -- {len(rows)} rows:")
|
| 50 |
+
for lead in rows:
|
| 51 |
+
print(
|
| 52 |
+
f" #{lead.id:<6} stage={lead.pipeline_stage:<15} "
|
| 53 |
+
f"deal_value={lead.deal_value} industry_tag={lead.industry_tag!r}"
|
| 54 |
+
)
|
| 55 |
+
print(
|
| 56 |
+
f" meeting_at={lead.meeting_at} callback_at={lead.callback_at} "
|
| 57 |
+
f"sent_to_desk_at={lead.sent_to_desk_at} hidden_at={lead.hidden_at}"
|
| 58 |
+
)
|
| 59 |
+
print(
|
| 60 |
+
f" created_at={lead.created_at} source={lead.source_prompt!r} "
|
| 61 |
+
f"notes={'yes' if (lead.notes or '').strip() else 'no'}"
|
| 62 |
+
)
|
| 63 |
+
print()
|
| 64 |
+
return 0
|
| 65 |
+
finally:
|
| 66 |
+
db.close()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
sys.exit(main())
|
scripts/check_structure_auto_desk.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
check_structure_auto_desk.py -- offline: every new lead now lands on Cold Call Desk immediately
|
| 3 |
+
(user, 2026-09-22: "hum ab chahty hein woh leads coldcalldesk me bhi jati rhyn... jo abhi abhi ati
|
| 4 |
+
jayengi wo upar coldcalldesk me ati rhyngi"). Reverses structure-plan.md Phase 2/3's "a lead reaches
|
| 5 |
+
the desk only once Contacts explicitly sends it".
|
| 6 |
+
|
| 7 |
+
uv run python scripts/check_structure_auto_desk.py
|
| 8 |
+
|
| 9 |
+
No database, no network -- throwaway in-memory SQLite. Covers both lead-creation paths: create_lead()
|
| 10 |
+
(manual Add Lead popover) and add_lead_dict()/lead_from_dict() (AI discovery, CSV import, Google Maps,
|
| 11 |
+
Apollo -- all four go through the same function).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 20 |
+
|
| 21 |
+
from sqlalchemy import create_engine # noqa: E402
|
| 22 |
+
from sqlalchemy.orm import sessionmaker # noqa: E402
|
| 23 |
+
from sqlalchemy.pool import StaticPool # noqa: E402
|
| 24 |
+
|
| 25 |
+
import app.models # noqa: E402,F401
|
| 26 |
+
from app.crud import leads as crud_leads # noqa: E402
|
| 27 |
+
from app.database import Base # noqa: E402
|
| 28 |
+
from app.schemas.lead import CreateLeadInput # noqa: E402
|
| 29 |
+
|
| 30 |
+
FAILED = 0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def check(label: str, ok: bool, detail: str = "") -> None:
|
| 34 |
+
global FAILED
|
| 35 |
+
FAILED += 0 if ok else 1
|
| 36 |
+
print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main() -> int:
|
| 40 |
+
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
| 41 |
+
Base.metadata.create_all(engine)
|
| 42 |
+
db = sessionmaker(bind=engine)()
|
| 43 |
+
|
| 44 |
+
print("\ncreate_lead() -- manual Add Lead popover")
|
| 45 |
+
lead = crud_leads.create_lead(db, CreateLeadInput(company_name="Manual Co"))
|
| 46 |
+
check("sent_to_desk_at set at creation", lead.sent_to_desk_at is not None)
|
| 47 |
+
# Cold Call Desk's own membership rule (components/cold-call/cold-call-view.tsx): every non-hidden
|
| 48 |
+
# lead with sent_to_desk_at set. list_leads() has no separate "call ready" filter of its own.
|
| 49 |
+
fetched = next(l for l in crud_leads.list_leads(db) if l.id == lead.id)
|
| 50 |
+
check("on the call-ready queue (list_leads + sent_to_desk_at set)", fetched.sent_to_desk_at is not None)
|
| 51 |
+
|
| 52 |
+
print("\nadd_lead_dict() -- AI discovery / CSV import / Google Maps / Apollo (shared path)")
|
| 53 |
+
for label in ("AI Co", "CSV Co", "Maps Co", "Apollo Co"):
|
| 54 |
+
lead = crud_leads.add_lead_dict(db, {"company_name": label, "source_prompt": "CSV Batch Import"})
|
| 55 |
+
check(f"{label}: sent_to_desk_at set at creation", lead.sent_to_desk_at is not None)
|
| 56 |
+
|
| 57 |
+
print(f"\n{'FAILED: ' + str(FAILED) if FAILED else 'All checks passed.'}")
|
| 58 |
+
return 1 if FAILED else 0
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
if __name__ == "__main__":
|
| 62 |
+
sys.exit(main())
|
scripts/check_structure_phase1.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
check_structure_phase1.py -- offline: do the new Phase 1 columns (structure-plan.md) line up?
|
| 3 |
+
|
| 4 |
+
uv run python scripts/check_structure_phase1.py
|
| 5 |
+
|
| 6 |
+
No database, no network. Checks: the model has sent_to_desk_at/call_picked_by, the migration
|
| 7 |
+
0013 adds exactly those two columns and reverses cleanly, and LeadOut.from_model() round-trips
|
| 8 |
+
both through a throwaway in-memory SQLite lead. This only proves the plumbing is consistent --
|
| 9 |
+
it does NOT touch Neon, so `uv run alembic upgrade head` is still required separately.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 18 |
+
|
| 19 |
+
from sqlalchemy import create_engine, inspect # noqa: E402
|
| 20 |
+
from sqlalchemy.orm import sessionmaker # noqa: E402
|
| 21 |
+
from sqlalchemy.pool import StaticPool # noqa: E402
|
| 22 |
+
|
| 23 |
+
import app.models # noqa: E402,F401
|
| 24 |
+
from app.database import Base # noqa: E402
|
| 25 |
+
from app.models import Lead # noqa: E402
|
| 26 |
+
from app.schemas.lead import LeadOut # noqa: E402
|
| 27 |
+
|
| 28 |
+
FAILED = 0
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def check(label: str, ok: bool, detail: str = "") -> None:
|
| 32 |
+
global FAILED
|
| 33 |
+
FAILED += 0 if ok else 1
|
| 34 |
+
print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def main() -> int:
|
| 38 |
+
print("\nmodel + migration")
|
| 39 |
+
cols = {c.name for c in Lead.__table__.columns}
|
| 40 |
+
check("Lead has sent_to_desk_at", "sent_to_desk_at" in cols)
|
| 41 |
+
check("Lead has call_picked_by", "call_picked_by" in cols)
|
| 42 |
+
|
| 43 |
+
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
| 44 |
+
Base.metadata.create_all(engine)
|
| 45 |
+
db_cols = {c["name"] for c in inspect(engine).get_columns("leads")}
|
| 46 |
+
check("sent_to_desk_at actually lands in the table DDL", "sent_to_desk_at" in db_cols)
|
| 47 |
+
check("call_picked_by actually lands in the table DDL", "call_picked_by" in db_cols)
|
| 48 |
+
|
| 49 |
+
print("\nLeadOut round-trip")
|
| 50 |
+
db = sessionmaker(bind=engine)()
|
| 51 |
+
lead = Lead(company_name="Acme")
|
| 52 |
+
db.add(lead)
|
| 53 |
+
db.commit()
|
| 54 |
+
db.refresh(lead)
|
| 55 |
+
out = LeadOut.from_model(lead)
|
| 56 |
+
check("a fresh lead reads sent_to_desk_at as '' (not on the desk)", out.sent_to_desk_at == "")
|
| 57 |
+
check("a fresh lead reads call_picked_by as ''", out.call_picked_by == "")
|
| 58 |
+
|
| 59 |
+
from datetime import datetime
|
| 60 |
+
|
| 61 |
+
lead.sent_to_desk_at = datetime(2026, 9, 22, 10, 0)
|
| 62 |
+
lead.call_picked_by = "receptionist"
|
| 63 |
+
db.commit()
|
| 64 |
+
db.refresh(lead)
|
| 65 |
+
out2 = LeadOut.from_model(lead)
|
| 66 |
+
check("sent_to_desk_at serializes as a UTC ISO string ('Z' suffix)", out2.sent_to_desk_at.endswith("Z"))
|
| 67 |
+
check("call_picked_by round-trips", out2.call_picked_by == "receptionist")
|
| 68 |
+
|
| 69 |
+
# Wrong Number's "leaves the desk" behaviour: clearing sent_to_desk_at.
|
| 70 |
+
lead.sent_to_desk_at = None
|
| 71 |
+
db.commit()
|
| 72 |
+
db.refresh(lead)
|
| 73 |
+
check("clearing sent_to_desk_at (Wrong Number -> back to Contacts) reads back as ''",
|
| 74 |
+
LeadOut.from_model(lead).sent_to_desk_at == "")
|
| 75 |
+
|
| 76 |
+
print()
|
| 77 |
+
print("All checks passed." if not FAILED else f"{FAILED} check(s) FAILED")
|
| 78 |
+
return 1 if FAILED else 0
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
if __name__ == "__main__":
|
| 82 |
+
sys.exit(main())
|
scripts/check_structure_phase2.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
check_structure_phase2.py -- offline: "Send to Cold Call Desk" (structure-plan.md Phase 2).
|
| 3 |
+
|
| 4 |
+
uv run python scripts/check_structure_phase2.py
|
| 5 |
+
|
| 6 |
+
No database, no network -- throwaway in-memory SQLite. Covers: sending a fresh lead marks it (and is
|
| 7 |
+
attributed to the signed-in user), re-sending an already-sent lead just refreshes the timestamp, it is
|
| 8 |
+
logged as an activity event, and a lead nobody has sent yet reads as "" (matches structure-plan.md's
|
| 9 |
+
"Cold Call Desk query becomes sent_to_desk_at is not null").
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 18 |
+
|
| 19 |
+
from sqlalchemy import select # noqa: E402
|
| 20 |
+
from sqlalchemy.orm import sessionmaker # noqa: E402
|
| 21 |
+
from sqlalchemy.pool import StaticPool # noqa: E402
|
| 22 |
+
from sqlalchemy import create_engine # noqa: E402
|
| 23 |
+
|
| 24 |
+
import app.models # noqa: E402,F401
|
| 25 |
+
from app.crud import leads as crud_leads # noqa: E402
|
| 26 |
+
from app.database import Base # noqa: E402
|
| 27 |
+
from app.models import CallEvent, User # noqa: E402
|
| 28 |
+
from app.schemas.lead import LeadOut # noqa: E402
|
| 29 |
+
|
| 30 |
+
FAILED = 0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def check(label: str, ok: bool, detail: str = "") -> None:
|
| 34 |
+
global FAILED
|
| 35 |
+
FAILED += 0 if ok else 1
|
| 36 |
+
print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main() -> int:
|
| 40 |
+
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
| 41 |
+
Base.metadata.create_all(engine)
|
| 42 |
+
db = sessionmaker(bind=engine)()
|
| 43 |
+
|
| 44 |
+
ali = User(email="ali@elipsestudio.com", name="Ali Khan", is_active=True)
|
| 45 |
+
db.add(ali)
|
| 46 |
+
db.commit()
|
| 47 |
+
|
| 48 |
+
lead = crud_leads.add_lead_dict(db, {"company_name": "Acme"})
|
| 49 |
+
check("a fresh lead is not on the desk", LeadOut.from_model(lead).sent_to_desk_at == "")
|
| 50 |
+
|
| 51 |
+
sent = crud_leads.send_to_desk(db, lead, ali)
|
| 52 |
+
out = LeadOut.from_model(sent)
|
| 53 |
+
check("sending it sets sent_to_desk_at", out.sent_to_desk_at != "")
|
| 54 |
+
check("sending it credits the signed-in user as last-touched", sent.last_touched_by_user_id == ali.id)
|
| 55 |
+
|
| 56 |
+
events = db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all()
|
| 57 |
+
check("it is logged as one activity event", len(events) == 1 and events[0].event_type == "sent_to_desk")
|
| 58 |
+
check("the event is attributed to the user", events[0].user_id == ali.id)
|
| 59 |
+
|
| 60 |
+
first_stamp = sent.sent_to_desk_at
|
| 61 |
+
resent = crud_leads.send_to_desk(db, lead, None) # re-send with nobody signed in
|
| 62 |
+
check("re-sending refreshes the timestamp", resent.sent_to_desk_at is not None
|
| 63 |
+
and resent.sent_to_desk_at >= first_stamp)
|
| 64 |
+
check("re-sending with nobody signed in still works (no crash)", True)
|
| 65 |
+
check("re-sending logs a second event", len(db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all()) == 2)
|
| 66 |
+
|
| 67 |
+
print()
|
| 68 |
+
print("All checks passed." if not FAILED else f"{FAILED} check(s) FAILED")
|
| 69 |
+
return 1 if FAILED else 0
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
sys.exit(main())
|
scripts/check_structure_phase3.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
check_structure_phase3.py -- offline: Cold Call Desk card redesign (structure-plan.md Phase 3).
|
| 3 |
+
|
| 4 |
+
uv run python scripts/check_structure_phase3.py
|
| 5 |
+
|
| 6 |
+
No database, no network -- throwaway in-memory SQLite. Covers: which outcomes leave the desk
|
| 7 |
+
(sent_to_desk_at cleared) vs stay on it, Dead Line/Not Interested hiding a lead from every list
|
| 8 |
+
query while a direct-by-id fetch still finds it, Wrong Number leaving the desk WITHOUT hiding it,
|
| 9 |
+
Callback Scheduled's separate callback_at column, "Call picked by" (tag only, no call_events row),
|
| 10 |
+
and "Email Send" (crud.mark_needs_email: stage -> contacted, leaves the desk, one activity event).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 20 |
+
|
| 21 |
+
from sqlalchemy import create_engine, select # noqa: E402
|
| 22 |
+
from sqlalchemy.orm import sessionmaker # noqa: E402
|
| 23 |
+
from sqlalchemy.pool import StaticPool # noqa: E402
|
| 24 |
+
|
| 25 |
+
import app.models # noqa: E402,F401
|
| 26 |
+
from app.crud import leads as crud_leads # noqa: E402
|
| 27 |
+
from app.database import Base # noqa: E402
|
| 28 |
+
from app.models import CallEvent # noqa: E402
|
| 29 |
+
|
| 30 |
+
FAILED = 0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def check(label: str, ok: bool, detail: str = "") -> None:
|
| 34 |
+
global FAILED
|
| 35 |
+
FAILED += 0 if ok else 1
|
| 36 |
+
print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def fresh_lead(db, name: str):
|
| 40 |
+
lead = crud_leads.add_lead_dict(db, {"company_name": name})
|
| 41 |
+
crud_leads.send_to_desk(db, lead)
|
| 42 |
+
return lead
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def visible_ids(db) -> set[int]:
|
| 46 |
+
return {l.id for l in crud_leads.list_leads(db)}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def main() -> int:
|
| 50 |
+
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
| 51 |
+
Base.metadata.create_all(engine)
|
| 52 |
+
db = sessionmaker(bind=engine)()
|
| 53 |
+
|
| 54 |
+
print("\nDead Line / Not Interested -- hidden everywhere")
|
| 55 |
+
for outcome, label in [("dead_number", "Dead Line"), ("not_interested", "Not Interested")]:
|
| 56 |
+
lead = fresh_lead(db, f"{label} Co")
|
| 57 |
+
crud_leads.apply_call_outcome(db, lead, outcome)
|
| 58 |
+
check(f"{label}: hidden_at is set", lead.hidden_at is not None)
|
| 59 |
+
check(f"{label}: leaves the desk too", lead.sent_to_desk_at is None)
|
| 60 |
+
check(f"{label}: excluded from list_leads()", lead.id not in visible_ids(db))
|
| 61 |
+
check(f"{label}: still fetchable directly by id", crud_leads.get_lead(db, lead.id) is not None)
|
| 62 |
+
|
| 63 |
+
print("\nWrong Number -- leaves the desk, stays visible (not hidden)")
|
| 64 |
+
lead = fresh_lead(db, "Wrong Number Co")
|
| 65 |
+
crud_leads.apply_call_outcome(db, lead, "wrong_number")
|
| 66 |
+
check("hidden_at is NOT set", lead.hidden_at is None)
|
| 67 |
+
check("leaves the desk", lead.sent_to_desk_at is None)
|
| 68 |
+
check("still shows in list_leads() (back in Contacts)", lead.id in visible_ids(db))
|
| 69 |
+
check("last_call_outcome records it for the Contacts badge", lead.last_call_outcome == "wrong_number")
|
| 70 |
+
|
| 71 |
+
print("\nCallback Scheduled -- separate column, leaves the desk, stays visible")
|
| 72 |
+
lead = fresh_lead(db, "Callback Co")
|
| 73 |
+
when = datetime(2026, 9, 25, 14, 30)
|
| 74 |
+
crud_leads.apply_call_outcome(db, lead, "callback_scheduled", callback_at=when)
|
| 75 |
+
check("callback_at is set to the given time", lead.callback_at == when)
|
| 76 |
+
check("meeting_at is untouched (never confused with a meeting)", lead.meeting_at is None)
|
| 77 |
+
check("leaves the desk", lead.sent_to_desk_at is None)
|
| 78 |
+
check("still shows in list_leads() (visible in Pipeline)", lead.id in visible_ids(db))
|
| 79 |
+
|
| 80 |
+
print("\nMeeting Booked / Closed -- leave the desk (unchanged destination, new desk-leaving rule)")
|
| 81 |
+
for outcome in ("meeting_booked", "closed"):
|
| 82 |
+
lead = fresh_lead(db, f"{outcome} Co")
|
| 83 |
+
crud_leads.apply_call_outcome(db, lead, outcome)
|
| 84 |
+
check(f"{outcome}: leaves the desk", lead.sent_to_desk_at is None)
|
| 85 |
+
check(f"{outcome}: not hidden", lead.hidden_at is None)
|
| 86 |
+
|
| 87 |
+
print("\nVoicemail / Hang Up / No Answer -- STAY on the desk")
|
| 88 |
+
for outcome in ("voicemail", "hang_up", "no_answer"):
|
| 89 |
+
lead = fresh_lead(db, f"{outcome} Co")
|
| 90 |
+
before = lead.sent_to_desk_at
|
| 91 |
+
crud_leads.apply_call_outcome(db, lead, outcome)
|
| 92 |
+
check(f"{outcome}: sent_to_desk_at unchanged", lead.sent_to_desk_at == before)
|
| 93 |
+
|
| 94 |
+
print("\nCall picked by -- tag only")
|
| 95 |
+
lead = fresh_lead(db, "Tag Co") # fresh_lead() itself logs one "sent_to_desk" event -- that is the baseline
|
| 96 |
+
before_stage, before_desk = lead.pipeline_stage, lead.sent_to_desk_at
|
| 97 |
+
before_events = len(db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all())
|
| 98 |
+
crud_leads.set_call_picked_by(db, lead, "receptionist")
|
| 99 |
+
check("call_picked_by is set", lead.call_picked_by == "receptionist")
|
| 100 |
+
check("pipeline_stage is untouched", lead.pipeline_stage == before_stage)
|
| 101 |
+
check("sent_to_desk_at is untouched (still on the desk)", lead.sent_to_desk_at == before_desk)
|
| 102 |
+
after_events = len(db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all())
|
| 103 |
+
check("no NEW call_events row is created", after_events == before_events, f"{before_events} -> {after_events}")
|
| 104 |
+
crud_leads.set_call_picked_by(db, lead, "team_member")
|
| 105 |
+
check("re-tagging overwrites the previous mark", lead.call_picked_by == "team_member")
|
| 106 |
+
|
| 107 |
+
print("\nEmail Send (mark_needs_email) -- flags for email, moves to Pipeline, leaves the desk")
|
| 108 |
+
lead = fresh_lead(db, "Email Send Co") # fresh_lead() logs one "sent_to_desk" event -- the baseline
|
| 109 |
+
before_events = len(db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all())
|
| 110 |
+
crud_leads.mark_needs_email(db, lead)
|
| 111 |
+
check("stage becomes contacted (\"Email Send\")", lead.pipeline_stage == "contacted")
|
| 112 |
+
check("leaves the desk", lead.sent_to_desk_at is None)
|
| 113 |
+
events = db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all()
|
| 114 |
+
check("logs exactly ONE NEW activity event", len(events) == before_events + 1)
|
| 115 |
+
check("...and it is a stage_change", events[-1].event_type == "stage_change" and events[-1].outcome == "contacted")
|
| 116 |
+
check("still shows in list_leads() (visible in Pipeline)", lead.id in visible_ids(db))
|
| 117 |
+
|
| 118 |
+
print()
|
| 119 |
+
print("All checks passed." if not FAILED else f"{FAILED} check(s) FAILED")
|
| 120 |
+
return 1 if FAILED else 0
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
if __name__ == "__main__":
|
| 124 |
+
sys.exit(main())
|
scripts/check_structure_phase5.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
check_structure_phase5.py -- offline: Meetings 4-way outcome picker (structure-plan.md Phase 5).
|
| 3 |
+
|
| 4 |
+
uv run python scripts/check_structure_phase5.py
|
| 5 |
+
|
| 6 |
+
No database, no network -- throwaway in-memory SQLite. Covers: crud.set_meeting_outcome() for all four
|
| 7 |
+
outcomes -- Client Closed / Send Proposal / Needs Follow-up just change stage (unchanged behaviour);
|
| 8 |
+
Not Interested ALSO hides the lead (the whole point of this phase); a manual stage-dropdown PATCH to
|
| 9 |
+
"lost" (Contacts/Pipeline's own Closed Lost option) does NOT hide a lead -- only this dedicated path does.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 18 |
+
|
| 19 |
+
from sqlalchemy import create_engine, select # noqa: E402
|
| 20 |
+
from sqlalchemy.orm import sessionmaker # noqa: E402
|
| 21 |
+
from sqlalchemy.pool import StaticPool # noqa: E402
|
| 22 |
+
|
| 23 |
+
import app.models # noqa: E402,F401
|
| 24 |
+
from app.crud import leads as crud_leads # noqa: E402
|
| 25 |
+
from app.database import Base # noqa: E402
|
| 26 |
+
from app.models import CallEvent # noqa: E402
|
| 27 |
+
from app.schemas.lead import UpdateLeadInput # noqa: E402
|
| 28 |
+
|
| 29 |
+
FAILED = 0
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def check(label: str, ok: bool, detail: str = "") -> None:
|
| 33 |
+
global FAILED
|
| 34 |
+
FAILED += 0 if ok else 1
|
| 35 |
+
print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main() -> int:
|
| 39 |
+
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
| 40 |
+
Base.metadata.create_all(engine)
|
| 41 |
+
db = sessionmaker(bind=engine)()
|
| 42 |
+
|
| 43 |
+
print("\nClient Closed / Send Proposal / Needs Follow-up -- stage only, never hidden")
|
| 44 |
+
for stage in ("won", "proposal_sent", "followup_due"):
|
| 45 |
+
lead = crud_leads.add_lead_dict(db, {"company_name": f"{stage} Co"})
|
| 46 |
+
crud_leads.set_meeting_outcome(db, lead, stage)
|
| 47 |
+
check(f"{stage}: pipeline_stage is set", lead.pipeline_stage == stage)
|
| 48 |
+
check(f"{stage}: not hidden", lead.hidden_at is None)
|
| 49 |
+
events = db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all()
|
| 50 |
+
check(f"{stage}: logs one stage_change event", len(events) == 1 and events[0].outcome == stage)
|
| 51 |
+
|
| 52 |
+
print("\nNot Interested -- hides the lead everywhere")
|
| 53 |
+
lead = crud_leads.add_lead_dict(db, {"company_name": "Not Interested Co"})
|
| 54 |
+
crud_leads.set_meeting_outcome(db, lead, "lost")
|
| 55 |
+
check("pipeline_stage is lost", lead.pipeline_stage == "lost")
|
| 56 |
+
check("hidden_at is set", lead.hidden_at is not None)
|
| 57 |
+
check("excluded from list_leads()", lead.id not in {l.id for l in crud_leads.list_leads(db)})
|
| 58 |
+
check("still fetchable directly by id", crud_leads.get_lead(db, lead.id) is not None)
|
| 59 |
+
|
| 60 |
+
print("\nManual stage change to Closed Lost (Contacts/Pipeline dropdown) does NOT hide a lead")
|
| 61 |
+
lead = crud_leads.add_lead_dict(db, {"company_name": "Manual Lost Co"})
|
| 62 |
+
crud_leads.update_lead(db, lead, UpdateLeadInput(pipeline_stage="lost"))
|
| 63 |
+
check("pipeline_stage is lost", lead.pipeline_stage == "lost")
|
| 64 |
+
check("hidden_at is NOT set -- only the meeting-outcome path hides a lead", lead.hidden_at is None)
|
| 65 |
+
check("still shows in list_leads()", lead.id in {l.id for l in crud_leads.list_leads(db)})
|
| 66 |
+
|
| 67 |
+
print()
|
| 68 |
+
print("All checks passed." if not FAILED else f"{FAILED} check(s) FAILED")
|
| 69 |
+
return 1 if FAILED else 0
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
sys.exit(main())
|
scripts/check_structure_phase8.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
check_structure_phase8.py -- offline: default deal value is 10000 everywhere (structure-plan.md Phase 8).
|
| 3 |
+
|
| 4 |
+
uv run python scripts/check_structure_phase8.py
|
| 5 |
+
|
| 6 |
+
No database, no network. Checks all five places a default deal value used to disagree (15000/18000/20000):
|
| 7 |
+
the Lead model's own column default, lead_from_dict()'s fallback (AI/Maps leads), lead_engine.py's CSV
|
| 8 |
+
importer (its own default AND its "value too small, don't trust it" fallback), and CreateLeadInput's
|
| 9 |
+
schema default (manual Add Lead, POST /leads). All five now read 10000.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
from decimal import Decimal
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 19 |
+
|
| 20 |
+
import app.models # noqa: E402,F401
|
| 21 |
+
from app.crud.leads import lead_from_dict # noqa: E402
|
| 22 |
+
from app.models import Lead # noqa: E402
|
| 23 |
+
from app.schemas.lead import CreateLeadInput # noqa: E402
|
| 24 |
+
from app.services.lead_engine import parse_and_enrich_csv # noqa: E402
|
| 25 |
+
|
| 26 |
+
FAILED = 0
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def check(label: str, ok: bool, detail: str = "") -> None:
|
| 30 |
+
global FAILED
|
| 31 |
+
FAILED += 0 if ok else 1
|
| 32 |
+
print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def main() -> int:
|
| 36 |
+
print("\nLead model column default")
|
| 37 |
+
col = Lead.__table__.columns["deal_value"]
|
| 38 |
+
check("Lead.deal_value defaults to 10000.00", col.default.arg == Decimal("10000.00"), str(col.default.arg))
|
| 39 |
+
|
| 40 |
+
print("\nlead_from_dict() -- AI / Maps leads with no deal_value given")
|
| 41 |
+
lead = lead_from_dict({"company_name": "No Deal Value Co"})
|
| 42 |
+
check("defaults to 10000", lead.deal_value == Decimal("10000.00"), str(lead.deal_value))
|
| 43 |
+
|
| 44 |
+
print("\nCreateLeadInput -- POST /leads (Add a Lead) schema default")
|
| 45 |
+
created = CreateLeadInput(company_name="Schema Default Co")
|
| 46 |
+
check("defaults to 10000.0", created.deal_value == 10000.0, str(created.deal_value))
|
| 47 |
+
|
| 48 |
+
print("\nCSV import -- lead_engine.parse_and_enrich_csv()")
|
| 49 |
+
no_deal_csv = "Company,Website\nNo Deal Co,example.com\n"
|
| 50 |
+
result = parse_and_enrich_csv(no_deal_csv)
|
| 51 |
+
check("row with no deal-value column at all defaults to 10000", result["leads"][0]["deal_value"] == 10000.0,
|
| 52 |
+
str(result["leads"][0]["deal_value"]))
|
| 53 |
+
|
| 54 |
+
junk_csv = "Company,Deal Value\nJunk Deal Co,not-a-real-number\n"
|
| 55 |
+
result2 = parse_and_enrich_csv(junk_csv)
|
| 56 |
+
check("an unparseable Deal Value cell also defaults to 10000", result2["leads"][0]["deal_value"] == 10000.0,
|
| 57 |
+
str(result2["leads"][0]["deal_value"]))
|
| 58 |
+
|
| 59 |
+
too_small_csv = "Company,Deal Value\nTiny Deal Co,500\n"
|
| 60 |
+
result3 = parse_and_enrich_csv(too_small_csv)
|
| 61 |
+
check("a parsed value under 5000 (untrustworthy) falls back to 10000, not the raw 500",
|
| 62 |
+
result3["leads"][0]["deal_value"] == 10000.0, str(result3["leads"][0]["deal_value"]))
|
| 63 |
+
|
| 64 |
+
real_csv = "Company,Deal Value\nReal Deal Co,75000\n"
|
| 65 |
+
result4 = parse_and_enrich_csv(real_csv)
|
| 66 |
+
check("a real value >= 5000 is kept as-is, not overridden", result4["leads"][0]["deal_value"] == 75000.0,
|
| 67 |
+
str(result4["leads"][0]["deal_value"]))
|
| 68 |
+
|
| 69 |
+
print()
|
| 70 |
+
print("All checks passed." if not FAILED else f"{FAILED} check(s) FAILED")
|
| 71 |
+
return 1 if FAILED else 0
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
if __name__ == "__main__":
|
| 75 |
+
sys.exit(main())
|
scripts/delete_lead_by_id.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
delete_lead_by_id.py -- permanently deletes one lead, by id, after printing it for confirmation.
|
| 3 |
+
|
| 4 |
+
Generic cleanup tool for the duplicate-lead groups check_duplicate_leads.py finds (user, 2026-09-22 --
|
| 5 |
+
"Key West Boats" and several others turned out to be manually double-added before the create_lead()
|
| 6 |
+
duplicate check went live 2026-09-17). Deletes exactly one id per run, on purpose -- no bulk mode -- so a
|
| 7 |
+
wrong id typed in only ever costs one lead, not a whole company's history.
|
| 8 |
+
|
| 9 |
+
uv run python scripts/delete_lead_by_id.py <id> # dry run -- prints the lead, deletes nothing
|
| 10 |
+
uv run python scripts/delete_lead_by_id.py <id> --apply # actually deletes it, permanently
|
| 11 |
+
|
| 12 |
+
Irreversible once run with --apply -- lead_contacts rows for it cascade-delete too.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 22 |
+
|
| 23 |
+
from app.crud import leads as crud_leads # noqa: E402
|
| 24 |
+
from app.database import SessionLocal # noqa: E402
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def main() -> int:
|
| 28 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 29 |
+
parser.add_argument("lead_id", type=int, help="the lead's id, e.g. from check_duplicate_leads.py")
|
| 30 |
+
parser.add_argument("--apply", action="store_true", help="actually delete (default: dry run only)")
|
| 31 |
+
args = parser.parse_args()
|
| 32 |
+
|
| 33 |
+
db = SessionLocal()
|
| 34 |
+
try:
|
| 35 |
+
lead = crud_leads.get_lead(db, args.lead_id)
|
| 36 |
+
if not lead:
|
| 37 |
+
print(f"No lead with id #{args.lead_id}.")
|
| 38 |
+
return 1
|
| 39 |
+
|
| 40 |
+
print(f"#{lead.id} {lead.company_name}")
|
| 41 |
+
print(f" stage={lead.pipeline_stage} deal_value={lead.deal_value} industry_tag={lead.industry_tag!r}")
|
| 42 |
+
print(f" meeting_at={lead.meeting_at} callback_at={lead.callback_at}")
|
| 43 |
+
print(f" created_at={lead.created_at} source={lead.source_prompt!r}")
|
| 44 |
+
|
| 45 |
+
if not args.apply:
|
| 46 |
+
print(f"\nDry run only -- re-run with --apply to permanently delete #{lead.id}.")
|
| 47 |
+
return 0
|
| 48 |
+
|
| 49 |
+
crud_leads.delete_lead(db, lead)
|
| 50 |
+
print(f"\nDeleted #{lead.id} ({lead.company_name}) permanently.")
|
| 51 |
+
return 0
|
| 52 |
+
finally:
|
| 53 |
+
db.close()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
if __name__ == "__main__":
|
| 57 |
+
sys.exit(main())
|
scripts/delete_user.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
delete_user.py -- remove (or just switch off) one account. Admin tool, dry run by default.
|
| 3 |
+
|
| 4 |
+
uv run python scripts/delete_user.py --email demo@elipsestudio.com # shows what it would do
|
| 5 |
+
uv run python scripts/delete_user.py --email demo@elipsestudio.com --apply # deletes the account
|
| 6 |
+
uv run python scripts/delete_user.py --email demo@elipsestudio.com --deactivate --apply # keeps it, blocks sign-in
|
| 7 |
+
|
| 8 |
+
What a delete does to the person's history (the database rows point at the user with ON DELETE SET NULL):
|
| 9 |
+
their calls, notes, stage changes, added leads and bulk jobs stay, but read as "Unassigned" from then on, and they
|
| 10 |
+
disappear from the Today and Weekly Report rows. Notifications sent TO them are removed. Nothing else is touched.
|
| 11 |
+
If you would rather keep their name on old activity, use --deactivate: they cannot sign in and drop out of the @mention
|
| 12 |
+
list, and their history keeps their name. A deleted email can sign up again later while it is still on the approved list.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 22 |
+
|
| 23 |
+
from sqlalchemy import func, select # noqa: E402
|
| 24 |
+
|
| 25 |
+
from app.crud import users as crud_users # noqa: E402
|
| 26 |
+
from app.database import SessionLocal # noqa: E402
|
| 27 |
+
from app.models import CallEvent, Job, Lead, Notification # noqa: E402
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def count(db, stmt) -> int:
|
| 31 |
+
return int(db.execute(stmt).scalar_one())
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def main() -> int:
|
| 35 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 36 |
+
ap.add_argument("--email", required=True)
|
| 37 |
+
ap.add_argument("--apply", action="store_true", help="actually do it (without this it only reports)")
|
| 38 |
+
ap.add_argument("--deactivate", action="store_true", help="switch the account off instead of deleting it")
|
| 39 |
+
args = ap.parse_args()
|
| 40 |
+
email = args.email.strip().lower()
|
| 41 |
+
|
| 42 |
+
db = SessionLocal()
|
| 43 |
+
try:
|
| 44 |
+
user = crud_users.get_user_by_email(db, email)
|
| 45 |
+
if user is None:
|
| 46 |
+
print(f"No account for {email}. Run scripts/check_users.py to see the exact emails.")
|
| 47 |
+
return 1
|
| 48 |
+
|
| 49 |
+
uid = user.id
|
| 50 |
+
n_events = count(db, select(func.count()).select_from(CallEvent).where(CallEvent.user_id == uid))
|
| 51 |
+
n_created = count(db, select(func.count()).select_from(Lead).where(Lead.created_by_user_id == uid))
|
| 52 |
+
n_touched = count(db, select(func.count()).select_from(Lead).where(Lead.last_touched_by_user_id == uid))
|
| 53 |
+
n_jobs = count(db, select(func.count()).select_from(Job).where(Job.user_id == uid))
|
| 54 |
+
n_in = count(db, select(func.count()).select_from(Notification).where(Notification.user_id == uid))
|
| 55 |
+
n_out = count(db, select(func.count()).select_from(Notification).where(Notification.actor_user_id == uid))
|
| 56 |
+
|
| 57 |
+
print(f"Account: {user.email} (id {uid}, name {user.name!r}, {'active' if user.is_active else 'already inactive'})")
|
| 58 |
+
print(f" actions recorded (calls, notes, stage changes, emails): {n_events}")
|
| 59 |
+
print(f" leads added: {n_created} leads last touched: {n_touched} bulk jobs started: {n_jobs}")
|
| 60 |
+
print(f" notifications received: {n_in} notifications they sent: {n_out}")
|
| 61 |
+
print()
|
| 62 |
+
if args.deactivate:
|
| 63 |
+
print("Would: switch the account off (cannot sign in, leaves the @mention list). History keeps the name.")
|
| 64 |
+
else:
|
| 65 |
+
print("Would: DELETE the account. The actions/leads/jobs above stay but become 'Unassigned';")
|
| 66 |
+
print(" notifications received are deleted; notifications they sent lose the sender name.")
|
| 67 |
+
if not args.apply:
|
| 68 |
+
print("\nDry run: nothing changed. Add --apply to do it.")
|
| 69 |
+
return 0
|
| 70 |
+
|
| 71 |
+
if args.deactivate:
|
| 72 |
+
user.is_active = False
|
| 73 |
+
db.commit()
|
| 74 |
+
print(f"\nDone. {email} is deactivated.")
|
| 75 |
+
else:
|
| 76 |
+
db.delete(user)
|
| 77 |
+
db.commit()
|
| 78 |
+
print(f"\nDone. {email} is deleted.")
|
| 79 |
+
finally:
|
| 80 |
+
db.close()
|
| 81 |
+
return 0
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
sys.exit(main())
|
scripts/revert_last_csv_import.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
revert_last_csv_import.py — permanently deletes the most recent CSV-import batch (user request,
|
| 3 |
+
2026-09-22: "kal raat ko jo humny csv sy leads add ki thin woh revert krdyn sari, ~105 leads, permanent
|
| 4 |
+
delete"). Same dry-run-by-default / --apply convention as cleanup_partial_september_import.py.
|
| 5 |
+
|
| 6 |
+
Only ever touches leads with source_prompt == "CSV Batch Import" (never any other source) -- and only the
|
| 7 |
+
most recent CONTIGUOUS cluster of them (gaps between consecutive created_at values under 4 hours are
|
| 8 |
+
treated as the same import run; a gap of 4+ hours starts a new, untouched, older batch). This finds "last
|
| 9 |
+
night's import" without hardcoding a date/timezone, so it can't accidentally reach back into an older CSV
|
| 10 |
+
import (e.g. the September tab) that happens to share the same source_prompt.
|
| 11 |
+
|
| 12 |
+
uv run python scripts/revert_last_csv_import.py # dry run -- prints what it would delete
|
| 13 |
+
uv run python scripts/revert_last_csv_import.py --apply # actually deletes it, permanently
|
| 14 |
+
|
| 15 |
+
Irreversible once run with --apply -- lead_contacts rows for each lead cascade-delete with it. Read the
|
| 16 |
+
dry-run output carefully (company count, name list, created_at range, who added them) before applying.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import sys
|
| 23 |
+
from datetime import timedelta
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 27 |
+
|
| 28 |
+
from sqlalchemy import select # noqa: E402
|
| 29 |
+
|
| 30 |
+
from app.crud import leads as crud_leads # noqa: E402
|
| 31 |
+
from app.database import SessionLocal # noqa: E402
|
| 32 |
+
from app.models import Lead, User # noqa: E402
|
| 33 |
+
|
| 34 |
+
# Leads created closer together than this are treated as one import run.
|
| 35 |
+
BATCH_GAP = timedelta(hours=4)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main() -> int:
|
| 39 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 40 |
+
parser.add_argument("--apply", action="store_true", help="actually delete (default: dry run only)")
|
| 41 |
+
args = parser.parse_args()
|
| 42 |
+
|
| 43 |
+
db = SessionLocal()
|
| 44 |
+
try:
|
| 45 |
+
candidates = list(
|
| 46 |
+
db.scalars(
|
| 47 |
+
select(Lead).where(Lead.source_prompt == "CSV Batch Import").order_by(Lead.created_at.desc())
|
| 48 |
+
).all()
|
| 49 |
+
)
|
| 50 |
+
if not candidates:
|
| 51 |
+
print("No CSV-imported leads found at all -- nothing to do.")
|
| 52 |
+
return 0
|
| 53 |
+
|
| 54 |
+
batch = [candidates[0]]
|
| 55 |
+
for lead in candidates[1:]:
|
| 56 |
+
if batch[-1].created_at - lead.created_at <= BATCH_GAP:
|
| 57 |
+
batch.append(lead)
|
| 58 |
+
else:
|
| 59 |
+
break
|
| 60 |
+
|
| 61 |
+
users = {u.id: u.name or u.email for u in db.scalars(select(User)).all()}
|
| 62 |
+
|
| 63 |
+
print(f"Most recent CSV-import batch: {len(batch)} lead(s)")
|
| 64 |
+
print(f" created_at range: {batch[-1].created_at} .. {batch[0].created_at} (UTC)")
|
| 65 |
+
older = len(candidates) - len(batch)
|
| 66 |
+
if older:
|
| 67 |
+
print(f" ({older} older CSV-imported lead(s) exist from before this batch -- NOT touched)")
|
| 68 |
+
print()
|
| 69 |
+
for lead in batch:
|
| 70 |
+
who = users.get(lead.created_by_user_id, "—") if lead.created_by_user_id else "—"
|
| 71 |
+
print(f" #{lead.id:<6} {lead.company_name:<40} created_at={lead.created_at} by={who}")
|
| 72 |
+
|
| 73 |
+
if not args.apply:
|
| 74 |
+
print(f"\nDry run only -- {len(batch)} lead(s) would be permanently deleted.")
|
| 75 |
+
print("Check the count and date range above match what you expect, then re-run with --apply.")
|
| 76 |
+
return 0
|
| 77 |
+
|
| 78 |
+
for lead in batch:
|
| 79 |
+
crud_leads.delete_lead(db, lead)
|
| 80 |
+
print(f"\nDeleted {len(batch)} lead(s) permanently (their lead_contacts rows cascade-deleted with them).")
|
| 81 |
+
return 0
|
| 82 |
+
finally:
|
| 83 |
+
db.close()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
sys.exit(main())
|
scripts/review_duplicate_leads.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
review_duplicate_leads.py -- scores each row in a duplicate-company group and recommends which to keep.
|
| 3 |
+
|
| 4 |
+
check_duplicate_leads.py found 22 companies with more than one Lead row, all source="Manual entry",
|
| 5 |
+
created 2026-09-15/17 (before create_lead()'s duplicate check went live 2026-09-17 -- see that script's
|
| 6 |
+
docstring). Reviewing all 22 pairs by hand in chat doesn't scale, so this scores each row:
|
| 7 |
+
|
| 8 |
+
+100 has notes (a rep actually wrote something -- the strongest "this one is real" signal)
|
| 9 |
+
+10 * stage rank (lost=1, draft_ready=0, followup_due=2, contacted=3, meeting_booked=4,
|
| 10 |
+
proposal_sent=5, won=6 -- "lost" ranks above draft_ready because it required an
|
| 11 |
+
actual outcome, not just sitting untouched)
|
| 12 |
+
+5 meeting_at or callback_at is set
|
| 13 |
+
|
| 14 |
+
A group only gets an automatic recommendation when exactly one row has the highest score (a clear win),
|
| 15 |
+
OR every tied top-scoring row has IDENTICAL deal_value and industry_tag (a true no-information-loss
|
| 16 |
+
duplicate, e.g. three byte-for-byte-identical rows). Every other tie -- most commonly "both rows have
|
| 17 |
+
notes" or "both are untouched but disagree on category/value" -- is never auto-resolved. It prints as
|
| 18 |
+
NEEDS MANUAL REVIEW with full detail on every row, and nothing about it is touched.
|
| 19 |
+
|
| 20 |
+
uv run python scripts/review_duplicate_leads.py # dry run -- prints recommendations only
|
| 21 |
+
uv run python scripts/review_duplicate_leads.py --apply # deletes the CLEAR-WIN groups only
|
| 22 |
+
|
| 23 |
+
Groups flagged NEEDS MANUAL REVIEW are never touched by --apply -- use delete_lead_by_id.py for those,
|
| 24 |
+
one id at a time, after deciding by hand.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import sys
|
| 31 |
+
from collections import defaultdict
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 35 |
+
|
| 36 |
+
from sqlalchemy import select # noqa: E402
|
| 37 |
+
|
| 38 |
+
from app.crud import leads as crud_leads # noqa: E402
|
| 39 |
+
from app.database import SessionLocal # noqa: E402
|
| 40 |
+
from app.models import Lead # noqa: E402
|
| 41 |
+
|
| 42 |
+
STAGE_RANK = {
|
| 43 |
+
"draft_ready": 0,
|
| 44 |
+
"lost": 1,
|
| 45 |
+
"followup_due": 2,
|
| 46 |
+
"contacted": 3,
|
| 47 |
+
"meeting_booked": 4,
|
| 48 |
+
"proposal_sent": 5,
|
| 49 |
+
"won": 6,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def score(lead: Lead) -> int:
|
| 54 |
+
s = 0
|
| 55 |
+
if (lead.notes or "").strip():
|
| 56 |
+
s += 100
|
| 57 |
+
s += STAGE_RANK.get(lead.pipeline_stage, 0) * 10
|
| 58 |
+
if lead.meeting_at or lead.callback_at:
|
| 59 |
+
s += 5
|
| 60 |
+
return s
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def describe(lead: Lead) -> str:
|
| 64 |
+
return (
|
| 65 |
+
f"#{lead.id:<6} stage={lead.pipeline_stage:<15} deal_value={lead.deal_value} "
|
| 66 |
+
f"industry_tag={lead.industry_tag!r} notes={'yes' if (lead.notes or '').strip() else 'no'} "
|
| 67 |
+
f"created_at={lead.created_at}"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main() -> int:
|
| 72 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 73 |
+
parser.add_argument("--apply", action="store_true", help="delete the clear-win groups (default: dry run)")
|
| 74 |
+
args = parser.parse_args()
|
| 75 |
+
|
| 76 |
+
db = SessionLocal()
|
| 77 |
+
try:
|
| 78 |
+
leads = list(db.scalars(select(Lead).order_by(Lead.company_name, Lead.created_at)).all())
|
| 79 |
+
groups: dict[str, list[Lead]] = defaultdict(list)
|
| 80 |
+
for lead in leads:
|
| 81 |
+
key = (lead.company_name or "").strip().lower()
|
| 82 |
+
if key:
|
| 83 |
+
groups[key].append(lead)
|
| 84 |
+
dupes = {k: v for k, v in groups.items() if len(v) > 1}
|
| 85 |
+
|
| 86 |
+
if not dupes:
|
| 87 |
+
print("No duplicate company names found.")
|
| 88 |
+
return 0
|
| 89 |
+
|
| 90 |
+
to_delete: list[Lead] = []
|
| 91 |
+
needs_review: list[list[Lead]] = []
|
| 92 |
+
|
| 93 |
+
for rows in dupes.values():
|
| 94 |
+
scored = [(score(r), r) for r in rows]
|
| 95 |
+
top = max(s for s, _ in scored)
|
| 96 |
+
leaders = [r for s, r in scored if s == top]
|
| 97 |
+
|
| 98 |
+
if len(leaders) == 1:
|
| 99 |
+
keep = leaders[0]
|
| 100 |
+
losers = [r for r in rows if r.id != keep.id]
|
| 101 |
+
print(f"'{keep.company_name}' -- CLEAR WIN, keep #{keep.id}, delete {[r.id for r in losers]}")
|
| 102 |
+
print(f" KEEP {describe(keep)}")
|
| 103 |
+
for l in losers:
|
| 104 |
+
print(f" DELETE {describe(l)}")
|
| 105 |
+
print()
|
| 106 |
+
to_delete.extend(losers)
|
| 107 |
+
continue
|
| 108 |
+
|
| 109 |
+
identical = all(
|
| 110 |
+
(r.deal_value, r.industry_tag) == (leaders[0].deal_value, leaders[0].industry_tag)
|
| 111 |
+
for r in leaders
|
| 112 |
+
)
|
| 113 |
+
if identical:
|
| 114 |
+
keep = min(leaders, key=lambda r: r.created_at)
|
| 115 |
+
losers = [r for r in rows if r.id != keep.id]
|
| 116 |
+
print(
|
| 117 |
+
f"'{keep.company_name}' -- IDENTICAL duplicate(s), keep oldest #{keep.id}, "
|
| 118 |
+
f"delete {[r.id for r in losers]}"
|
| 119 |
+
)
|
| 120 |
+
print(f" KEEP {describe(keep)}")
|
| 121 |
+
for l in losers:
|
| 122 |
+
print(f" DELETE {describe(l)}")
|
| 123 |
+
print()
|
| 124 |
+
to_delete.extend(losers)
|
| 125 |
+
continue
|
| 126 |
+
|
| 127 |
+
needs_review.append(rows)
|
| 128 |
+
|
| 129 |
+
if needs_review:
|
| 130 |
+
print(f"\n{'=' * 60}\n{len(needs_review)} group(s) NEED MANUAL REVIEW -- not touched:\n")
|
| 131 |
+
for rows in needs_review:
|
| 132 |
+
print(f"'{rows[0].company_name}':")
|
| 133 |
+
for r in rows:
|
| 134 |
+
print(f" {describe(r)}")
|
| 135 |
+
print()
|
| 136 |
+
|
| 137 |
+
print(f"{'=' * 60}")
|
| 138 |
+
print(f"{len(to_delete)} lead(s) recommended for deletion across the clear-win groups above.")
|
| 139 |
+
print(f"{len(needs_review)} group(s) need a manual decision (use delete_lead_by_id.py after choosing).")
|
| 140 |
+
|
| 141 |
+
if not args.apply:
|
| 142 |
+
print("\nDry run only -- re-run with --apply to delete the clear-win groups' losers.")
|
| 143 |
+
return 0
|
| 144 |
+
|
| 145 |
+
for lead in to_delete:
|
| 146 |
+
crud_leads.delete_lead(db, lead)
|
| 147 |
+
print(f"\nDeleted {len(to_delete)} lead(s).")
|
| 148 |
+
return 0
|
| 149 |
+
finally:
|
| 150 |
+
db.close()
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
if __name__ == "__main__":
|
| 154 |
+
sys.exit(main())
|
scripts/seed_leads.py
CHANGED
|
@@ -9,6 +9,10 @@ want the sample rows.
|
|
| 9 |
uv run python scripts/seed_leads.py
|
| 10 |
|
| 11 |
Skips any company already present, so it is safe to re-run.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
|
@@ -34,7 +38,7 @@ VERIFIED_SAMPLE_LEADS = [
|
|
| 34 |
"contact_phone": "+1 (843) 881-8883",
|
| 35 |
"contact_linkedin": "https://www.linkedin.com/in/hal-garrett-b02b6623",
|
| 36 |
"industry_tag": "⛳ Custom Golf Carts",
|
| 37 |
-
"deal_value":
|
| 38 |
"reason": "Buyers customize body paint, upholstery stitching, lift kits, and rim styles using static gallery photos, leading to drop-offs before requesting a quote.",
|
| 39 |
"subject": "3D cart builder for Garrett's Custom Golf Carts",
|
| 40 |
"body": "Hi Hal,\n\nI was looking at your custom golf cart builds at Garrett's, specifically your custom diamond-stitched seating and lift kit options. Your builds look great.\n\nRight now, buyers browse static photos to imagine custom paint and upholstery combinations. We build real-time 3D web configurators that let customers customize paint, seats, and rims in 3D directly on your site before buying.\n\nWould you be open to a quick demo tailored for your custom builds? You can pick a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
|
|
@@ -48,7 +52,7 @@ VERIFIED_SAMPLE_LEADS = [
|
|
| 48 |
"contact_phone": "+1 (803) 865-8016",
|
| 49 |
"contact_linkedin": "https://www.linkedin.com/company/tidewater-carts",
|
| 50 |
"industry_tag": "⛳ Custom Golf Carts",
|
| 51 |
-
"deal_value":
|
| 52 |
"reason": "Offers extensive custom colors, tops, and high-performance lift packages, but currently relies on static inventory listings.",
|
| 53 |
"subject": "Interactive 3D configurator for Tidewater Carts",
|
| 54 |
"body": "Hi Team,\n\nI came across Tidewater Carts while researching top custom golf cart builders in the US. Your custom lifted builds and sound bar packages stand out.\n\nSince buyers often want to compare custom body colors and seat patterns before ordering, an interactive 3D configurator on your website would allow them to visualize their build live and submit quote-ready specifications.\n\nWould you be open to seeing a 5-minute visual demo? Feel free to pick a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
|
|
@@ -62,7 +66,7 @@ VERIFIED_SAMPLE_LEADS = [
|
|
| 62 |
"contact_phone": "+1 (800) 508-4813",
|
| 63 |
"contact_linkedin": "https://www.linkedin.com/company/performance-golf-carts",
|
| 64 |
"industry_tag": "⛳ Custom Golf Carts",
|
| 65 |
-
"deal_value":
|
| 66 |
"reason": "High-volume custom builder with extensive parts & accessories inventory that would see higher conversions with live 3D visual customization.",
|
| 67 |
"subject": "3D visualizer for Performance Golf Carts",
|
| 68 |
"body": "Hi Team,\n\nI was browsing your custom EZ-GO and Club Car inventory at Performance Golf Carts. Your Double Take body kits and custom wheels look sharp.\n\nRight now, buyers browse 2D images to pick options. We create interactive 3D configurators that let customers swap body colors, seats, and lift kits in real time on your website.\n\nAre you open to exploring a quick 3D demo tailored for your builds? You can grab a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
|
|
@@ -76,7 +80,7 @@ VERIFIED_SAMPLE_LEADS = [
|
|
| 76 |
"contact_phone": "+1 (949) 328-9899",
|
| 77 |
"contact_linkedin": "https://www.linkedin.com/company/apex-golf-carts",
|
| 78 |
"industry_tag": "⛳ Custom Golf Carts",
|
| 79 |
-
"deal_value":
|
| 80 |
"reason": "Specializes in luxury street-legal electric carts with premium custom finishes that require high-end 3D visualization.",
|
| 81 |
"subject": "3D customization for Apex Golf Carts",
|
| 82 |
"body": "Hi Team,\n\nI was checking out your luxury street-legal electric cart models at Apex. The modern styling and premium interior finishes look exceptional.\n\nSince high-end buyers expect interactive digital experiences, a real-time 3D configurator on your site would let clients customize their cart exterior, wheels, and seating in full 3D before inquiring.\n\nWould you be open to a brief preview of how this looks in action? Pick a convenient time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
|
|
@@ -90,7 +94,7 @@ VERIFIED_SAMPLE_LEADS = [
|
|
| 90 |
"contact_phone": "+1 (712) 324-4900",
|
| 91 |
"contact_linkedin": "https://www.linkedin.com/company/streetrod-golf-cars",
|
| 92 |
"industry_tag": "⛳ Custom Golf Carts",
|
| 93 |
-
"deal_value":
|
| 94 |
"reason": "Handcrafted vintage hot rod replica golf carts with bespoke paint and chrome options, ideal for high-ticket 3D interactive customization.",
|
| 95 |
"subject": "Real-time 3D configurator for Streetrod Golf Cars",
|
| 96 |
"body": "Hi Team,\n\nI was admiring your handcrafted hot rod golf cars at Streetrod. The vintage fiberglass bodywork and custom chrome details are works of art.\n\nFor bespoke vehicles at this price point, an interactive 3D web configurator allows collectors to test paint colors, flame graphics, and wheel packages in photorealistic 3D directly on your site.\n\nWould you be open to seeing a quick concept tailored for Streetrod? You can choose a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
|
|
|
|
| 9 |
uv run python scripts/seed_leads.py
|
| 10 |
|
| 11 |
Skips any company already present, so it is safe to re-run.
|
| 12 |
+
|
| 13 |
+
structure-plan.md Phase 8: every deal_value here was made 10000, at the user's explicit request ("sab
|
| 14 |
+
jaga kr dy" -- do it everywhere) -- they were previously varied (17500-21000) on purpose so the sample
|
| 15 |
+
data didn't look uniform; that reasoning is now overridden by the same-default-everywhere rule.
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
|
|
|
| 38 |
"contact_phone": "+1 (843) 881-8883",
|
| 39 |
"contact_linkedin": "https://www.linkedin.com/in/hal-garrett-b02b6623",
|
| 40 |
"industry_tag": "⛳ Custom Golf Carts",
|
| 41 |
+
"deal_value": 10000.0,
|
| 42 |
"reason": "Buyers customize body paint, upholstery stitching, lift kits, and rim styles using static gallery photos, leading to drop-offs before requesting a quote.",
|
| 43 |
"subject": "3D cart builder for Garrett's Custom Golf Carts",
|
| 44 |
"body": "Hi Hal,\n\nI was looking at your custom golf cart builds at Garrett's, specifically your custom diamond-stitched seating and lift kit options. Your builds look great.\n\nRight now, buyers browse static photos to imagine custom paint and upholstery combinations. We build real-time 3D web configurators that let customers customize paint, seats, and rims in 3D directly on your site before buying.\n\nWould you be open to a quick demo tailored for your custom builds? You can pick a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
|
|
|
|
| 52 |
"contact_phone": "+1 (803) 865-8016",
|
| 53 |
"contact_linkedin": "https://www.linkedin.com/company/tidewater-carts",
|
| 54 |
"industry_tag": "⛳ Custom Golf Carts",
|
| 55 |
+
"deal_value": 10000.0,
|
| 56 |
"reason": "Offers extensive custom colors, tops, and high-performance lift packages, but currently relies on static inventory listings.",
|
| 57 |
"subject": "Interactive 3D configurator for Tidewater Carts",
|
| 58 |
"body": "Hi Team,\n\nI came across Tidewater Carts while researching top custom golf cart builders in the US. Your custom lifted builds and sound bar packages stand out.\n\nSince buyers often want to compare custom body colors and seat patterns before ordering, an interactive 3D configurator on your website would allow them to visualize their build live and submit quote-ready specifications.\n\nWould you be open to seeing a 5-minute visual demo? Feel free to pick a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
|
|
|
|
| 66 |
"contact_phone": "+1 (800) 508-4813",
|
| 67 |
"contact_linkedin": "https://www.linkedin.com/company/performance-golf-carts",
|
| 68 |
"industry_tag": "⛳ Custom Golf Carts",
|
| 69 |
+
"deal_value": 10000.0,
|
| 70 |
"reason": "High-volume custom builder with extensive parts & accessories inventory that would see higher conversions with live 3D visual customization.",
|
| 71 |
"subject": "3D visualizer for Performance Golf Carts",
|
| 72 |
"body": "Hi Team,\n\nI was browsing your custom EZ-GO and Club Car inventory at Performance Golf Carts. Your Double Take body kits and custom wheels look sharp.\n\nRight now, buyers browse 2D images to pick options. We create interactive 3D configurators that let customers swap body colors, seats, and lift kits in real time on your website.\n\nAre you open to exploring a quick 3D demo tailored for your builds? You can grab a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
|
|
|
|
| 80 |
"contact_phone": "+1 (949) 328-9899",
|
| 81 |
"contact_linkedin": "https://www.linkedin.com/company/apex-golf-carts",
|
| 82 |
"industry_tag": "⛳ Custom Golf Carts",
|
| 83 |
+
"deal_value": 10000.0,
|
| 84 |
"reason": "Specializes in luxury street-legal electric carts with premium custom finishes that require high-end 3D visualization.",
|
| 85 |
"subject": "3D customization for Apex Golf Carts",
|
| 86 |
"body": "Hi Team,\n\nI was checking out your luxury street-legal electric cart models at Apex. The modern styling and premium interior finishes look exceptional.\n\nSince high-end buyers expect interactive digital experiences, a real-time 3D configurator on your site would let clients customize their cart exterior, wheels, and seating in full 3D before inquiring.\n\nWould you be open to a brief preview of how this looks in action? Pick a convenient time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
|
|
|
|
| 94 |
"contact_phone": "+1 (712) 324-4900",
|
| 95 |
"contact_linkedin": "https://www.linkedin.com/company/streetrod-golf-cars",
|
| 96 |
"industry_tag": "⛳ Custom Golf Carts",
|
| 97 |
+
"deal_value": 10000.0,
|
| 98 |
"reason": "Handcrafted vintage hot rod replica golf carts with bespoke paint and chrome options, ideal for high-ticket 3D interactive customization.",
|
| 99 |
"subject": "Real-time 3D configurator for Streetrod Golf Cars",
|
| 100 |
"body": "Hi Team,\n\nI was admiring your handcrafted hot rod golf cars at Streetrod. The vintage fiberglass bodywork and custom chrome details are works of art.\n\nFor bespoke vehicles at this price point, an interactive 3D web configurator allows collectors to test paint colors, flame graphics, and wheel packages in photorealistic 3D directly on your site.\n\nWould you be open to seeing a quick concept tailored for Streetrod? You can choose a time here: https://calendly.com/bilal-lania-elipsestudio/15-mins-meeting\n\nBest,\nBilal\nElipse Studio"
|
scripts/smoke_test.py
CHANGED
|
@@ -137,7 +137,7 @@ def main() -> int:
|
|
| 137 |
"company_website": "https://example.com",
|
| 138 |
"contact_email": "probe@example.com",
|
| 139 |
"industry_tag": EMOJI_CATEGORY,
|
| 140 |
-
"deal_value":
|
| 141 |
}, timeout=15)
|
| 142 |
check("POST /leads -> 201", r.status_code == 201, f"{r.status_code} {r.text[:200]}")
|
| 143 |
if not r.ok:
|
|
|
|
| 137 |
"company_website": "https://example.com",
|
| 138 |
"contact_email": "probe@example.com",
|
| 139 |
"industry_tag": EMOJI_CATEGORY,
|
| 140 |
+
"deal_value": 10000, # structure-plan.md Phase 8: matches the app-wide default (was 24000).
|
| 141 |
}, timeout=15)
|
| 142 |
check("POST /leads -> 201", r.status_code == 201, f"{r.status_code} {r.text[:200]}")
|
| 143 |
if not r.ok:
|