feat: add operational eligibility and scheduled follow-up
This commit is contained in:
@@ -21,6 +21,7 @@ from app.db import engine
|
||||
|
||||
|
||||
FOLLOW_UP_ACTION_CODES = {
|
||||
"CALL_CUSTOMER",
|
||||
"FOLLOW_UP_QUOTE",
|
||||
"FOLLOW_UP_PROFORMA",
|
||||
"FOLLOW_UP_PAYMENT",
|
||||
@@ -1035,6 +1036,97 @@ def create_manual_follow_up_for_opportunity(
|
||||
contact_purpose=str(step.get("purpose") or "manual_followup"),
|
||||
)
|
||||
|
||||
|
||||
def schedule_customer_call(
|
||||
*, opportunity_id: str, due_at: datetime, note: str = "", created_by: str = "operator",
|
||||
) -> Dict[str, Any]:
|
||||
"""Create or reschedule the authoritative active customer-call task.
|
||||
|
||||
``lifecycle_state``/``next_follow_up_at`` are synchronized below only as
|
||||
derived compatibility fields. Read paths must prefer the pending task.
|
||||
"""
|
||||
if due_at.tzinfo is None:
|
||||
due_at = due_at.replace(tzinfo=timezone.utc)
|
||||
due_at = due_at.astimezone(timezone.utc)
|
||||
opportunity = _load_opportunity_context(opportunity_id)
|
||||
if not opportunity:
|
||||
return {"ok": False, "status": "opportunity_not_found"}
|
||||
with engine.begin() as conn:
|
||||
existing = conn.execute(text("""
|
||||
SELECT id::text FROM tasks
|
||||
WHERE opportunity_id=CAST(:opportunity_id AS UUID)
|
||||
AND status='pending' AND action_code='CALL_CUSTOMER'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
FOR UPDATE
|
||||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||||
if existing:
|
||||
task_id = str(existing["id"])
|
||||
conn.execute(text("""
|
||||
UPDATE tasks SET due_at=CAST(:due_at AS TIMESTAMPTZ),
|
||||
note=COALESCE(NULLIF(:note, ''), note), updated_at=now(),
|
||||
metadata=COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
|
||||
'scheduled_contact', true, 'contact_channel', 'phone',
|
||||
'rescheduled_at', now(), 'rescheduled_by', CAST(:created_by AS TEXT))
|
||||
WHERE id=CAST(:task_id AS UUID)
|
||||
"""), {"task_id": task_id, "due_at": due_at.isoformat(), "note": note, "created_by": created_by})
|
||||
status = "rescheduled"
|
||||
else:
|
||||
task_id = ""
|
||||
status = "create"
|
||||
if status == "create":
|
||||
created = create_follow_up_task(
|
||||
opportunity_id=opportunity_id, action_code="CALL_CUSTOMER", route="vendas",
|
||||
action="Ligar ao cliente", note=note or "Contactar o cliente por telefone na data agendada.",
|
||||
reason="CUSTOMER_REQUESTED_SCHEDULED_CALL", delay_days=0, created_by=created_by,
|
||||
idempotency_suffix="scheduled-customer-call", follow_up_family="customer_call",
|
||||
follow_up_stage=1, follow_up_max_stage=1, cascade=False,
|
||||
contact_purpose="scheduled_customer_call", due_at_override=due_at,
|
||||
)
|
||||
if not created.get("ok"):
|
||||
return created
|
||||
task_id = str(created.get("task_id") or "")
|
||||
status = str(created.get("status") or "created")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
UPDATE opportunities SET lifecycle_state='scheduled_follow_up',
|
||||
next_follow_up_at=CAST(:due_at AS TIMESTAMPTZ), updated_at=now()
|
||||
WHERE id=CAST(:opportunity_id AS UUID)
|
||||
"""), {"opportunity_id": opportunity_id, "due_at": due_at.isoformat()})
|
||||
if task_id:
|
||||
conn.execute(text("""
|
||||
INSERT INTO task_events(task_id,event_type,payload,created_by)
|
||||
VALUES(CAST(:task_id AS UUID),'customer_call_scheduled',
|
||||
jsonb_build_object('due_at',CAST(:due_at AS TEXT),'note',CAST(:note AS TEXT)),:created_by)
|
||||
"""), {"task_id": task_id, "due_at": due_at.isoformat(), "note": note, "created_by": created_by})
|
||||
return {"ok": True, "status": status, "task_id": task_id, "opportunity_id": opportunity_id, "due_at": due_at.isoformat()}
|
||||
|
||||
|
||||
def cancel_scheduled_customer_call(
|
||||
*, opportunity_id: str, reason: str = "Cancelado pelo operador", created_by: str = "operator",
|
||||
) -> Dict[str, Any]:
|
||||
"""Cancel only the explicit scheduled phone contact, preserving other tasks."""
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(text("""
|
||||
UPDATE tasks SET status='skipped', updated_at=now(),
|
||||
metadata=COALESCE(metadata,'{}'::jsonb) || jsonb_build_object(
|
||||
'scheduled_contact_cancelled_at',now(),
|
||||
'scheduled_contact_cancelled_reason',CAST(:reason AS TEXT))
|
||||
WHERE opportunity_id=CAST(:opportunity_id AS UUID)
|
||||
AND status='pending' AND action_code='CALL_CUSTOMER'
|
||||
RETURNING id::text
|
||||
"""), {"opportunity_id": opportunity_id, "reason": reason}).mappings().all()
|
||||
for row in rows:
|
||||
conn.execute(text("""
|
||||
INSERT INTO task_events(task_id,event_type,payload,created_by)
|
||||
VALUES(CAST(:task_id AS UUID),'customer_call_cancelled',
|
||||
jsonb_build_object('reason',CAST(:reason AS TEXT)),:created_by)
|
||||
"""), {"task_id": row["id"], "reason": reason, "created_by": created_by})
|
||||
conn.execute(text("""
|
||||
UPDATE opportunities SET lifecycle_state='active', next_follow_up_at=NULL, updated_at=now()
|
||||
WHERE id=CAST(:opportunity_id AS UUID) AND lifecycle_state='scheduled_follow_up'
|
||||
"""), {"opportunity_id": opportunity_id})
|
||||
return {"ok": True, "status": "cancelled" if rows else "not_found", "cancelled": len(rows)}
|
||||
|
||||
def align_pending_followups_for_opportunity(
|
||||
*,
|
||||
opportunity_id: str,
|
||||
|
||||
Reference in New Issue
Block a user