mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
- 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
1061 lines
40 KiB
Python
1061 lines
40 KiB
Python
"""
|
||
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 0–4")
|
||
|
||
|
||
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 "لم يتم العثور على فرص مقايضة. حاول إضافة المزيد من القدرات والاحتياجات في ملفك."
|
||
),
|
||
}
|