Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
217
scripts/sync_opportunity_followup_lifecycle.py
Executable file
217
scripts/sync_opportunity_followup_lifecycle.py
Executable file
@@ -0,0 +1,217 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user