system-prompts-and-models-o.../salesflow-saas/backend/app/api/v1/strategic_deals.py
Sami Assiri 07557c4be9 feat(dealix): GTM polish, CRM/AI APIs, launch verification hardening
- Add integrations CRM and AI routing APIs; Salesforce OAuth refresh; lead CRM metadata
- Marketer hub, settings CRM UI, OS views; premium landing and strategy_summary differentiators
- Docs: API-MAP, product guide, competitive matrix, launch simulation, AGENT-MAP LLM routing
- Sync script: strategy legal + competitive matrix to public; pytest DB isolation (.pytest_dealix.sqlite)
- Tests: CRM status and AI routing smoke; check_go_live_gate UTF-8 stdout on Windows
- Alembic migrations for strategic deal links and lead company/sector/city

Made-with: Cursor
2026-04-13 05:08:39 +03:00

1061 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Strategic Deals API — B2B deal discovery, matching, negotiation, and outreach.
واجهة الصفقات الاستراتيجية: اكتشاف وتوفيق وتفاوض وتواصل الشراكات
"""
from datetime import datetime, timezone
from decimal import Decimal
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel as Schema, Field
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.strategic_deal import (
CompanyProfile, StrategicDeal, DealMatch,
DealStatus, DealType, DealChannel, MatchStatus,
)
from app.services.strategic_deals.company_profiler import CompanyProfiler
from app.services.strategic_deals.deal_matcher import DealMatcher
from app.services.strategic_deals.deal_negotiator import DealNegotiator, NegotiationStrategy
from app.services.strategic_deals.deal_agent import DealAgent
from app.services.strategic_deals.operating_modes import OperatingMode, ModeEnforcer
from app.services.strategic_deals.deal_taxonomy import DealTaxonomyService
from app.services.dealix_os.vertical_playbooks import get_playbook, list_playbook_ids
from app.services.dealix_os.partner_archetypes import list_archetypes, archetype_for_deal_type
from app.services.dealix_os.policy_engine import evaluate_action, suggested_playbook_for_industry
router = APIRouter(prefix="/strategic-deals", tags=["Strategic Deals"])
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
class ProfileCreate(Schema):
company_name: str
company_name_ar: Optional[str] = None
industry: Optional[str] = None
sub_industry: Optional[str] = None
cr_number: Optional[str] = None
city: Optional[str] = None
region: Optional[str] = None
employee_count: Optional[int] = None
annual_revenue_sar: Optional[float] = None
capabilities: list[str] = []
needs: list[str] = []
deal_preferences: dict = {}
website: Optional[str] = None
linkedin_url: Optional[str] = None
whatsapp_number: Optional[str] = None
class ProfileResponse(Schema):
id: UUID
tenant_id: UUID
company_name: str
company_name_ar: Optional[str] = None
industry: Optional[str] = None
sub_industry: Optional[str] = None
cr_number: Optional[str] = None
city: Optional[str] = None
region: Optional[str] = None
employee_count: Optional[float] = None
annual_revenue_sar: Optional[float] = None
capabilities: list = []
needs: list = []
deal_preferences: dict = {}
website: Optional[str] = None
linkedin_url: Optional[str] = None
whatsapp_number: Optional[str] = None
trust_score: float = 0.0
is_verified: bool = False
created_at: datetime
updated_at: Optional[datetime] = None
model_config = {"from_attributes": True}
class NeedsAnalysisRequest(Schema):
description: str = Field(..., description="وصف الاحتياجات بالعربي أو الإنجليزي")
class DealCreate(Schema):
initiator_profile_id: UUID
target_profile_id: Optional[UUID] = None
target_company_name: Optional[str] = None
target_contact_phone: Optional[str] = None
target_contact_email: Optional[str] = None
deal_type: str = "partnership"
deal_title: str
deal_title_ar: Optional[str] = None
our_offer: Optional[str] = None
our_need: Optional[str] = None
proposed_terms: dict = {}
estimated_value_sar: Optional[float] = None
channel: str = "whatsapp"
lead_id: Optional[UUID] = None
sales_deal_id: Optional[UUID] = None
class DealResponse(Schema):
id: UUID
tenant_id: UUID
initiator_profile_id: UUID
target_profile_id: Optional[UUID] = None
target_company_name: Optional[str] = None
target_contact_phone: Optional[str] = None
target_contact_email: Optional[str] = None
deal_type: str
deal_title: str
deal_title_ar: Optional[str] = None
our_offer: Optional[str] = None
our_need: Optional[str] = None
proposed_terms: dict = {}
agreed_terms: dict = {}
estimated_value_sar: Optional[float] = None
status: str
channel: str
ai_confidence: float = 0.0
negotiation_history: list = []
notes: Optional[str] = None
notes_ar: Optional[str] = None
lead_id: Optional[UUID] = None
sales_deal_id: Optional[UUID] = None
created_at: datetime
updated_at: Optional[datetime] = None
closed_at: Optional[datetime] = None
model_config = {"from_attributes": True}
class MatchResponse(Schema):
id: UUID
tenant_id: UUID
company_a_id: UUID
company_b_id: Optional[UUID] = None
company_b_name: Optional[str] = None
company_b_data: dict = {}
match_score: float = 0.0
match_reasons: list = []
deal_type_suggested: Optional[str] = None
terms_suggested: dict = {}
status: str
created_at: datetime
model_config = {"from_attributes": True}
class NegotiateRequest(Schema):
their_terms: Optional[dict] = None
message: Optional[str] = None
strategy: Optional[NegotiationStrategy] = None
class OutreachRequest(Schema):
channel: str = "whatsapp"
style: str = "as_company"
class DiscoveryScanRequest(Schema):
profile_id: UUID
deal_type: Optional[str] = None
class BarterScanRequest(Schema):
profile_id: UUID
class OperatingModeSet(Schema):
mode: int = Field(..., ge=0, le=4, description="OperatingMode 04")
class PolicyEvaluateRequest(Schema):
channel: str = "whatsapp"
action: str = "send_custom_message"
deal_value_sar: float = 0.0
industry: Optional[str] = None
class DealLinksUpdate(Schema):
lead_id: Optional[UUID] = None
sales_deal_id: Optional[UUID] = None
# ── Profile Endpoints ────────────────────────────────────────────────────────
@router.get("/profiles", response_model=list[ProfileResponse])
async def list_profiles(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List company profiles for the tenant. | عرض ملفات الشركات"""
q = (
select(CompanyProfile)
.where(CompanyProfile.tenant_id == current_user.tenant_id)
.order_by(CompanyProfile.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
)
result = await db.execute(q)
return [ProfileResponse.model_validate(p) for p in result.scalars().all()]
@router.post("/profiles", response_model=ProfileResponse, status_code=201)
async def create_profile(
data: ProfileCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Create a company profile for B2B matching. | إنشاء ملف شركة للمطابقة"""
profiler = CompanyProfiler()
profile = await profiler.create_profile(
company_data=data.model_dump(),
tenant_id=current_user.tenant_id,
db=db,
)
return ProfileResponse.model_validate(profile)
@router.put("/profiles/{profile_id}/enrich", response_model=ProfileResponse)
async def enrich_profile(
profile_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""AI-enrich a company profile. | إثراء ملف الشركة بالذكاء الاصطناعي"""
result = await db.execute(
select(CompanyProfile).where(
CompanyProfile.id == profile_id,
CompanyProfile.tenant_id == current_user.tenant_id,
)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="الملف غير موجود | Profile not found")
profiler = CompanyProfiler()
profile = await profiler.enrich_profile(profile_id, db)
return ProfileResponse.model_validate(profile)
@router.post("/profiles/{profile_id}/analyze-needs")
async def analyze_needs(
profile_id: UUID,
data: NeedsAnalysisRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Analyze what a company needs (Arabic input). | تحليل احتياجات الشركة"""
result = await db.execute(
select(CompanyProfile).where(
CompanyProfile.id == profile_id,
CompanyProfile.tenant_id == current_user.tenant_id,
)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="الملف غير موجود | Profile not found")
profiler = CompanyProfiler()
analysis = await profiler.analyze_needs(profile_id, data.description, db)
return {"status": "ok", "analysis": analysis}
# ── Match Endpoints ──────────────────────────────────────────────────────────
@router.get("/matches", response_model=list[MatchResponse])
async def list_matches(
profile_id: UUID = Query(None, description="Filter by company profile"),
status: str = Query(None, description="Filter by match status"),
min_score: float = Query(None, ge=0, le=1, description="Minimum match score"),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get AI-suggested matches. | عرض المطابقات المقترحة بالذكاء الاصطناعي"""
query = select(DealMatch).where(DealMatch.tenant_id == current_user.tenant_id)
if profile_id:
query = query.where(
(DealMatch.company_a_id == profile_id) | (DealMatch.company_b_id == profile_id)
)
if status:
query = query.where(DealMatch.status == status)
if min_score is not None:
query = query.where(DealMatch.match_score >= min_score)
query = query.order_by(DealMatch.match_score.desc()).offset((page - 1) * per_page).limit(per_page)
result = await db.execute(query)
return [MatchResponse.model_validate(m) for m in result.scalars().all()]
@router.post("/matches/{match_id}/approve", response_model=MatchResponse)
async def approve_match(
match_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Approve a match for outreach. | الموافقة على مطابقة للتواصل"""
result = await db.execute(
select(DealMatch).where(
DealMatch.id == match_id,
DealMatch.tenant_id == current_user.tenant_id,
)
)
match = result.scalar_one_or_none()
if not match:
raise HTTPException(status_code=404, detail="المطابقة غير موجودة | Match not found")
if match.status != MatchStatus.SUGGESTED.value:
raise HTTPException(status_code=400, detail="المطابقة تمت الموافقة عليها مسبقاً | Match already processed")
match.status = MatchStatus.APPROVED.value
await db.flush()
await db.refresh(match)
return MatchResponse.model_validate(match)
# ── Discovery Scan ───────────────────────────────────────────────────────────
@router.post("/scan", response_model=list[MatchResponse])
async def run_discovery_scan(
data: DiscoveryScanRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Run a full AI discovery scan for partners. | تشغيل فحص اكتشاف شامل"""
result = await db.execute(
select(CompanyProfile).where(
CompanyProfile.id == data.profile_id,
CompanyProfile.tenant_id == current_user.tenant_id,
)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="الملف غير موجود | Profile not found")
agent = DealAgent()
matches = await agent.run_discovery_scan(data.profile_id, data.deal_type, db)
return [MatchResponse.model_validate(m) for m in matches]
# ── Deal CRUD ────────────────────────────────────────────────────────────────
@router.post("", response_model=DealResponse, status_code=201)
async def create_deal(
data: DealCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Create a strategic deal. | إنشاء صفقة استراتيجية"""
# Verify initiator profile belongs to tenant
init_result = await db.execute(
select(CompanyProfile).where(
CompanyProfile.id == data.initiator_profile_id,
CompanyProfile.tenant_id == current_user.tenant_id,
)
)
if not init_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="ملف المبادر غير موجود | Initiator profile not found")
deal = StrategicDeal(
tenant_id=current_user.tenant_id,
initiator_profile_id=data.initiator_profile_id,
target_profile_id=data.target_profile_id,
target_company_name=data.target_company_name,
target_contact_phone=data.target_contact_phone,
target_contact_email=data.target_contact_email,
deal_type=data.deal_type,
deal_title=data.deal_title,
deal_title_ar=data.deal_title_ar,
our_offer=data.our_offer,
our_need=data.our_need,
proposed_terms=data.proposed_terms,
estimated_value_sar=Decimal(str(data.estimated_value_sar)) if data.estimated_value_sar else None,
status=DealStatus.DISCOVERY.value,
channel=data.channel,
ai_confidence=0.0,
negotiation_history=[],
lead_id=data.lead_id,
sales_deal_id=data.sales_deal_id,
)
db.add(deal)
await db.flush()
await db.refresh(deal)
return DealResponse.model_validate(deal)
@router.get("", response_model=list[DealResponse])
async def list_deals(
status: str = Query(None),
deal_type: str = Query(None),
profile_id: UUID = Query(None, description="Filter by initiator or target profile"),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List strategic deals with filters. | عرض الصفقات الاستراتيجية"""
query = select(StrategicDeal).where(StrategicDeal.tenant_id == current_user.tenant_id)
if status:
query = query.where(StrategicDeal.status == status)
if deal_type:
query = query.where(StrategicDeal.deal_type == deal_type)
if profile_id:
query = query.where(
(StrategicDeal.initiator_profile_id == profile_id)
| (StrategicDeal.target_profile_id == profile_id)
)
query = query.order_by(StrategicDeal.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
result = await db.execute(query)
return [DealResponse.model_validate(d) for d in result.scalars().all()]
@router.get("/operating-model")
async def get_operating_model(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Current AI operating mode and all mode definitions. | وضع التشغيل والأوصاف"""
mode = await ModeEnforcer.get_current_mode(str(current_user.tenant_id), db)
policy = ModeEnforcer.get_mode_policy(mode)
return {
"current": {
"mode": mode.value,
"name": mode.name,
"label_ar": policy.label_ar,
"description_ar": policy.description_ar,
"auto_send": policy.auto_send,
"auto_negotiate": policy.auto_negotiate,
"max_auto_commitment_sar": policy.max_auto_commitment_sar,
"allowed_channels": policy.allowed_channels,
},
"modes": ModeEnforcer.get_all_modes(),
"roles_ar": [
{"id": "owner", "label": "المالك", "scope": "تغيير وضع التشغيل، الالتزامات الكبرى"},
{"id": "revops", "label": "عمليات الإيرادات", "scope": "القمع، السياسات، التقارير"},
{"id": "partner_manager", "label": "مدير شراكات", "scope": "مسار الشراكات والتفاوض"},
{"id": "compliance", "label": "الامتثال", "scope": "الموافقات الحساسة والقطاعات المنظمة"},
],
"sla_hints_ar": {
"response_window": "الرد على العملاء المؤهلين خلال ٢٤–٤٨ ساعة عمل",
"followup_cap": "حد أقصى ٣ متابعات تلقائية ثم تصعيد بشري",
"opt_out": "احترام طلب التوقف فوراً وتسجيله في السجل",
},
}
@router.put("/operating-model")
async def set_operating_model(
data: OperatingModeSet,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Set tenant operating mode (stored on first company profile). | تعيين وضع التشغيل"""
try:
om = OperatingMode(data.mode)
except ValueError:
raise HTTPException(status_code=400, detail="وضع تشغيل غير صالح | Invalid mode")
try:
await ModeEnforcer.set_mode(str(current_user.tenant_id), om, db)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"status": "ok", "mode": om.value, "name": om.name}
@router.get("/taxonomy/deal-types")
async def taxonomy_deal_types():
"""Full 15-type partnership taxonomy for UI. | تصنيف أنواع الصفقات"""
return [t.model_dump() for t in DealTaxonomyService.get_all_types()]
@router.get("/taxonomy/deal-types/{type_id}")
async def taxonomy_deal_type_detail(type_id: str):
spec = DealTaxonomyService.get_deal_type(type_id)
if not spec:
raise HTTPException(status_code=404, detail="نوع غير معروف | Unknown type")
return spec.model_dump()
@router.get("/partner-archetypes")
async def partner_archetypes():
"""Map DB deal_type values to operational archetypes. | أنماط الشراكات التشغيلية"""
return {"archetypes": list_archetypes()}
@router.get("/playbooks")
async def playbooks_list():
"""Vertical playbooks (sector defaults). | قوالب قطاعية"""
return {
"ids": list_playbook_ids(),
"items": [get_playbook(i) for i in list_playbook_ids()],
}
@router.get("/playbooks/{playbook_id}")
async def playbook_detail(playbook_id: str):
pb = get_playbook(playbook_id)
if not pb:
raise HTTPException(status_code=404, detail="playbook not found")
return pb
@router.post("/policy/evaluate")
async def policy_evaluate(
data: PolicyEvaluateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Graded policy: auto_execute | approval_required | blocked."""
result = await evaluate_action(
tenant_id=current_user.tenant_id,
channel=data.channel,
action=data.action,
deal_value_sar=data.deal_value_sar,
industry=data.industry,
db=db,
)
sp = suggested_playbook_for_industry(data.industry)
result["suggested_playbook_id"] = sp
return result
@router.get("/identity/graph")
async def identity_graph(
profile_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Counts and links for one company profile (light account graph)."""
pr = await db.execute(
select(CompanyProfile).where(
CompanyProfile.id == profile_id,
CompanyProfile.tenant_id == current_user.tenant_id,
)
)
profile = pr.scalar_one_or_none()
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
deals_init = await db.execute(
select(func.count()).select_from(StrategicDeal).where(
StrategicDeal.tenant_id == current_user.tenant_id,
StrategicDeal.initiator_profile_id == profile_id,
)
)
deals_tgt = await db.execute(
select(func.count()).select_from(StrategicDeal).where(
StrategicDeal.tenant_id == current_user.tenant_id,
StrategicDeal.target_profile_id == profile_id,
)
)
matches_a = await db.execute(
select(func.count()).select_from(DealMatch).where(
DealMatch.tenant_id == current_user.tenant_id,
DealMatch.company_a_id == profile_id,
)
)
matches_b = await db.execute(
select(func.count()).select_from(DealMatch).where(
DealMatch.tenant_id == current_user.tenant_id,
DealMatch.company_b_id == profile_id,
)
)
linked_leads = await db.execute(
select(func.count()).select_from(StrategicDeal).where(
StrategicDeal.tenant_id == current_user.tenant_id,
(StrategicDeal.initiator_profile_id == profile_id)
| (StrategicDeal.target_profile_id == profile_id),
StrategicDeal.lead_id.isnot(None),
)
)
linked_sales = await db.execute(
select(func.count()).select_from(StrategicDeal).where(
StrategicDeal.tenant_id == current_user.tenant_id,
(StrategicDeal.initiator_profile_id == profile_id)
| (StrategicDeal.target_profile_id == profile_id),
StrategicDeal.sales_deal_id.isnot(None),
)
)
return {
"profile_id": str(profile_id),
"company_name": profile.company_name,
"suggested_playbook_id": suggested_playbook_for_industry(profile.industry),
"archetype_hint": archetype_for_deal_type("partnership"),
"counts": {
"strategic_deals_as_initiator": deals_init.scalar() or 0,
"strategic_deals_as_target": deals_tgt.scalar() or 0,
"matches_as_party_a": matches_a.scalar() or 0,
"matches_as_party_b": matches_b.scalar() or 0,
"deals_with_lead_link": linked_leads.scalar() or 0,
"deals_with_sales_deal_link": linked_sales.scalar() or 0,
},
}
@router.get("/governance/snapshot")
async def governance_snapshot(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""North-star style KPIs + policy posture for dashboards."""
mode = await ModeEnforcer.get_current_mode(str(current_user.tenant_id), db)
policy = ModeEnforcer.get_mode_policy(mode)
tenant_id = current_user.tenant_id
total_deals = (await db.execute(
select(func.count()).select_from(StrategicDeal).where(StrategicDeal.tenant_id == tenant_id)
)).scalar() or 0
hist_rows = (
await db.execute(
select(StrategicDeal.negotiation_history).where(StrategicDeal.tenant_id == tenant_id)
)
).all()
deals_with_history = sum(
1 for (h,) in hist_rows if isinstance(h, list) and len(h) > 0
)
return {
"operating_mode": {"value": mode.value, "name": mode.name, "label_ar": policy.label_ar},
"north_star_hints_ar": {
"touch_to_meeting": "تقليل الزمن من أول لمسة إلى اجتماع مؤهل",
"stage_conversion": "تحسين تحويل المراحل في القمع",
"partner_attribution": "مساهمة الشراكات في خط الأنابيب",
},
"governance_kpis": {
"auto_send_enabled": policy.auto_send,
"auto_negotiate_enabled": policy.auto_negotiate,
"max_auto_commitment_sar": policy.max_auto_commitment_sar,
"strategic_deals_total": total_deals,
"deals_with_negotiation_rounds": deals_with_history,
},
}
@router.get("/growth/checklist")
async def growth_ma_checklist():
"""Light M&A / expansion checklist (human decisions required)."""
return {
"disclaimer_ar": "قائمة إرشادية فقط — لا تغني عن مستشار قانوني أو مالي.",
"phases": [
{
"id": "thesis",
"title_ar": "أطروحة الاستثمار",
"items_ar": [
"تحديد القطاع والجغرافيا والحجم المستهدف",
"ربط الصفقة بأهداف الشركة الاستراتيجية (٣–٥ نقاط)",
],
},
{
"id": "screen",
"title_ar": "فرز أولي",
"items_ar": [
"تطبيق معايير إقصاء واضحة (حجم، نمو، تركيز)",
"تسجيل مصادر البيانات لكل هدف",
],
},
{
"id": "dd_lite",
"title_ar": "عناية واجبة خفيفة",
"items_ar": [
"المالية: إيرادات، هامش، تدفقات",
"التقنية والمنتج: نضج، ديون تقنية، IP",
"العملاء: تركيز، انحراف، مخاطر تجميع",
],
},
{
"id": "approval",
"title_ar": "موافقة وإغلاق داخلي",
"items_ar": [
"لجنة استثمار / مجلس إدارة حسب الحوكمة",
"توثيق الشروط الرئيسية قبل أي التزام",
],
},
],
}
@router.get("/agent-quality/snapshot")
async def agent_quality_snapshot(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Proxy metrics for QA / improvement loop (extend with real message logs later)."""
tenant_id = current_user.tenant_id
total = (await db.execute(
select(func.count()).select_from(StrategicDeal).where(StrategicDeal.tenant_id == tenant_id)
)).scalar() or 0
with_hist = (await db.execute(
select(StrategicDeal.negotiation_history).where(StrategicDeal.tenant_id == tenant_id)
)).all()
rounds = 0
for row in with_hist:
h = row[0] or []
if isinstance(h, list):
rounds += len(h)
avg_rounds = (rounds / total) if total else 0.0
high_conf = (await db.execute(
select(func.count()).select_from(StrategicDeal).where(
StrategicDeal.tenant_id == tenant_id,
StrategicDeal.ai_confidence >= 0.7,
)
)).scalar() or 0
return {
"labels_ar": {
"negotiation_depth": "عمق جولات التفاوض المسجّل",
"high_confidence_deals": "صفقات بثقة نموذج مرتفعة",
},
"strategic_deals_total": total,
"negotiation_rounds_total": rounds,
"avg_negotiation_rounds_per_deal": round(avg_rounds, 2),
"deals_high_ai_confidence": high_conf,
"loop_hints_ar": [
"اربط هذه المؤشرات لاحقاً بردود العملاء الفعلية ومعدلات التحويل",
"استخدم وضع «مسودات» عند ارتفاع معدل التصعيد",
],
}
@router.get("/{deal_id}", response_model=DealResponse)
async def get_deal(
deal_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get deal details with negotiation history. | تفاصيل الصفقة مع سجل التفاوض"""
result = await db.execute(
select(StrategicDeal).where(
StrategicDeal.id == deal_id,
StrategicDeal.tenant_id == current_user.tenant_id,
)
)
deal = result.scalar_one_or_none()
if not deal:
raise HTTPException(status_code=404, detail="الصفقة غير موجودة | Deal not found")
return DealResponse.model_validate(deal)
@router.patch("/{deal_id}/links", response_model=DealResponse)
async def patch_deal_links(
deal_id: UUID,
data: DealLinksUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Link strategic deal to CRM lead and/or sales deal. | ربط الصفقة بعميل محتمل أو صفقة مبيعات"""
result = await db.execute(
select(StrategicDeal).where(
StrategicDeal.id == deal_id,
StrategicDeal.tenant_id == current_user.tenant_id,
)
)
deal = result.scalar_one_or_none()
if not deal:
raise HTTPException(status_code=404, detail="الصفقة غير موجودة | Deal not found")
if data.lead_id is not None:
deal.lead_id = data.lead_id
if data.sales_deal_id is not None:
deal.sales_deal_id = data.sales_deal_id
await db.flush()
await db.refresh(deal)
return DealResponse.model_validate(deal)
# ── Negotiation ──────────────────────────────────────────────────────────────
@router.put("/{deal_id}/negotiate")
async def negotiate_deal(
deal_id: UUID,
data: NegotiateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Submit terms, counter-offer, or free-text message. | تقديم شروط أو عرض مضاد"""
result = await db.execute(
select(StrategicDeal).where(
StrategicDeal.id == deal_id,
StrategicDeal.tenant_id == current_user.tenant_id,
)
)
deal = result.scalar_one_or_none()
if not deal:
raise HTTPException(status_code=404, detail="الصفقة غير موجودة | Deal not found")
negotiator = DealNegotiator()
# Check if we should escalate
should_escalate = await negotiator.should_escalate(deal_id, db)
if should_escalate:
return {
"status": "escalation_required",
"message_ar": "هذه الصفقة تحتاج تدخل بشري. يرجى التواصل مع مدير الحساب.",
"message_en": "This deal requires human intervention. Please contact the account manager.",
}
# Start new negotiation
if data.strategy and not deal.negotiation_history:
round_data = await negotiator.start_negotiation(deal_id, data.strategy, db)
return {
"status": "negotiation_started",
"round": round_data.round_number,
"action": round_data.action,
"our_terms": round_data.our_terms,
"message_ar": round_data.message_ar,
"message_en": round_data.message_en,
"confidence": round_data.confidence,
}
# Handle counter-offer
if data.their_terms:
round_data = await negotiator.handle_counter_offer(deal_id, data.their_terms, db)
return {
"status": "counter_processed",
"round": round_data.round_number,
"action": round_data.action,
"our_terms": round_data.our_terms,
"message_ar": round_data.message_ar,
"message_en": round_data.message_en,
"within_range": round_data.within_range,
"confidence": round_data.confidence,
}
# Handle free-text message
if data.message:
response = await negotiator.generate_response(deal_id, data.message, db)
return {
"status": "response_generated",
"response": response,
}
raise HTTPException(
status_code=400,
detail="يرجى تقديم شروط أو رسالة أو استراتيجية | Provide terms, message, or strategy",
)
# ── Outreach ─────────────────────────────────────────────────────────────────
@router.post("/{deal_id}/outreach")
async def send_outreach(
deal_id: UUID,
data: OutreachRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Send outreach via channel. | إرسال تواصل عبر قناة"""
# Find the deal and verify ownership
deal_result = await db.execute(
select(StrategicDeal).where(
StrategicDeal.id == deal_id,
StrategicDeal.tenant_id == current_user.tenant_id,
)
)
deal = deal_result.scalar_one_or_none()
if not deal:
raise HTTPException(status_code=404, detail="الصفقة غير موجودة | Deal not found")
# Find or create a match for this deal to run outreach
match_result = await db.execute(
select(DealMatch).where(
DealMatch.company_a_id == deal.initiator_profile_id,
DealMatch.tenant_id == current_user.tenant_id,
).order_by(DealMatch.match_score.desc()).limit(1)
)
match = match_result.scalar_one_or_none()
if not match:
# Create a placeholder match for outreach
match = DealMatch(
tenant_id=current_user.tenant_id,
company_a_id=deal.initiator_profile_id,
company_b_id=deal.target_profile_id,
company_b_name=deal.target_company_name,
match_score=deal.ai_confidence or 0.5,
match_reasons=["تواصل مباشر من المستخدم"],
deal_type_suggested=deal.deal_type,
status=MatchStatus.APPROVED.value,
)
db.add(match)
await db.flush()
agent = DealAgent()
result = await agent.run_outreach_campaign(match.id, data.channel, db)
return {
"status": "sent" if result.success else "failed",
"channel": result.channel,
"message_sent": result.message_sent,
"next_action_ar": result.next_action_ar,
"error": result.error,
}
# ── Proposal & Term Sheet ───────────────────────────────────────────────────
@router.post("/{deal_id}/proposal")
async def generate_proposal(
deal_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Generate an Arabic business proposal. | إنشاء مقترح أعمال بالعربي"""
result = await db.execute(
select(StrategicDeal).where(
StrategicDeal.id == deal_id,
StrategicDeal.tenant_id == current_user.tenant_id,
)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="الصفقة غير موجودة | Deal not found")
agent = DealAgent()
proposal = await agent.generate_proposal(deal_id, db)
return {"status": "ok", "proposal": proposal}
@router.post("/{deal_id}/term-sheet")
async def generate_term_sheet(
deal_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Generate an Arabic term sheet. | إنشاء ورقة شروط بالعربي"""
result = await db.execute(
select(StrategicDeal).where(
StrategicDeal.id == deal_id,
StrategicDeal.tenant_id == current_user.tenant_id,
)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="الصفقة غير موجودة | Deal not found")
negotiator = DealNegotiator()
term_sheet = await negotiator.generate_term_sheet(deal_id, db)
return {"status": "ok", "term_sheet": term_sheet}
# ── Analytics ────────────────────────────────────────────────────────────────
@router.get("/analytics/overview")
async def deal_analytics(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Deal flow analytics: match rate, close rate, avg deal value. | تحليلات الصفقات"""
tenant_id = current_user.tenant_id
# Total deals
total_q = select(func.count()).select_from(StrategicDeal).where(
StrategicDeal.tenant_id == tenant_id,
)
total_deals = (await db.execute(total_q)).scalar() or 0
# By status
status_q = select(
StrategicDeal.status, func.count()
).where(
StrategicDeal.tenant_id == tenant_id,
).group_by(StrategicDeal.status)
status_rows = (await db.execute(status_q)).all()
by_status = {row[0]: row[1] for row in status_rows}
won = by_status.get(DealStatus.CLOSED_WON.value, 0)
lost = by_status.get(DealStatus.CLOSED_LOST.value, 0)
closed = won + lost
close_rate = (won / closed * 100) if closed > 0 else 0.0
# Average deal value (closed won)
avg_val_q = select(func.avg(StrategicDeal.estimated_value_sar)).where(
StrategicDeal.tenant_id == tenant_id,
StrategicDeal.status == DealStatus.CLOSED_WON.value,
)
avg_value = (await db.execute(avg_val_q)).scalar()
avg_value_float = float(avg_value) if avg_value else 0.0
# Total matches and conversion
total_matches_q = select(func.count()).select_from(DealMatch).where(
DealMatch.tenant_id == tenant_id,
)
total_matches = (await db.execute(total_matches_q)).scalar() or 0
converted_q = select(func.count()).select_from(DealMatch).where(
DealMatch.tenant_id == tenant_id,
DealMatch.status == MatchStatus.CONVERTED.value,
)
converted_matches = (await db.execute(converted_q)).scalar() or 0
match_rate = (converted_matches / total_matches * 100) if total_matches > 0 else 0.0
# By deal type
type_q = select(
StrategicDeal.deal_type, func.count()
).where(
StrategicDeal.tenant_id == tenant_id,
).group_by(StrategicDeal.deal_type)
type_rows = (await db.execute(type_q)).all()
by_type = {row[0]: row[1] for row in type_rows}
return {
"total_deals": total_deals,
"by_status": by_status,
"close_rate_percent": round(close_rate, 1),
"avg_deal_value_sar": round(avg_value_float, 2),
"total_matches": total_matches,
"converted_matches": converted_matches,
"match_conversion_rate_percent": round(match_rate, 1),
"by_deal_type": by_type,
"labels_ar": {
"total_deals": "إجمالي الصفقات",
"close_rate": "نسبة الإغلاق",
"avg_value": "متوسط قيمة الصفقة",
"match_rate": "نسبة تحول المطابقات",
},
}
# ── Barter Scan ──────────────────────────────────────────────────────────────
@router.post("/barter-scan")
async def barter_scan(
data: BarterScanRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Find multi-party barter opportunities. | اكتشاف فرص المقايضة المتعددة"""
result = await db.execute(
select(CompanyProfile).where(
CompanyProfile.id == data.profile_id,
CompanyProfile.tenant_id == current_user.tenant_id,
)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="الملف غير موجود | Profile not found")
matcher = DealMatcher()
chains = await matcher.find_barter_chains(data.profile_id, db)
return {
"status": "ok",
"chains_found": len(chains),
"chains": chains,
"summary_ar": (
f"تم العثور على {len(chains)} سلسلة مقايضة محتملة"
if chains
else "لم يتم العثور على فرص مقايضة. حاول إضافة المزيد من القدرات والاحتياجات في ملفك."
),
}