#!/usr/bin/env python3 """Materialize the normal follow-up lifecycle for existing opportunities. Dry-run is the default. v4928.1.5.127 no longer creates automatic ``CONFIRM_DELIVERY`` tasks. Existing processes start directly at the normal commercial follow-up for their stage. Delivery verification remains an explicit operator action. The backfill is intentionally conservative: - zero-value opportunities are excluded by default; - opportunities with any pending human task are skipped; - recovery/nurture queues remain supported; - no customer communication is sent. """ from __future__ import annotations import argparse from datetime import datetime, timezone from pathlib import Path import sys from typing import Any PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from sqlalchemy import text STAGE_FAMILY = { "INFO_SENT": "info", "QUOTE_SENT": "quote", "PROFORMA_SENT": "payment", "INVOICE_SENT": "payment", "WAITING_PAYMENT": "payment", } ACTION_LABEL = { "info": "follow_up_information", "quote": "follow_up_quote", "payment": "follow_up_payment", "generic": "follow_up_generic", } def _rows(limit: int, include_zero_value: bool = False) -> list[dict[str, Any]]: from app.db import engine from app.opportunity_service import ensure_opportunity_schema ensure_opportunity_schema() sql = text(""" SELECT o.id::text, o.customer_name, o.title, o.stage, o.value_amount, o.lifecycle_state, o.last_customer_activity_at, o.last_operator_activity_at, o.last_commercial_activity_at, o.last_message_at, o.next_follow_up_at, o.nurture_until, o.follow_up_attempts FROM opportunities o WHERE o.status = 'open' AND ( o.stage IN ('INFO_SENT','QUOTE_SENT','PROFORMA_SENT','INVOICE_SENT','WAITING_PAYMENT') OR o.lifecycle_state IN ('recovery','nurture') ) AND ( CAST(:include_zero_value AS BOOLEAN) OR COALESCE(o.value_amount, 0) > 0 OR o.lifecycle_state IN ('recovery','nurture') ) AND NOT EXISTS ( SELECT 1 FROM tasks t WHERE t.opportunity_id = o.id AND t.status = 'pending' AND COALESCE(t.action_required, TRUE) = TRUE ) ORDER BY CASE WHEN o.lifecycle_state = 'recovery' THEN 0 WHEN o.stage = 'WAITING_PAYMENT' THEN 1 WHEN COALESCE(o.value_amount,0) > 0 THEN 2 ELSE 3 END, COALESCE(o.value_amount,0) DESC, COALESCE(o.last_commercial_activity_at, o.last_message_at, o.created_at) ASC LIMIT :limit """) with engine.begin() as conn: return [ dict(row) for row in conn.execute( sql, { "limit": max(1, int(limit)), "include_zero_value": bool(include_zero_value), }, ).mappings().all() ] def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--apply", action="store_true", help="Create missing tasks. Default is dry-run.") parser.add_argument("--limit", type=int, default=50) parser.add_argument( "--include-zero-value", action="store_true", help="Include zero-value opportunities. Disabled by default.", ) args = parser.parse_args() from app.db import engine from app.followup_service import create_follow_up_task, materialize_initial_follow_up_for_opportunity candidates = _rows(args.limit, include_zero_value=args.include_zero_value) print(f"Candidates without pending human task: {len(candidates)}") created = 0 now = datetime.now(timezone.utc) for row in candidates: opportunity_id = str(row.get("id") or "") state = str(row.get("lifecycle_state") or "active") stage = str(row.get("stage") or "") family = STAGE_FAMILY.get(stage, "generic") value = float(row.get("value_amount") or 0) label = str(row.get("customer_name") or row.get("title") or opportunity_id) action = ACTION_LABEL.get(family, "follow_up_generic") if state == "recovery": action = "recovery_review" elif state == "nurture": action = "nurture_review" print(f"{action:22} | {stage:18} | {value:9.2f} | {label}") if not args.apply: continue # Backfill only from message/commercial timestamps, never from updated_at. with engine.begin() as conn: conn.execute(text(""" UPDATE opportunities SET last_customer_activity_at = COALESCE(last_customer_activity_at, last_message_at), last_commercial_activity_at = COALESCE( last_commercial_activity_at, last_operator_activity_at, last_customer_activity_at, last_message_at ), metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object( 'lifecycle_backfill_version', 'v4928.1.5.127', 'lifecycle_backfill_used_updated_at', FALSE, 'automatic_confirm_delivery_disabled', TRUE ) WHERE id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": opportunity_id}) if state == "recovery": result = create_follow_up_task( opportunity_id=opportunity_id, action_code="RECOVER_OPPORTUNITY", route="financeiro" if family == "payment" else "vendas", action="Recuperar oportunidade sem resposta", note="Rever canal, abordagem, timing e decidir entre nova tentativa, acompanhamento futuro ou perda.", reason="RECOVERY_BACKFILL_V127", delay_days=1, created_by="migration_v4928_1_5_127", idempotency_suffix="recovery-backfill-v127", follow_up_family=family, follow_up_stage=99, follow_up_max_stage=99, cascade=False, contact_purpose="recovery_review", due_at_override=now, ) elif state == "nurture": due_at = row.get("nurture_until") or row.get("next_follow_up_at") or now result = create_follow_up_task( opportunity_id=opportunity_id, action_code="REVIEW_NURTURE", route="vendas", action="Rever oportunidade em acompanhamento futuro", note="Retomar contacto na data acordada ou rever se o timing continua vĂ¡lido.", reason="NURTURE_BACKFILL_V127", delay_days=1, created_by="migration_v4928_1_5_127", idempotency_suffix="nurture-backfill-v127", follow_up_family="generic", follow_up_stage=99, follow_up_max_stage=99, cascade=False, contact_purpose="nurture_review", due_at_override=due_at, ) else: result = materialize_initial_follow_up_for_opportunity( opportunity_id=opportunity_id, family=family, due_at=None, reason="LEGACY_OPEN_OPPORTUNITY_BACKFILL_V127", created_by="migration_v4928_1_5_127", ) if result.get("status") == "created": created += 1 if args.apply: print(f"Created: {created}") else: print("Dry-run only. Use --apply after reviewing the candidates.") return 0 if __name__ == "__main__": raise SystemExit(main())