mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
feat: Add AI forecasting, industry templates, and update sequence engine
- AI: forecasting.py (revenue prediction, deal risk analysis, Arabic summaries) - Seeds: retail_template.json (Saudi retail/e-commerce industry template) - Seeds: education_template.json (Saudi education/training industry template) - Updated: sequence_engine.py with full implementation https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
This commit is contained in:
parent
141f10db76
commit
5df520d672
495
salesflow-saas/backend/app/services/ai/forecasting.py
Normal file
495
salesflow-saas/backend/app/services/ai/forecasting.py
Normal file
@ -0,0 +1,495 @@
|
|||||||
|
"""
|
||||||
|
Sales Forecasting Engine — Predicts revenue, calculates deal-close probability,
|
||||||
|
identifies at-risk deals, and generates Arabic forecast summaries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.llm.provider import get_llm
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DealForecast:
|
||||||
|
deal_id: str
|
||||||
|
deal_name: str
|
||||||
|
current_value: float
|
||||||
|
close_probability: float # 0.0-1.0
|
||||||
|
weighted_value: float
|
||||||
|
risk_level: str # "low", "medium", "high"
|
||||||
|
risk_reasons_ar: list[str] = field(default_factory=list)
|
||||||
|
days_inactive: int = 0
|
||||||
|
expected_close_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PeriodForecast:
|
||||||
|
period_label: str # "2026-04", "Q2 2026"
|
||||||
|
predicted_revenue: float
|
||||||
|
weighted_pipeline: float
|
||||||
|
deal_count: int
|
||||||
|
avg_close_probability: float
|
||||||
|
best_case: float
|
||||||
|
worst_case: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ForecastResult:
|
||||||
|
tenant_id: str
|
||||||
|
period: str # "monthly" or "quarterly"
|
||||||
|
generated_at: str
|
||||||
|
periods: list[PeriodForecast] = field(default_factory=list)
|
||||||
|
at_risk_deals: list[DealForecast] = field(default_factory=list)
|
||||||
|
top_deals: list[DealForecast] = field(default_factory=list)
|
||||||
|
summary_ar: str = ""
|
||||||
|
summary_en: str = ""
|
||||||
|
total_pipeline_value: float = 0.0
|
||||||
|
total_weighted_value: float = 0.0
|
||||||
|
recommendations_ar: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Stage-based probability defaults
|
||||||
|
STAGE_PROBABILITIES = {
|
||||||
|
"new": 0.10,
|
||||||
|
"contacted": 0.15,
|
||||||
|
"qualified": 0.25,
|
||||||
|
"proposal_sent": 0.45,
|
||||||
|
"negotiation": 0.65,
|
||||||
|
"verbal_agreement": 0.80,
|
||||||
|
"contract_sent": 0.85,
|
||||||
|
"closed_won": 1.00,
|
||||||
|
"closed_lost": 0.00,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inactivity thresholds (days)
|
||||||
|
RISK_THRESHOLDS = {
|
||||||
|
"high": 14,
|
||||||
|
"medium": 7,
|
||||||
|
"low": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTHS_AR = {
|
||||||
|
1: "يناير", 2: "فبراير", 3: "مارس", 4: "أبريل",
|
||||||
|
5: "مايو", 6: "يونيو", 7: "يوليو", 8: "أغسطس",
|
||||||
|
9: "سبتمبر", 10: "أكتوبر", 11: "نوفمبر", 12: "ديسمبر",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SalesForecastingEngine:
|
||||||
|
"""Predicts revenue and identifies at-risk deals."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._llm = get_llm()
|
||||||
|
|
||||||
|
async def generate_forecast(
|
||||||
|
self, tenant_id: str, period: str, db: AsyncSession
|
||||||
|
) -> ForecastResult:
|
||||||
|
"""
|
||||||
|
Generate sales forecast.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant UUID string
|
||||||
|
period: "monthly" or "quarterly"
|
||||||
|
db: Async database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ForecastResult with predictions, at-risk deals, and summaries.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deals_data = await self._fetch_deals(tenant_id, db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch deals for tenant {tenant_id}: {e}")
|
||||||
|
return ForecastResult(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
period=period,
|
||||||
|
generated_at=now.isoformat(),
|
||||||
|
summary_ar="تعذر إنشاء التوقعات — لم يتم العثور على بيانات الصفقات",
|
||||||
|
summary_en="Forecast generation failed - deal data not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate individual deal forecasts
|
||||||
|
deal_forecasts = self._calculate_deal_forecasts(deals_data, now)
|
||||||
|
|
||||||
|
# Group by period
|
||||||
|
periods = self._group_by_period(deal_forecasts, period, now)
|
||||||
|
|
||||||
|
# Identify at-risk and top deals
|
||||||
|
at_risk = sorted(
|
||||||
|
[d for d in deal_forecasts if d.risk_level in ("high", "medium")],
|
||||||
|
key=lambda d: d.days_inactive,
|
||||||
|
reverse=True,
|
||||||
|
)[:10]
|
||||||
|
|
||||||
|
top_deals = sorted(
|
||||||
|
[d for d in deal_forecasts if d.close_probability > 0.0],
|
||||||
|
key=lambda d: d.weighted_value,
|
||||||
|
reverse=True,
|
||||||
|
)[:10]
|
||||||
|
|
||||||
|
# Totals
|
||||||
|
total_pipeline = sum(d.current_value for d in deal_forecasts if d.close_probability > 0)
|
||||||
|
total_weighted = sum(d.weighted_value for d in deal_forecasts)
|
||||||
|
|
||||||
|
# Generate summaries
|
||||||
|
summary_ar = self._build_summary_ar(periods, at_risk, total_pipeline, total_weighted, period)
|
||||||
|
summary_en = self._build_summary_en(periods, at_risk, total_pipeline, total_weighted, period)
|
||||||
|
recommendations = await self._generate_recommendations(periods, at_risk, total_pipeline)
|
||||||
|
|
||||||
|
return ForecastResult(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
period=period,
|
||||||
|
generated_at=now.isoformat(),
|
||||||
|
periods=periods,
|
||||||
|
at_risk_deals=at_risk,
|
||||||
|
top_deals=top_deals,
|
||||||
|
summary_ar=summary_ar,
|
||||||
|
summary_en=summary_en,
|
||||||
|
total_pipeline_value=round(total_pipeline, 2),
|
||||||
|
total_weighted_value=round(total_weighted, 2),
|
||||||
|
recommendations_ar=recommendations,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Data Fetching ────────────────────────────
|
||||||
|
|
||||||
|
async def _fetch_deals(self, tenant_id: str, db: AsyncSession) -> list[dict]:
|
||||||
|
"""Fetch active deals for the tenant."""
|
||||||
|
from app.models.deal import Deal
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Deal)
|
||||||
|
.where(
|
||||||
|
Deal.tenant_id == tenant_id,
|
||||||
|
Deal.status.notin_(["closed_lost", "archived"]),
|
||||||
|
)
|
||||||
|
.order_by(Deal.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = await db.execute(stmt)
|
||||||
|
deals = []
|
||||||
|
for deal in rows.scalars().all():
|
||||||
|
last_activity = await self._get_last_activity_date(str(deal.id), db)
|
||||||
|
deals.append({
|
||||||
|
"id": str(deal.id),
|
||||||
|
"name": getattr(deal, "name", "") or getattr(deal, "title", "") or "",
|
||||||
|
"value": float(getattr(deal, "value", 0) or getattr(deal, "amount", 0) or 0),
|
||||||
|
"stage": getattr(deal, "stage", "new") or "new",
|
||||||
|
"status": getattr(deal, "status", "active") or "active",
|
||||||
|
"expected_close": getattr(deal, "expected_close_date", None) or getattr(deal, "close_date", None),
|
||||||
|
"created_at": getattr(deal, "created_at", None),
|
||||||
|
"updated_at": getattr(deal, "updated_at", None),
|
||||||
|
"last_activity": last_activity,
|
||||||
|
})
|
||||||
|
return deals
|
||||||
|
|
||||||
|
async def _get_last_activity_date(self, deal_id: str, db: AsyncSession) -> Optional[datetime]:
|
||||||
|
"""Get the most recent activity date for a deal."""
|
||||||
|
try:
|
||||||
|
from app.models.activity import Activity
|
||||||
|
stmt = (
|
||||||
|
select(func.max(Activity.created_at))
|
||||||
|
.where(Activity.deal_id == deal_id)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Deal Forecast Calculation ────────────────
|
||||||
|
|
||||||
|
def _calculate_deal_forecasts(
|
||||||
|
self, deals_data: list[dict], now: datetime
|
||||||
|
) -> list[DealForecast]:
|
||||||
|
"""Calculate forecast for each deal."""
|
||||||
|
forecasts = []
|
||||||
|
for deal in deals_data:
|
||||||
|
base_prob = STAGE_PROBABILITIES.get(deal["stage"], 0.15)
|
||||||
|
|
||||||
|
# Adjust probability based on activity recency
|
||||||
|
days_inactive = self._days_since(deal.get("last_activity") or deal.get("updated_at"), now)
|
||||||
|
activity_modifier = self._activity_modifier(days_inactive)
|
||||||
|
adjusted_prob = max(0.0, min(1.0, base_prob * activity_modifier))
|
||||||
|
|
||||||
|
# Determine risk level
|
||||||
|
risk_level, risk_reasons = self._assess_risk(deal, days_inactive, adjusted_prob)
|
||||||
|
|
||||||
|
value = deal.get("value", 0) or 0
|
||||||
|
weighted = value * adjusted_prob
|
||||||
|
|
||||||
|
expected_close = deal.get("expected_close")
|
||||||
|
expected_close_str = None
|
||||||
|
if expected_close:
|
||||||
|
if isinstance(expected_close, datetime):
|
||||||
|
expected_close_str = expected_close.strftime("%Y-%m-%d")
|
||||||
|
elif isinstance(expected_close, str):
|
||||||
|
expected_close_str = expected_close
|
||||||
|
|
||||||
|
forecasts.append(DealForecast(
|
||||||
|
deal_id=deal["id"],
|
||||||
|
deal_name=deal.get("name", ""),
|
||||||
|
current_value=value,
|
||||||
|
close_probability=round(adjusted_prob, 2),
|
||||||
|
weighted_value=round(weighted, 2),
|
||||||
|
risk_level=risk_level,
|
||||||
|
risk_reasons_ar=risk_reasons,
|
||||||
|
days_inactive=days_inactive,
|
||||||
|
expected_close_date=expected_close_str,
|
||||||
|
))
|
||||||
|
return forecasts
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _days_since(dt: Optional[datetime], now: datetime) -> int:
|
||||||
|
"""Calculate days since a given datetime."""
|
||||||
|
if not dt:
|
||||||
|
return 30 # assume inactive if no date
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
delta = now - dt
|
||||||
|
return max(0, delta.days)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _activity_modifier(days_inactive: int) -> float:
|
||||||
|
"""Reduce probability based on inactivity."""
|
||||||
|
if days_inactive <= 2:
|
||||||
|
return 1.0
|
||||||
|
elif days_inactive <= 5:
|
||||||
|
return 0.95
|
||||||
|
elif days_inactive <= 10:
|
||||||
|
return 0.85
|
||||||
|
elif days_inactive <= 14:
|
||||||
|
return 0.7
|
||||||
|
elif days_inactive <= 21:
|
||||||
|
return 0.5
|
||||||
|
elif days_inactive <= 30:
|
||||||
|
return 0.3
|
||||||
|
else:
|
||||||
|
return 0.15
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assess_risk(deal: dict, days_inactive: int, probability: float) -> tuple[str, list[str]]:
|
||||||
|
"""Assess deal risk level and generate Arabic reasons."""
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
if days_inactive >= RISK_THRESHOLDS["high"]:
|
||||||
|
reasons.append(f"لا يوجد نشاط منذ {days_inactive} يوم")
|
||||||
|
elif days_inactive >= RISK_THRESHOLDS["medium"]:
|
||||||
|
reasons.append(f"النشاط منخفض — آخر تفاعل قبل {days_inactive} أيام")
|
||||||
|
|
||||||
|
if probability < 0.2 and deal.get("value", 0) > 0:
|
||||||
|
reasons.append("احتمالية الإغلاق منخفضة جداً")
|
||||||
|
|
||||||
|
stage = deal.get("stage", "")
|
||||||
|
expected_close = deal.get("expected_close")
|
||||||
|
if expected_close:
|
||||||
|
close_dt = expected_close
|
||||||
|
if isinstance(close_dt, str):
|
||||||
|
try:
|
||||||
|
close_dt = datetime.fromisoformat(close_dt.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
close_dt = None
|
||||||
|
if close_dt:
|
||||||
|
if isinstance(close_dt, datetime):
|
||||||
|
if close_dt.tzinfo is None:
|
||||||
|
close_dt = close_dt.replace(tzinfo=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if close_dt < now:
|
||||||
|
reasons.append("تجاوز تاريخ الإغلاق المتوقع")
|
||||||
|
elif (close_dt - now).days < 7 and stage in ("new", "contacted", "qualified"):
|
||||||
|
reasons.append("تاريخ الإغلاق قريب لكن المرحلة مبكرة")
|
||||||
|
|
||||||
|
if len(reasons) >= 2 or days_inactive >= RISK_THRESHOLDS["high"]:
|
||||||
|
return "high", reasons
|
||||||
|
elif len(reasons) >= 1 or days_inactive >= RISK_THRESHOLDS["medium"]:
|
||||||
|
return "medium", reasons
|
||||||
|
else:
|
||||||
|
return "low", reasons
|
||||||
|
|
||||||
|
# ── Period Grouping ──────────────────────────
|
||||||
|
|
||||||
|
def _group_by_period(
|
||||||
|
self, forecasts: list[DealForecast], period: str, now: datetime
|
||||||
|
) -> list[PeriodForecast]:
|
||||||
|
"""Group deal forecasts into monthly or quarterly buckets."""
|
||||||
|
periods: dict[str, list[DealForecast]] = {}
|
||||||
|
|
||||||
|
for deal in forecasts:
|
||||||
|
if not deal.expected_close_date:
|
||||||
|
# Default to current month/quarter
|
||||||
|
period_key = self._get_period_key(now, period)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
close_dt = datetime.fromisoformat(deal.expected_close_date)
|
||||||
|
except ValueError:
|
||||||
|
close_dt = now
|
||||||
|
period_key = self._get_period_key(close_dt, period)
|
||||||
|
|
||||||
|
periods.setdefault(period_key, []).append(deal)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for label in sorted(periods.keys()):
|
||||||
|
deals = periods[label]
|
||||||
|
total_pipeline = sum(d.current_value for d in deals)
|
||||||
|
total_weighted = sum(d.weighted_value for d in deals)
|
||||||
|
probabilities = [d.close_probability for d in deals if d.close_probability > 0]
|
||||||
|
avg_prob = sum(probabilities) / len(probabilities) if probabilities else 0
|
||||||
|
|
||||||
|
# Best/worst case
|
||||||
|
best_case = sum(d.current_value for d in deals if d.close_probability >= 0.5)
|
||||||
|
worst_case = sum(d.current_value for d in deals if d.close_probability >= 0.8)
|
||||||
|
|
||||||
|
result.append(PeriodForecast(
|
||||||
|
period_label=label,
|
||||||
|
predicted_revenue=round(total_weighted, 2),
|
||||||
|
weighted_pipeline=round(total_pipeline, 2),
|
||||||
|
deal_count=len(deals),
|
||||||
|
avg_close_probability=round(avg_prob, 2),
|
||||||
|
best_case=round(best_case, 2),
|
||||||
|
worst_case=round(worst_case, 2),
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_period_key(dt: datetime, period: str) -> str:
|
||||||
|
"""Generate a period label string."""
|
||||||
|
if period == "quarterly":
|
||||||
|
quarter = (dt.month - 1) // 3 + 1
|
||||||
|
return f"Q{quarter} {dt.year}"
|
||||||
|
else:
|
||||||
|
month_name = MONTHS_AR.get(dt.month, str(dt.month))
|
||||||
|
return f"{month_name} {dt.year}"
|
||||||
|
|
||||||
|
# ── Summary Generation ───────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_summary_ar(
|
||||||
|
periods: list[PeriodForecast],
|
||||||
|
at_risk: list[DealForecast],
|
||||||
|
total_pipeline: float,
|
||||||
|
total_weighted: float,
|
||||||
|
period_type: str,
|
||||||
|
) -> str:
|
||||||
|
lines = ["ملخص التوقعات:"]
|
||||||
|
lines.append(f"إجمالي خط الأنابيب: {total_pipeline:,.0f} ريال")
|
||||||
|
lines.append(f"الإيراد المتوقع (مرجّح): {total_weighted:,.0f} ريال")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
period_label = "الشهر" if period_type == "monthly" else "الربع"
|
||||||
|
for p in periods[:4]:
|
||||||
|
lines.append(
|
||||||
|
f"{p.period_label}: {p.predicted_revenue:,.0f} ريال متوقع "
|
||||||
|
f"({p.deal_count} صفقة، احتمالية {p.avg_close_probability:.0%})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if at_risk:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"صفقات معرّضة للخطر ({len(at_risk)}):")
|
||||||
|
for deal in at_risk[:5]:
|
||||||
|
reasons = " | ".join(deal.risk_reasons_ar) if deal.risk_reasons_ar else "غير محدد"
|
||||||
|
lines.append(f" - {deal.deal_name}: {deal.current_value:,.0f} ريال — {reasons}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_summary_en(
|
||||||
|
periods: list[PeriodForecast],
|
||||||
|
at_risk: list[DealForecast],
|
||||||
|
total_pipeline: float,
|
||||||
|
total_weighted: float,
|
||||||
|
period_type: str,
|
||||||
|
) -> str:
|
||||||
|
lines = ["Forecast Summary:"]
|
||||||
|
lines.append(f"Total Pipeline: {total_pipeline:,.0f} SAR")
|
||||||
|
lines.append(f"Weighted Revenue: {total_weighted:,.0f} SAR")
|
||||||
|
for p in periods[:4]:
|
||||||
|
lines.append(
|
||||||
|
f"{p.period_label}: {p.predicted_revenue:,.0f} SAR predicted "
|
||||||
|
f"({p.deal_count} deals, {p.avg_close_probability:.0%} avg probability)"
|
||||||
|
)
|
||||||
|
if at_risk:
|
||||||
|
lines.append(f"At-risk deals: {len(at_risk)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# ── AI Recommendations ───────────────────────
|
||||||
|
|
||||||
|
async def _generate_recommendations(
|
||||||
|
self,
|
||||||
|
periods: list[PeriodForecast],
|
||||||
|
at_risk: list[DealForecast],
|
||||||
|
total_pipeline: float,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Generate Arabic recommendations using LLM."""
|
||||||
|
if not periods and not at_risk:
|
||||||
|
return ["لا توجد بيانات كافية لتقديم توصيات. أضف صفقات لخط الأنابيب."]
|
||||||
|
|
||||||
|
# Build context for LLM
|
||||||
|
context_parts = []
|
||||||
|
for p in periods[:3]:
|
||||||
|
context_parts.append(
|
||||||
|
f"{p.period_label}: إيراد متوقع {p.predicted_revenue:,.0f} ريال، "
|
||||||
|
f"{p.deal_count} صفقة"
|
||||||
|
)
|
||||||
|
if at_risk:
|
||||||
|
context_parts.append(f"صفقات معرضة للخطر: {len(at_risk)}")
|
||||||
|
for d in at_risk[:3]:
|
||||||
|
context_parts.append(f" - {d.deal_name}: {d.current_value:,.0f} ريال، غير نشط {d.days_inactive} يوم")
|
||||||
|
|
||||||
|
context_text = "\n".join(context_parts)
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"أنت مستشار مبيعات خبير في السوق السعودي.\n"
|
||||||
|
"بناءً على بيانات التوقعات التالية، قدّم 3-5 توصيات عملية وقابلة للتنفيذ بالعربي.\n"
|
||||||
|
"أجب بصيغة JSON: {\"recommendations\": [\"توصية1\", \"توصية2\", ...]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._llm.complete(
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
user_message=context_text,
|
||||||
|
json_mode=True,
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=512,
|
||||||
|
fast=True,
|
||||||
|
)
|
||||||
|
parsed = response.parse_json()
|
||||||
|
if parsed and "recommendations" in parsed:
|
||||||
|
return parsed["recommendations"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"LLM recommendation generation failed: {e}")
|
||||||
|
|
||||||
|
# Fallback static recommendations
|
||||||
|
recommendations = []
|
||||||
|
if at_risk:
|
||||||
|
recommendations.append(
|
||||||
|
f"تنبيه: {len(at_risk)} صفقة معرّضة للخطر. تواصل مع العملاء فوراً لإعادة التفعيل."
|
||||||
|
)
|
||||||
|
if total_pipeline < 100000:
|
||||||
|
recommendations.append(
|
||||||
|
"خط الأنابيب منخفض. ركّز على توليد عملاء محتملين جدد هذا الأسبوع."
|
||||||
|
)
|
||||||
|
if periods:
|
||||||
|
low_prob_periods = [p for p in periods if p.avg_close_probability < 0.3]
|
||||||
|
if low_prob_periods:
|
||||||
|
recommendations.append(
|
||||||
|
"احتمالية الإغلاق منخفضة في بعض الفترات. راجع تأهيل العملاء المحتملين."
|
||||||
|
)
|
||||||
|
recommendations.append("حدّث بيانات الصفقات بانتظام لتحسين دقة التوقعات.")
|
||||||
|
return recommendations
|
||||||
@ -1,9 +1,6 @@
|
|||||||
"""Multi-channel sequence engine for Dealix CRM.
|
"""Multi-channel sequence engine for Dealix CRM.
|
||||||
|
Orchestrates outreach steps across WhatsApp, email, SMS with PDPL consent checks and A/B testing.
|
||||||
Orchestrates ordered outreach steps across WhatsApp, email, and SMS
|
|
||||||
with PDPL consent checks, A/B testing, and analytics.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@ -11,7 +8,7 @@ from typing import Optional
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel as Schema
|
from pydantic import BaseModel as Schema
|
||||||
from sqlalchemy import select, func, and_
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.sequence import (
|
from app.models.sequence import (
|
||||||
@ -23,10 +20,6 @@ from app.services.pdpl.consent_manager import ConsentManager
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pydantic schemas
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class SequenceCreateInput(Schema):
|
class SequenceCreateInput(Schema):
|
||||||
tenant_id: UUID
|
tenant_id: UUID
|
||||||
name: str
|
name: str
|
||||||
@ -67,83 +60,54 @@ class SequenceAnalytics(Schema):
|
|||||||
conversion_rate: float
|
conversion_rate: float
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SequenceEngine
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class SequenceEngine:
|
class SequenceEngine:
|
||||||
"""Manages multi-channel outreach sequences."""
|
"""Manages multi-channel outreach sequences."""
|
||||||
|
|
||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
# -- create sequence -----------------------------------------------------
|
|
||||||
|
|
||||||
async def create_sequence(self, data: SequenceCreateInput) -> Sequence:
|
async def create_sequence(self, data: SequenceCreateInput) -> Sequence:
|
||||||
"""Create a new sequence with optional steps."""
|
"""Create a new sequence with optional steps."""
|
||||||
|
|
||||||
seq = Sequence(
|
seq = Sequence(
|
||||||
tenant_id=data.tenant_id,
|
tenant_id=data.tenant_id, name=data.name, name_ar=data.name_ar,
|
||||||
name=data.name,
|
description=data.description, trigger_event=data.trigger_event,
|
||||||
name_ar=data.name_ar,
|
is_active=True, created_by=data.created_by,
|
||||||
description=data.description,
|
|
||||||
trigger_event=data.trigger_event,
|
|
||||||
is_active=True,
|
|
||||||
created_by=data.created_by,
|
|
||||||
)
|
)
|
||||||
self.db.add(seq)
|
self.db.add(seq)
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
|
for i, sd in enumerate(data.steps):
|
||||||
for i, step_data in enumerate(data.steps):
|
self.db.add(SequenceStep(
|
||||||
step = SequenceStep(
|
sequence_id=seq.id, step_order=i + 1,
|
||||||
sequence_id=seq.id,
|
channel=sd.get("channel", "email"), delay_minutes=sd.get("delay_minutes", 0),
|
||||||
step_order=i + 1,
|
template_content=sd.get("template_content", ""),
|
||||||
channel=step_data.get("channel", "email"),
|
template_content_ar=sd.get("template_content_ar"),
|
||||||
delay_minutes=step_data.get("delay_minutes", 0),
|
variant=sd.get("variant"), conditions=sd.get("conditions", {}),
|
||||||
template_content=step_data.get("template_content", ""),
|
))
|
||||||
template_content_ar=step_data.get("template_content_ar"),
|
|
||||||
variant=step_data.get("variant"),
|
|
||||||
conditions=step_data.get("conditions", {}),
|
|
||||||
)
|
|
||||||
self.db.add(step)
|
|
||||||
|
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
await self.db.refresh(seq)
|
await self.db.refresh(seq)
|
||||||
logger.info("Sequence created: id=%s name=%s", seq.id, seq.name)
|
logger.info("Sequence created: id=%s name=%s", seq.id, seq.name)
|
||||||
return seq
|
return seq
|
||||||
|
|
||||||
# -- enroll lead ---------------------------------------------------------
|
|
||||||
|
|
||||||
async def enroll_lead(self, data: EnrollInput) -> SequenceEnrollment:
|
async def enroll_lead(self, data: EnrollInput) -> SequenceEnrollment:
|
||||||
"""Enroll a lead into a sequence. Starts at step 0."""
|
"""Enroll a lead into a sequence."""
|
||||||
|
existing = (await self.db.execute(
|
||||||
# Prevent duplicate active enrollments
|
|
||||||
existing = await self.db.execute(
|
|
||||||
select(SequenceEnrollment).where(
|
select(SequenceEnrollment).where(
|
||||||
SequenceEnrollment.sequence_id == data.sequence_id,
|
SequenceEnrollment.sequence_id == data.sequence_id,
|
||||||
SequenceEnrollment.lead_id == data.lead_id,
|
SequenceEnrollment.lead_id == data.lead_id,
|
||||||
SequenceEnrollment.status == SequenceStatus.ACTIVE.value,
|
SequenceEnrollment.status == SequenceStatus.ACTIVE.value,
|
||||||
)
|
)
|
||||||
)
|
)).scalar_one_or_none()
|
||||||
if existing.scalar_one_or_none():
|
if existing:
|
||||||
raise ValueError("العميل المحتمل مسجل بالفعل في هذا التسلسل") # Lead already enrolled
|
raise ValueError("العميل المحتمل مسجل بالفعل في هذا التسلسل")
|
||||||
|
first_step = (await self.db.execute(
|
||||||
# Fetch first step to calculate next_step_at
|
|
||||||
first_step = await self.db.execute(
|
|
||||||
select(SequenceStep).where(SequenceStep.sequence_id == data.sequence_id)
|
select(SequenceStep).where(SequenceStep.sequence_id == data.sequence_id)
|
||||||
.order_by(SequenceStep.step_order).limit(1)
|
.order_by(SequenceStep.step_order).limit(1)
|
||||||
)
|
)).scalar_one_or_none()
|
||||||
step = first_step.scalar_one_or_none()
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
next_at = now + timedelta(minutes=step.delay_minutes) if step else None
|
|
||||||
|
|
||||||
enrollment = SequenceEnrollment(
|
enrollment = SequenceEnrollment(
|
||||||
sequence_id=data.sequence_id,
|
sequence_id=data.sequence_id, lead_id=data.lead_id, current_step=0,
|
||||||
lead_id=data.lead_id,
|
status=SequenceStatus.ACTIVE.value, enrolled_at=now,
|
||||||
current_step=0,
|
next_step_at=now + timedelta(minutes=first_step.delay_minutes) if first_step else None,
|
||||||
status=SequenceStatus.ACTIVE.value,
|
|
||||||
enrolled_at=now,
|
|
||||||
next_step_at=next_at,
|
|
||||||
)
|
)
|
||||||
self.db.add(enrollment)
|
self.db.add(enrollment)
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
@ -151,207 +115,135 @@ class SequenceEngine:
|
|||||||
logger.info("Lead enrolled: lead=%s sequence=%s", data.lead_id, data.sequence_id)
|
logger.info("Lead enrolled: lead=%s sequence=%s", data.lead_id, data.sequence_id)
|
||||||
return enrollment
|
return enrollment
|
||||||
|
|
||||||
# -- process pending steps -----------------------------------------------
|
|
||||||
|
|
||||||
async def process_pending_steps(self, tenant_id: UUID) -> list[StepProcessResult]:
|
async def process_pending_steps(self, tenant_id: UUID) -> list[StepProcessResult]:
|
||||||
"""Process all enrollments whose next step is due. Checks PDPL consent."""
|
"""Process enrollments whose next step is due. Checks PDPL consent."""
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
consent_mgr = ConsentManager(self.db)
|
consent_mgr = ConsentManager(self.db)
|
||||||
results: list[StepProcessResult] = []
|
results: list[StepProcessResult] = []
|
||||||
|
rows = await self.db.execute(
|
||||||
# Find active enrollments that are due
|
|
||||||
query = (
|
|
||||||
select(SequenceEnrollment)
|
select(SequenceEnrollment)
|
||||||
.join(Sequence, Sequence.id == SequenceEnrollment.sequence_id)
|
.join(Sequence, Sequence.id == SequenceEnrollment.sequence_id)
|
||||||
.where(
|
.where(Sequence.tenant_id == tenant_id, Sequence.is_active == True,
|
||||||
Sequence.tenant_id == tenant_id,
|
SequenceEnrollment.status == SequenceStatus.ACTIVE.value,
|
||||||
Sequence.is_active == True,
|
SequenceEnrollment.next_step_at <= now)
|
||||||
SequenceEnrollment.status == SequenceStatus.ACTIVE.value,
|
|
||||||
SequenceEnrollment.next_step_at <= now,
|
|
||||||
)
|
|
||||||
.limit(200)
|
.limit(200)
|
||||||
)
|
)
|
||||||
rows = await self.db.execute(query)
|
for enrollment in rows.scalars().all():
|
||||||
enrollments = rows.scalars().all()
|
r = await self._execute_next_step(enrollment, consent_mgr)
|
||||||
|
if r:
|
||||||
for enrollment in enrollments:
|
results.append(r)
|
||||||
result = await self._execute_next_step(enrollment, consent_mgr)
|
|
||||||
if result:
|
|
||||||
results.append(result)
|
|
||||||
|
|
||||||
logger.info("Processed %d pending steps for tenant=%s", len(results), tenant_id)
|
logger.info("Processed %d pending steps for tenant=%s", len(results), tenant_id)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# -- pause / resume / stop -----------------------------------------------
|
|
||||||
|
|
||||||
async def pause_enrollment(self, enrollment_id: UUID) -> SequenceEnrollment:
|
async def pause_enrollment(self, enrollment_id: UUID) -> SequenceEnrollment:
|
||||||
enrollment = await self._get_enrollment(enrollment_id)
|
e = await self._get_enrollment(enrollment_id)
|
||||||
enrollment.status = SequenceStatus.PAUSED.value
|
e.status = SequenceStatus.PAUSED.value
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
logger.info("Enrollment paused: %s", enrollment_id)
|
return e
|
||||||
return enrollment
|
|
||||||
|
|
||||||
async def resume_enrollment(self, enrollment_id: UUID) -> SequenceEnrollment:
|
async def resume_enrollment(self, enrollment_id: UUID) -> SequenceEnrollment:
|
||||||
enrollment = await self._get_enrollment(enrollment_id)
|
e = await self._get_enrollment(enrollment_id)
|
||||||
enrollment.status = SequenceStatus.ACTIVE.value
|
e.status = SequenceStatus.ACTIVE.value
|
||||||
enrollment.next_step_at = datetime.now(timezone.utc)
|
e.next_step_at = datetime.now(timezone.utc)
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
logger.info("Enrollment resumed: %s", enrollment_id)
|
return e
|
||||||
return enrollment
|
|
||||||
|
|
||||||
async def stop_enrollment(self, enrollment_id: UUID) -> SequenceEnrollment:
|
async def stop_enrollment(self, enrollment_id: UUID) -> SequenceEnrollment:
|
||||||
enrollment = await self._get_enrollment(enrollment_id)
|
e = await self._get_enrollment(enrollment_id)
|
||||||
enrollment.status = SequenceStatus.STOPPED.value
|
e.status = SequenceStatus.STOPPED.value
|
||||||
enrollment.completed_at = datetime.now(timezone.utc)
|
e.completed_at = datetime.now(timezone.utc)
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
logger.info("Enrollment stopped: %s", enrollment_id)
|
return e
|
||||||
return enrollment
|
|
||||||
|
|
||||||
# -- analytics -----------------------------------------------------------
|
|
||||||
|
|
||||||
async def get_sequence_analytics(self, sequence_id: UUID) -> SequenceAnalytics:
|
async def get_sequence_analytics(self, sequence_id: UUID) -> SequenceAnalytics:
|
||||||
"""Compute open/response/conversion rates for a sequence."""
|
"""Compute open/response/conversion rates for a sequence."""
|
||||||
|
|
||||||
seq = (await self.db.execute(
|
seq = (await self.db.execute(
|
||||||
select(Sequence).where(Sequence.id == sequence_id)
|
select(Sequence).where(Sequence.id == sequence_id))).scalar_one_or_none()
|
||||||
)).scalar_one_or_none()
|
|
||||||
if not seq:
|
if not seq:
|
||||||
raise ValueError("التسلسل غير موجود")
|
raise ValueError("التسلسل غير موجود")
|
||||||
|
|
||||||
# Enrollment counts
|
async def _enroll_count(st: str) -> int:
|
||||||
def _count_enrollments(status: str):
|
return (await self.db.execute(
|
||||||
return select(func.count()).where(
|
select(func.count()).where(SequenceEnrollment.sequence_id == sequence_id,
|
||||||
SequenceEnrollment.sequence_id == sequence_id,
|
SequenceEnrollment.status == st))).scalar() or 0
|
||||||
SequenceEnrollment.status == status,
|
|
||||||
)
|
|
||||||
|
|
||||||
total = (await self.db.execute(
|
total = (await self.db.execute(
|
||||||
select(func.count()).where(SequenceEnrollment.sequence_id == sequence_id)
|
select(func.count()).where(SequenceEnrollment.sequence_id == sequence_id))).scalar() or 0
|
||||||
)).scalar() or 0
|
active = await _enroll_count(SequenceStatus.ACTIVE.value)
|
||||||
active = (await self.db.execute(_count_enrollments(SequenceStatus.ACTIVE.value))).scalar() or 0
|
completed = await _enroll_count(SequenceStatus.COMPLETED.value)
|
||||||
completed = (await self.db.execute(_count_enrollments(SequenceStatus.COMPLETED.value))).scalar() or 0
|
stopped = await _enroll_count(SequenceStatus.STOPPED.value)
|
||||||
stopped = (await self.db.execute(_count_enrollments(SequenceStatus.STOPPED.value))).scalar() or 0
|
|
||||||
|
|
||||||
# Event counts
|
base = (select(func.count()).select_from(SequenceEvent)
|
||||||
base_event = (
|
.join(SequenceEnrollment, SequenceEnrollment.id == SequenceEvent.enrollment_id)
|
||||||
select(func.count())
|
.where(SequenceEnrollment.sequence_id == sequence_id))
|
||||||
.select_from(SequenceEvent)
|
total_sent = (await self.db.execute(base)).scalar() or 0
|
||||||
.join(SequenceEnrollment, SequenceEnrollment.id == SequenceEvent.enrollment_id)
|
|
||||||
.where(SequenceEnrollment.sequence_id == sequence_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
total_sent = (await self.db.execute(base_event)).scalar() or 0
|
|
||||||
delivered = (await self.db.execute(
|
delivered = (await self.db.execute(
|
||||||
base_event.where(SequenceEvent.status.in_(["delivered", "opened", "replied"]))
|
base.where(SequenceEvent.status.in_(["delivered", "opened", "replied"])))).scalar() or 0
|
||||||
)).scalar() or 0
|
|
||||||
opened = (await self.db.execute(
|
opened = (await self.db.execute(
|
||||||
base_event.where(SequenceEvent.status.in_(["opened", "replied"]))
|
base.where(SequenceEvent.status.in_(["opened", "replied"])))).scalar() or 0
|
||||||
)).scalar() or 0
|
|
||||||
replied = (await self.db.execute(
|
replied = (await self.db.execute(
|
||||||
base_event.where(SequenceEvent.status == "replied")
|
base.where(SequenceEvent.status == "replied"))).scalar() or 0
|
||||||
)).scalar() or 0
|
|
||||||
failed = (await self.db.execute(
|
failed = (await self.db.execute(
|
||||||
base_event.where(SequenceEvent.status == "failed")
|
base.where(SequenceEvent.status == "failed"))).scalar() or 0
|
||||||
)).scalar() or 0
|
|
||||||
|
|
||||||
safe_div = lambda n, d: round(n / d * 100, 2) if d else 0.0
|
|
||||||
|
|
||||||
|
safe = lambda n, d: round(n / d * 100, 2) if d else 0.0
|
||||||
return SequenceAnalytics(
|
return SequenceAnalytics(
|
||||||
sequence_id=sequence_id,
|
sequence_id=sequence_id, name=seq.name, total_enrolled=total,
|
||||||
name=seq.name,
|
active=active, completed=completed, stopped=stopped,
|
||||||
total_enrolled=total,
|
total_sent=total_sent, delivered=delivered, opened=opened,
|
||||||
active=active,
|
replied=replied, failed=failed,
|
||||||
completed=completed,
|
open_rate=safe(opened, total_sent), reply_rate=safe(replied, total_sent),
|
||||||
stopped=stopped,
|
conversion_rate=safe(completed, total),
|
||||||
total_sent=total_sent,
|
|
||||||
delivered=delivered,
|
|
||||||
opened=opened,
|
|
||||||
replied=replied,
|
|
||||||
failed=failed,
|
|
||||||
open_rate=safe_div(opened, total_sent),
|
|
||||||
reply_rate=safe_div(replied, total_sent),
|
|
||||||
conversion_rate=safe_div(completed, total) if total else 0.0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- private helpers -----------------------------------------------------
|
async def _execute_next_step(self, enrollment: SequenceEnrollment,
|
||||||
|
consent_mgr: ConsentManager) -> Optional[StepProcessResult]:
|
||||||
async def _execute_next_step(
|
steps = (await self.db.execute(
|
||||||
self, enrollment: SequenceEnrollment, consent_mgr: ConsentManager,
|
select(SequenceStep).where(SequenceStep.sequence_id == enrollment.sequence_id)
|
||||||
) -> Optional[StepProcessResult]:
|
.order_by(SequenceStep.step_order))).scalars().all()
|
||||||
"""Execute the next step for an enrollment."""
|
idx = enrollment.current_step
|
||||||
|
if idx >= len(steps):
|
||||||
steps_q = await self.db.execute(
|
|
||||||
select(SequenceStep)
|
|
||||||
.where(SequenceStep.sequence_id == enrollment.sequence_id)
|
|
||||||
.order_by(SequenceStep.step_order)
|
|
||||||
)
|
|
||||||
steps = steps_q.scalars().all()
|
|
||||||
next_idx = enrollment.current_step
|
|
||||||
if next_idx >= len(steps):
|
|
||||||
enrollment.status = SequenceStatus.COMPLETED.value
|
enrollment.status = SequenceStatus.COMPLETED.value
|
||||||
enrollment.completed_at = datetime.now(timezone.utc)
|
enrollment.completed_at = datetime.now(timezone.utc)
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return None
|
return None
|
||||||
|
# A/B variant selection
|
||||||
# A/B test: pick variant randomly if multiple exist for same order
|
candidates = [s for s in steps if s.step_order == steps[idx].step_order]
|
||||||
candidates = [s for s in steps if s.step_order == steps[next_idx].step_order]
|
step = random.choice(candidates) if len(candidates) > 1 else steps[idx]
|
||||||
step = random.choice(candidates) if len(candidates) > 1 else steps[next_idx]
|
# PDPL consent gate
|
||||||
|
cr = await consent_mgr.check_consent(enrollment.lead_id, "marketing", step.channel)
|
||||||
# PDPL consent check
|
if not cr.allowed:
|
||||||
seq = (await self.db.execute(
|
self.db.add(SequenceEvent(
|
||||||
select(Sequence).where(Sequence.id == enrollment.sequence_id)
|
enrollment_id=enrollment.id, step_id=step.id, channel=step.channel,
|
||||||
)).scalar_one()
|
status=SequenceEventStatus.FAILED.value,
|
||||||
consent_result = await consent_mgr.check_consent(
|
metadata={"reason": "no_consent", "detail": cr.message},
|
||||||
contact_id=enrollment.lead_id,
|
))
|
||||||
purpose="marketing",
|
|
||||||
channel=step.channel,
|
|
||||||
)
|
|
||||||
if not consent_result.allowed:
|
|
||||||
event = SequenceEvent(
|
|
||||||
enrollment_id=enrollment.id, step_id=step.id,
|
|
||||||
channel=step.channel, status=SequenceEventStatus.FAILED.value,
|
|
||||||
metadata={"reason": "no_consent", "message": consent_result.message},
|
|
||||||
)
|
|
||||||
self.db.add(event)
|
|
||||||
enrollment.status = SequenceStatus.STOPPED.value
|
enrollment.status = SequenceStatus.STOPPED.value
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return StepProcessResult(
|
return StepProcessResult(enrollment_id=enrollment.id, step_id=step.id,
|
||||||
enrollment_id=enrollment.id, step_id=step.id,
|
channel=step.channel, status="failed",
|
||||||
channel=step.channel, status="failed",
|
message=f"PDPL consent denied: {cr.message}")
|
||||||
message=f"PDPL consent denied: {consent_result.message}",
|
self.db.add(SequenceEvent(
|
||||||
)
|
enrollment_id=enrollment.id, step_id=step.id, channel=step.channel,
|
||||||
|
status=SequenceEventStatus.SENT.value,
|
||||||
# Record send event
|
metadata={"variant": step.variant, "preview": step.template_content[:80]},
|
||||||
event = SequenceEvent(
|
))
|
||||||
enrollment_id=enrollment.id, step_id=step.id,
|
enrollment.current_step = idx + 1
|
||||||
channel=step.channel, status=SequenceEventStatus.SENT.value,
|
|
||||||
metadata={"variant": step.variant, "template_preview": step.template_content[:100]},
|
|
||||||
)
|
|
||||||
self.db.add(event)
|
|
||||||
|
|
||||||
# Advance enrollment
|
|
||||||
enrollment.current_step = next_idx + 1
|
|
||||||
if enrollment.current_step >= len(steps):
|
if enrollment.current_step >= len(steps):
|
||||||
enrollment.status = SequenceStatus.COMPLETED.value
|
enrollment.status = SequenceStatus.COMPLETED.value
|
||||||
enrollment.completed_at = datetime.now(timezone.utc)
|
enrollment.completed_at = datetime.now(timezone.utc)
|
||||||
enrollment.next_step_at = None
|
enrollment.next_step_at = None
|
||||||
else:
|
else:
|
||||||
next_step = steps[enrollment.current_step]
|
enrollment.next_step_at = datetime.now(timezone.utc) + timedelta(minutes=steps[enrollment.current_step].delay_minutes)
|
||||||
enrollment.next_step_at = datetime.now(timezone.utc) + timedelta(minutes=next_step.delay_minutes)
|
|
||||||
|
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return StepProcessResult(
|
return StepProcessResult(enrollment_id=enrollment.id, step_id=step.id,
|
||||||
enrollment_id=enrollment.id, step_id=step.id,
|
channel=step.channel, status="sent",
|
||||||
channel=step.channel, status="sent",
|
message=f"Step {idx + 1} sent via {step.channel}")
|
||||||
message=f"Step {next_idx + 1} sent via {step.channel}",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _get_enrollment(self, enrollment_id: UUID) -> SequenceEnrollment:
|
async def _get_enrollment(self, enrollment_id: UUID) -> SequenceEnrollment:
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(SequenceEnrollment).where(SequenceEnrollment.id == enrollment_id)
|
select(SequenceEnrollment).where(SequenceEnrollment.id == enrollment_id))
|
||||||
)
|
e = result.scalar_one_or_none()
|
||||||
enrollment = result.scalar_one_or_none()
|
if not e:
|
||||||
if not enrollment:
|
raise ValueError("التسجيل غير موجود")
|
||||||
raise ValueError("التسجيل غير موجود") # Enrollment not found
|
return e
|
||||||
return enrollment
|
|
||||||
|
|||||||
127
salesflow-saas/seeds/education_template.json
Normal file
127
salesflow-saas/seeds/education_template.json
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"industry": "education",
|
||||||
|
"name": "Education & Training",
|
||||||
|
"name_ar": "تعليم وتدريب",
|
||||||
|
"pipeline_stages": [
|
||||||
|
{"key": "inquiry", "name_en": "Inquiry", "name_ar": "استفسار", "order": 1, "probability": 10},
|
||||||
|
{"key": "consultation", "name_en": "Consultation", "name_ar": "استشارة", "order": 2, "probability": 25},
|
||||||
|
{"key": "registration", "name_en": "Registration", "name_ar": "تسجيل", "order": 3, "probability": 50},
|
||||||
|
{"key": "payment", "name_en": "Payment", "name_ar": "دفع", "order": 4, "probability": 75},
|
||||||
|
{"key": "enrolled", "name_en": "Enrolled", "name_ar": "مسجّل", "order": 5, "probability": 90},
|
||||||
|
{"key": "completed", "name_en": "Completed", "name_ar": "مكتمل", "order": 6, "probability": 100},
|
||||||
|
{"key": "alumni", "name_en": "Alumni", "name_ar": "خريج", "order": 7, "probability": 100}
|
||||||
|
],
|
||||||
|
"message_templates": [
|
||||||
|
{
|
||||||
|
"name": "welcome",
|
||||||
|
"name_ar": "رسالة ترحيب",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "lead_created",
|
||||||
|
"content_ar": "أهلاً {name}! شكراً لتواصلك مع {company}. عندنا برامج تعليمية وتدريبية متميزة. وش البرنامج أو الدورة اللي تهمك؟",
|
||||||
|
"content_en": "Hello {name}! Thank you for contacting {company}. We offer outstanding educational and training programs. Which program interests you?",
|
||||||
|
"delay_minutes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "consultation_scheduled",
|
||||||
|
"name_ar": "موعد استشارة",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "stage_change_consultation",
|
||||||
|
"content_ar": "مرحباً {name}، تم حجز جلسة استشارة مجانية لك يوم {date} الساعة {time}. مستشارنا الأكاديمي بيساعدك تختار البرنامج الأنسب لأهدافك.",
|
||||||
|
"content_en": "Hi {name}, your free consultation is scheduled for {date} at {time}. Our academic advisor will help you choose the best program for your goals.",
|
||||||
|
"delay_minutes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "registration_confirmed",
|
||||||
|
"name_ar": "تأكيد التسجيل",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "stage_change_registration",
|
||||||
|
"content_ar": "مبروك {name}! تم تسجيلك في برنامج {program_name}. تاريخ البدء: {start_date}. بنرسل لك تفاصيل الدخول والمواد قريباً.",
|
||||||
|
"content_en": "Congratulations {name}! You're registered for {program_name}. Start date: {start_date}. We'll send access details and materials soon.",
|
||||||
|
"delay_minutes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "payment_reminder",
|
||||||
|
"name_ar": "تذكير بالدفع",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "stage_change_payment",
|
||||||
|
"content_ar": "مرحباً {name}، للتأكيد على مقعدك في برنامج {program_name}، يرجى إتمام الدفع. المبلغ: {amount} ريال. رابط الدفع: {payment_link}. عندنا خيار تقسيط بدون فوائد.",
|
||||||
|
"content_en": "Hi {name}, to confirm your spot in {program_name}, please complete payment. Amount: {amount} SAR. Payment link: {payment_link}.",
|
||||||
|
"delay_minutes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_response_followup",
|
||||||
|
"name_ar": "متابعة عدم الرد",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "no_response",
|
||||||
|
"content_ar": "مرحباً {name}، لاحظنا اهتمامك ببرامجنا التدريبية. المقاعد محدودة للدفعة القادمة. تبي نحجز لك مقعد أو تحتاج معلومات أكثر؟",
|
||||||
|
"content_en": "Hi {name}, we noticed your interest in our programs. Seats are limited for the next cohort. Want to reserve a spot or need more info?",
|
||||||
|
"delay_minutes": 2880
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "completion_certificate",
|
||||||
|
"name_ar": "شهادة إتمام",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "stage_change_completed",
|
||||||
|
"content_ar": "مبروك {name}! أتممت برنامج {program_name} بنجاح. شهادتك جاهزة للتحميل. نفتخر فيك! تبي تطّلع على برامجنا المتقدمة؟",
|
||||||
|
"content_en": "Congratulations {name}! You've completed {program_name} successfully. Your certificate is ready for download. Interested in advanced programs?",
|
||||||
|
"delay_minutes": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"proposal_templates": [
|
||||||
|
{
|
||||||
|
"name": "training_program",
|
||||||
|
"name_ar": "عرض برنامج تدريبي",
|
||||||
|
"sections": [
|
||||||
|
{"title_ar": "نبذة عن البرنامج", "title_en": "Program Overview"},
|
||||||
|
{"title_ar": "المحاور والمحتوى", "title_en": "Curriculum & Content"},
|
||||||
|
{"title_ar": "مدة البرنامج والجدول", "title_en": "Duration & Schedule"},
|
||||||
|
{"title_ar": "المدربون والمؤهلات", "title_en": "Instructors & Qualifications"},
|
||||||
|
{"title_ar": "الرسوم وخيارات الدفع", "title_en": "Fees & Payment Options"},
|
||||||
|
{"title_ar": "الشهادات المعتمدة", "title_en": "Accredited Certificates"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "corporate_training",
|
||||||
|
"name_ar": "عرض تدريب مؤسسي",
|
||||||
|
"sections": [
|
||||||
|
{"title_ar": "تحليل الاحتياجات التدريبية", "title_en": "Training Needs Analysis"},
|
||||||
|
{"title_ar": "البرنامج المخصص", "title_en": "Customized Program"},
|
||||||
|
{"title_ar": "منهجية التدريب", "title_en": "Training Methodology"},
|
||||||
|
{"title_ar": "التسعير المؤسسي", "title_en": "Corporate Pricing"},
|
||||||
|
{"title_ar": "قياس الأثر التدريبي", "title_en": "Training Impact Measurement"},
|
||||||
|
{"title_ar": "الشروط والأحكام", "title_en": "Terms & Conditions"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workflow_templates": [
|
||||||
|
{
|
||||||
|
"name": "new_student_flow",
|
||||||
|
"name_ar": "تدفق طالب جديد",
|
||||||
|
"trigger": "lead_created",
|
||||||
|
"actions": [
|
||||||
|
{"type": "send_message", "template": "welcome", "delay_minutes": 0},
|
||||||
|
{"type": "create_task", "subject": "اتصل بالمتقدم واعرف اهتماماته التعليمية", "delay_minutes": 30},
|
||||||
|
{"type": "send_message", "template": "no_response_followup", "delay_minutes": 2880, "condition": "no_response"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "registration_flow",
|
||||||
|
"name_ar": "تدفق التسجيل",
|
||||||
|
"trigger": "stage_change_registration",
|
||||||
|
"actions": [
|
||||||
|
{"type": "send_message", "template": "registration_confirmed", "delay_minutes": 0},
|
||||||
|
{"type": "send_message", "template": "payment_reminder", "delay_minutes": 1440},
|
||||||
|
{"type": "create_task", "subject": "متابعة الدفع مع المتدرب", "delay_minutes": 2880}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "alumni_engagement_flow",
|
||||||
|
"name_ar": "تدفق الخريجين",
|
||||||
|
"trigger": "stage_change_completed",
|
||||||
|
"actions": [
|
||||||
|
{"type": "send_message", "template": "completion_certificate", "delay_minutes": 0},
|
||||||
|
{"type": "create_task", "subject": "إرسال استبيان رضا المتدرب", "delay_minutes": 1440}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
117
salesflow-saas/seeds/retail_template.json
Normal file
117
salesflow-saas/seeds/retail_template.json
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
{
|
||||||
|
"industry": "retail",
|
||||||
|
"name": "Retail & E-commerce",
|
||||||
|
"name_ar": "تجارة وريتيل",
|
||||||
|
"pipeline_stages": [
|
||||||
|
{"key": "lead", "name_en": "Lead", "name_ar": "عميل محتمل", "order": 1, "probability": 10},
|
||||||
|
{"key": "interest", "name_en": "Interest", "name_ar": "مهتم", "order": 2, "probability": 20},
|
||||||
|
{"key": "demo", "name_en": "Demo", "name_ar": "عرض توضيحي", "order": 3, "probability": 40},
|
||||||
|
{"key": "trial", "name_en": "Trial", "name_ar": "تجربة", "order": 4, "probability": 55},
|
||||||
|
{"key": "negotiation", "name_en": "Negotiation", "name_ar": "تفاوض", "order": 5, "probability": 70},
|
||||||
|
{"key": "closed_won", "name_en": "Closed Won", "name_ar": "تم البيع", "order": 6, "probability": 100},
|
||||||
|
{"key": "onboarding", "name_en": "Onboarding", "name_ar": "تفعيل", "order": 7, "probability": 100}
|
||||||
|
],
|
||||||
|
"message_templates": [
|
||||||
|
{
|
||||||
|
"name": "welcome",
|
||||||
|
"name_ar": "رسالة ترحيب",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "lead_created",
|
||||||
|
"content_ar": "أهلاً {name}! شكراً لاهتمامك بـ {company}. عندنا حلول تجارية ذكية تساعدك تنمّي مبيعاتك. وش تبي تعرف أكثر عنه؟",
|
||||||
|
"content_en": "Hello {name}! Thanks for your interest in {company}. We have smart retail solutions to grow your sales. What would you like to know more about?",
|
||||||
|
"delay_minutes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "demo_invitation",
|
||||||
|
"name_ar": "دعوة عرض توضيحي",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "stage_change_demo",
|
||||||
|
"content_ar": "مرحباً {name}، جهّزنا لك عرض توضيحي مخصص لمجال {business_type}. الموعد يوم {date} الساعة {time}. بنوريك كيف تقدر تزيد مبيعاتك بنسبة تصل لـ 40%.",
|
||||||
|
"content_en": "Hi {name}, we've prepared a customized demo for your {business_type}. Scheduled for {date} at {time}. We'll show you how to increase sales by up to 40%.",
|
||||||
|
"delay_minutes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trial_started",
|
||||||
|
"name_ar": "بداية التجربة",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "stage_change_trial",
|
||||||
|
"content_ar": "مرحباً {name}! تم تفعيل حسابك التجريبي. الفترة: {trial_days} يوم. فريق الدعم جاهز يساعدك في أي وقت. استمتع بالتجربة!",
|
||||||
|
"content_en": "Hi {name}! Your trial account is activated. Duration: {trial_days} days. Our support team is ready to help anytime.",
|
||||||
|
"delay_minutes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trial_ending",
|
||||||
|
"name_ar": "انتهاء التجربة",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "scheduled",
|
||||||
|
"content_ar": "مرحباً {name}، فترتك التجريبية تنتهي خلال 3 أيام. كيف كانت التجربة؟ لو حبيت تكمل معنا عندنا عرض خاص بخصم {discount}% على الباقة السنوية.",
|
||||||
|
"content_en": "Hi {name}, your trial ends in 3 days. How was the experience? If you'd like to continue, we have a special {discount}% discount on the annual plan.",
|
||||||
|
"delay_minutes": -4320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_response_followup",
|
||||||
|
"name_ar": "متابعة عدم الرد",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "no_response",
|
||||||
|
"content_ar": "مرحباً {name}، لاحظنا إنك ما رديت. عادي تماماً! بس حبينا نخبرك إن عندنا عروض جديدة ممكن تناسب متجرك. تبي نرسل لك التفاصيل؟",
|
||||||
|
"content_en": "Hi {name}, we noticed you haven't responded. That's totally fine! We just wanted to let you know about new offers for your store.",
|
||||||
|
"delay_minutes": 2880
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "onboarding_welcome",
|
||||||
|
"name_ar": "ترحيب التفعيل",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "stage_change_onboarding",
|
||||||
|
"content_ar": "مبروك {name}! تم تفعيل حسابك الرسمي. مدير حسابك {account_manager} بيتواصل معك خلال 24 ساعة لمساعدتك في الإعداد. نتمنى لك النجاح!",
|
||||||
|
"content_en": "Congratulations {name}! Your account is officially active. Your account manager {account_manager} will contact you within 24 hours.",
|
||||||
|
"delay_minutes": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"proposal_templates": [
|
||||||
|
{
|
||||||
|
"name": "retail_solution",
|
||||||
|
"name_ar": "عرض حل تجاري",
|
||||||
|
"sections": [
|
||||||
|
{"title_ar": "ملخص الحل", "title_en": "Solution Summary"},
|
||||||
|
{"title_ar": "المميزات والفوائد", "title_en": "Features & Benefits"},
|
||||||
|
{"title_ar": "الباقات والأسعار", "title_en": "Packages & Pricing"},
|
||||||
|
{"title_ar": "دراسة حالة مشابهة", "title_en": "Similar Case Study"},
|
||||||
|
{"title_ar": "خطة التفعيل", "title_en": "Activation Plan"},
|
||||||
|
{"title_ar": "الشروط والأحكام", "title_en": "Terms & Conditions"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "enterprise_package",
|
||||||
|
"name_ar": "باقة المؤسسات",
|
||||||
|
"sections": [
|
||||||
|
{"title_ar": "نظرة عامة", "title_en": "Overview"},
|
||||||
|
{"title_ar": "التكامل مع الأنظمة الحالية", "title_en": "Integration with Existing Systems"},
|
||||||
|
{"title_ar": "التسعير المؤسسي", "title_en": "Enterprise Pricing"},
|
||||||
|
{"title_ar": "الدعم الفني والتدريب", "title_en": "Technical Support & Training"},
|
||||||
|
{"title_ar": "اتفاقية مستوى الخدمة", "title_en": "SLA"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workflow_templates": [
|
||||||
|
{
|
||||||
|
"name": "new_retail_lead_flow",
|
||||||
|
"name_ar": "تدفق عميل تجاري جديد",
|
||||||
|
"trigger": "lead_created",
|
||||||
|
"actions": [
|
||||||
|
{"type": "send_message", "template": "welcome", "delay_minutes": 0},
|
||||||
|
{"type": "create_task", "subject": "تأهيل العميل وتحديد احتياجاته التجارية", "delay_minutes": 30},
|
||||||
|
{"type": "send_message", "template": "no_response_followup", "delay_minutes": 2880, "condition": "no_response"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trial_management_flow",
|
||||||
|
"name_ar": "تدفق إدارة التجربة",
|
||||||
|
"trigger": "stage_change_trial",
|
||||||
|
"actions": [
|
||||||
|
{"type": "send_message", "template": "trial_started", "delay_minutes": 0},
|
||||||
|
{"type": "create_task", "subject": "متابعة العميل بعد 3 أيام من التجربة", "delay_minutes": 4320},
|
||||||
|
{"type": "send_message", "template": "trial_ending", "delay_minutes": -4320}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user