feat: add operational eligibility and scheduled follow-up

This commit is contained in:
plx
2026-08-15 07:56:06 +00:00
parent 71452b1e5c
commit ee8e2de606
13 changed files with 812 additions and 26 deletions

View File

@@ -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,