mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 07:19:35 +00:00
445 lines
16 KiB
Python
445 lines
16 KiB
Python
"""
|
||
Sales OS: commission ledger, quota helpers, rep onboarding playbook (in-memory / tenant.settings).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from typing import Any, Dict, List, Optional
|
||
from uuid import UUID
|
||
|
||
from sqlalchemy import select, func
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.models.commission import Commission, CommissionStatus, Payout, PayoutStatus
|
||
from app.models.deal import Deal
|
||
from app.models.affiliate import AffiliateMarketer
|
||
from app.models.activity import Activity
|
||
from app.models.user import User
|
||
|
||
|
||
def _status_ar(cs: CommissionStatus) -> str:
|
||
m = {
|
||
CommissionStatus.DRAFT: "مسودة",
|
||
CommissionStatus.PENDING: "قيد المراجعة",
|
||
CommissionStatus.APPROVED: "معتمد — جاهز للدفع",
|
||
CommissionStatus.HELD: "معلّق",
|
||
CommissionStatus.PAID: "مدفوع للمسوّق",
|
||
CommissionStatus.REJECTED: "مرفوض",
|
||
CommissionStatus.DISPUTED: "نزاع",
|
||
CommissionStatus.CLAWBACK: "استرداد",
|
||
}
|
||
return m.get(cs, cs.value if hasattr(cs, "value") else str(cs))
|
||
|
||
|
||
def _payout_status_ar(ps: Optional[PayoutStatus]) -> Optional[str]:
|
||
if ps is None:
|
||
return None
|
||
m = {
|
||
PayoutStatus.PENDING: "دفعة قيد الانتظار",
|
||
PayoutStatus.PROCESSING: "قيد التحويل",
|
||
PayoutStatus.PAID: "تم التحويل",
|
||
PayoutStatus.FAILED: "فشل التحويل",
|
||
}
|
||
return m.get(ps, ps.value)
|
||
|
||
|
||
async def build_commission_ledger(
|
||
db: AsyncSession,
|
||
tenant_id: UUID,
|
||
*,
|
||
limit: int = 100,
|
||
) -> Dict[str, Any]:
|
||
"""Join commissions → deal → affiliate → payout for transparency UI."""
|
||
q = (
|
||
select(Commission, Deal, AffiliateMarketer, Payout)
|
||
.join(Deal, Commission.deal_id == Deal.id)
|
||
.join(AffiliateMarketer, Commission.affiliate_id == AffiliateMarketer.id)
|
||
.outerjoin(Payout, Commission.payout_id == Payout.id)
|
||
.where(Commission.tenant_id == tenant_id)
|
||
.order_by(Commission.created_at.desc())
|
||
.limit(limit)
|
||
)
|
||
result = await db.execute(q)
|
||
rows = result.all()
|
||
items: List[Dict[str, Any]] = []
|
||
for c, deal, aff, po in rows:
|
||
items.append(
|
||
{
|
||
"commission_id": str(c.id),
|
||
"deal_id": str(deal.id),
|
||
"deal_title": deal.title,
|
||
"deal_stage": deal.stage,
|
||
"deal_value_sar": float(deal.value) if deal.value is not None else None,
|
||
"affiliate_name": aff.full_name_ar or aff.full_name,
|
||
"affiliate_id": str(aff.id),
|
||
"amount_sar": float(c.amount),
|
||
"rate": float(c.rate),
|
||
"plan_type": c.plan_type,
|
||
"status": c.status.value,
|
||
"status_ar": _status_ar(c.status),
|
||
"payout_id": str(c.payout_id) if c.payout_id else None,
|
||
"payout_status": po.status.value if po else None,
|
||
"payout_status_ar": _payout_status_ar(po.status if po else None),
|
||
"payout_total_sar": float(po.total_amount) if po else None,
|
||
"approved_at": c.approved_at.isoformat() if c.approved_at else None,
|
||
"paid_at": c.paid_at.isoformat() if c.paid_at else None,
|
||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||
}
|
||
)
|
||
|
||
totals = {
|
||
"pending_review": sum(1 for i in items if i["status"] in ("draft", "pending")),
|
||
"approved_unpaid": sum(1 for i in items if i["status"] == "approved"),
|
||
"paid": sum(1 for i in items if i["status"] == "paid"),
|
||
"total_amount_sar": sum(i["amount_sar"] for i in items if i["status"] != "rejected"),
|
||
}
|
||
return {
|
||
"demo_mode": False,
|
||
"items": items,
|
||
"summary": totals,
|
||
}
|
||
|
||
|
||
def demo_commission_ledger() -> Dict[str, Any]:
|
||
return {
|
||
"demo_mode": True,
|
||
"items": [
|
||
{
|
||
"commission_id": "demo-1",
|
||
"deal_id": "demo-deal-1",
|
||
"deal_title": "اشتراك احترافي — عميل تجريبي",
|
||
"deal_stage": "closed_won",
|
||
"deal_value_sar": 699.0,
|
||
"affiliate_name": "مسوّق تجريبي",
|
||
"affiliate_id": "demo-aff",
|
||
"amount_sar": 140.0,
|
||
"rate": 0.2,
|
||
"plan_type": "professional",
|
||
"status": "approved",
|
||
"status_ar": "معتمد — جاهز للدفع",
|
||
"payout_id": None,
|
||
"payout_status": None,
|
||
"payout_status_ar": None,
|
||
"payout_total_sar": None,
|
||
"approved_at": datetime.now(timezone.utc).isoformat(),
|
||
"paid_at": None,
|
||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||
},
|
||
{
|
||
"commission_id": "demo-2",
|
||
"deal_id": "demo-deal-2",
|
||
"deal_title": "تجديد سنوي",
|
||
"deal_stage": "negotiation",
|
||
"deal_value_sar": 12000.0,
|
||
"affiliate_name": "مسوّق تجريبي",
|
||
"affiliate_id": "demo-aff",
|
||
"amount_sar": 2400.0,
|
||
"rate": 0.2,
|
||
"plan_type": "enterprise",
|
||
"status": "pending",
|
||
"status_ar": "قيد المراجعة",
|
||
"payout_id": None,
|
||
"payout_status": None,
|
||
"payout_status_ar": None,
|
||
"payout_total_sar": None,
|
||
"approved_at": None,
|
||
"paid_at": None,
|
||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||
},
|
||
],
|
||
"summary": {
|
||
"pending_review": 1,
|
||
"approved_unpaid": 1,
|
||
"paid": 0,
|
||
"total_amount_sar": 2540.0,
|
||
},
|
||
}
|
||
|
||
|
||
async def pipeline_value_open_deals(db: AsyncSession, tenant_id: UUID) -> float:
|
||
q = await db.execute(
|
||
select(func.coalesce(func.sum(Deal.value), 0)).where(
|
||
Deal.tenant_id == tenant_id,
|
||
Deal.stage.notin_(["closed_lost", "closed_won"]),
|
||
)
|
||
)
|
||
v = q.scalar()
|
||
return float(v) if v is not None else 0.0
|
||
|
||
|
||
async def pipeline_value_open_deals_scoped(
|
||
db: AsyncSession,
|
||
tenant_id: UUID,
|
||
*,
|
||
user_id: UUID,
|
||
role: str,
|
||
) -> float:
|
||
"""مندوب: أنبوبه فقط. مدير/مالك: كامل المستأجر."""
|
||
q = select(func.coalesce(func.sum(Deal.value), 0)).where(
|
||
Deal.tenant_id == tenant_id,
|
||
Deal.stage.notin_(["closed_lost", "closed_won"]),
|
||
)
|
||
if role == "agent":
|
||
q = q.where(Deal.assigned_to == user_id)
|
||
r = await db.execute(q)
|
||
v = r.scalar()
|
||
return float(v) if v is not None else 0.0
|
||
|
||
|
||
def _deal_scope_filter(user_id: UUID, role: str):
|
||
if role == "agent":
|
||
return Deal.assigned_to == user_id
|
||
return None
|
||
|
||
|
||
async def build_daily_digest(
|
||
db: AsyncSession,
|
||
tenant_id: UUID,
|
||
user_id: UUID,
|
||
role: str,
|
||
tenant_settings: dict,
|
||
) -> Dict[str, Any]:
|
||
tasks = await tasks_inbox_today(db, tenant_id, user_id)
|
||
pipeline = await pipeline_value_open_deals_scoped(db, tenant_id, user_id=user_id, role=role)
|
||
quota = merge_quota_view(tenant_settings, user_id, pipeline)
|
||
|
||
dq = select(Deal).where(
|
||
Deal.tenant_id == tenant_id,
|
||
Deal.stage.notin_(["closed_lost", "closed_won"]),
|
||
)
|
||
cond = _deal_scope_filter(user_id, role)
|
||
if cond is not None:
|
||
dq = dq.where(cond)
|
||
today = date.today()
|
||
week = today + timedelta(days=7)
|
||
dq = dq.where(Deal.expected_close_date.isnot(None)).where(Deal.expected_close_date <= week).where(Deal.expected_close_date >= today)
|
||
dq = dq.order_by(Deal.expected_close_date.asc()).limit(15)
|
||
upcoming = await db.execute(dq)
|
||
upcoming_rows = []
|
||
for d in upcoming.scalars().all():
|
||
upcoming_rows.append(
|
||
{
|
||
"deal_id": str(d.id),
|
||
"title": d.title,
|
||
"stage": d.stage,
|
||
"expected_close_date": d.expected_close_date.isoformat() if d.expected_close_date else None,
|
||
"value_sar": float(d.value) if d.value is not None else None,
|
||
}
|
||
)
|
||
|
||
suggested: List[str] = []
|
||
if not tasks:
|
||
suggested.append("لا أنشطة حديثة — جدّد أول اتصال أو رسالة متابعة لأعلى صفقة قيمة.")
|
||
if quota.get("attainment_ratio", 0) < 0.25:
|
||
suggested.append("الأنبوب أقل من ربع الهدف الشهري — ركّز على تأهيل 3 فرص جديدة هذا الأسبوع.")
|
||
if upcoming_rows:
|
||
suggested.append(f"لديك {len(upcoming_rows)} صفقة بإغلاق متوقع خلال 7 أيام — راجع المراحل والاعتراضات.")
|
||
|
||
return {
|
||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||
"tasks_preview": tasks[:12],
|
||
"quota": quota,
|
||
"upcoming_closes": upcoming_rows,
|
||
"suggested_actions_ar": suggested[:6],
|
||
}
|
||
|
||
|
||
async def build_manager_team_summary(db: AsyncSession, tenant_id: UUID) -> Dict[str, Any]:
|
||
q = await db.execute(
|
||
select(
|
||
Deal.assigned_to,
|
||
func.count(Deal.id),
|
||
func.coalesce(func.sum(Deal.value), 0),
|
||
)
|
||
.where(Deal.tenant_id == tenant_id, Deal.stage.notin_(["closed_lost", "closed_won"]))
|
||
.group_by(Deal.assigned_to)
|
||
)
|
||
rows = q.all()
|
||
user_ids = [r[0] for r in rows if r[0] is not None]
|
||
names: Dict[str, str] = {}
|
||
if len(user_ids) > 0:
|
||
uq = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||
for u in uq.scalars().all():
|
||
names[str(u.id)] = u.full_name_ar or u.full_name or u.email
|
||
|
||
reps = []
|
||
open_pipeline_total = 0.0
|
||
for assigned_to, cnt, val in rows:
|
||
if assigned_to is None:
|
||
continue
|
||
v = float(val) if val is not None else 0.0
|
||
open_pipeline_total += v
|
||
reps.append(
|
||
{
|
||
"user_id": str(assigned_to),
|
||
"name": names.get(str(assigned_to), "—"),
|
||
"open_deals": int(cnt),
|
||
"open_pipeline_sar": round(v, 2),
|
||
}
|
||
)
|
||
reps.sort(key=lambda x: x["open_pipeline_sar"], reverse=True)
|
||
|
||
total_open = await db.execute(
|
||
select(func.count(Deal.id)).where(
|
||
Deal.tenant_id == tenant_id,
|
||
Deal.stage.notin_(["closed_lost", "closed_won"]),
|
||
)
|
||
)
|
||
return {
|
||
"open_pipeline_total_sar": round(open_pipeline_total, 2),
|
||
"open_deals_total": int(total_open.scalar() or 0),
|
||
"reps": reps,
|
||
"note_ar": "ملخّص الفريق — أنبوب مفتوح حسب المندوب المسند.",
|
||
}
|
||
|
||
|
||
async def build_deal_health(
|
||
db: AsyncSession,
|
||
tenant_id: UUID,
|
||
user_id: UUID,
|
||
role: str,
|
||
*,
|
||
limit: int = 40,
|
||
) -> Dict[str, Any]:
|
||
q = select(Deal).where(Deal.tenant_id == tenant_id, Deal.stage.notin_(["closed_lost", "closed_won"]))
|
||
cond = _deal_scope_filter(user_id, role)
|
||
if cond is not None:
|
||
q = q.where(cond)
|
||
q = q.order_by(Deal.updated_at.asc()).limit(limit)
|
||
result = await db.execute(q)
|
||
deals = result.scalars().all()
|
||
now = datetime.now(timezone.utc)
|
||
today = date.today()
|
||
items: List[Dict[str, Any]] = []
|
||
for d in deals:
|
||
flags: List[str] = []
|
||
score = 100
|
||
if d.updated_at:
|
||
du = d.updated_at
|
||
if du.tzinfo is None:
|
||
du = du.replace(tzinfo=timezone.utc)
|
||
age = now - du
|
||
if age.days >= 14:
|
||
flags.append("لا تحديث على الصفقة منذ 14+ يوماً")
|
||
score -= 25
|
||
else:
|
||
flags.append("بلا تاريخ تحديث")
|
||
score -= 10
|
||
if d.expected_close_date and d.expected_close_date < today:
|
||
flags.append("تاريخ إغلاق متوقع متجاوز")
|
||
score -= 20
|
||
if d.stage == "new" and d.probability and d.probability > 40:
|
||
flags.append("احتمالية عالية لكن المرحلة ما زالت جديدة — راجع الجودة")
|
||
score -= 10
|
||
score = max(0, min(100, score))
|
||
risk = "high" if score < 45 else "medium" if score < 70 else "low"
|
||
items.append(
|
||
{
|
||
"deal_id": str(d.id),
|
||
"title": d.title,
|
||
"stage": d.stage,
|
||
"health_score": score,
|
||
"risk_level": risk,
|
||
"flags_ar": flags,
|
||
"value_sar": float(d.value) if d.value is not None else None,
|
||
}
|
||
)
|
||
return {"items": items, "note_ar": "مؤشر أولي من التحديث والمواعيد — يُعزّى لاحقاً بسجل المكالمات."}
|
||
|
||
|
||
async def tasks_inbox_today(
|
||
db: AsyncSession,
|
||
tenant_id: UUID,
|
||
user_id: UUID,
|
||
) -> List[Dict[str, Any]]:
|
||
q = await db.execute(
|
||
select(Activity)
|
||
.where(
|
||
Activity.tenant_id == tenant_id,
|
||
Activity.user_id == user_id,
|
||
)
|
||
.order_by(Activity.created_at.desc())
|
||
.limit(40)
|
||
)
|
||
out: List[Dict[str, Any]] = []
|
||
for a in q.scalars().all():
|
||
out.append(
|
||
{
|
||
"id": str(a.id),
|
||
"type": a.type,
|
||
"subject": a.subject or "",
|
||
"description": (a.description or "")[:500],
|
||
"scheduled_at": a.scheduled_at.isoformat() if a.scheduled_at else None,
|
||
"completed_at": a.completed_at.isoformat() if a.completed_at else None,
|
||
"deal_id": str(a.deal_id) if a.deal_id else None,
|
||
"lead_id": str(a.lead_id) if a.lead_id else None,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def default_sales_os_settings() -> Dict[str, Any]:
|
||
return {
|
||
"default_monthly_quota_sar": 500_000,
|
||
"rep_quotas": {},
|
||
"currency": "SAR",
|
||
}
|
||
|
||
|
||
def merge_quota_view(
|
||
tenant_settings: dict,
|
||
user_id: UUID,
|
||
pipeline_open_sar: float,
|
||
) -> Dict[str, Any]:
|
||
raw = (tenant_settings or {}).get("sales_os") or {}
|
||
base = {**default_sales_os_settings(), **raw}
|
||
target = float(base["rep_quotas"].get(str(user_id), base.get("default_monthly_quota_sar", 500_000)))
|
||
ratio = (pipeline_open_sar / target) if target > 0 else 0.0
|
||
return {
|
||
"monthly_target_sar": target,
|
||
"pipeline_open_sar": round(pipeline_open_sar, 2),
|
||
"attainment_ratio": round(min(ratio, 2.0), 3),
|
||
"note_ar": "الهدف مقابل أنبوب مفتوح (تقريبي) — يُحدَّث من إعدادات المستأجر.",
|
||
}
|
||
|
||
|
||
def rep_onboarding_playbook() -> Dict[str, Any]:
|
||
return {
|
||
"title_ar": "تأهيل مندوب المبيعات — 30 يوماً",
|
||
"phases": [
|
||
{
|
||
"day_range": "1–7",
|
||
"title_ar": "الأسبوع الأول — الأدوات والقنوات",
|
||
"tasks_ar": [
|
||
"إكمال الملف والقطاع في النظام",
|
||
"ربط واتساب التجريبي أو القناة المعتمدة",
|
||
"قراءة سكربت الافتتاحية + تسجيل محاكاة واحدة",
|
||
],
|
||
},
|
||
{
|
||
"day_range": "8–14",
|
||
"title_ar": "الأسبوع الثاني — الأنبوب والمتابعة",
|
||
"tasks_ar": [
|
||
"10 اتصالات/محادثات مؤهّلة في CRM",
|
||
"استخدام تذكير المتابعة التلقائي",
|
||
"اجتماع مراجعة مع المدير (15 دقيقة)",
|
||
],
|
||
},
|
||
{
|
||
"day_range": "15–30",
|
||
"title_ar": "الأسبوعان 3–4 — الإغلاق والعمولة",
|
||
"tasks_ar": [
|
||
"عرض سعر واحد على الأقل في مرحلة متأخرة",
|
||
"فهم شفافية العمولة من لوحة «دفتر العمولات»",
|
||
"تحليل أسبوعي: معدل التحويل مقابل الهدف",
|
||
],
|
||
},
|
||
],
|
||
"kpi_ar": [
|
||
"عدد اللقاءات المؤهّلة",
|
||
"قيمة الأنبوب المفتوح",
|
||
"صفقات مغلقة / عمولة معتمدة",
|
||
],
|
||
}
|