system-prompts-and-models-o.../salesflow-saas/backend/app/services/territory_manager.py
Claude 141f10db76
feat: Add conversation intelligence, message writer, sales agent, APIs, and templates
Continuing Phase 3-6 implementation:

- AI: conversation_intelligence.py (Arabic dialogue analysis, buying signals)
- AI: message_writer.py (Arabic/English multi-channel message generation)
- AI: sales_agent.py (autonomous WhatsApp qualification bot)
- API: compliance.py (PDPL consent & data rights endpoints)
- API: inbox.py (unified multi-channel inbox)
- API: proposals.py (CPQ quote management endpoints)
- API: sequences.py (multi-channel sequence management)
- Services: territory_manager.py (Saudi region-based lead routing)
- Seeds: contracting_template.json (Saudi contracting industry template)
- Updated: router.py, consent_manager.py, data_rights.py

https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
2026-04-11 07:43:11 +00:00

282 lines
9.2 KiB
Python

"""
Dealix Saudi Territory Manager
إدارة المناطق وتوزيع العملاء على مندوبي المبيعات تلقائياً
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from pydantic import BaseModel, Field
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.lead import Lead
from app.models.user import User
from app.models.deal import Deal
logger = logging.getLogger("dealix.territory")
SAUDI_REGIONS: dict[str, dict] = {
"riyadh": {
"name_ar": "الرياض",
"name_en": "Riyadh",
"cities_ar": ["الرياض", "الخرج", "الدرعية", "المجمعة"],
},
"jeddah": {
"name_ar": "جدة",
"name_en": "Jeddah",
"cities_ar": ["جدة", "رابغ", "الليث"],
},
"eastern": {
"name_ar": "المنطقة الشرقية",
"name_en": "Eastern Province",
"cities_ar": ["الدمام", "الخبر", "الظهران", "الجبيل", "الأحساء", "القطيف"],
},
"makkah": {
"name_ar": "مكة المكرمة",
"name_en": "Makkah",
"cities_ar": ["مكة المكرمة", "الطائف"],
},
"madinah": {
"name_ar": "المدينة المنورة",
"name_en": "Madinah",
"cities_ar": ["المدينة المنورة", "ينبع"],
},
"asir": {
"name_ar": "عسير",
"name_en": "Asir",
"cities_ar": ["أبها", "خميس مشيط", "النماص"],
},
"qassim": {
"name_ar": "القصيم",
"name_en": "Qassim",
"cities_ar": ["بريدة", "عنيزة", "الرس"],
},
"tabuk": {
"name_ar": "تبوك",
"name_en": "Tabuk",
"cities_ar": ["تبوك", "ضبا", "الوجه"],
},
"hail": {
"name_ar": "حائل",
"name_en": "Hail",
"cities_ar": ["حائل", "بقعاء"],
},
"jazan": {
"name_ar": "جازان",
"name_en": "Jazan",
"cities_ar": ["جازان", "صبيا", "أبو عريش"],
},
"najran": {
"name_ar": "نجران",
"name_en": "Najran",
"cities_ar": ["نجران", "شرورة"],
},
"baha": {
"name_ar": "الباحة",
"name_en": "Al Baha",
"cities_ar": ["الباحة", "بلجرشي"],
},
"jouf": {
"name_ar": "الجوف",
"name_en": "Al Jouf",
"cities_ar": ["سكاكا", "دومة الجندل"],
},
}
class TerritoryAssignment(BaseModel):
territory_key: str
rep_ids: list[str] = Field(default_factory=list)
round_robin_index: int = 0
class TerritoryStats(BaseModel):
territory_key: str
name_ar: str
name_en: str
total_leads: int = 0
total_deals: int = 0
total_value: float = 0.0
win_rate: float = 0.0
reps_count: int = 0
class TerritoryManager:
"""Territory-based lead routing and performance analytics for Saudi regions."""
def __init__(self, db: AsyncSession):
self.db = db
self._assignments: dict[str, TerritoryAssignment] = {}
async def assign_territory(
self, territory_key: str, rep_ids: list[str],
) -> dict:
"""Assign sales reps to a territory."""
if territory_key not in SAUDI_REGIONS:
raise ValueError(f"منطقة غير معروفة: {territory_key}")
self._assignments[territory_key] = TerritoryAssignment(
territory_key=territory_key,
rep_ids=rep_ids,
round_robin_index=0,
)
region = SAUDI_REGIONS[territory_key]
logger.info(
"Territory '%s' assigned to %d reps", territory_key, len(rep_ids),
)
return {
"territory": territory_key,
"name_ar": region["name_ar"],
"name_en": region["name_en"],
"reps_assigned": len(rep_ids),
"rep_ids": rep_ids,
}
async def auto_route_lead(
self,
tenant_id: str,
lead_id: str,
region_key: Optional[str] = None,
city_hint: Optional[str] = None,
) -> dict:
"""Auto-assign a lead to the next rep in the matching territory via round-robin."""
territory_key = region_key
if not territory_key and city_hint:
territory_key = self._detect_territory(city_hint)
if not territory_key:
territory_key = "riyadh"
assignment = self._assignments.get(territory_key)
if not assignment or not assignment.rep_ids:
logger.warning(
"No reps assigned to territory '%s', falling back to riyadh",
territory_key,
)
assignment = self._assignments.get("riyadh")
territory_key = "riyadh"
if not assignment or not assignment.rep_ids:
return {
"lead_id": lead_id,
"assigned_to": None,
"territory": territory_key,
"error_ar": "لا يوجد مندوبين معينين لهذه المنطقة",
}
rep_id = assignment.rep_ids[assignment.round_robin_index % len(assignment.rep_ids)]
assignment.round_robin_index += 1
import uuid
result = await self.db.execute(
select(Lead).where(
Lead.id == uuid.UUID(lead_id),
Lead.tenant_id == uuid.UUID(tenant_id),
)
)
lead = result.scalar_one_or_none()
if lead:
lead.assigned_to = uuid.UUID(rep_id)
metadata = dict(lead.extra_metadata or {})
metadata["territory"] = territory_key
metadata["auto_routed_at"] = datetime.now(timezone.utc).isoformat()
lead.extra_metadata = metadata
await self.db.flush()
region = SAUDI_REGIONS.get(territory_key, {})
logger.info("Lead %s routed to rep %s in %s", lead_id, rep_id, territory_key)
return {
"lead_id": lead_id,
"assigned_to": rep_id,
"territory": territory_key,
"territory_name_ar": region.get("name_ar", ""),
}
async def get_territory_stats(
self, tenant_id: str, territory_key: Optional[str] = None,
) -> list[TerritoryStats]:
"""Get performance analytics per territory."""
import uuid
keys = [territory_key] if territory_key else list(SAUDI_REGIONS.keys())
stats_list: list[TerritoryStats] = []
for key in keys:
region = SAUDI_REGIONS.get(key)
if not region:
continue
assignment = self._assignments.get(key)
rep_ids = assignment.rep_ids if assignment else []
if not rep_ids:
stats_list.append(TerritoryStats(
territory_key=key,
name_ar=region["name_ar"],
name_en=region["name_en"],
))
continue
rep_uuids = [uuid.UUID(r) for r in rep_ids]
tid = uuid.UUID(tenant_id)
lead_count_q = select(func.count()).where(
Lead.tenant_id == tid,
Lead.assigned_to.in_(rep_uuids),
)
total_leads = (await self.db.execute(lead_count_q)).scalar() or 0
deals_q = select(func.count(), func.coalesce(func.sum(Deal.value), 0)).where(
Deal.tenant_id == tid,
Deal.assigned_to.in_(rep_uuids),
)
row = (await self.db.execute(deals_q)).one_or_none()
total_deals = row[0] if row else 0
total_value = float(row[1]) if row else 0.0
won_q = select(func.count()).where(
Deal.tenant_id == tid,
Deal.assigned_to.in_(rep_uuids),
Deal.stage == "closed_won",
)
won_count = (await self.db.execute(won_q)).scalar() or 0
win_rate = round((won_count / total_deals) * 100, 1) if total_deals > 0 else 0.0
stats_list.append(TerritoryStats(
territory_key=key,
name_ar=region["name_ar"],
name_en=region["name_en"],
total_leads=total_leads,
total_deals=total_deals,
total_value=total_value,
win_rate=win_rate,
reps_count=len(rep_ids),
))
return stats_list
def list_regions(self) -> list[dict]:
"""Return all Saudi regions with metadata."""
return [
{
"key": key,
"name_ar": info["name_ar"],
"name_en": info["name_en"],
"cities_ar": info["cities_ar"],
"reps_assigned": len(self._assignments.get(key, TerritoryAssignment(territory_key=key)).rep_ids),
}
for key, info in SAUDI_REGIONS.items()
]
def _detect_territory(self, city_hint: str) -> Optional[str]:
"""Detect territory from a city name hint (Arabic or English)."""
hint_lower = city_hint.strip().lower()
for key, info in SAUDI_REGIONS.items():
if hint_lower in info["name_en"].lower() or hint_lower == key:
return key
for city in info["cities_ar"]:
if city in city_hint:
return key
return None