""" 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 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" 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 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 # ── Profile Endpoints ──────────────────────────────────────────────────────── @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=[], ) 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("/{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) # ── 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 "لم يتم العثور على فرص مقايضة. حاول إضافة المزيد من القدرات والاحتياجات في ملفك." ), }