feat: add operational eligibility and scheduled follow-up
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user