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

@@ -18,6 +18,7 @@ from datetime import datetime, timezone
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
from app.admin_ui.labels import primary_action_label
from app.timezone_utils import format_operator_datetime, parse_operator_local_datetime
from app.operation_noise import is_noise_operation_item
from app.opportunity_next_action_service import get_opportunity_next_action, get_opportunity_next_actions
from app.work_center_action_policy import (
@@ -1543,11 +1544,19 @@ def _parse_opportunity_dt(value: object):
def _opportunity_lifecycle_state(opp: dict) -> str:
state = str(opp.get("lifecycle_state") or "active").strip().lower() or "active"
now = datetime.now(timezone.utc)
pending_call = str(opp.get("pending_follow_up_action_code") or "").strip().upper() == "CALL_CUSTOMER"
pending_call_due = _parse_opportunity_dt(opp.get("pending_follow_up_due_at"))
if pending_call:
return "follow_up_due" if pending_call_due and pending_call_due <= now else "scheduled_follow_up"
# Compatibility only: a stale denormalized lifecycle marker cannot invent
# a scheduled call when no active CALL_CUSTOMER task exists.
if state == "scheduled_follow_up":
return "active"
nurture_until = _parse_opportunity_dt(opp.get("nurture_until"))
next_follow_up = _parse_opportunity_dt(opp.get("next_follow_up_at"))
if state == "nurture" and nurture_until and nurture_until <= now:
return "follow_up_due"
if state in {"awaiting_customer", "active"} and next_follow_up and next_follow_up <= now:
if state in {"awaiting_customer", "active", "scheduled_follow_up"} and next_follow_up and next_follow_up <= now:
return "follow_up_due"
return state
@@ -1705,6 +1714,9 @@ def _opportunity_card_next_action(opp: dict) -> str:
return pending_follow_up_action or primary_action_label(pending_follow_up_code, fallback="Executar follow-up vencido")
if lifecycle_state == "nurture":
return pending_follow_up_action or "Aguardar data para retomar contacto"
if lifecycle_state == "scheduled_follow_up":
due = opp.get("pending_follow_up_due_at")
return f"Ligar ao cliente em {format_operator_datetime(due)}" if due else "Ligar ao cliente"
if lifecycle_state == "awaiting_customer":
due = _parse_opportunity_dt(opp.get("next_follow_up_at"))
return f"Aguardar resposta até {due.strftime('%d/%m')}" if due else "Aguardar resposta do cliente"
@@ -2041,13 +2053,13 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
lifecycle_task = next((
task for task in pending_tasks
if str(task.get("action_code") or "").upper() in {
"CONFIRM_DELIVERY", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA",
"CALL_CUSTOMER", "CONFIRM_DELIVERY", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA",
"FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC",
"RECOVER_OPPORTUNITY", "REVIEW_NURTURE",
}
), None)
lifecycle_override = False
if lifecycle_task and lifecycle_state_for_detail in {"follow_up_due", "recovery", "nurture"}:
if lifecycle_task and lifecycle_state_for_detail in {"follow_up_due", "scheduled_follow_up", "recovery", "nurture"}:
lifecycle_override = True
next_action = {
"action_code": str(lifecycle_task.get("action_code") or "FOLLOW_UP_GENERIC"),
@@ -2305,9 +2317,28 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
</section>
'''
scheduled_call_task = next((
task for task in pending_tasks
if str(task.get("action_code") or "").upper() == "CALL_CUSTOMER"
), None)
scheduled_call_cancel_html = (
f'<form method="post" action="/opportunities/{esc(opportunity_id)}/scheduled-call/cancel" class="mt-2">'
'<button class="btn btn-sm btn-outline-danger" type="submit">Cancelar contacto agendado</button></form>'
if scheduled_call_task else ""
)
manual_follow_up_html = f'''
<section class="card cf-card"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Criar follow-up</h2>
<h2 class="cf-section-title mb-3">Agendar contacto</h2>
<div class="small text-secondary mb-3">Agenda uma chamada sem alterar a fase comercial da oportunidade.</div>
<form method="post" action="/opportunities/{esc(opportunity_id)}/follow-up" class="d-grid gap-2 mb-4">
<input type="hidden" name="follow_up_type" value="customer_call">
<label class="form-label small fw-bold mb-0">Data e hora</label>
<input class="form-control" type="datetime-local" name="due_at" required>
<textarea class="form-control" name="note" rows="2" placeholder="Motivo/nota opcional"></textarea>
<button class="btn btn-primary" type="submit">Agendar chamada</button>
</form>
{scheduled_call_cancel_html}
<h3 class="h6 fw-bold">Outros follow-ups</h3>
<div class="small text-secondary mb-3">Agenda uma tarefa de follow-up. O sistema não cria confirmações de entrega automaticamente; usa “Verificar entrega” apenas quando o histórico sugere um problema real.</div>
<form method="post" action="/opportunities/{esc(opportunity_id)}/follow-up" class="d-grid gap-2">
<select class="form-select" name="follow_up_type">
@@ -2335,11 +2366,12 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
lifecycle_state_label = lifecycle_label(lifecycle_state)
last_customer_dt = _parse_opportunity_dt(opportunity.get("last_customer_activity_at") or opportunity.get("last_message_at"))
last_operator_dt = _parse_opportunity_dt(opportunity.get("last_operator_activity_at"))
next_follow_dt = _parse_opportunity_dt(opportunity.get("next_follow_up_at") or opportunity.get("nurture_until"))
next_follow_value = scheduled_call_task.get("due_at") if scheduled_call_task else (opportunity.get("next_follow_up_at") or opportunity.get("nurture_until"))
next_follow_dt = _parse_opportunity_dt(next_follow_value)
lifecycle_summary = [
f"Última atividade do cliente: {last_customer_dt.strftime('%d/%m/%Y %H:%M') if last_customer_dt else 'sem registo'}",
f"Último contacto do operador: {last_operator_dt.strftime('%d/%m/%Y %H:%M') if last_operator_dt else 'sem registo'}",
f"Próximo contacto: {next_follow_dt.strftime('%d/%m/%Y') if next_follow_dt else 'não agendado'}",
f"Próximo contacto: {format_operator_datetime(next_follow_dt) if next_follow_dt else 'não agendado'}",
f"Tentativas: {int(opportunity.get('follow_up_attempts') or 0)}",
f"Entrega da última comunicação: {str(opportunity.get('last_delivery_status') or 'não verificada')}",
]
@@ -3016,26 +3048,45 @@ async def create_opportunity_follow_up_action(opportunity_id: str, request: Requ
form = await request.form()
follow_up_type = str(form.get("follow_up_type") or "generic").strip()
note = str(form.get("note") or "").strip()
due_at_text = str(form.get("due_at") or "").strip()
try:
delay_days = int(str(form.get("delay_days") or "3"))
except Exception:
delay_days = 3
try:
from app.followup_service import create_manual_follow_up_for_opportunity
from app.followup_service import create_manual_follow_up_for_opportunity, schedule_customer_call
result = create_manual_follow_up_for_opportunity(
opportunity_id=opportunity_id,
follow_up_type=follow_up_type,
delay_days=delay_days,
note=note,
created_by="operator",
)
notice = "Follow-up agendado." if result.get("ok") else "Não foi possível agendar follow-up."
if follow_up_type == "customer_call":
if not due_at_text:
return PlainTextResponse("Data/hora obrigatória.", status_code=422)
due_at = parse_operator_local_datetime(due_at_text)
result = schedule_customer_call(
opportunity_id=opportunity_id, due_at=due_at, note=note, created_by="operator",
)
notice = "Contacto telefónico agendado." if result.get("ok") else "Não foi possível agendar o contacto."
else:
result = create_manual_follow_up_for_opportunity(
opportunity_id=opportunity_id, follow_up_type=follow_up_type,
delay_days=delay_days, note=note, created_by="operator",
)
notice = "Follow-up agendado." if result.get("ok") else "Não foi possível agendar follow-up."
except Exception as exc:
notice = f"Erro ao agendar follow-up: {exc}"
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
@router.post("/opportunities/{opportunity_id}/scheduled-call/cancel")
async def cancel_opportunity_scheduled_call_action(opportunity_id: str, request: Request):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
from app.followup_service import cancel_scheduled_customer_call
result = cancel_scheduled_customer_call(
opportunity_id=opportunity_id, reason="Cancelado pelo operador", created_by="operator",
)
notice = "Contacto agendado cancelado." if result.get("cancelled") else "Não existe contacto agendado ativo."
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
@router.post("/opportunities/{opportunity_id}/commercial-terms")
async def update_opportunity_commercial_terms_action(opportunity_id: str, request: Request):
if not is_uuid_text(opportunity_id):