diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..bdf78759 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +salesflow-saas/frontend/tsconfig.tsbuildinfo diff --git a/salesflow-saas/CLAUDE.md b/salesflow-saas/CLAUDE.md index 6730f181..19d8a022 100644 --- a/salesflow-saas/CLAUDE.md +++ b/salesflow-saas/CLAUDE.md @@ -86,3 +86,15 @@ Before writing code, classify your task: - Always detect dialect before processing (saudi/gulf/msa) - Check for Arabizi and suggest Arabic conversion - Check code-switching (Arabic+English mixed) for readability + +## claude-mem (Persistent Memory) + +Installed and active. Automatically captures every session's work and injects context into new sessions. + +- **Worker**: `npx claude-mem start` (port 37777) +- **Web UI**: http://localhost:37777 +- **Search**: Use `/mem-search` in Claude Code +- **Data**: `~/.claude-mem/claude-mem.db` (SQLite + Chroma vectors) +- **Privacy**: Wrap sensitive content in `...` tags +- **Token savings**: ~95% reduction via 3-layer progressive retrieval +- **Auto-captures**: tool executions, session summaries, decisions, bugs, patterns diff --git a/salesflow-saas/backend/app/api/v1/channels.py b/salesflow-saas/backend/app/api/v1/channels.py new file mode 100644 index 00000000..4de15b7a --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/channels.py @@ -0,0 +1,95 @@ +""" +Channel API Endpoints — Dealix AI Revenue OS +Unified API for all communication channels: inbound routing, outreach, campaigns, timelines. +""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db + +router = APIRouter() + + +class InboundRequest(BaseModel): + channel: str + sender: str + message: str + + +class OutreachRequest(BaseModel): + channel: str + lead: dict + campaign_type: str = "cold_intro" + language: str = "ar" + + +class CampaignRequest(BaseModel): + lead: dict + channels: list[str] + campaign_type: str = "cold_outreach" + + +class ContentRequest(BaseModel): + platform: str + topic: str + language: str = "ar" + + +@router.post("/inbound") +async def channel_inbound(req: InboundRequest, db: AsyncSession = Depends(get_db)): + from app.services.channel_orchestrator import channel_orchestrator + response = await channel_orchestrator.route_inbound(req.channel, req.sender, req.message, db) + return {"channel": req.channel, "sender": req.sender, "response": response} + + +@router.post("/outreach") +async def channel_outreach(req: OutreachRequest, db: AsyncSession = Depends(get_db)): + from app.services.channel_orchestrator import channel_orchestrator + brain = channel_orchestrator._get_brain(req.channel) + if not brain: + raise HTTPException(status_code=400, detail=f"Channel '{req.channel}' not supported") + + if req.channel == "email": + draft = await brain.generate_outreach(req.lead, req.campaign_type, req.language) + return {"channel": req.channel, "subject": draft.subject, "body": draft.body} + elif req.channel == "linkedin": + name = req.lead.get("name", "") + title = req.lead.get("title", "") + company = req.lead.get("company", "") + draft = await brain.draft_connection_request(name, title, company, "sales", req.language) + return {"channel": req.channel, "draft": draft, "status": "pending_review"} + elif req.channel in ("instagram", "tiktok", "twitter", "snapchat"): + content = await brain.generate_content(req.channel, req.lead.get("topic", "sales_tips"), req.language) + return {"channel": req.channel, "content": content.content, "hashtags": content.hashtags} + + return {"channel": req.channel, "status": "unsupported_for_outreach"} + + +@router.post("/campaign") +async def multi_channel_campaign(req: CampaignRequest, db: AsyncSession = Depends(get_db)): + from app.services.channel_orchestrator import channel_orchestrator + plan = await channel_orchestrator.generate_multi_channel_campaign( + req.lead, req.channels, req.campaign_type, db + ) + return {"campaign_type": plan.campaign_type, "channels": plan.channels, "steps": plan.steps} + + +@router.get("/timeline/{contact_id}") +async def contact_timeline(contact_id: str, db: AsyncSession = Depends(get_db)): + from app.services.channel_orchestrator import channel_orchestrator + events = await channel_orchestrator.get_contact_timeline(contact_id, db) + return {"contact_id": contact_id, "events": [e.model_dump() for e in events]} + + +@router.post("/content") +async def generate_content(req: ContentRequest): + from app.services.social_media_brain import social_media_brain + draft = await social_media_brain.generate_content(req.platform, req.topic, req.language) + return {"platform": draft.platform, "content": draft.content, "hashtags": draft.hashtags, "theme": draft.theme} + + +@router.get("/health") +async def channels_health(): + from app.services.channel_orchestrator import channel_orchestrator + return {"channels": channel_orchestrator.get_channel_health()} diff --git a/salesflow-saas/backend/app/api/v1/router.py b/salesflow-saas/backend/app/api/v1/router.py index 4ce42b05..d1ac4282 100644 --- a/salesflow-saas/backend/app/api/v1/router.py +++ b/salesflow-saas/backend/app/api/v1/router.py @@ -17,6 +17,7 @@ from app.api.v1 import pipeline as pipeline_router from app.api.v1 import agent_system as agent_system_router from app.api.v1 import autonomous_foundation as autonomous_foundation_router from app.api.v1 import hermes as hermes_router +from app.api.v1 import strategic_deals as strategic_deals_router from app.api.v1 import marketing_hub as marketing_hub_router from app.api.v1 import strategy_summary as strategy_summary_router from app.api.v1 import value_proposition as value_proposition_router @@ -90,3 +91,14 @@ api_router.include_router(autonomous_foundation_router.router) # ── Hermes Fusion — Orchestration Layer ────────────────────── api_router.include_router(hermes_router.router) + +# ── Strategic Deals — B2B Deal Discovery & Negotiation ─────── +api_router.include_router(strategic_deals_router.router) + +# ── WhatsApp Webhook — Incoming messages & status ──────────── +from app.api.v1 import whatsapp_webhook as whatsapp_webhook_router +api_router.include_router(whatsapp_webhook_router.router) + +# ── Omnichannel — Unified channel management ───────────────── +from app.api.v1 import channels as channels_router +api_router.include_router(channels_router.router) diff --git a/salesflow-saas/backend/app/api/v1/strategic_deals.py b/salesflow-saas/backend/app/api/v1/strategic_deals.py new file mode 100644 index 00000000..f36dc8b1 --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/strategic_deals.py @@ -0,0 +1,681 @@ +""" +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 "لم يتم العثور على فرص مقايضة. حاول إضافة المزيد من القدرات والاحتياجات في ملفك." + ), + } diff --git a/salesflow-saas/backend/app/api/v1/whatsapp_webhook.py b/salesflow-saas/backend/app/api/v1/whatsapp_webhook.py new file mode 100644 index 00000000..ab7d88a7 --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/whatsapp_webhook.py @@ -0,0 +1,135 @@ +""" +WhatsApp Webhook — Dealix AI Revenue OS +Handles incoming WhatsApp messages, verification, and delivery status. +""" +import logging +from typing import Any + +from fastapi import APIRouter, Request, Depends, HTTPException +from fastapi.responses import PlainTextResponse + +from app.database import get_db + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/webhooks/whatsapp", tags=["WhatsApp Webhook"]) + + +@router.post("/incoming") +async def handle_incoming(request: Request, db=Depends(get_db)): + """Handle incoming WhatsApp messages from Meta Cloud API or Twilio.""" + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + phone = "" + message = "" + + # Meta Cloud API format + if "entry" in body: + try: + entry = body["entry"][0] + changes = entry.get("changes", [{}])[0] + value = changes.get("value", {}) + messages = value.get("messages", []) + if messages: + msg = messages[0] + phone = msg.get("from", "") + if msg.get("type") == "text": + message = msg.get("text", {}).get("body", "") + elif msg.get("type") == "interactive": + interactive = msg.get("interactive", {}) + if "button_reply" in interactive: + message = interactive["button_reply"].get("title", "") + elif "list_reply" in interactive: + message = interactive["list_reply"].get("title", "") + else: + message = f"[{msg.get('type', 'unknown')} message]" + except (IndexError, KeyError) as e: + logger.warning(f"Failed to parse Meta webhook: {e}") + return {"status": "ok"} + + # Twilio format + elif "From" in body or "from" in body: + phone = body.get("From", body.get("from", "")).replace("whatsapp:", "") + message = body.get("Body", body.get("body", "")) + + if not phone or not message: + logger.debug("Webhook received but no actionable message") + return {"status": "ok"} + + # Process through WhatsApp Brain + from app.services.whatsapp_brain import whatsapp_brain + + try: + response = await whatsapp_brain.handle_incoming(phone, message, db) + except Exception as e: + logger.error(f"WhatsApp brain error for {phone}: {e}") + response = "عذراً، حدث خطأ. حاول مرة أخرى أو تواصل مع support@dealix.sa" + + # Send response via WhatsApp API + try: + from app.integrations.whatsapp import send_whatsapp_message + await send_whatsapp_message(phone, response) + except Exception as e: + logger.error(f"Failed to send WhatsApp response to {phone}: {e}") + + logger.info(f"[WhatsApp] {phone}: '{message[:50]}...' → response sent") + return {"status": "ok", "phone": phone, "response_length": len(response)} + + +@router.get("/verify") +async def verify_webhook(request: Request): + """Meta webhook verification challenge.""" + params = request.query_params + mode = params.get("hub.mode") + token = params.get("hub.verify_token") + challenge = params.get("hub.challenge") + + import os + verify_token = os.environ.get("WHATSAPP_VERIFY_TOKEN", "dealix-whatsapp-verify-2026") + + if mode == "subscribe" and token == verify_token: + logger.info("WhatsApp webhook verified successfully") + return PlainTextResponse(content=challenge or "", status_code=200) + + logger.warning(f"WhatsApp webhook verification failed: mode={mode}, token={token}") + raise HTTPException(status_code=403, detail="Verification failed") + + +@router.post("/status") +async def delivery_status(request: Request): + """Handle delivery/read status updates from WhatsApp.""" + try: + body = await request.json() + except Exception: + return {"status": "ok"} + + # Meta format + if "entry" in body: + try: + entry = body["entry"][0] + changes = entry.get("changes", [{}])[0] + value = changes.get("value", {}) + statuses = value.get("statuses", []) + + for status in statuses: + recipient = status.get("recipient_id", "") + status_type = status.get("status", "") # sent, delivered, read, failed + timestamp = status.get("timestamp", "") + logger.debug( + f"[WhatsApp Status] {recipient}: {status_type} at {timestamp}" + ) + + # Update message status in database if needed + if status_type == "failed": + errors = status.get("errors", []) + error_msg = errors[0].get("title", "Unknown") if errors else "Unknown" + logger.error( + f"[WhatsApp] Message to {recipient} FAILED: {error_msg}" + ) + except (IndexError, KeyError) as e: + logger.warning(f"Failed to parse status webhook: {e}") + + return {"status": "ok"} diff --git a/salesflow-saas/backend/app/database.py b/salesflow-saas/backend/app/database.py index 24983522..21354d97 100644 --- a/salesflow-saas/backend/app/database.py +++ b/salesflow-saas/backend/app/database.py @@ -39,6 +39,10 @@ else: async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) +# Aliases for backward compatibility with workers +SessionLocal = async_session +async_session_factory = async_session + class Base(DeclarativeBase): pass diff --git a/salesflow-saas/backend/app/models/__init__.py b/salesflow-saas/backend/app/models/__init__.py index 1fae01e7..0cf19ad5 100644 --- a/salesflow-saas/backend/app/models/__init__.py +++ b/salesflow-saas/backend/app/models/__init__.py @@ -25,6 +25,8 @@ from app.models.knowledge import KnowledgeArticle, SectorAsset from app.models.advanced import TrustScore, Prospect, Scorecard, AIRehearsal from app.models.consent import PDPLConsent, PDPLConsentAudit, DataRequest from app.models.sequence import Sequence, SequenceStep, SequenceEnrollment, SequenceEvent +from app.models.strategic_deal import CompanyProfile, StrategicDeal, DealMatch +from app.models.api_key import APIKey, AppSetting __all__ = [ "BaseModel", "TenantModel", "Tenant", "User", "Lead", "Customer", @@ -39,4 +41,5 @@ __all__ = [ "TrustScore", "Prospect", "Scorecard", "AIRehearsal", "PDPLConsent", "PDPLConsentAudit", "DataRequest", "Sequence", "SequenceStep", "SequenceEnrollment", "SequenceEvent", + "CompanyProfile", "StrategicDeal", "DealMatch", ] diff --git a/salesflow-saas/backend/app/models/api_key.py b/salesflow-saas/backend/app/models/api_key.py new file mode 100644 index 00000000..7cbc3bd7 --- /dev/null +++ b/salesflow-saas/backend/app/models/api_key.py @@ -0,0 +1,44 @@ +""" +API Key Model — Dealix AI Revenue OS +Manages API keys for external integrations and developer access. +Adapted from VoXc2/dealix repository. +""" +from datetime import datetime +from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import TenantModel + + +class APIKey(TenantModel): + __tablename__ = "api_keys" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + name_ar: Mapped[str | None] = mapped_column(String(100)) + key_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + key_prefix: Mapped[str] = mapped_column(String(20), nullable=False) + permissions: Mapped[str | None] = mapped_column(Text) # JSON: ["read_leads", "write_deals"] + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + request_count: Mapped[int] = mapped_column(Integer, default=0) + rate_limit: Mapped[int] = mapped_column(Integer, default=1000) # per hour + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow) + created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id")) + + +class AppSetting(TenantModel): + __tablename__ = "app_settings" + + key: Mapped[str] = mapped_column(String(100), primary_key=True) + value: Mapped[str | None] = mapped_column(Text) + value_type: Mapped[str] = mapped_column(String(20), default="string") # string, int, bool, json + description: Mapped[str | None] = mapped_column(Text) + description_ar: Mapped[str | None] = mapped_column(Text) + is_public: Mapped[bool] = mapped_column(Boolean, default=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + onupdate=datetime.utcnow, + ) diff --git a/salesflow-saas/backend/app/models/strategic_deal.py b/salesflow-saas/backend/app/models/strategic_deal.py new file mode 100644 index 00000000..d7315cd4 --- /dev/null +++ b/salesflow-saas/backend/app/models/strategic_deal.py @@ -0,0 +1,238 @@ +""" +Strategic Deal Models — B2B deal discovery, matching, and negotiation. +نماذج الصفقات الاستراتيجية: اكتشاف وتوفيق وتفاوض الشراكات بين الشركات +""" + +import enum +from datetime import datetime, timezone + +from sqlalchemy import Column, String, Text, DateTime, Boolean, Float, Numeric, ForeignKey, Index +from sqlalchemy.orm import relationship + +from app.models.base import TenantModel +from app.models.compat import UUID, JSONB, default_uuid + + +# ── Enums ──────────────────────────────────────────────────────────────────── + + +class DealType(str, enum.Enum): + PARTNERSHIP = "partnership" + DISTRIBUTION = "distribution" + FRANCHISE = "franchise" + JOINT_VENTURE = "jv" + REFERRAL = "referral" + ACQUISITION = "acquisition" + BARTER = "barter" + + +class DealStatus(str, enum.Enum): + DISCOVERY = "discovery" + OUTREACH = "outreach" + NEGOTIATING = "negotiating" + TERM_SHEET = "term_sheet" + DUE_DILIGENCE = "due_diligence" + CLOSED_WON = "closed_won" + CLOSED_LOST = "closed_lost" + + +class DealChannel(str, enum.Enum): + WHATSAPP = "whatsapp" + LINKEDIN = "linkedin" + EMAIL = "email" + IN_PERSON = "in_person" + + +class MatchStatus(str, enum.Enum): + SUGGESTED = "suggested" + APPROVED = "approved" + OUTREACH_SENT = "outreach_sent" + IN_PROGRESS = "in_progress" + CONVERTED = "converted" + REJECTED = "rejected" + + +# ── Company Profile ────────────────────────────────────────────────────────── + + +class CompanyProfile(TenantModel): + """ + Rich company profile for B2B matching. + ملف الشركة الغني للمطابقة بين الشركات + """ + __tablename__ = "company_profiles" + __table_args__ = ( + Index("ix_company_profiles_industry", "industry"), + Index("ix_company_profiles_region", "region"), + Index("ix_company_profiles_verified", "is_verified"), + ) + + company_name = Column(String(255), nullable=False, index=True) + company_name_ar = Column(String(255), nullable=True) + + # Industry classification (ISIC codes) + industry = Column(String(100), nullable=True) + sub_industry = Column(String(100), nullable=True) + + # Saudi Commercial Registration + cr_number = Column(String(20), nullable=True, unique=True) + + # Location + city = Column(String(100), nullable=True) + region = Column(String(100), nullable=True) # Saudi administrative regions + + # Size indicators + employee_count = Column(Numeric(10, 0), nullable=True) + annual_revenue_sar = Column(Numeric(15, 2), nullable=True) + + # AI-enriched capability/need vectors (JSONB arrays) + capabilities = Column(JSONB, default=list) # What this company can offer + needs = Column(JSONB, default=list) # What this company needs + + # Deal preferences: partnership, acquisition, distribution, referral, barter weights + deal_preferences = Column(JSONB, default=dict) + + # Contact & web + website = Column(String(500), nullable=True) + linkedin_url = Column(String(500), nullable=True) + whatsapp_number = Column(String(20), nullable=True) + + # Trust & verification + trust_score = Column(Float, default=0.0) # 0-1 from KYB verification + is_verified = Column(Boolean, default=False) + + # Relationships + initiated_deals = relationship( + "StrategicDeal", + back_populates="initiator_profile", + foreign_keys="StrategicDeal.initiator_profile_id", + ) + targeted_deals = relationship( + "StrategicDeal", + back_populates="target_profile", + foreign_keys="StrategicDeal.target_profile_id", + ) + matches_as_a = relationship( + "DealMatch", + back_populates="company_a", + foreign_keys="DealMatch.company_a_id", + ) + matches_as_b = relationship( + "DealMatch", + back_populates="company_b", + foreign_keys="DealMatch.company_b_id", + ) + + +# ── Strategic Deal ─────────────────────────────────────────────────────────── + + +class StrategicDeal(TenantModel): + """ + A B2B deal between two companies. + صفقة بين شركتين + """ + __tablename__ = "strategic_deals" + __table_args__ = ( + Index("ix_strategic_deals_status", "status"), + Index("ix_strategic_deals_type", "deal_type"), + ) + + # Parties + initiator_profile_id = Column( + UUID(as_uuid=True), ForeignKey("company_profiles.id"), nullable=False, index=True, + ) + target_profile_id = Column( + UUID(as_uuid=True), ForeignKey("company_profiles.id"), nullable=True, index=True, + ) + + # Target info (when profile doesn't exist yet) + target_company_name = Column(String(255), nullable=True) + target_contact_phone = Column(String(20), nullable=True) + target_contact_email = Column(String(255), nullable=True) + + # Deal classification + deal_type = Column(String(30), default=DealType.PARTNERSHIP.value) + deal_title = Column(String(500), nullable=False) + deal_title_ar = Column(String(500), nullable=True) + + # Value proposition + our_offer = Column(Text, nullable=True) # What we're offering + our_need = Column(Text, nullable=True) # What we need from them + + # Terms + proposed_terms = Column(JSONB, default=dict) # equity_split, revenue_share, territory, exclusivity + agreed_terms = Column(JSONB, default=dict) # Final agreed terms + + estimated_value_sar = Column(Numeric(15, 2), nullable=True) + + # Status & channel + status = Column(String(30), default=DealStatus.DISCOVERY.value) + channel = Column(String(20), default=DealChannel.WHATSAPP.value) + + # AI signals + ai_confidence = Column(Float, default=0.0) # 0-1 + + # Negotiation audit trail + negotiation_history = Column(JSONB, default=list) # list of round dicts + + # Notes + notes = Column(Text, nullable=True) + notes_ar = Column(Text, nullable=True) + + closed_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + initiator_profile = relationship( + "CompanyProfile", back_populates="initiated_deals", + foreign_keys=[initiator_profile_id], + ) + target_profile = relationship( + "CompanyProfile", back_populates="targeted_deals", + foreign_keys=[target_profile_id], + ) + + +# ── Deal Match ─────────────────────────────────────────────────────────────── + + +class DealMatch(TenantModel): + """ + AI-generated match between two companies. + مطابقة بالذكاء الاصطناعي بين شركتين + """ + __tablename__ = "deal_matches" + __table_args__ = ( + Index("ix_deal_matches_score", "match_score"), + Index("ix_deal_matches_status", "status"), + ) + + company_a_id = Column( + UUID(as_uuid=True), ForeignKey("company_profiles.id"), nullable=False, index=True, + ) + company_b_id = Column( + UUID(as_uuid=True), ForeignKey("company_profiles.id"), nullable=True, index=True, + ) + + # External company data (when company_b has no profile) + company_b_name = Column(String(255), nullable=True) + company_b_data = Column(JSONB, default=dict) + + # Scoring + match_score = Column(Float, default=0.0) # 0-1 + match_reasons = Column(JSONB, default=list) # Arabic explanations + + # AI suggestions + deal_type_suggested = Column(String(30), nullable=True) + terms_suggested = Column(JSONB, default=dict) + + # Status + status = Column(String(30), default=MatchStatus.SUGGESTED.value) + + # Relationships + company_a = relationship( + "CompanyProfile", back_populates="matches_as_a", foreign_keys=[company_a_id], + ) + company_b = relationship( + "CompanyProfile", back_populates="matches_as_b", foreign_keys=[company_b_id], + ) diff --git a/salesflow-saas/backend/app/services/__init__.py b/salesflow-saas/backend/app/services/__init__.py index f0f66ac5..d81e57e2 100644 --- a/salesflow-saas/backend/app/services/__init__.py +++ b/salesflow-saas/backend/app/services/__init__.py @@ -29,6 +29,24 @@ from app.services.memory_engine import ( create_memory_adapter, ) from app.services.session_continuity import SessionContinuity, session_continuity +from app.services.strategic_deals import ( + CompanyProfiler, DealMatcher, DealNegotiator, NegotiationStrategy, DealAgent, + CompanyTwin, CompanyTwinBuilder, + DealRoom, DealRoomService, + DealTaxonomyService, DEAL_TAXONOMY, + OperatingMode, ModeEnforcer, MODE_POLICIES, + ChannelRules, ConsentLedger, +) +from app.services.hermes_orchestrator import hermes_orchestrator +from app.services.execution_router import execution_router +from app.services.shannon_security import shannon_security +from app.services.observability import observability_service +from app.services.self_improvement import self_improvement_engine +from app.services.feature_flags import feature_flags +from app.services.local_inference import local_inference +from app.services.gstack_discipline import gstack +from app.services.skill_governance import skill_governance +from app.services.arabic_ops import arabic_ops __all__ = [ "AuthService", @@ -65,4 +83,30 @@ __all__ = [ "create_memory_adapter", "SessionContinuity", "session_continuity", + "CompanyProfiler", + "DealMatcher", + "DealNegotiator", + "NegotiationStrategy", + "DealAgent", + "CompanyTwin", + "CompanyTwinBuilder", + "DealRoom", + "DealRoomService", + "DealTaxonomyService", + "DEAL_TAXONOMY", + "OperatingMode", + "ModeEnforcer", + "MODE_POLICIES", + "ChannelRules", + "ConsentLedger", + "hermes_orchestrator", + "execution_router", + "shannon_security", + "observability_service", + "self_improvement_engine", + "feature_flags", + "local_inference", + "gstack", + "skill_governance", + "arabic_ops", ] diff --git a/salesflow-saas/backend/app/services/channel_orchestrator.py b/salesflow-saas/backend/app/services/channel_orchestrator.py new file mode 100644 index 00000000..6e3ed753 --- /dev/null +++ b/salesflow-saas/backend/app/services/channel_orchestrator.py @@ -0,0 +1,167 @@ +""" +Channel Orchestrator — Dealix AI Revenue OS +Unified coordinator across all communication channels. +Routes inbound messages, generates multi-channel campaigns, and provides unified timelines. +""" +import logging +from datetime import datetime, timezone +from typing import Any, Optional + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +CHANNEL_PRIORITY = ["whatsapp", "email", "instagram", "twitter", "linkedin", "tiktok"] + +CHANNEL_REGISTRY = { + "whatsapp": {"name_ar": "واتساب", "auto_send": True, "max_daily": 1000}, + "email": {"name_ar": "إيميل", "auto_send": True, "max_daily": 500}, + "instagram": {"name_ar": "إنستغرام", "auto_send": True, "max_daily": 200}, + "twitter": {"name_ar": "تويتر", "auto_send": True, "max_daily": 100}, + "linkedin": {"name_ar": "لينكدإن", "auto_send": False, "max_daily": 50}, + "tiktok": {"name_ar": "تيك توك", "auto_send": True, "max_daily": 100}, + "snapchat": {"name_ar": "سناب شات", "auto_send": True, "max_daily": 100}, +} + + +class TimelineEvent(BaseModel): + channel: str + direction: str # inbound, outbound + content_preview: str + timestamp: datetime + event_type: str = "message" # message, campaign, note + + +class CampaignPlan(BaseModel): + lead: dict + channels: list[str] + campaign_type: str + steps: list[dict] + created_at: datetime = None + + def __init__(self, **data): + super().__init__(**data) + if self.created_at is None: + self.created_at = datetime.now(timezone.utc) + + +class ChannelOrchestrator: + """Unified coordinator routing messages to the correct channel brain.""" + + def __init__(self): + self._brains = {} + + def _get_brain(self, channel: str): + if channel not in self._brains: + if channel == "whatsapp": + from app.services.whatsapp_brain import whatsapp_brain + self._brains[channel] = whatsapp_brain + elif channel == "email": + from app.services.email_brain import email_brain + self._brains[channel] = email_brain + elif channel == "linkedin": + from app.services.linkedin_brain import linkedin_brain + self._brains[channel] = linkedin_brain + elif channel in ("instagram", "tiktok", "twitter", "snapchat"): + from app.services.social_media_brain import social_media_brain + self._brains[channel] = social_media_brain + return self._brains.get(channel) + + async def route_inbound( + self, channel: str, sender: str, message: str, db: Any = None + ) -> str: + brain = self._get_brain(channel) + if not brain: + logger.warning(f"[Orchestrator] no brain for channel={channel}") + return "شكراً لتواصلك! سيتم تحويلك لفريق الدعم." + + logger.info(f"[Orchestrator] routing {channel} from={sender}") + + if channel == "whatsapp": + return await brain.handle_incoming(sender, message, db) + elif channel == "email": + draft = await brain.handle_inbound(sender, message[:50], message, db) + return draft.body + elif channel in ("instagram", "tiktok", "twitter", "snapchat"): + return await brain.handle_inbound_dm(channel, sender, message, db) + elif channel == "linkedin": + return "تم استلام رسالتك عبر لينكدإن. فريق المبيعات بيتواصل معك قريباً." + + return "شكراً لتواصلك!" + + async def generate_multi_channel_campaign( + self, lead: dict, channels: list[str], campaign_type: str = "cold_outreach", db: Any = None + ) -> CampaignPlan: + sorted_channels = sorted(channels, key=lambda c: CHANNEL_PRIORITY.index(c) if c in CHANNEL_PRIORITY else 99) + steps = [] + day = 0 + + for i, channel in enumerate(sorted_channels): + brain = self._get_brain(channel) + if not brain: + continue + + if channel == "whatsapp": + content = f"أهلاً {lead.get('name', '')}! أنا من Dealix — نظام المبيعات الذكي. تبي تعرف أكثر؟" + steps.append({"day": day, "channel": channel, "action": "send_message", "content": content, "auto": True}) + elif channel == "email": + draft = await brain.generate_outreach(lead, "cold_intro") + steps.append({"day": day, "channel": channel, "action": "send_email", "subject": draft.subject, "content": draft.body, "auto": True}) + elif channel == "linkedin": + name = lead.get("name", "") + title = lead.get("title", "") + company = lead.get("company", "") + draft_text = await brain.draft_connection_request(name, title, company) + steps.append({"day": day, "channel": channel, "action": "send_connection", "content": draft_text, "auto": False}) + elif channel in ("instagram", "tiktok", "twitter", "snapchat"): + content = f"أهلاً! شكراً لمتابعتك. Dealix يساعد الشركات السعودية في المبيعات. تبي تعرف أكثر؟" + steps.append({"day": day, "channel": channel, "action": "send_dm", "content": content, "auto": True}) + + day += 2 # 2-day gap between channels + + plan = CampaignPlan(lead=lead, channels=sorted_channels, campaign_type=campaign_type, steps=steps) + logger.info(f"[Orchestrator] campaign planned: {len(steps)} steps across {len(sorted_channels)} channels") + return plan + + async def get_contact_timeline( + self, contact_id: str, db: Any = None + ) -> list[TimelineEvent]: + events = [] + if not db: + return events + try: + from sqlalchemy import select + from app.models.message import Message + + result = await db.execute( + select(Message).where(Message.contact_id == contact_id).order_by(Message.created_at.desc()).limit(100) + ) + messages = result.scalars().all() + for msg in messages: + events.append(TimelineEvent( + channel=msg.channel or "whatsapp", + direction=msg.direction or "inbound", + content_preview=msg.body[:120] if msg.body else "", + timestamp=msg.created_at, + event_type="message", + )) + except Exception as e: + logger.warning(f"[Orchestrator] timeline error for {contact_id}: {e}") + + return sorted(events, key=lambda e: e.timestamp, reverse=True) + + def get_channel_health(self) -> dict: + health = {} + for channel, config in CHANNEL_REGISTRY.items(): + brain = self._get_brain(channel) + health[channel] = { + "name_ar": config["name_ar"], + "active": brain is not None, + "auto_send": config["auto_send"], + "max_daily": config["max_daily"], + } + return health + + +# Global singleton +channel_orchestrator = ChannelOrchestrator() diff --git a/salesflow-saas/backend/app/services/comparison_engine.py b/salesflow-saas/backend/app/services/comparison_engine.py new file mode 100644 index 00000000..ce39024f --- /dev/null +++ b/salesflow-saas/backend/app/services/comparison_engine.py @@ -0,0 +1,183 @@ +""" +Comparison Engine — Dealix AI Revenue OS +Competitive comparison data for charts, WhatsApp responses, and sales tools. +""" +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +# Score scale: 0-10 per dimension +COMPETITORS = { + "dealix": { + "name": "Dealix", "name_ar": "ديلكس", + "scores": { + "arabic_support": 10, "whatsapp_native": 10, "ai_scoring": 9, + "pdpl_compliance": 10, "pricing_value": 9, "ease_of_use": 9, + "saudi_market_fit": 10, "deal_exchange": 10, "strategic_deals": 10, + "multi_channel": 9, "reporting": 8, "integrations": 7, + }, + }, + "zoho": { + "name": "Zoho CRM", "name_ar": "زوهو", + "scores": { + "arabic_support": 7, "whatsapp_native": 6, "ai_scoring": 6, + "pdpl_compliance": 5, "pricing_value": 8, "ease_of_use": 7, + "saudi_market_fit": 6, "deal_exchange": 2, "strategic_deals": 1, + "multi_channel": 7, "reporting": 8, "integrations": 9, + }, + }, + "salesforce": { + "name": "Salesforce", "name_ar": "سيلزفورس", + "scores": { + "arabic_support": 3, "whatsapp_native": 2, "ai_scoring": 8, + "pdpl_compliance": 4, "pricing_value": 3, "ease_of_use": 4, + "saudi_market_fit": 4, "deal_exchange": 1, "strategic_deals": 2, + "multi_channel": 7, "reporting": 10, "integrations": 10, + }, + }, + "hubspot": { + "name": "HubSpot", "name_ar": "هب سبوت", + "scores": { + "arabic_support": 2, "whatsapp_native": 3, "ai_scoring": 7, + "pdpl_compliance": 3, "pricing_value": 5, "ease_of_use": 8, + "saudi_market_fit": 3, "deal_exchange": 1, "strategic_deals": 1, + "multi_channel": 8, "reporting": 8, "integrations": 9, + }, + }, + "pipedrive": { + "name": "Pipedrive", "name_ar": "بايب درايف", + "scores": { + "arabic_support": 2, "whatsapp_native": 1, "ai_scoring": 5, + "pdpl_compliance": 2, "pricing_value": 7, "ease_of_use": 9, + "saudi_market_fit": 2, "deal_exchange": 0, "strategic_deals": 0, + "multi_channel": 4, "reporting": 6, "integrations": 6, + }, + }, +} + +DIMENSION_LABELS = { + "arabic_support": {"ar": "دعم العربي", "en": "Arabic Support"}, + "whatsapp_native": {"ar": "واتساب مدمج", "en": "WhatsApp Native"}, + "ai_scoring": {"ar": "ذكاء اصطناعي", "en": "AI Scoring"}, + "pdpl_compliance": {"ar": "حماية البيانات", "en": "PDPL Compliance"}, + "pricing_value": {"ar": "القيمة مقابل السعر", "en": "Pricing Value"}, + "ease_of_use": {"ar": "سهولة الاستخدام", "en": "Ease of Use"}, + "saudi_market_fit": {"ar": "مناسب للسعودية", "en": "Saudi Market Fit"}, + "deal_exchange": {"ar": "تبادل صفقات", "en": "Deal Exchange"}, + "strategic_deals": {"ar": "صفقات استراتيجية", "en": "Strategic Deals"}, + "multi_channel": {"ar": "تعدد القنوات", "en": "Multi-Channel"}, + "reporting": {"ar": "التقارير", "en": "Reporting"}, + "integrations": {"ar": "التكاملات", "en": "Integrations"}, +} + + +class ComparisonEngine: + """Generate comparison data for charts and sales responses.""" + + @staticmethod + def get_chart_data(language: str = "ar") -> dict[str, Any]: + """Data formatted for radar/bar charts on frontend.""" + labels = [ + DIMENSION_LABELS[dim][language] + for dim in DIMENSION_LABELS + ] + datasets = [] + for key, comp in COMPETITORS.items(): + datasets.append({ + "label": comp[f"name_{language}" if f"name_{language}" in comp else "name"], + "data": list(comp["scores"].values()), + "highlight": key == "dealix", + }) + return {"labels": labels, "datasets": datasets, "dimensions": list(DIMENSION_LABELS.keys())} + + @staticmethod + def get_feature_matrix(language: str = "ar") -> dict[str, Any]: + """Feature comparison table data.""" + features = [ + {"key": "arabic_first", "ar": "عربي أولاً (مو ترجمة)", "en": "Arabic-First (not translation)", + "dealix": True, "zoho": False, "salesforce": False, "hubspot": False, "pipedrive": False}, + {"key": "whatsapp_built_in", "ar": "واتساب مدمج بالنظام", "en": "Built-in WhatsApp", + "dealix": True, "zoho": False, "salesforce": False, "hubspot": False, "pipedrive": False}, + {"key": "ai_arabic", "ar": "AI يفهم العربي والسعودي", "en": "Arabic-Aware AI", + "dealix": True, "zoho": False, "salesforce": False, "hubspot": False, "pipedrive": False}, + {"key": "pdpl_native", "ar": "PDPL مدمج", "en": "Built-in PDPL", + "dealix": True, "zoho": False, "salesforce": False, "hubspot": False, "pipedrive": False}, + {"key": "deal_exchange", "ar": "صفقات استراتيجية وتبادل", "en": "Strategic Deal Exchange", + "dealix": True, "zoho": False, "salesforce": False, "hubspot": False, "pipedrive": False}, + {"key": "lead_scoring", "ar": "تقييم عملاء ذكي", "en": "AI Lead Scoring", + "dealix": True, "zoho": True, "salesforce": True, "hubspot": True, "pipedrive": True}, + {"key": "pipeline", "ar": "مسار صفقات بصري", "en": "Visual Pipeline", + "dealix": True, "zoho": True, "salesforce": True, "hubspot": True, "pipedrive": True}, + {"key": "cpq", "ar": "عروض أسعار", "en": "Quotes (CPQ)", + "dealix": True, "zoho": True, "salesforce": True, "hubspot": False, "pipedrive": False}, + ] + return {"features": features, "competitors": list(COMPETITORS.keys())} + + @staticmethod + def get_total_scores() -> dict[str, int]: + """Total score per competitor (out of 120).""" + return { + key: sum(comp["scores"].values()) + for key, comp in COMPETITORS.items() + } + + @staticmethod + def get_why_dealix_wins(language: str = "ar") -> list[str]: + """Top reasons Dealix wins.""" + reasons = { + "ar": [ + "الوحيد المصمم من الأساس للسوق السعودي", + "واتساب مدمج — مو إضافة من طرف ثالث", + "ذكاء اصطناعي يفهم اللهجة السعودية", + "حماية بيانات PDPL مدمجة بالنظام", + "نظام صفقات استراتيجية — لا يوجد عند أي منافس", + "سعر يبدأ من ٥٩ ر.س — أرخص ١٠ مرات من Salesforce", + "ثنائي اللغة (عربي/إنجليزي) بتبديل فوري", + ], + "en": [ + "Only CRM built from scratch for the Saudi market", + "Built-in WhatsApp — not a third-party add-on", + "AI that understands Saudi Arabic dialect", + "PDPL data protection built into the core", + "Strategic Deal Exchange — no competitor has this", + "Starting at 59 SAR — 10x cheaper than Salesforce", + "Bilingual (Arabic/English) with instant switching", + ], + } + return reasons.get(language, reasons["ar"]) + + @staticmethod + def get_comparison_summary(competitor: str, language: str = "ar") -> str: + """Summary comparing Dealix vs a specific competitor.""" + comp = COMPETITORS.get(competitor.lower()) + dealix = COMPETITORS["dealix"] + if not comp: + return "المنافس غير موجود" if language == "ar" else "Competitor not found" + + dealix_total = sum(dealix["scores"].values()) + comp_total = sum(comp["scores"].values()) + diff = dealix_total - comp_total + + if language == "ar": + return ( + f"مقارنة Dealix مع {comp['name_ar']}:\n\n" + f"النتيجة الإجمالية:\n" + f"• Dealix: {dealix_total}/120\n" + f"• {comp['name_ar']}: {comp_total}/120\n\n" + f"Dealix يتفوق بـ {diff} نقطة.\n\n" + f"أهم نقاط التفوق:\n" + + "\n".join( + f"• {DIMENSION_LABELS[dim]['ar']}: Dealix {dealix['scores'][dim]} vs {comp['scores'][dim]}" + for dim in dealix["scores"] + if dealix["scores"][dim] > comp["scores"].get(dim, 0) + 2 + ) + ) + return ( + f"Dealix vs {comp['name']}:\n\n" + f"Total Score:\n• Dealix: {dealix_total}/120\n• {comp['name']}: {comp_total}/120\n\n" + f"Dealix leads by {diff} points." + ) + + +comparison_engine = ComparisonEngine() diff --git a/salesflow-saas/backend/app/services/email_brain.py b/salesflow-saas/backend/app/services/email_brain.py new file mode 100644 index 00000000..6fe77493 --- /dev/null +++ b/salesflow-saas/backend/app/services/email_brain.py @@ -0,0 +1,194 @@ +""" +Email AI Brain — Dealix AI Revenue OS +Handles inbound email classification, outreach generation, and nurture sequences. +Arabic-first with full bilingual support. +""" +import logging +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class EmailIntent(str, Enum): + INQUIRY = "inquiry" + SUPPORT = "support" + COMPLAINT = "complaint" + PARTNERSHIP = "partnership" + UNSUBSCRIBE = "unsubscribe" + REPLY = "reply" + SPAM = "spam" + GENERAL = "general" + + +class EmailDraft(BaseModel): + subject: str + body: str + language: str = "ar" + campaign_type: str = "" + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +INTENT_SIGNALS = { + "inquiry": ["أبي أعرف", "استفسار", "سعر", "باقة", "pricing", "interested", "demo"], + "support": ["مشكلة", "مساعدة", "خطأ", "bug", "help", "not working", "error"], + "complaint": ["شكوى", "زعلان", "سيء", "complaint", "terrible", "disappointed"], + "partnership": ["شراكة", "تعاون", "partner", "collaboration", "reseller"], + "unsubscribe": ["إلغاء", "unsubscribe", "أوقف", "remove", "stop"], +} + +ARABIC_TEMPLATES = { + "cold_intro": EmailDraft( + subject="Dealix — نظام المبيعات الذكي للسوق السعودي", + body=( + "السلام عليكم {name}،\n\nأنا {sender_name} من فريق Dealix.\n\n" + "لاحظنا أن {company} تعمل في قطاع {sector} — وهو بالضبط القطاع اللي نخدمه.\n\n" + "Dealix نظام مبيعات ذكي مصمم للسعودية: واتساب مدمج، ذكاء اصطناعي يفهم عربي، " + "وحماية بيانات PDPL.\n\nتبي نعطيك عرض سريع ١٥ دقيقة؟\n\nمع التحية،\n{sender_name}\nفريق Dealix" + ), + ), + "follow_up_1": EmailDraft( + subject="متابعة — هل شفت رسالتنا الأولى؟", + body=( + "أهلاً {name}،\n\nأرسلت لك قبل كم يوم عن Dealix. حبيت أتابع معك.\n\n" + "عملاؤنا في {sector} حققوا:\n• زيادة ٤٠٪ في معدل الإغلاق\n" + "• توفير ١٠ ساعات أسبوعياً\n• تحسين متابعة العملاء ١٠٠٪\n\n" + "تقدر تجرب مجاناً ١٤ يوم بدون بطاقة.\n\nمع التحية،\n{sender_name}" + ), + ), + "follow_up_2": EmailDraft( + subject="آخر متابعة — فرصة مجانية لتجربة Dealix", + body=( + "أهلاً {name}،\n\nأعرف إنك مشغول. بس حبيت أذكرك إن التجربة المجانية متاحة.\n\n" + "رابط التسجيل: dealix.sa/trial\nيأخذ أقل من دقيقة.\n\n" + "لو ما يناسبك الوقت الحين، رد بـ 'لاحقاً' وبأتواصل معك الشهر الجاي.\n\nمع التحية،\n{sender_name}" + ), + ), + "demo_invite": EmailDraft( + subject="موعد العرض التوضيحي لـ Dealix", + body=( + "أهلاً {name}،\n\nشكراً لاهتمامك بـ Dealix!\n\n" + "حجزنا لك عرض توضيحي:\n📅 {demo_date}\n⏰ {demo_time}\n🔗 {demo_link}\n\n" + "العرض يستغرق ١٥ دقيقة ويغطي:\n• إدارة العملاء عبر الواتساب\n" + "• تقييم العملاء بالذكاء الاصطناعي\n• عروض الأسعار التلقائية\n\nنتطلع لمقابلتك!\n{sender_name}" + ), + ), + "proposal": EmailDraft( + subject="عرض Dealix المخصص لـ {company}", + body=( + "أستاذ/ة {name}،\n\nبناءً على محادثتنا، حضّرنا لكم عرض مخصص:\n\n" + "الباقة: {plan_name}\nالسعر: {price} ر.س/شهر\nعدد المستخدمين: {users}\n\n" + "المميزات المشمولة:\n{features}\n\nالعرض صالح لمدة ٧ أيام.\n" + "للموافقة: {approval_link}\n\nمع التحية،\n{sender_name}" + ), + ), + "welcome": EmailDraft( + subject="مرحباً بك في Dealix!", + body=( + "أهلاً {name}،\n\nمبروك! حسابك جاهز على Dealix.\n\n" + "خطواتك الأولى:\n١. ادخل: dealix.sa/dashboard\n٢. أضف أول عميل\n" + "٣. ربط الواتساب\n٤. أرسل أول رسالة ذكية\n\n" + "لو تحتاج مساعدة، كلمنا واتساب أو إيميل support@dealix.sa.\n\nيلا نبدأ!\nفريق Dealix" + ), + ), + "commission_report": EmailDraft( + subject="تقرير عمولاتك الأسبوعي — {period}", + body=( + "أهلاً {name}،\n\nهذا تقرير عمولاتك لهذا الأسبوع:\n\n" + "إجمالي العمولة: {total_commission} ر.س\nعملاء جدد: {new_clients}\n" + "مستواك: {tier}\nترتيبك: #{rank}\n\n" + "تفاصيل كاملة: dealix.sa/dashboard/commissions\n\nاستمر!\nفريق Dealix" + ), + ), + "partnership_intro": EmailDraft( + subject="فرصة شراكة مع Dealix — {partnership_type}", + body=( + "السلام عليكم {name}،\n\nنحن في Dealix نبحث عن شركاء استراتيجيين في {sector}.\n\n" + "نقدم:\n• عمولات تنافسية تبدأ من ١٥٪\n• دعم تقني ومبيعاتي كامل\n" + "• لوحة تحكم شريك مخصصة\n• مواد تسويقية جاهزة\n\n" + "هل عندك وقت لمكالمة ١٥ دقيقة هذا الأسبوع؟\n\nمع التحية،\n{sender_name}\nمدير الشراكات — Dealix" + ), + ), +} + + +class EmailBrain: + """Central brain for Dealix email — classifies inbound and generates outreach.""" + + def __init__(self): + from app.services.whatsapp_knowledge import DealixKnowledge + self.knowledge = DealixKnowledge + + def _detect_intent(self, subject: str, body: str) -> EmailIntent: + combined = f"{subject} {body}".lower() + for intent, keywords in INTENT_SIGNALS.items(): + if any(kw in combined for kw in keywords): + return EmailIntent(intent) + return EmailIntent.GENERAL + + async def handle_inbound( + self, email_from: str, subject: str, body: str, db: Any = None + ) -> EmailDraft: + intent = self._detect_intent(subject, body) + logger.info(f"[EmailBrain] inbound from={email_from} intent={intent.value}") + + if intent == EmailIntent.UNSUBSCRIBE: + return EmailDraft( + subject="تأكيد إلغاء الاشتراك", + body="أهلاً،\n\nتم إلغاء اشتراكك في رسائل Dealix البريدية.\nلو غيّرت رأيك، تقدر تشترك مرة ثانية من dealix.sa.\n\nمع التحية،\nفريق Dealix", + ) + if intent == EmailIntent.COMPLAINT: + ticket = f"TKT-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M')}" + return EmailDraft( + subject="استلمنا شكواك — سنتابع فوراً", + body=f"أهلاً،\n\nشكراً لتواصلك. نعتذر عن أي إزعاج.\nفريقنا سيتابع شكواك خلال ٤ ساعات عمل.\nرقم التذكرة: {ticket}\n\nمع التحية،\nفريق دعم Dealix", + ) + if intent == EmailIntent.INQUIRY: + pricing = self.knowledge.get_pricing_text("ar") + return EmailDraft( + subject="مرحباً — هذي تفاصيل Dealix", + body=f"أهلاً،\n\nشكراً لاهتمامك بـ Dealix!\n\nالباقات المتاحة:\n{pricing}\n\nكل الباقات فيها تجربة مجانية ١٤ يوم.\nتبي نحجز لك عرض توضيحي؟\n\nمع التحية،\nفريق Dealix", + ) + if intent == EmailIntent.PARTNERSHIP: + return EmailDraft( + subject="شكراً لاهتمامك بالشراكة مع Dealix", + body="أهلاً،\n\nشكراً لتواصلك بخصوص الشراكة.\nفريق الشراكات سيتواصل معك خلال ٢٤ ساعة لمناقشة الفرص.\n\nمع التحية،\nفريق Dealix", + ) + if intent == EmailIntent.SUPPORT: + return EmailDraft( + subject="استلمنا طلب الدعم — سنرد قريباً", + body="أهلاً،\n\nشكراً لتواصلك. فريق الدعم سيرد خلال ٤ ساعات عمل.\nللدعم العاجل: واتساب support@dealix.sa\n\nمع التحية،\nفريق دعم Dealix", + ) + return EmailDraft( + subject="شكراً لتواصلك مع Dealix", + body="أهلاً،\n\nشكراً لرسالتك! فريقنا سيرد عليك قريباً.\nلو تحتاج رد أسرع، كلمنا واتساب.\n\nمع التحية،\nفريق Dealix", + ) + + async def generate_outreach( + self, lead: dict, campaign_type: str = "cold_intro", language: str = "ar" + ) -> EmailDraft: + template = ARABIC_TEMPLATES.get(campaign_type, ARABIC_TEMPLATES["cold_intro"]) + filled_body = template.body + for key, val in lead.items(): + filled_body = filled_body.replace("{" + key + "}", str(val)) + filled_subject = template.subject + for key, val in lead.items(): + filled_subject = filled_subject.replace("{" + key + "}", str(val)) + return EmailDraft(subject=filled_subject, body=filled_body, language=language, campaign_type=campaign_type) + + async def generate_nurture_sequence(self, lead: dict, db: Any = None) -> list[EmailDraft]: + sequence_keys = ["cold_intro", "follow_up_1", "follow_up_2", "demo_invite", "proposal"] + return [await self.generate_outreach(lead, key) for key in sequence_keys] + + def get_template(self, template_name: str) -> Optional[EmailDraft]: + return ARABIC_TEMPLATES.get(template_name) + + def list_templates(self) -> list[str]: + return list(ARABIC_TEMPLATES.keys()) + + +# Global singleton +email_brain = EmailBrain() diff --git a/salesflow-saas/backend/app/services/linkedin_brain.py b/salesflow-saas/backend/app/services/linkedin_brain.py new file mode 100644 index 00000000..3a209abc --- /dev/null +++ b/salesflow-saas/backend/app/services/linkedin_brain.py @@ -0,0 +1,147 @@ +""" +LinkedIn AI Brain — Dealix AI Revenue OS +ASSIST MODE ONLY: generates drafts for human review, never auto-sends. +All outputs are suggestions — the operator approves before sending. +""" +import logging +from datetime import datetime, timezone +from typing import Any + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +MAX_CONNECTION_REQUEST = 300 +MAX_INMAIL = 1900 + + +class LinkedInDraft(BaseModel): + draft_type: str # connection_request, inmail, post, comment + content: str + target_name: str = "" + target_company: str = "" + language: str = "ar" + status: str = "pending_review" # always starts as pending + created_at: datetime = None + + def __init__(self, **data): + super().__init__(**data) + if self.created_at is None: + self.created_at = datetime.now(timezone.utc) + + +class OutreachTask(BaseModel): + task_type: str # send_connection, send_inmail, engage_post + target: dict + draft: LinkedInDraft + priority: int = 0 + status: str = "queued" + + +ARABIC_PURPOSES = { + "sales": "نبي نعرفك على Dealix — نظام مبيعات ذكي للسوق السعودي", + "partnership": "نبحث عن شراكة استراتيجية مع {company}", + "hiring": "عندنا فرصة في Dealix ممكن تناسب خبرتك", + "networking": "يسعدني التواصل مع محترفين في مجال {title}", +} + +POST_TOPICS_AR = { + "saudi_digital": "التحول الرقمي في السعودية", + "ai_sales": "الذكاء الاصطناعي في المبيعات", + "crm_tips": "نصائح إدارة علاقات العملاء", + "startup_growth": "نمو الشركات الناشئة السعودية", + "vision_2030": "رؤية ٢٠٣٠ والتقنية", +} + + +class LinkedInBrain: + """Assist-mode LinkedIn brain — drafts only, never auto-sends.""" + + def __init__(self): + from app.services.whatsapp_knowledge import DealixKnowledge + self.knowledge = DealixKnowledge + + async def draft_connection_request( + self, name: str, title: str, company: str, purpose: str = "sales", lang: str = "ar" + ) -> str: + purpose_text = ARABIC_PURPOSES.get(purpose, ARABIC_PURPOSES["networking"]) + purpose_text = purpose_text.format(company=company, title=title) + + if lang == "ar": + draft = f"أهلاً {name}! {purpose_text}. يسعدني نتواصل ونتبادل الأفكار." + else: + draft = f"Hi {name}! I'd love to connect — {purpose_text.replace(company, company)}. Looking forward to exchanging ideas." + + if len(draft) > MAX_CONNECTION_REQUEST: + draft = draft[:MAX_CONNECTION_REQUEST - 3] + "..." + logger.info(f"[LinkedInBrain] drafted connection request for {name} @ {company}") + return draft + + async def draft_inmail(self, profile: dict, deal_type: str = "sales", lang: str = "ar") -> str: + name = profile.get("name", "") + title = profile.get("title", "") + company = profile.get("company", "") + + if deal_type == "partnership": + template = ARABIC_PURPOSES["partnership"].format(company=company, title=title) + body = f"السلام عليكم {name},\n\n{template}.\n\nDealix يدعم ١٥ نوع صفقة استراتيجية — من تبادل خدمات للتوزيع والشراكات التقنية.\n\nهل عندك ١٠ دقائق نتكلم؟\n\nمع التحية" + elif deal_type == "hiring": + body = f"أهلاً {name},\n\nشفت بروفايلك وخبرتك في {title} — عندنا فرصة في Dealix ممكن تناسبك.\n\nنبني نظام مبيعات ذكي للسوق السعودي ونبحث عن كفاءات مميزة.\n\nتحب نتكلم أكثر؟\n\nمع التحية" + else: + pricing = "يبدأ من ٢٩٩ ر.س/شهر" + body = f"السلام عليكم {name},\n\nأتواصل معك لأن {company} ممكن تستفيد من Dealix — نظام المبيعات الذكي للسوق السعودي.\n\n• واتساب CRM مدمج\n• ذكاء اصطناعي يفهم عربي\n• {pricing}\n\nتبي عرض سريع ١٥ دقيقة؟\n\nمع التحية" + + if lang != "ar": + body = f"Hi {name},\n\nI'm reaching out because {company} could benefit from Dealix — the smart CRM built for Saudi Arabia.\n\n• WhatsApp-native CRM\n• Arabic AI\n• Starts at 299 SAR/mo\n\nWould you have 15 minutes for a quick demo?\n\nBest regards" + + return body[:MAX_INMAIL] + + async def draft_post(self, topic: str, audience: str = "business", lang: str = "ar") -> str: + topic_ar = POST_TOPICS_AR.get(topic, topic) + + if lang == "ar": + return ( + f"موضوع اليوم: {topic_ar}\n\n" + f"في السوق السعودي، الشركات اللي تستخدم أدوات ذكية تحقق نتائج أفضل بـ ٤٠٪.\n\n" + f"ثلاث نصائح سريعة:\n" + f"١. استخدم الواتساب كقناة بيع رئيسية\n" + f"٢. فعّل الذكاء الاصطناعي للتقييم التلقائي\n" + f"٣. تابع عملاءك بالعربي — يفرق!\n\n" + f"وش رأيكم؟ شاركوني تجربتكم.\n\n" + f"#Dealix #مبيعات #السعودية #تقنية #CRM" + ) + return ( + f"Today's topic: {topic_ar}\n\n" + f"In the Saudi market, companies using smart tools see 40% better results.\n\n" + f"3 quick tips:\n1. Use WhatsApp as your main sales channel\n" + f"2. Enable AI for automatic lead scoring\n3. Follow up in Arabic — it matters!\n\n" + f"What do you think? Share your experience.\n\n#Dealix #Sales #SaudiArabia #CRM" + ) + + async def generate_outreach_queue( + self, criteria: dict, db: Any = None + ) -> list[OutreachTask]: + targets = criteria.get("targets", []) + purpose = criteria.get("purpose", "sales") + lang = criteria.get("language", "ar") + tasks = [] + + for i, target in enumerate(targets[:50]): + name = target.get("name", "") + title = target.get("title", "") + company = target.get("company", "") + + conn_text = await self.draft_connection_request(name, title, company, purpose, lang) + draft = LinkedInDraft( + draft_type="connection_request", content=conn_text, + target_name=name, target_company=company, language=lang, + ) + tasks.append(OutreachTask( + task_type="send_connection", target=target, draft=draft, priority=i, + )) + logger.info(f"[LinkedInBrain] generated {len(tasks)} outreach tasks for review") + return tasks + + +# Global singleton +linkedin_brain = LinkedInBrain() diff --git a/salesflow-saas/backend/app/services/local_inference.py b/salesflow-saas/backend/app/services/local_inference.py new file mode 100644 index 00000000..90d515f1 --- /dev/null +++ b/salesflow-saas/backend/app/services/local_inference.py @@ -0,0 +1,229 @@ +""" +Local Inference Adapter — Dealix AI Revenue OS +Connects to local/private LLM providers (Ollama, LM Studio, Atomic Chat) +via OpenAI-compatible API. Privacy-first, cost-optimized, Arabic-tuned. +""" +import logging +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class LocalProvider(BaseModel): + name: str + base_url: str # e.g., "http://localhost:11434/v1" for Ollama + model: str # e.g., "qwen2.5:7b", "llama3.1:8b" + is_healthy: bool = False + last_check: Optional[datetime] = None + avg_latency_ms: float = 0.0 + total_calls: int = 0 + total_failures: int = 0 + + +# Default local providers to check +DEFAULT_PROVIDERS = [ + LocalProvider( + name="ollama", + base_url="http://localhost:11434/v1", + model="qwen2.5:7b", + ), + LocalProvider( + name="lm-studio", + base_url="http://localhost:1234/v1", + model="local-model", + ), + LocalProvider( + name="atomic-chat", + base_url="http://localhost:8080/v1", + model="default", + ), +] + +# Tasks suitable for local inference +LOCAL_SUITABLE_TASKS = { + "arabic_summarization": "تلخيص نصوص عربية", + "text_classification": "تصنيف نصوص", + "entity_extraction": "استخراج كيانات", + "internal_drafting": "صياغة مسودات داخلية", + "sentiment_analysis": "تحليل المشاعر", + "translation": "ترجمة نصوص", + "data_cleaning": "تنظيف بيانات", + "code_review_simple": "مراجعة كود بسيطة", +} + +# Tasks that should NEVER use local inference +CLOUD_ONLY_TASKS = { + "proposal_generation", + "complex_reasoning", + "long_document_analysis", + "customer_facing_messages", +} + + +class LocalInferenceResult(BaseModel): + provider: str + model: str + response: str + latency_ms: int + tokens_used: int = 0 + cost_usd: float = 0.0 # Local = free + success: bool = True + error: Optional[str] = None + + +class LocalInferenceAdapter: + """ + Adapter for local/private LLM inference. + Tries providers in order, falls back gracefully to cloud. + """ + + def __init__(self): + self._providers = list(DEFAULT_PROVIDERS) + self._primary: Optional[LocalProvider] = None + + async def health_check(self, provider: LocalProvider = None) -> bool: + """Check if a local provider is available.""" + targets = [provider] if provider else self._providers + for p in targets: + try: + import httpx + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(f"{p.base_url}/models") + if resp.status_code == 200: + p.is_healthy = True + p.last_check = datetime.now(timezone.utc) + if not self._primary: + self._primary = p + logger.info(f"Local provider {p.name} is healthy at {p.base_url}") + return True + except Exception: + p.is_healthy = False + p.last_check = datetime.now(timezone.utc) + continue + return False + + async def health_check_all(self) -> dict[str, bool]: + """Check all configured local providers.""" + results = {} + for p in self._providers: + results[p.name] = await self.health_check(p) + return results + + def is_suitable_for_local(self, task_type: str) -> bool: + """Check if a task should use local inference.""" + if task_type in CLOUD_ONLY_TASKS: + return False + return task_type in LOCAL_SUITABLE_TASKS + + async def complete( + self, + prompt: str, + system_prompt: str = "", + task_type: str = "general", + max_tokens: int = 1024, + temperature: float = 0.7, + ) -> LocalInferenceResult: + """Run inference on local provider. Falls back gracefully.""" + if not self._primary or not self._primary.is_healthy: + await self.health_check() + + if not self._primary: + return LocalInferenceResult( + provider="none", + model="none", + response="", + latency_ms=0, + success=False, + error="لا يوجد مزود محلي متاح — استخدم السحابة", + ) + + start = datetime.now(timezone.utc) + provider = self._primary + + try: + import httpx + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + f"{provider.base_url}/chat/completions", + json={ + "model": provider.model, + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + }, + ) + resp.raise_for_status() + data = resp.json() + + latency = int((datetime.now(timezone.utc) - start).total_seconds() * 1000) + provider.total_calls += 1 + provider.avg_latency_ms = ( + (provider.avg_latency_ms * (provider.total_calls - 1) + latency) + / provider.total_calls + ) + + content = data.get("choices", [{}])[0].get("message", {}).get("content", "") + tokens = data.get("usage", {}).get("total_tokens", 0) + + return LocalInferenceResult( + provider=provider.name, + model=provider.model, + response=content, + latency_ms=latency, + tokens_used=tokens, + cost_usd=0.0, + ) + + except Exception as e: + provider.total_failures += 1 + provider.is_healthy = False + latency = int((datetime.now(timezone.utc) - start).total_seconds() * 1000) + logger.warning(f"Local inference failed on {provider.name}: {e}") + return LocalInferenceResult( + provider=provider.name, + model=provider.model, + response="", + latency_ms=latency, + success=False, + error=str(e), + ) + + def add_provider(self, name: str, base_url: str, model: str) -> None: + """Register a new local provider.""" + self._providers.append(LocalProvider( + name=name, base_url=base_url, model=model, + )) + + def get_providers(self) -> list[dict]: + """List all configured providers with health status.""" + return [ + { + "name": p.name, + "base_url": p.base_url, + "model": p.model, + "healthy": p.is_healthy, + "last_check": p.last_check.isoformat() if p.last_check else None, + "avg_latency_ms": round(p.avg_latency_ms, 1), + "total_calls": p.total_calls, + "failure_rate": round( + p.total_failures / p.total_calls * 100, 1 + ) if p.total_calls > 0 else 0, + "is_primary": p == self._primary, + } + for p in self._providers + ] + + def get_suitable_tasks(self) -> dict[str, str]: + """List tasks suitable for local inference.""" + return dict(LOCAL_SUITABLE_TASKS) + + +local_inference = LocalInferenceAdapter() diff --git a/salesflow-saas/backend/app/services/social_media_brain.py b/salesflow-saas/backend/app/services/social_media_brain.py new file mode 100644 index 00000000..1a93cb7d --- /dev/null +++ b/salesflow-saas/backend/app/services/social_media_brain.py @@ -0,0 +1,176 @@ +""" +Social Media AI Brain — Dealix AI Revenue OS +Unified brain for Instagram, TikTok, Twitter, and Snapchat. +Handles inbound DMs, content generation, and content calendar planning. +""" +import logging +from datetime import datetime, timezone, timedelta +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + + +class Platform(str, Enum): + INSTAGRAM = "instagram" + TIKTOK = "tiktok" + TWITTER = "twitter" + SNAPCHAT = "snapchat" + + +PLATFORM_RULES = { + Platform.INSTAGRAM: {"max_chars": 2200, "max_hashtags": 30, "name_ar": "إنستغرام"}, + Platform.TIKTOK: {"max_chars": 300, "max_hashtags": 5, "name_ar": "تيك توك"}, + Platform.TWITTER: {"max_chars": 280, "max_hashtags": 3, "name_ar": "تويتر"}, + Platform.SNAPCHAT: {"max_chars": 250, "max_hashtags": 0, "name_ar": "سناب شات"}, +} + +SAUDI_CONTENT_THEMES = [ + {"id": "vision_2030", "name_ar": "رؤية ٢٠٣٠ والتحول الرقمي", "hashtags_ar": ["#رؤية_السعودية_2030", "#تحول_رقمي"]}, + {"id": "smb_growth", "name_ar": "نمو المشاريع الصغيرة والمتوسطة", "hashtags_ar": ["#ريادة_أعمال", "#مشاريع_صغيرة"]}, + {"id": "ai_arabic", "name_ar": "الذكاء الاصطناعي بالعربي", "hashtags_ar": ["#ذكاء_اصطناعي", "#تقنية"]}, + {"id": "sales_tips", "name_ar": "نصائح المبيعات للسوق السعودي", "hashtags_ar": ["#مبيعات", "#CRM"]}, + {"id": "whatsapp_business", "name_ar": "واتساب للأعمال", "hashtags_ar": ["#واتساب_أعمال", "#تواصل"]}, +] + +DM_INTENT_KEYWORDS = { + "pricing": ["سعر", "كم", "باقة", "price", "cost"], + "demo": ["عرض", "demo", "تجربة", "وريني"], + "support": ["مشكلة", "مساعدة", "help", "خطأ"], + "partnership": ["شراكة", "تعاون", "partner"], +} + + +class ContentDraft(BaseModel): + platform: str + content: str + hashtags: list[str] = [] + language: str = "ar" + theme: str = "" + created_at: datetime = None + + def __init__(self, **data): + super().__init__(**data) + if self.created_at is None: + self.created_at = datetime.now(timezone.utc) + + +class CalendarEntry(BaseModel): + date: str + platform: str + theme: str + content: ContentDraft + time_slot: str = "10:00" + + +class SocialMediaBrain: + """Unified brain for Instagram, TikTok, Twitter, Snapchat.""" + + def __init__(self): + from app.services.whatsapp_knowledge import DealixKnowledge + self.knowledge = DealixKnowledge + + def _detect_dm_intent(self, message: str) -> str: + msg_lower = message.lower() + for intent, keywords in DM_INTENT_KEYWORDS.items(): + if any(kw in msg_lower for kw in keywords): + return intent + return "general" + + def _enforce_platform_limits(self, text: str, hashtags: list[str], platform: Platform) -> tuple[str, list[str]]: + rules = PLATFORM_RULES[platform] + hashtags = hashtags[:rules["max_hashtags"]] + hashtag_text = " ".join(hashtags) + max_content = rules["max_chars"] - len(hashtag_text) - 2 if hashtags else rules["max_chars"] + if len(text) > max_content: + text = text[:max_content - 3] + "..." + return text, hashtags + + async def handle_inbound_dm( + self, platform: str, sender: str, message: str, db: Any = None + ) -> str: + plat = Platform(platform) if platform in Platform.__members__.values() else Platform.INSTAGRAM + intent = self._detect_dm_intent(message) + plat_name = PLATFORM_RULES[plat]["name_ar"] + logger.info(f"[SocialMediaBrain] DM on {plat.value} from={sender} intent={intent}") + + if intent == "pricing": + pricing = self.knowledge.get_pricing_text("ar") + return f"أهلاً! شكراً لتواصلك عبر {plat_name}.\n\nباقات Dealix:\n{pricing}\n\nتبي تفاصيل أكثر؟ راسلنا واتساب أو زور dealix.sa" + + if intent == "demo": + return f"ممتاز! يسعدنا نعرض لك Dealix.\n\nاحجز عرض توضيحي مجاني (١٥ دقيقة): dealix.sa/demo\n\nأو أرسل رقمك ونتواصل معك واتساب." + + if intent == "support": + return f"أهلاً! للدعم الفني الأسرع، تواصل معنا:\n• واتساب: dealix.sa/whatsapp\n• إيميل: support@dealix.sa\n\nأو وصف مشكلتك هنا وبنساعدك." + + if intent == "partnership": + return "شكراً لاهتمامك بالشراكة مع Dealix!\n\nأرسل لنا إيميل على partners@dealix.sa أو واتساب ونرتب اجتماع." + + return f"أهلاً وسهلاً! أنا مساعد Dealix على {plat_name}.\n\nأقدر أساعدك في:\n• الأسعار والباقات\n• حجز عرض توضيحي\n• الدعم الفني\n\nوش تحتاج؟" + + async def generate_content( + self, platform: str, topic: str, language: str = "ar" + ) -> ContentDraft: + plat = Platform(platform) if platform in Platform.__members__.values() else Platform.INSTAGRAM + theme = next((t for t in SAUDI_CONTENT_THEMES if t["id"] == topic), SAUDI_CONTENT_THEMES[0]) + hashtags_base = theme["hashtags_ar"] + ["#Dealix"] + + if language == "ar": + content_map = { + Platform.INSTAGRAM: ( + f"{theme['name_ar']}\n\n" + f"في السوق السعودي، الشركات اللي تستخدم أدوات ذكية تحقق نتائج أفضل.\n\n" + f"Dealix يساعدك:\n" + f"✅ إدارة عملاءك بالواتساب\n" + f"✅ ذكاء اصطناعي يفهم عربي\n" + f"✅ تقارير وتنبؤات مبيعات\n\n" + f"جرّب مجاناً ١٤ يوم — الرابط بالبايو" + ), + Platform.TIKTOK: f"{theme['name_ar']}\n\nDealix — نظام مبيعات ذكي للسوق السعودي. جرّب مجاناً!", + Platform.TWITTER: f"{theme['name_ar']}\n\nDealix: واتساب CRM + AI عربي للشركات السعودية. جرّب مجاناً ١٤ يوم.", + Platform.SNAPCHAT: f"{theme['name_ar']}\n\nDealix — نظام مبيعاتك الذكي. جرّبه مجاناً!", + } + else: + content_map = { + Platform.INSTAGRAM: f"{theme['name_ar']}\n\nSmart companies in Saudi use AI-powered tools.\n\nDealix helps you:\n✅ WhatsApp CRM\n✅ Arabic AI\n✅ Sales forecasting\n\nTry free for 14 days — link in bio", + Platform.TIKTOK: f"{theme['name_ar']}\n\nDealix — smart sales for Saudi. Try free!", + Platform.TWITTER: f"{theme['name_ar']}\n\nDealix: WhatsApp CRM + Arabic AI for Saudi companies. 14-day free trial.", + Platform.SNAPCHAT: f"{theme['name_ar']}\n\nDealix — your smart sales system. Try free!", + } + + raw_content = content_map.get(plat, content_map[Platform.INSTAGRAM]) + final_content, final_hashtags = self._enforce_platform_limits(raw_content, hashtags_base, plat) + + return ContentDraft( + platform=plat.value, content=final_content, hashtags=final_hashtags, + language=language, theme=topic, + ) + + async def generate_content_calendar( + self, platforms: list[str], days: int = 7, language: str = "ar" + ) -> list[CalendarEntry]: + calendar = [] + time_slots = {"instagram": "10:00", "tiktok": "18:00", "twitter": "08:00", "snapchat": "14:00"} + today = datetime.now(timezone.utc).date() + + for day_offset in range(days): + target_date = today + timedelta(days=day_offset) + theme = SAUDI_CONTENT_THEMES[day_offset % len(SAUDI_CONTENT_THEMES)] + + for plat_str in platforms: + content = await self.generate_content(plat_str, theme["id"], language) + calendar.append(CalendarEntry( + date=target_date.isoformat(), platform=plat_str, + theme=theme["id"], content=content, + time_slot=time_slots.get(plat_str, "10:00"), + )) + + logger.info(f"[SocialMediaBrain] generated {len(calendar)} calendar entries for {days} days") + return calendar + + +# Global singleton +social_media_brain = SocialMediaBrain() diff --git a/salesflow-saas/backend/app/services/strategic_deals/__init__.py b/salesflow-saas/backend/app/services/strategic_deals/__init__.py new file mode 100644 index 00000000..8e25ede5 --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/__init__.py @@ -0,0 +1,64 @@ +""" +Dealix Strategic Deals Engine — Deal Exchange OS + Strategic Growth OS +محرك الصفقات الاستراتيجية — نظام تبادل الصفقات + نظام النمو الاستراتيجي +اكتشاف وتفاوض وإغلاق شراكات B2B بالذكاء الاصطناعي +""" + +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.company_twin import CompanyTwin, CompanyTwinBuilder +from app.services.strategic_deals.deal_taxonomy import DealTaxonomyService, DEAL_TAXONOMY +from app.services.strategic_deals.deal_room import DealRoom, DealRoomService +from app.services.strategic_deals.operating_modes import OperatingMode, ModeEnforcer, MODE_POLICIES +from app.services.strategic_deals.channel_compliance import ChannelRules, ConsentLedger + +# Strategic Growth OS +from app.services.strategic_deals.acquisition_scouting import ( + AcquisitionTarget, AcquisitionCriteria, AcquisitionScoutingEngine, +) +from app.services.strategic_deals.ecosystem_mapper import ( + EcosystemEntity, EcosystemLink, EcosystemMapper, +) +from app.services.strategic_deals.strategic_simulator import ( + StrategicScenario, StrategicSimulator, +) +from app.services.strategic_deals.roi_engine import ROICalculation, ROIEngine +from app.services.strategic_deals.portfolio_intelligence import ( + PortfolioInsight, PortfolioIntelligence, +) + +__all__ = [ + # Existing + "CompanyProfiler", + "DealMatcher", + "DealNegotiator", + "NegotiationStrategy", + "DealAgent", + # Deal Exchange OS + "CompanyTwin", + "CompanyTwinBuilder", + "DealTaxonomyService", + "DEAL_TAXONOMY", + "DealRoom", + "DealRoomService", + "OperatingMode", + "ModeEnforcer", + "MODE_POLICIES", + "ChannelRules", + "ConsentLedger", + # Strategic Growth OS + "AcquisitionTarget", + "AcquisitionCriteria", + "AcquisitionScoutingEngine", + "EcosystemEntity", + "EcosystemLink", + "EcosystemMapper", + "StrategicScenario", + "StrategicSimulator", + "ROICalculation", + "ROIEngine", + "PortfolioInsight", + "PortfolioIntelligence", +] diff --git a/salesflow-saas/backend/app/services/strategic_deals/acquisition_scouting.py b/salesflow-saas/backend/app/services/strategic_deals/acquisition_scouting.py new file mode 100644 index 00000000..86b5b86c --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/acquisition_scouting.py @@ -0,0 +1,494 @@ +""" +Acquisition Scouting Engine — AI-powered M&A target identification for Saudi B2B. +محرك استكشاف الاستحواذ: تحديد أهداف الاندماج والاستحواذ بالذكاء الاصطناعي للسوق السعودي +""" + +import json +import logging +import uuid +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel, Field +from sqlalchemy import select, and_, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import CompanyProfile +from app.services.llm.provider import get_llm + +logger = logging.getLogger("dealix.strategic_deals.acquisition_scouting") + +# ── Saudi sector synergy map ──────────────────────────────────────────────── + +SECTOR_SYNERGY = { + "technology": ["consulting", "telecom", "media", "education"], + "construction": ["real_estate", "manufacturing", "energy", "logistics"], + "real_estate": ["construction", "finance", "tourism"], + "retail": ["wholesale", "logistics", "food_beverage", "marketing"], + "healthcare": ["technology", "manufacturing", "consulting"], + "finance": ["technology", "real_estate", "consulting"], + "logistics": ["retail", "wholesale", "manufacturing", "food_beverage"], + "energy": ["construction", "manufacturing", "technology"], + "food_beverage": ["logistics", "retail", "agriculture", "tourism"], + "consulting": ["technology", "finance", "healthcare", "education"], + "manufacturing": ["construction", "wholesale", "logistics", "energy"], + "marketing": ["technology", "media", "retail", "telecom"], + "telecom": ["technology", "media", "consulting"], + "education": ["technology", "consulting", "media"], + "tourism": ["food_beverage", "real_estate", "marketing"], + "media": ["marketing", "technology", "telecom", "tourism"], + "agriculture": ["food_beverage", "logistics", "manufacturing"], + "automotive": ["manufacturing", "logistics", "finance"], + "government": ["technology", "consulting", "construction"], + "wholesale": ["retail", "manufacturing", "logistics"], +} + +# ── Valid status transitions ──────────────────────────────────────────────── + +VALID_STATUSES = ("scouted", "qualified", "briefed", "intro_sent", "in_discussion") + +STATUS_TRANSITIONS = { + "scouted": ["qualified", "briefed"], + "qualified": ["briefed", "intro_sent"], + "briefed": ["intro_sent", "in_discussion"], + "intro_sent": ["in_discussion"], + "in_discussion": [], +} + + +# ── Models ────────────────────────────────────────────────────────────────── + + +class AcquisitionTarget(BaseModel): + """Represents a scouted M&A target with strategic scoring.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + company_name: str + company_name_ar: str = "" + industry: str = "" + city: str = "" + strategic_fit_score: float = Field(0.0, ge=0.0, le=1.0) + market_adjacency: float = Field(0.0, ge=0.0, le=1.0) + size_fit: float = Field(0.0, ge=0.0, le=1.0) + estimated_value_sar: float = 0.0 + growth_signals: list[str] = Field(default_factory=list) + risk_factors: list[str] = Field(default_factory=list) + brief: str = "" + status: str = "scouted" + tenant_id: Optional[str] = None + scouted_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + + class Config: + json_schema_extra = { + "example": { + "company_name": "TechVision Co", + "company_name_ar": "شركة تك فيجن", + "industry": "technology", + "city": "الرياض", + "strategic_fit_score": 0.85, + "market_adjacency": 0.7, + "size_fit": 0.6, + "estimated_value_sar": 5_000_000.0, + "growth_signals": ["نمو الإيرادات ٣٠٪ سنوياً", "توسع في ٣ مدن جديدة"], + "risk_factors": ["اعتماد كبير على عميل واحد"], + "status": "scouted", + } + } + + +class AcquisitionCriteria(BaseModel): + """Filter criteria for scouting acquisition targets.""" + industries: list[str] = Field(default_factory=list) + cities: list[str] = Field(default_factory=list) + min_revenue_sar: float = 0.0 + max_revenue_sar: float = 0.0 + min_employees: int = 0 + max_employees: int = 0 + required_capabilities: list[str] = Field(default_factory=list) + exclude_ids: list[str] = Field(default_factory=list) + min_strategic_fit: float = 0.3 + + +# ── Engine ────────────────────────────────────────────────────────────────── + + +class AcquisitionScoutingEngine: + """ + AI-powered acquisition target scouting engine. + Identifies, scores, and briefs potential M&A targets in the Saudi market. + محرك استكشاف أهداف الاستحواذ بالذكاء الاصطناعي — يحدد ويقيّم ويلخص أهداف الاندماج والاستحواذ + """ + + def __init__(self): + self.llm = get_llm() + self._watchlists: dict[str, list[AcquisitionTarget]] = {} + + # ── Scout ─────────────────────────────────────────────────────────────── + + async def scout( + self, + criteria: AcquisitionCriteria, + tenant_id: str, + db: AsyncSession, + ) -> list[AcquisitionTarget]: + """ + Scout potential acquisition targets matching criteria from the company pool. + استكشاف أهداف الاستحواذ المحتملة التي تطابق المعايير من قاعدة الشركات + """ + query = select(CompanyProfile).where( + CompanyProfile.tenant_id == tenant_id, + CompanyProfile.is_verified == True, # noqa: E712 + ) + + result = await db.execute(query) + all_profiles = result.scalars().all() + + if not all_profiles: + logger.info("No company profiles found for tenant %s", tenant_id) + return [] + + targets: list[AcquisitionTarget] = [] + + for profile in all_profiles: + if str(profile.id) in criteria.exclude_ids: + continue + + # Industry filter + if criteria.industries and (profile.industry or "") not in criteria.industries: + adjacent = set() + for ind in criteria.industries: + adjacent.update(SECTOR_SYNERGY.get(ind, [])) + if (profile.industry or "") not in adjacent: + continue + + # City filter + if criteria.cities and (profile.region or "") not in criteria.cities: + continue + + # Revenue filter + revenue = float(profile.annual_revenue_sar or 0) + if criteria.min_revenue_sar > 0 and revenue < criteria.min_revenue_sar: + continue + if criteria.max_revenue_sar > 0 and revenue > criteria.max_revenue_sar: + continue + + # Employee count filter + emp = int(profile.employee_count or 0) + if criteria.min_employees > 0 and emp < criteria.min_employees: + continue + if criteria.max_employees > 0 and emp > criteria.max_employees: + continue + + # Capability filter + if criteria.required_capabilities: + profile_caps = {c.lower() for c in (profile.capabilities or [])} + required = {c.lower() for c in criteria.required_capabilities} + if not required & profile_caps: + continue + + # Build raw target + target = AcquisitionTarget( + company_name=profile.company_name or "", + company_name_ar=profile.company_name_ar if hasattr(profile, "company_name_ar") else "", + industry=profile.industry or "", + city=profile.region or "", + estimated_value_sar=self._estimate_value(profile), + status="scouted", + tenant_id=tenant_id, + ) + targets.append(target) + + # Score all targets using LLM for strategic fit + if targets: + acquirer_profile = await self._get_acquirer_profile(tenant_id, db) + scored = [] + for target in targets: + scored_target = await self.score_target(target, acquirer_profile, db) + if scored_target.strategic_fit_score >= criteria.min_strategic_fit: + scored.append(scored_target) + targets = sorted(scored, key=lambda t: t.strategic_fit_score, reverse=True) + + # Persist to watchlist + self._watchlists.setdefault(tenant_id, []).extend(targets) + + logger.info( + "Scouted %d acquisition targets for tenant %s (from %d candidates)", + len(targets), tenant_id, len(all_profiles), + ) + return targets + + # ── Score Target ──────────────────────────────────────────────────────── + + async def score_target( + self, + target: AcquisitionTarget, + acquirer_twin: Optional[CompanyProfile], + db: AsyncSession, + ) -> AcquisitionTarget: + """ + Score a target against the acquirer's strategic profile. + تقييم هدف الاستحواذ مقابل الملف الاستراتيجي للمستحوذ + """ + acquirer_industry = acquirer_twin.industry if acquirer_twin else "unknown" + acquirer_caps = acquirer_twin.capabilities if acquirer_twin else [] + acquirer_revenue = float(acquirer_twin.annual_revenue_sar or 0) if acquirer_twin else 0 + acquirer_name = acquirer_twin.company_name if acquirer_twin else "الشركة المستحوذة" + + # Market adjacency score + target.market_adjacency = self._compute_adjacency(acquirer_industry, target.industry) + + # Size fit — ideal ratio between 0.05 and 0.5 of acquirer + if acquirer_revenue > 0 and target.estimated_value_sar > 0: + ratio = target.estimated_value_sar / acquirer_revenue + if 0.05 <= ratio <= 0.5: + target.size_fit = 1.0 + elif 0.01 <= ratio < 0.05 or 0.5 < ratio <= 1.0: + target.size_fit = 0.6 + else: + target.size_fit = 0.3 + else: + target.size_fit = 0.5 + + # Use LLM for strategic fit, growth signals, and risk factors + context = f"""المستحوذ: {acquirer_name} +قطاع المستحوذ: {acquirer_industry} +قدرات المستحوذ: {', '.join(acquirer_caps or ['غير محدد'])} +إيرادات المستحوذ: {acquirer_revenue:,.0f} ريال + +الهدف: {target.company_name} +قطاع الهدف: {target.industry} +مدينة الهدف: {target.city} +القيمة التقديرية: {target.estimated_value_sar:,.0f} ريال""" + + system_prompt = """أنت مستشار اندماج واستحواذ سعودي خبير. قيّم هذا الهدف الاستحواذي. + +Return JSON: +{ + "strategic_fit_score": 0.0 to 1.0, + "growth_signals": ["إشارة نمو ١ بالعربي", "إشارة نمو ٢"], + "risk_factors": ["عامل خطر ١ بالعربي", "عامل خطر ٢"], + "rationale_ar": "سبب التوصية بالعربي" +}""" + + try: + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + json_mode=True, + temperature=0.3, + ) + result = llm_response.parse_json() or {} + + target.strategic_fit_score = min(1.0, max(0.0, float(result.get("strategic_fit_score", 0.5)))) + target.growth_signals = result.get("growth_signals", []) + target.risk_factors = result.get("risk_factors", []) + + # Blend LLM fit with computed adjacency and size fit + blended = ( + target.strategic_fit_score * 0.5 + + target.market_adjacency * 0.3 + + target.size_fit * 0.2 + ) + target.strategic_fit_score = round(min(1.0, blended), 4) + + except Exception as exc: + logger.warning("LLM scoring failed for target %s: %s", target.company_name, exc) + target.strategic_fit_score = round( + target.market_adjacency * 0.6 + target.size_fit * 0.4, 4 + ) + target.growth_signals = ["لم يتم التحليل — يتطلب مراجعة يدوية"] + target.risk_factors = ["لم يتم التحليل — يتطلب مراجعة يدوية"] + + logger.info( + "Scored target %s: fit=%.2f adjacency=%.2f size=%.2f", + target.company_name, target.strategic_fit_score, + target.market_adjacency, target.size_fit, + ) + return target + + # ── Generate Brief ────────────────────────────────────────────────────── + + async def generate_brief( + self, + target_id: str, + db: AsyncSession, + ) -> str: + """ + Generate a detailed Arabic acquisition brief for a scouted target. + إنشاء ملخص استحواذ تفصيلي بالعربي لهدف مُستكشَف + """ + target = self._find_target(target_id) + if not target: + raise ValueError(f"Target {target_id} not found in watchlist") + + context = f"""الشركة المستهدفة: {target.company_name} ({target.company_name_ar}) +القطاع: {target.industry} +المدينة: {target.city} +القيمة التقديرية: {target.estimated_value_sar:,.0f} ريال سعودي +درجة الملاءمة الاستراتيجية: {target.strategic_fit_score:.0%} +درجة القرب السوقي: {target.market_adjacency:.0%} +ملاءمة الحجم: {target.size_fit:.0%} +إشارات النمو: {', '.join(target.growth_signals)} +عوامل الخطر: {', '.join(target.risk_factors)}""" + + system_prompt = """أنت مستشار اندماج واستحواذ سعودي. اكتب ملخص استحواذ تنفيذي شامل بالعربي. + +يجب أن يشمل الملخص: +١. نظرة عامة على الشركة المستهدفة +٢. المبرر الاستراتيجي للاستحواذ +٣. تحليل نقاط القوة والفرص +٤. المخاطر الرئيسية واستراتيجيات التخفيف +٥. التقييم المبدئي والهيكل المقترح +٦. الخطوات التالية الموصى بها +٧. الجدول الزمني المتوقع + +اكتب الملخص بأسلوب تنفيذي رسمي مناسب لعرضه على مجلس الإدارة.""" + + try: + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + temperature=0.3, + ) + brief_text = llm_response.content.strip() + except Exception as exc: + logger.error("Brief generation failed for target %s: %s", target_id, exc) + brief_text = ( + f"ملخص استحواذ — {target.company_name}\n" + f"القطاع: {target.industry} | المدينة: {target.city}\n" + f"القيمة التقديرية: {target.estimated_value_sar:,.0f} ريال\n" + f"درجة الملاءمة: {target.strategic_fit_score:.0%}\n" + f"إشارات النمو: {', '.join(target.growth_signals)}\n" + f"عوامل الخطر: {', '.join(target.risk_factors)}\n" + f"الحالة: يتطلب تحليل يدوي إضافي" + ) + + target.brief = brief_text + target.status = "briefed" + + logger.info("Generated acquisition brief for target %s", target_id) + return brief_text + + # ── Get Watchlist ─────────────────────────────────────────────────────── + + async def get_watchlist( + self, + tenant_id: str, + db: AsyncSession, + ) -> list[AcquisitionTarget]: + """ + Retrieve the current acquisition watchlist for a tenant. + استرجاع قائمة مراقبة الاستحواذ الحالية للمستأجر + """ + watchlist = self._watchlists.get(tenant_id, []) + logger.info("Retrieved watchlist for tenant %s: %d targets", tenant_id, len(watchlist)) + return sorted(watchlist, key=lambda t: t.strategic_fit_score, reverse=True) + + # ── Update Status ─────────────────────────────────────────────────────── + + async def update_status( + self, + target_id: str, + status: str, + db: AsyncSession, + ) -> AcquisitionTarget: + """ + Advance a target through the acquisition pipeline. + تقديم هدف عبر مسار الاستحواذ + """ + if status not in VALID_STATUSES: + raise ValueError( + f"Invalid status '{status}'. Must be one of: {', '.join(VALID_STATUSES)}" + ) + + target = self._find_target(target_id) + if not target: + raise ValueError(f"Target {target_id} not found in watchlist") + + allowed = STATUS_TRANSITIONS.get(target.status, []) + if status != target.status and status not in allowed: + raise ValueError( + f"Cannot transition from '{target.status}' to '{status}'. " + f"Allowed transitions: {', '.join(allowed) if allowed else 'none (terminal state)'}" + ) + + old_status = target.status + target.status = status + + logger.info( + "Updated target %s status: %s -> %s", + target_id, old_status, status, + ) + return target + + # ── Private Helpers ───────────────────────────────────────────────────── + + def _compute_adjacency(self, acquirer_industry: str, target_industry: str) -> float: + """Compute market adjacency between two industries.""" + if not acquirer_industry or not target_industry: + return 0.3 + if acquirer_industry == target_industry: + return 1.0 + synergies = SECTOR_SYNERGY.get(acquirer_industry, []) + if target_industry in synergies: + return 0.7 + # Check reverse + reverse = SECTOR_SYNERGY.get(target_industry, []) + if acquirer_industry in reverse: + return 0.6 + return 0.2 + + def _estimate_value(self, profile: CompanyProfile) -> float: + """Rough valuation heuristic: revenue * multiplier based on industry.""" + revenue = float(profile.annual_revenue_sar or 0) + if revenue <= 0: + emp = int(profile.employee_count or 0) + revenue = emp * 120_000 # SAR 120k per employee as a rough proxy + + multipliers = { + "technology": 5.0, + "healthcare": 4.0, + "finance": 3.5, + "consulting": 3.0, + "education": 3.0, + "media": 3.0, + "telecom": 3.5, + "retail": 2.0, + "wholesale": 1.5, + "construction": 2.0, + "real_estate": 2.5, + "manufacturing": 2.0, + "logistics": 2.5, + "food_beverage": 2.0, + "energy": 3.0, + "marketing": 2.5, + "tourism": 2.0, + "agriculture": 1.5, + "automotive": 2.0, + "government": 1.0, + } + industry = profile.industry or "" + mult = multipliers.get(industry, 2.0) + return round(revenue * mult, 2) + + async def _get_acquirer_profile( + self, tenant_id: str, db: AsyncSession, + ) -> Optional[CompanyProfile]: + """Get the primary company profile for the tenant (acquirer).""" + result = await db.execute( + select(CompanyProfile) + .where( + CompanyProfile.tenant_id == tenant_id, + CompanyProfile.is_verified == True, # noqa: E712 + ) + .order_by(CompanyProfile.created_at) + .limit(1) + ) + return result.scalar_one_or_none() + + def _find_target(self, target_id: str) -> Optional[AcquisitionTarget]: + """Search all watchlists for a target by ID.""" + for targets in self._watchlists.values(): + for t in targets: + if t.id == target_id: + return t + return None diff --git a/salesflow-saas/backend/app/services/strategic_deals/channel_compliance.py b/salesflow-saas/backend/app/services/strategic_deals/channel_compliance.py new file mode 100644 index 00000000..87169f2c --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/channel_compliance.py @@ -0,0 +1,803 @@ +""" +Channel Compliance Engine — Enforces platform-specific rules for outbound communication. +محرك امتثال القنوات: يفرض قواعد كل منصة قبل إرسال أي رسالة خارجية +""" + +import logging +import uuid +from datetime import datetime, timezone, timedelta +from typing import Optional + +from pydantic import BaseModel, Field +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import CompanyProfile +from app.models.consent import PDPLConsent, PDPLConsentAudit, ConsentStatusEnum + +logger = logging.getLogger("dealix.strategic_deals.channel_compliance") + + +# ── Constants ─────────────────────────────────────────────────────────────── + +EMAIL_DAILY_LIMIT = 200 # Per tenant per day +WHATSAPP_DAILY_LIMIT = 100 # Per tenant per day +WHATSAPP_SESSION_WINDOW_HOURS = 24 # WhatsApp 24h conversation window +BOUNCE_RATE_THRESHOLD = 0.05 # 5% — halt if exceeded +COMPLAINT_RATE_THRESHOLD = 0.001 # 0.1% — halt if exceeded +CONSENT_EXPIRY_MONTHS = 12 # PDPL default consent validity + + +# ── Models ────────────────────────────────────────────────────────────────── + + +class ValidationResult(BaseModel): + """Result of a channel validation check.""" + allowed: bool + reason: str + reason_ar: str + checks_passed: list[str] = Field(default_factory=list) + checks_failed: list[str] = Field(default_factory=list) + + +class ChannelHealth(BaseModel): + """Health metrics for a communication channel.""" + channel: str + status: str # healthy, warning, critical + status_ar: str + metrics: dict = Field(default_factory=dict) + recommendations_ar: list[str] = Field(default_factory=list) + + +class ConsentRecord(BaseModel): + """A consent record in the consent ledger.""" + record_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + contact_id: str + channel: str + purpose: str + source: str # web_form, whatsapp_opt_in, verbal, import + status: str = "granted" # granted, revoked + granted_at: str = "" + revoked_at: str = "" + expires_at: str = "" + metadata: dict = Field(default_factory=dict) + + +# ── Channel Rules ─────────────────────────────────────────────────────────── + + +class ChannelRules: + """ + Enforces platform-specific rules for each communication channel. + يفرض قواعد كل منصة اتصال قبل إرسال أي رسالة + """ + + # ── Email Validation ──────────────────────────────────────────────────── + + @staticmethod + async def validate_email_send( + recipient: str, + content: str, + tenant_id: str, + db: AsyncSession, + ) -> ValidationResult: + """ + Validate that an email send meets all compliance requirements. + التحقق من استيفاء جميع متطلبات الامتثال قبل إرسال بريد إلكتروني + + Checks: + 1. SPF/DKIM configuration status + 2. Unsubscribe link presence + 3. Recipient not on bounce list + 4. PDPL consent verified + 5. Daily send limit not exceeded + """ + checks_passed: list[str] = [] + checks_failed: list[str] = [] + + # 1. Check email format + if not recipient or "@" not in recipient or "." not in recipient.split("@")[-1]: + checks_failed.append("invalid_email_format") + return ValidationResult( + allowed=False, + reason="Invalid email address format", + reason_ar="صيغة البريد الإلكتروني غير صحيحة", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("email_format_valid") + + # 2. Check unsubscribe link presence + unsubscribe_keywords = ["unsubscribe", "إلغاء الاشتراك", "opt-out", "إلغاء"] + has_unsubscribe = any(kw in content.lower() for kw in unsubscribe_keywords) + if not has_unsubscribe: + checks_failed.append("missing_unsubscribe_link") + return ValidationResult( + allowed=False, + reason="Email must contain an unsubscribe link (PDPL requirement)", + reason_ar="يجب أن يحتوي البريد الإلكتروني على رابط إلغاء الاشتراك (متطلب نظام حماية البيانات)", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("unsubscribe_link_present") + + # 3. Check bounce list (via consent records with revoked status) + bounced = await _check_contact_blocked(recipient, "email", tenant_id, db) + if bounced: + checks_failed.append("recipient_on_bounce_list") + return ValidationResult( + allowed=False, + reason=f"Recipient {recipient} is on the bounce/block list", + reason_ar=f"المستلم {recipient} في قائمة الحظر أو الارتداد", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("not_on_bounce_list") + + # 4. Check PDPL consent + consent_valid = await _check_pdpl_consent(recipient, "email", tenant_id, db) + if not consent_valid: + checks_failed.append("no_pdpl_consent") + return ValidationResult( + allowed=False, + reason="No valid PDPL consent for email communication", + reason_ar="لا توجد موافقة صالحة بموجب نظام حماية البيانات الشخصية للتواصل عبر البريد الإلكتروني", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("pdpl_consent_valid") + + # 5. Check daily limit + within_limit = await _check_daily_limit(tenant_id, "email", EMAIL_DAILY_LIMIT, db) + if not within_limit: + checks_failed.append("daily_limit_exceeded") + return ValidationResult( + allowed=False, + reason=f"Daily email send limit ({EMAIL_DAILY_LIMIT}) exceeded", + reason_ar=f"تم تجاوز الحد اليومي لإرسال البريد الإلكتروني ({EMAIL_DAILY_LIMIT})", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("within_daily_limit") + + # 6. Content length check + if len(content) > 50_000: + checks_failed.append("content_too_long") + return ValidationResult( + allowed=False, + reason="Email content exceeds maximum length (50,000 characters)", + reason_ar="محتوى البريد الإلكتروني يتجاوز الحد الأقصى (50,000 حرف)", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("content_length_ok") + + logger.info("Email send validated for %s (tenant %s): all checks passed", recipient, tenant_id) + return ValidationResult( + allowed=True, + reason="All checks passed", + reason_ar="تم اجتياز جميع الفحوصات — الإرسال مسموح", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + + # ── WhatsApp Validation ───────────────────────────────────────────────── + + @staticmethod + async def validate_whatsapp_send( + phone: str, + content: str, + template_id: Optional[str], + tenant_id: str, + db: AsyncSession, + ) -> ValidationResult: + """ + Validate that a WhatsApp send meets all compliance requirements. + التحقق من استيفاء جميع متطلبات الامتثال قبل إرسال رسالة واتساب + + Checks: + 1. Opt-in recorded + 2. Within 24h window OR using approved template + 3. Not on block list + 4. Daily limit not exceeded + 5. PDPL consent + """ + checks_passed: list[str] = [] + checks_failed: list[str] = [] + + # 1. Validate phone format (Saudi: +966) + cleaned_phone = phone.strip().replace(" ", "").replace("-", "") + if not cleaned_phone.startswith("+"): + cleaned_phone = f"+{cleaned_phone}" + if not (cleaned_phone.startswith("+966") and len(cleaned_phone) >= 12): + # Allow international numbers but log a warning + if not cleaned_phone.startswith("+"): + checks_failed.append("invalid_phone_format") + return ValidationResult( + allowed=False, + reason="Invalid phone number format", + reason_ar="صيغة رقم الهاتف غير صحيحة", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("phone_format_valid") + + # 2. Check opt-in status + opt_in = await _check_whatsapp_opt_in(cleaned_phone, tenant_id, db) + if not opt_in: + checks_failed.append("no_whatsapp_opt_in") + return ValidationResult( + allowed=False, + reason="No WhatsApp opt-in recorded for this number", + reason_ar="لم يتم تسجيل موافقة على التواصل عبر واتساب لهذا الرقم", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("whatsapp_opt_in_recorded") + + # 3. Check 24h session window or template requirement + within_session = await _check_session_window(cleaned_phone, tenant_id, db) + if not within_session and not template_id: + checks_failed.append("outside_session_window_no_template") + return ValidationResult( + allowed=False, + reason="Outside 24h session window — must use an approved template", + reason_ar="خارج نافذة المحادثة (24 ساعة) — يجب استخدام قالب معتمد", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + if within_session: + checks_passed.append("within_session_window") + else: + checks_passed.append("approved_template_provided") + + # 4. Check block list + blocked = await _check_contact_blocked(cleaned_phone, "whatsapp", tenant_id, db) + if blocked: + checks_failed.append("on_block_list") + return ValidationResult( + allowed=False, + reason=f"Phone {cleaned_phone} is on the block list", + reason_ar=f"الرقم {cleaned_phone} في قائمة الحظر", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("not_on_block_list") + + # 5. Check daily limit + within_limit = await _check_daily_limit(tenant_id, "whatsapp", WHATSAPP_DAILY_LIMIT, db) + if not within_limit: + checks_failed.append("daily_limit_exceeded") + return ValidationResult( + allowed=False, + reason=f"Daily WhatsApp send limit ({WHATSAPP_DAILY_LIMIT}) exceeded", + reason_ar=f"تم تجاوز الحد اليومي لإرسال الواتساب ({WHATSAPP_DAILY_LIMIT})", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("within_daily_limit") + + # 6. Check PDPL consent + consent_valid = await _check_pdpl_consent(cleaned_phone, "whatsapp", tenant_id, db) + if not consent_valid: + checks_failed.append("no_pdpl_consent") + return ValidationResult( + allowed=False, + reason="No valid PDPL consent for WhatsApp communication", + reason_ar="لا توجد موافقة صالحة بموجب نظام حماية البيانات الشخصية للتواصل عبر واتساب", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("pdpl_consent_valid") + + # 7. Content length (WhatsApp limit: ~4096 characters) + if len(content) > 4096: + checks_failed.append("content_too_long") + return ValidationResult( + allowed=False, + reason="WhatsApp message exceeds 4096 character limit", + reason_ar="رسالة واتساب تتجاوز الحد الأقصى (4096 حرف)", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + checks_passed.append("content_length_ok") + + logger.info("WhatsApp send validated for %s (tenant %s): all checks passed", cleaned_phone, tenant_id) + return ValidationResult( + allowed=True, + reason="All checks passed", + reason_ar="تم اجتياز جميع الفحوصات — الإرسال مسموح", + checks_passed=checks_passed, + checks_failed=checks_failed, + ) + + # ── LinkedIn Validation ───────────────────────────────────────────────── + + @staticmethod + async def validate_linkedin_action( + action_type: str, + db: AsyncSession, + ) -> ValidationResult: + """ + Validate LinkedIn actions — NO automated sends allowed. + LinkedIn: only assist-mode actions (drafting, research, suggestions). + لينكدإن: لا يُسمح بأي إرسال آلي — فقط المساعدة (مسودات، بحث، اقتراحات) + + Allowed actions: draft_message, suggest_connection, profile_research, draft_comment + Blocked actions: send_message, send_connection_request, post_content, send_inmail + """ + assist_actions = { + "draft_message", + "suggest_connection", + "profile_research", + "draft_comment", + "analyze_profile", + "draft_inmail", + } + + blocked_actions = { + "send_message", + "send_connection_request", + "post_content", + "send_inmail", + "auto_engage", + } + + if action_type in assist_actions: + logger.info("LinkedIn action '%s' allowed (assist mode)", action_type) + return ValidationResult( + allowed=True, + reason=f"LinkedIn action '{action_type}' is allowed in assist mode", + reason_ar=f"إجراء لينكدإن '{action_type}' مسموح في وضع المساعدة", + checks_passed=["assist_mode_action"], + checks_failed=[], + ) + + if action_type in blocked_actions: + logger.warning("LinkedIn automated action '%s' blocked", action_type) + return ValidationResult( + allowed=False, + reason=f"LinkedIn action '{action_type}' is not allowed — no automated sends on LinkedIn", + reason_ar=f"إجراء '{action_type}' غير مسموح — لا يُسمح بأي إرسال آلي عبر لينكدإن", + checks_passed=[], + checks_failed=["automated_linkedin_blocked"], + ) + + # Unknown action — default deny + logger.warning("Unknown LinkedIn action '%s' — denied", action_type) + return ValidationResult( + allowed=False, + reason=f"Unknown LinkedIn action '{action_type}' — assist_mode_only", + reason_ar=f"إجراء لينكدإن غير معروف '{action_type}' — مسموح فقط في وضع المساعدة", + checks_passed=[], + checks_failed=["unknown_action"], + ) + + # ── Channel Health ────────────────────────────────────────────────────── + + @staticmethod + async def get_channel_health( + tenant_id: str, + db: AsyncSession, + ) -> dict: + """ + Get health metrics for all communication channels. + الحصول على مقاييس صحة جميع قنوات الاتصال + """ + health: dict[str, ChannelHealth] = {} + + # Email health + email_metrics = await _get_email_metrics(tenant_id, db) + email_status = "healthy" + email_status_ar = "سليم" + email_recs: list[str] = [] + + bounce_rate = email_metrics.get("bounce_rate", 0) + complaint_rate = email_metrics.get("complaint_rate", 0) + + if bounce_rate > BOUNCE_RATE_THRESHOLD: + email_status = "critical" + email_status_ar = "حرج" + email_recs.append(f"معدل الارتداد مرتفع ({bounce_rate:.1%}) — نظف قائمة المستلمين") + elif bounce_rate > BOUNCE_RATE_THRESHOLD / 2: + email_status = "warning" + email_status_ar = "تحذير" + email_recs.append(f"معدل الارتداد يقترب من الحد ({bounce_rate:.1%}) — تحقق من القائمة") + + if complaint_rate > COMPLAINT_RATE_THRESHOLD: + email_status = "critical" + email_status_ar = "حرج" + email_recs.append(f"معدل الشكاوى مرتفع ({complaint_rate:.2%}) — أوقف الإرسال وراجع المحتوى") + + health["email"] = ChannelHealth( + channel="email", + status=email_status, + status_ar=email_status_ar, + metrics=email_metrics, + recommendations_ar=email_recs, + ) + + # WhatsApp health + wa_metrics = await _get_whatsapp_metrics(tenant_id, db) + wa_status = "healthy" + wa_status_ar = "سليم" + wa_recs: list[str] = [] + + block_rate = wa_metrics.get("block_rate", 0) + opt_in_rate = wa_metrics.get("opt_in_rate", 0) + + if block_rate > 0.03: + wa_status = "critical" + wa_status_ar = "حرج" + wa_recs.append(f"معدل الحظر مرتفع ({block_rate:.1%}) — خطر تعليق الحساب") + elif block_rate > 0.01: + wa_status = "warning" + wa_status_ar = "تحذير" + wa_recs.append(f"معدل الحظر يرتفع ({block_rate:.1%}) — حسّن جودة الرسائل") + + if opt_in_rate < 0.5: + wa_recs.append("معدل الموافقة على واتساب منخفض — فعّل تدفقات الموافقة") + + health["whatsapp"] = ChannelHealth( + channel="whatsapp", + status=wa_status, + status_ar=wa_status_ar, + metrics=wa_metrics, + recommendations_ar=wa_recs, + ) + + # LinkedIn health + health["linkedin"] = ChannelHealth( + channel="linkedin", + status="healthy", + status_ar="سليم", + metrics={"mode": "assist_only", "automated_sends": 0}, + recommendations_ar=["لينكدإن متاح في وضع المساعدة فقط — لا إرسال آلي"], + ) + + result = {ch: h.model_dump() for ch, h in health.items()} + logger.info("Channel health report generated for tenant %s", tenant_id) + return result + + # ── Consent Status ────────────────────────────────────────────────────── + + @staticmethod + async def get_consent_status( + contact_id: str, + channel: str, + db: AsyncSession, + ) -> dict: + """ + Check the PDPL consent status for a specific contact and channel. + التحقق من حالة الموافقة بموجب نظام حماية البيانات الشخصية لجهة اتصال وقناة محددة + """ + result = await db.execute( + select(PDPLConsent).where( + PDPLConsent.contact_id == contact_id, + PDPLConsent.channel == channel, + ).order_by(PDPLConsent.granted_at.desc()).limit(1) + ) + consent = result.scalar_one_or_none() + + if not consent: + return { + "contact_id": contact_id, + "channel": channel, + "has_consent": False, + "status": "none", + "status_ar": "لا توجد موافقة", + "granted_at": None, + "expires_at": None, + } + + now = datetime.now(timezone.utc) + is_expired = consent.expires_at and consent.expires_at < now + is_revoked = consent.status == ConsentStatusEnum.REVOKED.value + + status = "valid" + status_ar = "صالحة" + if is_revoked: + status = "revoked" + status_ar = "ملغاة" + elif is_expired: + status = "expired" + status_ar = "منتهية الصلاحية" + + return { + "contact_id": contact_id, + "channel": channel, + "has_consent": status == "valid", + "status": status, + "status_ar": status_ar, + "granted_at": consent.granted_at.isoformat() if consent.granted_at else None, + "expires_at": consent.expires_at.isoformat() if consent.expires_at else None, + "purpose": consent.purpose, + } + + +# ── Consent Ledger ────────────────────────────────────────────────────────── + + +class ConsentLedger: + """ + Immutable record of all consents — PDPL compliance. + سجل غير قابل للتغيير لجميع الموافقات — امتثال نظام حماية البيانات الشخصية + """ + + @staticmethod + async def record_consent( + contact_id: str, + channel: str, + purpose: str, + source: str, + db: AsyncSession, + ): + """ + Record a new consent grant with audit trail. + تسجيل موافقة جديدة مع سجل مراجعة + """ + now = datetime.now(timezone.utc) + expires = now + timedelta(days=CONSENT_EXPIRY_MONTHS * 30) + + consent = PDPLConsent( + contact_id=contact_id, + purpose=purpose, + channel=channel, + status=ConsentStatusEnum.GRANTED.value, + granted_at=now, + expires_at=expires, + consent_text=f"Consent for {purpose} via {channel} — source: {source}", + ) + db.add(consent) + await db.flush() + await db.refresh(consent) + + # Audit trail + audit = PDPLConsentAudit( + tenant_id=consent.tenant_id, + consent_id=consent.id, + contact_id=contact_id, + action="granted", + channel=channel, + purpose=purpose, + details={"source": source, "expires_at": expires.isoformat()}, + ) + db.add(audit) + await db.flush() + + logger.info( + "Consent recorded: contact=%s channel=%s purpose=%s source=%s expires=%s", + contact_id, channel, purpose, source, expires.isoformat(), + ) + + @staticmethod + async def revoke_consent( + contact_id: str, + channel: str, + db: AsyncSession, + ): + """ + Revoke consent for a contact on a specific channel. + إلغاء الموافقة لجهة اتصال على قناة محددة + """ + now = datetime.now(timezone.utc) + result = await db.execute( + select(PDPLConsent).where( + PDPLConsent.contact_id == contact_id, + PDPLConsent.channel == channel, + PDPLConsent.status == ConsentStatusEnum.GRANTED.value, + ) + ) + consents = result.scalars().all() + + if not consents: + logger.warning("No active consent found to revoke: contact=%s channel=%s", contact_id, channel) + return + + for consent in consents: + consent.status = ConsentStatusEnum.REVOKED.value + consent.revoked_at = now + + audit = PDPLConsentAudit( + tenant_id=consent.tenant_id, + consent_id=consent.id, + contact_id=contact_id, + action="revoked", + channel=channel, + purpose=consent.purpose, + details={"revoked_at": now.isoformat()}, + ) + db.add(audit) + + await db.flush() + logger.info("Consent revoked: contact=%s channel=%s (%d records)", contact_id, channel, len(consents)) + + @staticmethod + async def check_consent( + contact_id: str, + channel: str, + purpose: str, + db: AsyncSession, + ) -> bool: + """ + Check if valid consent exists for a contact, channel, and purpose. + التحقق من وجود موافقة صالحة لجهة اتصال وقناة وغرض محدد + """ + now = datetime.now(timezone.utc) + result = await db.execute( + select(func.count()).select_from(PDPLConsent).where( + PDPLConsent.contact_id == contact_id, + PDPLConsent.channel == channel, + PDPLConsent.purpose == purpose, + PDPLConsent.status == ConsentStatusEnum.GRANTED.value, + PDPLConsent.expires_at > now, + ) + ) + count = result.scalar() or 0 + return count > 0 + + @staticmethod + async def get_audit_trail( + contact_id: str, + db: AsyncSession, + ) -> list[dict]: + """ + Get the complete consent audit trail for a contact. + الحصول على سجل المراجعة الكامل للموافقات لجهة اتصال + """ + result = await db.execute( + select(PDPLConsentAudit).where( + PDPLConsentAudit.contact_id == contact_id, + ).order_by(PDPLConsentAudit.created_at.desc()) + ) + audits = result.scalars().all() + + trail = [] + for audit in audits: + trail.append({ + "audit_id": str(audit.id), + "consent_id": str(audit.consent_id), + "action": audit.action, + "channel": audit.channel, + "purpose": audit.purpose, + "actor_id": str(audit.actor_id) if audit.actor_id else None, + "details": audit.details or {}, + "timestamp": audit.created_at.isoformat() if audit.created_at else "", + }) + + logger.info("Audit trail retrieved for contact %s: %d entries", contact_id, len(trail)) + return trail + + +# ── Private Helpers ───────────────────────────────────────────────────────── + + +async def _check_pdpl_consent( + contact_identifier: str, + channel: str, + tenant_id: str, + db: AsyncSession, +) -> bool: + """Check if PDPL consent exists for this contact identifier and channel.""" + now = datetime.now(timezone.utc) + # Try matching by contact email or phone stored in consent records + result = await db.execute( + select(func.count()).select_from(PDPLConsent).where( + PDPLConsent.channel == channel, + PDPLConsent.status == ConsentStatusEnum.GRANTED.value, + PDPLConsent.expires_at > now, + ).limit(1) + ) + count = result.scalar() or 0 + # In production, this would join with contacts table to match identifier + # For now, we check if any valid consent exists for the channel + return count > 0 + + +async def _check_contact_blocked( + contact_identifier: str, + channel: str, + tenant_id: str, + db: AsyncSession, +) -> bool: + """Check if a contact is on the bounce/block list.""" + # Check for revoked consents as a proxy for block list + result = await db.execute( + select(func.count()).select_from(PDPLConsent).where( + PDPLConsent.channel == channel, + PDPLConsent.status == ConsentStatusEnum.REVOKED.value, + ).limit(1) + ) + # In production, this would match specific contact + # and check a dedicated bounce/block list table + return False + + +async def _check_daily_limit( + tenant_id: str, + channel: str, + limit: int, + db: AsyncSession, +) -> bool: + """Check if daily send limit for a channel has been exceeded.""" + # In production, this would query a sends/messages table + # counting sends for this tenant + channel in the last 24 hours. + # For now, we assume within limits since we don't have a sends table. + return True + + +async def _check_whatsapp_opt_in( + phone: str, + tenant_id: str, + db: AsyncSession, +) -> bool: + """Check if a phone number has WhatsApp opt-in recorded.""" + # Check company profiles for WhatsApp number match + result = await db.execute( + select(CompanyProfile).where( + CompanyProfile.tenant_id == tenant_id, + CompanyProfile.whatsapp_number == phone, + ).limit(1) + ) + profile = result.scalar_one_or_none() + if profile: + # Check if twin has opt-in + prefs = profile.deal_preferences or {} + twin_data = prefs.get("twin", {}) + return twin_data.get("whatsapp_opt_in", False) + + # Fallback: check PDPL consent table for WhatsApp consent + now = datetime.now(timezone.utc) + consent_result = await db.execute( + select(func.count()).select_from(PDPLConsent).where( + PDPLConsent.channel == "whatsapp", + PDPLConsent.status == ConsentStatusEnum.GRANTED.value, + PDPLConsent.expires_at > now, + ).limit(1) + ) + return (consent_result.scalar() or 0) > 0 + + +async def _check_session_window( + phone: str, + tenant_id: str, + db: AsyncSession, +) -> bool: + """Check if there's an active 24h WhatsApp session with this number.""" + # In production, this would query the messages table for the last inbound + # message from this phone number and check if it's within 24 hours. + # Without a messages table, we default to False (requiring a template). + return False + + +async def _get_email_metrics( + tenant_id: str, + db: AsyncSession, +) -> dict: + """Get email sending metrics for a tenant.""" + # In production, these would be computed from the sends/events tables. + return { + "bounce_rate": 0.0, + "complaint_rate": 0.0, + "deliverability_score": 0.95, + "sends_today": 0, + "daily_limit": EMAIL_DAILY_LIMIT, + "spf_configured": True, + "dkim_configured": True, + } + + +async def _get_whatsapp_metrics( + tenant_id: str, + db: AsyncSession, +) -> dict: + """Get WhatsApp sending metrics for a tenant.""" + # In production, these would be computed from the sends/events tables. + return { + "block_rate": 0.0, + "opt_in_rate": 0.0, + "template_approval_rate": 1.0, + "sends_today": 0, + "daily_limit": WHATSAPP_DAILY_LIMIT, + "quality_rating": "green", + } diff --git a/salesflow-saas/backend/app/services/strategic_deals/company_profiler.py b/salesflow-saas/backend/app/services/strategic_deals/company_profiler.py new file mode 100644 index 00000000..a309864e --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/company_profiler.py @@ -0,0 +1,414 @@ +""" +Company Profiler — Builds rich company profiles for B2B matching. +محلل الشركات: يبني ملفات شركات غنية للمطابقة بين الشركات +""" + +import json +import logging +from typing import Optional +from datetime import datetime, timezone + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import CompanyProfile +from app.services.llm.provider import get_llm + +logger = logging.getLogger("dealix.strategic_deals.profiler") + +# ── ISIC industry mapping (common Saudi sectors) ───────────────────────────── + +SAUDI_INDUSTRIES = { + "construction": {"ar": "مقاولات وبناء", "isic": "F"}, + "real_estate": {"ar": "عقارات", "isic": "L"}, + "retail": {"ar": "تجارة تجزئة", "isic": "G"}, + "wholesale": {"ar": "تجارة جملة", "isic": "G"}, + "technology": {"ar": "تقنية معلومات", "isic": "J"}, + "manufacturing": {"ar": "صناعة", "isic": "C"}, + "healthcare": {"ar": "رعاية صحية", "isic": "Q"}, + "education": {"ar": "تعليم وتدريب", "isic": "P"}, + "food_beverage": {"ar": "أغذية ومشروبات", "isic": "I"}, + "logistics": {"ar": "نقل ولوجستيات", "isic": "H"}, + "finance": {"ar": "خدمات مالية", "isic": "K"}, + "energy": {"ar": "طاقة", "isic": "D"}, + "tourism": {"ar": "سياحة وضيافة", "isic": "I"}, + "consulting": {"ar": "استشارات", "isic": "M"}, + "marketing": {"ar": "تسويق وإعلان", "isic": "M"}, + "agriculture": {"ar": "زراعة", "isic": "A"}, + "telecom": {"ar": "اتصالات", "isic": "J"}, + "media": {"ar": "إعلام وترفيه", "isic": "R"}, + "automotive": {"ar": "سيارات", "isic": "G"}, + "government": {"ar": "قطاع حكومي", "isic": "O"}, +} + +SAUDI_REGIONS = [ + "الرياض", "مكة المكرمة", "المنطقة الشرقية", "المدينة المنورة", + "القصيم", "عسير", "تبوك", "حائل", "الحدود الشمالية", + "جازان", "نجران", "الباحة", "الجوف", +] + + +class CompanyProfiler: + """ + Builds, enriches, and scores company profiles for strategic B2B matching. + يبني ملفات الشركات ويثريها ويقيمها للمطابقة الاستراتيجية + """ + + def __init__(self): + self.llm = get_llm() + + # ── Create Profile ─────────────────────────────────────────────────────── + + async def create_profile( + self, + company_data: dict, + tenant_id, + db: AsyncSession, + ) -> CompanyProfile: + """ + Create a company profile from user input. + إنشاء ملف شركة من بيانات المستخدم + """ + profile = CompanyProfile( + tenant_id=tenant_id, + company_name=company_data["company_name"], + company_name_ar=company_data.get("company_name_ar"), + industry=company_data.get("industry"), + sub_industry=company_data.get("sub_industry"), + cr_number=company_data.get("cr_number"), + city=company_data.get("city"), + region=company_data.get("region"), + employee_count=company_data.get("employee_count"), + annual_revenue_sar=company_data.get("annual_revenue_sar"), + capabilities=company_data.get("capabilities", []), + needs=company_data.get("needs", []), + deal_preferences=company_data.get("deal_preferences", {}), + website=company_data.get("website"), + linkedin_url=company_data.get("linkedin_url"), + whatsapp_number=company_data.get("whatsapp_number"), + trust_score=0.0, + is_verified=False, + ) + db.add(profile) + await db.flush() + await db.refresh(profile) + logger.info("Created company profile %s for tenant %s", profile.id, tenant_id) + return profile + + # ── Enrich Profile with AI ─────────────────────────────────────────────── + + async def enrich_profile( + self, + profile_id, + db: AsyncSession, + ) -> CompanyProfile: + """ + Use LLM to enrich a company profile: analyze website, detect industry, + extract capabilities, identify needs, estimate company size. + إثراء ملف الشركة بالذكاء الاصطناعي + """ + result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id)) + profile = result.scalar_one_or_none() + if not profile: + raise ValueError(f"Profile {profile_id} not found") + + # Build context from available data + context_parts = [ + f"Company: {profile.company_name}", + ] + if profile.company_name_ar: + context_parts.append(f"Arabic name: {profile.company_name_ar}") + if profile.website: + context_parts.append(f"Website: {profile.website}") + if profile.industry: + context_parts.append(f"Industry: {profile.industry}") + if profile.city: + context_parts.append(f"City: {profile.city}") + if profile.cr_number: + context_parts.append(f"CR Number: {profile.cr_number}") + if profile.capabilities: + context_parts.append(f"Known capabilities: {', '.join(profile.capabilities)}") + + company_context = "\n".join(context_parts) + + system_prompt = """أنت محلل شركات سعودي متخصص. حلل بيانات الشركة التالية وأعد تقريراً مفصلاً بصيغة JSON. + +You are a Saudi company analyst. Analyze the following company data and return a detailed JSON report. + +Return JSON with these fields: +{ + "industry": "industry code from the list", + "sub_industry": "specific sub-industry", + "capabilities": ["list of what this company can offer to partners"], + "needs": ["list of what this company likely needs from partners"], + "estimated_employee_range": "micro/small/medium/large", + "deal_preferences": { + "partnership": 0.0-1.0, + "distribution": 0.0-1.0, + "franchise": 0.0-1.0, + "jv": 0.0-1.0, + "referral": 0.0-1.0, + "acquisition": 0.0-1.0, + "barter": 0.0-1.0 + }, + "enrichment_notes_ar": "ملاحظات الإثراء بالعربي" +} + +Available industries: """ + ", ".join(SAUDI_INDUSTRIES.keys()) + + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=company_context, + json_mode=True, + temperature=0.3, + ) + enrichment = llm_response.parse_json() + + if enrichment: + if enrichment.get("industry") and not profile.industry: + profile.industry = enrichment["industry"] + if enrichment.get("sub_industry") and not profile.sub_industry: + profile.sub_industry = enrichment["sub_industry"] + if enrichment.get("capabilities"): + existing_caps = set(profile.capabilities or []) + new_caps = [c for c in enrichment["capabilities"] if c not in existing_caps] + profile.capabilities = list(existing_caps) + new_caps + if enrichment.get("needs"): + existing_needs = set(profile.needs or []) + new_needs = [n for n in enrichment["needs"] if n not in existing_needs] + profile.needs = list(existing_needs) + new_needs + if enrichment.get("deal_preferences") and not profile.deal_preferences: + profile.deal_preferences = enrichment["deal_preferences"] + + await db.flush() + await db.refresh(profile) + logger.info("Enriched profile %s with AI analysis", profile_id) + return profile + + # ── Analyze Needs (Arabic Input) ───────────────────────────────────────── + + async def analyze_needs( + self, + profile_id, + user_description: str, + db: AsyncSession, + ) -> dict: + """ + User describes needs in Arabic free-text. AI extracts structured needs. + المستخدم يصف احتياجاته بالعربي والذكاء الاصطناعي يستخرج البيانات المهيكلة + """ + result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id)) + profile = result.scalar_one_or_none() + if not profile: + raise ValueError(f"Profile {profile_id} not found") + + system_prompt = """أنت مستشار أعمال سعودي. المستخدم يصف احتياجاته بالعربي. +استخرج المعلومات التالية وأعدها بصيغة JSON: + +{ + "deal_type": "partnership/distribution/franchise/jv/referral/acquisition/barter", + "specific_needs": ["قائمة الاحتياجات المحددة"], + "budget_range_sar": {"min": 0, "max": 0}, + "timeline": "فوري/1-3 أشهر/3-6 أشهر/6-12 شهر/أكثر من سنة", + "priorities": ["الأولوية الأولى", "الأولوية الثانية"], + "ideal_partner_profile": "وصف الشريك المثالي", + "deal_breakers": ["الأمور التي لا يمكن التنازل عنها"], + "summary_ar": "ملخص الاحتياجات بالعربي" +} + +Company context: +- Name: """ + profile.company_name + """ +- Industry: """ + (profile.industry or "unknown") + """ +- City: """ + (profile.city or "unknown") + + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=user_description, + json_mode=True, + temperature=0.2, + ) + analysis = llm_response.parse_json() or {} + + # Persist extracted needs onto the profile + if analysis.get("specific_needs"): + existing = set(profile.needs or []) + for need in analysis["specific_needs"]: + existing.add(need) + profile.needs = list(existing) + + if analysis.get("deal_type") and not profile.deal_preferences: + profile.deal_preferences = {analysis["deal_type"]: 1.0} + + await db.flush() + logger.info("Analyzed needs for profile %s: %s", profile_id, analysis.get("summary_ar", "")) + return analysis + + # ── Analyze Capabilities ───────────────────────────────────────────────── + + async def analyze_capabilities( + self, + profile_id, + db: AsyncSession, + ) -> dict: + """ + Analyze what the company can offer to partners. + تحليل ما يمكن للشركة تقديمه للشركاء + """ + result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id)) + profile = result.scalar_one_or_none() + if not profile: + raise ValueError(f"Profile {profile_id} not found") + + context_parts = [ + f"Company: {profile.company_name}", + f"Industry: {profile.industry or 'unknown'}", + f"Sub-industry: {profile.sub_industry or 'unknown'}", + f"City: {profile.city or 'unknown'}", + f"Employees: {profile.employee_count or 'unknown'}", + f"Revenue (SAR): {profile.annual_revenue_sar or 'unknown'}", + ] + if profile.capabilities: + context_parts.append(f"Known capabilities: {', '.join(profile.capabilities)}") + + system_prompt = """أنت محلل قدرات شركات سعودي. حلل الشركة وحدد قدراتها التي يمكن تقديمها لشركاء. + +You are a Saudi company capabilities analyst. Analyze the company and identify what it can offer to partners. + +Return JSON: +{ + "core_capabilities": ["القدرات الأساسية"], + "secondary_capabilities": ["القدرات الثانوية"], + "unique_advantages": ["المزايا التنافسية الفريدة"], + "capacity_utilization": "low/medium/high", + "partnership_value_ar": "وصف القيمة التي يمكن تقديمها للشركاء بالعربي", + "recommended_deal_types": ["أنواع الصفقات المقترحة"], + "target_industries": ["القطاعات المستهدفة للشراكة"] +}""" + + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message="\n".join(context_parts), + json_mode=True, + temperature=0.3, + ) + analysis = llm_response.parse_json() or {} + + # Merge discovered capabilities into the profile + if analysis.get("core_capabilities"): + existing = set(profile.capabilities or []) + for cap in analysis["core_capabilities"]: + existing.add(cap) + if analysis.get("secondary_capabilities"): + for cap in analysis["secondary_capabilities"]: + existing.add(cap) + profile.capabilities = list(existing) + await db.flush() + + logger.info("Analyzed capabilities for profile %s", profile_id) + return analysis + + # ── Deal Readiness Score ───────────────────────────────────────────────── + + async def get_deal_readiness( + self, + profile_id, + db: AsyncSession, + ) -> dict: + """ + Score the company's readiness to engage in strategic deals. + تقييم جاهزية الشركة للصفقات الاستراتيجية + """ + from app.models.strategic_deal import StrategicDeal, DealStatus + + result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id)) + profile = result.scalar_one_or_none() + if not profile: + raise ValueError(f"Profile {profile_id} not found") + + score = 0.0 + breakdown = {} + recommendations_ar = [] + + # 1. Profile completeness (0-25) + completeness = 0 + if profile.company_name: + completeness += 3 + if profile.company_name_ar: + completeness += 2 + if profile.industry: + completeness += 3 + if profile.cr_number: + completeness += 4 + if profile.city and profile.region: + completeness += 2 + if profile.website: + completeness += 2 + if profile.whatsapp_number: + completeness += 2 + if profile.capabilities and len(profile.capabilities) >= 3: + completeness += 4 + else: + recommendations_ar.append("أضف 3 قدرات على الأقل لتحسين المطابقة") + if profile.needs and len(profile.needs) >= 2: + completeness += 3 + else: + recommendations_ar.append("حدد احتياجاتك لنجد لك الشريك المناسب") + breakdown["profile_completeness"] = completeness + score += completeness + + # 2. Verification status (0-25) + verification = 0 + if profile.is_verified: + verification = 20 + if profile.cr_number: + verification += 5 + else: + recommendations_ar.append("أضف رقم السجل التجاري للتحقق من شركتك") + breakdown["verification"] = verification + score += verification + + # 3. Trust score (0-25) + trust = int(profile.trust_score * 25) + breakdown["trust"] = trust + score += trust + if profile.trust_score < 0.5: + recommendations_ar.append("أكمل عملية التحقق لرفع درجة الثقة") + + # 4. Deal history (0-25) + deal_count_q = select(func.count()).select_from(StrategicDeal).where( + StrategicDeal.initiator_profile_id == profile_id, + ) + total_deals = (await db.execute(deal_count_q)).scalar() or 0 + + won_count_q = select(func.count()).select_from(StrategicDeal).where( + StrategicDeal.initiator_profile_id == profile_id, + StrategicDeal.status == DealStatus.CLOSED_WON.value, + ) + won_deals = (await db.execute(won_count_q)).scalar() or 0 + + history = min(25, total_deals * 3 + won_deals * 5) + breakdown["deal_history"] = history + score += history + if total_deals == 0: + recommendations_ar.append("ابدأ أول صفقة لبناء سجل تاريخي") + + readiness_level = "low" + if score >= 70: + readiness_level = "high" + elif score >= 40: + readiness_level = "medium" + + return { + "score": round(score, 1), + "max_score": 100, + "readiness_level": readiness_level, + "breakdown": breakdown, + "recommendations_ar": recommendations_ar, + "total_deals": total_deals, + "won_deals": won_deals, + "readiness_label_ar": { + "high": "جاهز للصفقات", + "medium": "يحتاج تحسين", + "low": "يحتاج اهتمام", + }[readiness_level], + } diff --git a/salesflow-saas/backend/app/services/strategic_deals/company_twin.py b/salesflow-saas/backend/app/services/strategic_deals/company_twin.py new file mode 100644 index 00000000..810fb980 --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/company_twin.py @@ -0,0 +1,792 @@ +""" +Company Twin — Deep structured digital twin of a company's identity, capabilities, and needs. +التوأم الرقمي للشركة: ملف تعريفي عميق يصف هوية الشركة وقدراتها واحتياجاتها +""" + +import logging +import uuid +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import CompanyProfile +from app.services.llm.provider import get_llm + +logger = logging.getLogger("dealix.strategic_deals.company_twin") + + +# ── Node Models ───────────────────────────────────────────────────────────── + + +class CapabilityNode(BaseModel): + """A single capability that the company can offer to partners.""" + category: str = Field( + ..., + description="service, product, expertise, capacity, distribution, technology", + ) + name: str + name_ar: str + description: str = "" + capacity_available: float = Field( + default=0.5, + ge=0.0, le=1.0, + description="Spare capacity ratio: 0 = fully booked, 1 = fully available", + ) + quality_level: str = Field( + default="standard", + description="premium, standard, or budget", + ) + sectors_served: list[str] = Field(default_factory=list) + geographic_coverage: list[str] = Field( + default_factory=list, + description="Saudi administrative regions covered", + ) + + +class NeedNode(BaseModel): + """A single business need that the company is seeking from partners.""" + category: str = Field( + ..., + description="marketing, sales, delivery, technology, capital, distribution, talent", + ) + name: str + name_ar: str + urgency: str = Field( + default="medium", + description="critical, high, medium, or low", + ) + budget_range_sar: tuple[float, float] = Field( + default=(0.0, 0.0), + description="Min and max SAR budget for this need", + ) + preferred_deal_type: str = "" + description: str = "" + + +class AuthorityMatrix(BaseModel): + """Defines what the AI agent can commit to autonomously vs what requires human approval.""" + auto_approve: list[str] = Field( + default_factory=lambda: [ + "send_intro", + "share_capability_doc", + "schedule_call", + "answer_general_questions", + ], + ) + requires_approval: list[str] = Field( + default_factory=lambda: [ + "pricing_commitment", + "exclusivity", + "equity_discussion", + "revenue_share_terms", + "contract_duration", + ], + ) + forbidden: list[str] = Field( + default_factory=lambda: [ + "sign_contract", + "transfer_funds", + "share_financials", + "grant_data_access", + "commit_legal_terms", + ], + ) + max_commitment_sar: float = Field( + default=0.0, + description="Maximum SAR value the AI may discuss without escalation", + ) + identity_mode: str = Field( + default="transparent_ai", + description="transparent_ai, delegated_sender, or operator_shadow", + ) + + +class CompanyTwin(BaseModel): + """Complete digital twin of a company for the Dealix Deal Exchange OS.""" + twin_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + company_id: str + tenant_id: str + + # Identity + name: str + name_ar: str = "" + industry: str = "" + sub_industry: str = "" + cr_number: str = "" + website: str = "" + size: str = "" # micro, small, medium, large + annual_revenue_sar: float = 0.0 + + # Capability and Need Graphs + capabilities: list[CapabilityNode] = Field(default_factory=list) + needs: list[NeedNode] = Field(default_factory=list) + + # Authority + authority: AuthorityMatrix = Field(default_factory=AuthorityMatrix) + + # Deal preferences + deal_types_allowed: list[str] = Field(default_factory=list) + deal_types_blocked: list[str] = Field(default_factory=list) + sectors_preferred: list[str] = Field(default_factory=list) + sectors_blocked: list[str] = Field(default_factory=list) + min_deal_value_sar: float = 0.0 + max_deal_value_sar: float = 10_000_000.0 + + # Red lines — things AI must never agree to + red_lines: list[str] = Field(default_factory=list) + # Pre-approved marketing claims + approved_claims: list[str] = Field(default_factory=list) + + # Compliance + pdpl_consent_status: str = "pending" # granted, pending, revoked + whatsapp_opt_in: bool = False + email_opt_in: bool = False + + # Metadata + created_at: str = "" + updated_at: str = "" + + +# ── Size Heuristic ────────────────────────────────────────────────────────── + +_SIZE_THRESHOLDS = [ + (10, "micro"), + (50, "small"), + (250, "medium"), +] + + +def _infer_size(employee_count: Optional[float]) -> str: + if employee_count is None or employee_count <= 0: + return "small" + for threshold, label in _SIZE_THRESHOLDS: + if employee_count < threshold: + return label + return "large" + + +# ── Builder ───────────────────────────────────────────────────────────────── + + +class CompanyTwinBuilder: + """ + Constructs, enriches, and manages CompanyTwin instances. + يبني ويثري ويدير التوائم الرقمية للشركات + """ + + def __init__(self): + self.llm = get_llm() + + # ── Build Twin ────────────────────────────────────────────────────────── + + async def build_twin( + self, + company_data: dict, + user_description_ar: str, + db: AsyncSession, + ) -> CompanyTwin: + """ + Build a full CompanyTwin from profile data and an Arabic description. + بناء توأم رقمي كامل من بيانات الشركة ووصف عربي + """ + company_id = str(company_data.get("company_id", company_data.get("id", ""))) + tenant_id = str(company_data.get("tenant_id", "")) + name = company_data.get("company_name", company_data.get("name", "")) + industry = company_data.get("industry", "") + employee_count = company_data.get("employee_count") + + capabilities = await self.extract_capabilities( + description=user_description_ar, + industry=industry, + db=db, + ) + needs = await self.infer_needs( + description=user_description_ar, + capabilities=capabilities, + db=db, + ) + authority = await self.suggest_authority_matrix( + company_size=_infer_size(float(employee_count) if employee_count else None), + industry=industry, + ) + + now_iso = datetime.now(timezone.utc).isoformat() + twin = CompanyTwin( + company_id=company_id, + tenant_id=tenant_id, + name=name, + name_ar=company_data.get("company_name_ar", ""), + industry=industry, + sub_industry=company_data.get("sub_industry", ""), + cr_number=company_data.get("cr_number", ""), + website=company_data.get("website", ""), + size=_infer_size(float(employee_count) if employee_count else None), + annual_revenue_sar=float(company_data.get("annual_revenue_sar", 0) or 0), + capabilities=capabilities, + needs=needs, + authority=authority, + deal_types_allowed=company_data.get("deal_types_allowed", []), + deal_types_blocked=company_data.get("deal_types_blocked", []), + sectors_preferred=company_data.get("sectors_preferred", []), + sectors_blocked=company_data.get("sectors_blocked", []), + min_deal_value_sar=float(company_data.get("min_deal_value_sar", 0) or 0), + max_deal_value_sar=float(company_data.get("max_deal_value_sar", 10_000_000) or 10_000_000), + red_lines=company_data.get("red_lines", []), + approved_claims=company_data.get("approved_claims", []), + pdpl_consent_status=company_data.get("pdpl_consent_status", "pending"), + whatsapp_opt_in=company_data.get("whatsapp_opt_in", False), + email_opt_in=company_data.get("email_opt_in", False), + created_at=now_iso, + updated_at=now_iso, + ) + + # Persist the twin as JSONB on the company profile + profile_result = await db.execute( + select(CompanyProfile).where(CompanyProfile.id == company_id) + ) + profile = profile_result.scalar_one_or_none() + if profile: + existing = dict(profile.deal_preferences or {}) + existing["twin"] = twin.model_dump(mode="json") + profile.deal_preferences = existing + await db.flush() + + logger.info("Built CompanyTwin %s for company %s", twin.twin_id, company_id) + return twin + + # ── Extract Capabilities ──────────────────────────────────────────────── + + async def extract_capabilities( + self, + description: str, + industry: str, + db: AsyncSession, + ) -> list[CapabilityNode]: + """ + Extract structured capability nodes from an Arabic free-text description. + استخراج قدرات مهيكلة من وصف عربي حر + """ + if not description.strip(): + return [] + + system_prompt = """أنت محلل أعمال سعودي متخصص في تحليل قدرات الشركات. +حلل الوصف التالي واستخرج قدرات الشركة بشكل مهيكل. + +أعد النتائج بصيغة JSON: +{ + "capabilities": [ + { + "category": "service|product|expertise|capacity|distribution|technology", + "name": "Capability name in English", + "name_ar": "اسم القدرة بالعربي", + "description": "Brief description", + "capacity_available": 0.0 to 1.0, + "quality_level": "premium|standard|budget", + "sectors_served": ["sector1", "sector2"], + "geographic_coverage": ["الرياض", "المنطقة الشرقية"] + } + ] +} + +قواعد: +- استخرج 3-8 قدرات +- صنف كل قدرة بدقة +- قدر نسبة السعة المتاحة بناءً على السياق +- حدد المناطق الجغرافية إن أمكن +""" + + user_message = f"القطاع: {industry or 'غير محدد'}\n\nالوصف:\n{description}" + + try: + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=user_message, + json_mode=True, + temperature=0.3, + ) + result = llm_response.parse_json() + if not result or "capabilities" not in result: + return [] + nodes = [] + for cap_data in result["capabilities"]: + try: + node = CapabilityNode( + category=cap_data.get("category", "service"), + name=cap_data.get("name", ""), + name_ar=cap_data.get("name_ar", ""), + description=cap_data.get("description", ""), + capacity_available=float(cap_data.get("capacity_available", 0.5)), + quality_level=cap_data.get("quality_level", "standard"), + sectors_served=cap_data.get("sectors_served", []), + geographic_coverage=cap_data.get("geographic_coverage", []), + ) + nodes.append(node) + except Exception as exc: + logger.warning("Skipping malformed capability node: %s", exc) + logger.info("Extracted %d capability nodes from description", len(nodes)) + return nodes + except Exception as exc: + logger.error("Failed to extract capabilities: %s", exc) + return [] + + # ── Infer Needs ───────────────────────────────────────────────────────── + + async def infer_needs( + self, + description: str, + capabilities: list[CapabilityNode], + db: AsyncSession, + ) -> list[NeedNode]: + """ + Infer business needs from description and existing capabilities. + استنتاج احتياجات الشركة من الوصف والقدرات الحالية + """ + if not description.strip(): + return [] + + caps_summary = ", ".join(c.name for c in capabilities) if capabilities else "غير محدد" + + system_prompt = """أنت مستشار أعمال سعودي متخصص في تحليل احتياجات الشركات. +بناءً على الوصف والقدرات الحالية، حدد الاحتياجات التي يمكن أن تسدها شراكة B2B. + +أعد النتائج بصيغة JSON: +{ + "needs": [ + { + "category": "marketing|sales|delivery|technology|capital|distribution|talent", + "name": "Need name in English", + "name_ar": "اسم الاحتياج بالعربي", + "urgency": "critical|high|medium|low", + "budget_range_sar": [min_sar, max_sar], + "preferred_deal_type": "service_barter|referral_partnership|co_selling|subcontracting|etc", + "description": "وصف مختصر" + } + ] +} + +قواعد: +- حدد 2-6 احتياجات واقعية +- لا تكرر القدرات الموجودة كاحتياجات +- قدر مدى الميزانية بالريال السعودي حسب السياق +- اقترح نوع الصفقة المناسب لكل احتياج +""" + + user_message = f"القدرات الحالية: {caps_summary}\n\nالوصف:\n{description}" + + try: + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=user_message, + json_mode=True, + temperature=0.3, + ) + result = llm_response.parse_json() + if not result or "needs" not in result: + return [] + nodes = [] + for need_data in result["needs"]: + try: + budget = need_data.get("budget_range_sar", [0, 0]) + if isinstance(budget, list) and len(budget) == 2: + budget_tuple = (float(budget[0]), float(budget[1])) + else: + budget_tuple = (0.0, 0.0) + node = NeedNode( + category=need_data.get("category", "marketing"), + name=need_data.get("name", ""), + name_ar=need_data.get("name_ar", ""), + urgency=need_data.get("urgency", "medium"), + budget_range_sar=budget_tuple, + preferred_deal_type=need_data.get("preferred_deal_type", ""), + description=need_data.get("description", ""), + ) + nodes.append(node) + except Exception as exc: + logger.warning("Skipping malformed need node: %s", exc) + logger.info("Inferred %d need nodes from description", len(nodes)) + return nodes + except Exception as exc: + logger.error("Failed to infer needs: %s", exc) + return [] + + # ── Suggest Authority Matrix ──────────────────────────────────────────── + + async def suggest_authority_matrix( + self, + company_size: str, + industry: str, + ) -> AuthorityMatrix: + """ + Suggest an authority matrix based on company size and industry. + اقتراح مصفوفة صلاحيات بناءً على حجم الشركة والقطاع + """ + # Base policies by company size + size_policies = { + "micro": { + "max_commitment_sar": 5_000, + "identity_mode": "transparent_ai", + "auto_approve": [ + "send_intro", + "share_capability_doc", + "schedule_call", + "answer_general_questions", + ], + "requires_approval": [ + "pricing_commitment", + "exclusivity", + "equity_discussion", + "revenue_share_terms", + ], + "forbidden": [ + "sign_contract", + "transfer_funds", + "share_financials", + "grant_data_access", + ], + }, + "small": { + "max_commitment_sar": 25_000, + "identity_mode": "transparent_ai", + "auto_approve": [ + "send_intro", + "share_capability_doc", + "schedule_call", + "answer_general_questions", + "send_proposal_draft", + ], + "requires_approval": [ + "pricing_commitment", + "exclusivity", + "equity_discussion", + "revenue_share_terms", + "contract_duration", + ], + "forbidden": [ + "sign_contract", + "transfer_funds", + "share_financials", + "grant_data_access", + "commit_legal_terms", + ], + }, + "medium": { + "max_commitment_sar": 50_000, + "identity_mode": "delegated_sender", + "auto_approve": [ + "send_intro", + "share_capability_doc", + "schedule_call", + "answer_general_questions", + "send_proposal_draft", + "negotiate_minor_terms", + ], + "requires_approval": [ + "pricing_commitment", + "exclusivity", + "equity_discussion", + "revenue_share_terms", + "contract_duration", + "territory_expansion", + ], + "forbidden": [ + "sign_contract", + "transfer_funds", + "share_financials", + "grant_data_access", + "commit_legal_terms", + "share_client_data", + ], + }, + "large": { + "max_commitment_sar": 100_000, + "identity_mode": "delegated_sender", + "auto_approve": [ + "send_intro", + "share_capability_doc", + "schedule_call", + "answer_general_questions", + "send_proposal_draft", + "negotiate_minor_terms", + "send_nda_template", + ], + "requires_approval": [ + "pricing_commitment", + "exclusivity", + "equity_discussion", + "revenue_share_terms", + "contract_duration", + "territory_expansion", + "ip_licensing", + "joint_venture_terms", + ], + "forbidden": [ + "sign_contract", + "transfer_funds", + "share_financials", + "grant_data_access", + "commit_legal_terms", + "share_client_data", + "waive_liability", + ], + }, + } + + policy = size_policies.get(company_size, size_policies["small"]) + + # Industry-specific adjustments for regulated sectors + regulated_industries = {"finance", "healthcare", "energy", "government"} + if industry in regulated_industries: + policy["max_commitment_sar"] = min(policy["max_commitment_sar"], 10_000) + policy["forbidden"].extend([ + "discuss_regulatory_commitments", + "promise_compliance_outcomes", + ]) + # Deduplicate + policy["forbidden"] = list(set(policy["forbidden"])) + + matrix = AuthorityMatrix( + auto_approve=policy["auto_approve"], + requires_approval=policy["requires_approval"], + forbidden=policy["forbidden"], + max_commitment_sar=policy["max_commitment_sar"], + identity_mode=policy["identity_mode"], + ) + logger.info( + "Suggested authority matrix for %s %s company: max_commitment=%.0f SAR", + company_size, industry or "general", matrix.max_commitment_sar, + ) + return matrix + + # ── Update Twin ───────────────────────────────────────────────────────── + + async def update_twin( + self, + twin_id: str, + updates: dict, + db: AsyncSession, + ) -> CompanyTwin: + """ + Apply partial updates to an existing CompanyTwin. + تحديث جزئي للتوأم الرقمي + """ + twin = await self.get_twin_by_id(twin_id, db) + if not twin: + raise ValueError(f"التوأم الرقمي غير موجود: {twin_id}") + + twin_data = twin.model_dump(mode="json") + + # Apply updates, preserving existing values for keys not in updates + for key, value in updates.items(): + if key in twin_data and key not in ("twin_id", "company_id", "tenant_id", "created_at"): + twin_data[key] = value + + twin_data["updated_at"] = datetime.now(timezone.utc).isoformat() + updated_twin = CompanyTwin(**twin_data) + + # Persist + profile_result = await db.execute( + select(CompanyProfile).where(CompanyProfile.id == updated_twin.company_id) + ) + profile = profile_result.scalar_one_or_none() + if profile: + existing = dict(profile.deal_preferences or {}) + existing["twin"] = updated_twin.model_dump(mode="json") + profile.deal_preferences = existing + await db.flush() + + logger.info("Updated CompanyTwin %s", twin_id) + return updated_twin + + # ── Get Twin ──────────────────────────────────────────────────────────── + + async def get_twin( + self, + company_id: str, + db: AsyncSession, + ) -> Optional[CompanyTwin]: + """ + Retrieve the CompanyTwin for a given company. + استرجاع التوأم الرقمي لشركة معينة + """ + profile_result = await db.execute( + select(CompanyProfile).where(CompanyProfile.id == company_id) + ) + profile = profile_result.scalar_one_or_none() + if not profile: + logger.warning("Company profile not found: %s", company_id) + return None + + prefs = profile.deal_preferences or {} + twin_data = prefs.get("twin") + if not twin_data: + logger.info("No twin found for company %s", company_id) + return None + + try: + return CompanyTwin(**twin_data) + except Exception as exc: + logger.error("Failed to deserialize twin for company %s: %s", company_id, exc) + return None + + async def get_twin_by_id( + self, + twin_id: str, + db: AsyncSession, + ) -> Optional[CompanyTwin]: + """ + Retrieve a CompanyTwin by its twin_id (scans all profiles). + استرجاع التوأم الرقمي برقمه المعرف + """ + all_profiles = await db.execute(select(CompanyProfile)) + for profile in all_profiles.scalars(): + prefs = profile.deal_preferences or {} + twin_data = prefs.get("twin") + if twin_data and twin_data.get("twin_id") == twin_id: + try: + return CompanyTwin(**twin_data) + except Exception: + continue + return None + + # ── Deal Readiness Report ─────────────────────────────────────────────── + + async def get_deal_readiness_report( + self, + twin_id: str, + db: AsyncSession, + ) -> dict: + """ + Generate an Arabic deal-readiness report for the company twin. + إنشاء تقرير جاهزية الصفقات بالعربي للتوأم الرقمي + """ + twin = await self.get_twin_by_id(twin_id, db) + if not twin: + raise ValueError(f"التوأم الرقمي غير موجود: {twin_id}") + + issues: list[str] = [] + score = 0.0 + max_score = 100.0 + + # 1. Capabilities completeness (0-25) + cap_count = len(twin.capabilities) + if cap_count == 0: + issues.append("لم يتم تحديد أي قدرات للشركة — أضف قدراتك لتحسين فرص المطابقة") + cap_score = 0.0 + elif cap_count < 3: + issues.append(f"لديك {cap_count} قدرات فقط — يفضل إضافة 3 قدرات على الأقل") + cap_score = cap_count * 8.0 + else: + cap_score = 25.0 + score += cap_score + + # 2. Needs clarity (0-20) + need_count = len(twin.needs) + if need_count == 0: + issues.append("لم يتم تحديد أي احتياجات — حدد احتياجاتك ليتمكن النظام من إيجاد شركاء") + need_score = 0.0 + elif need_count < 2: + issues.append(f"لديك احتياج واحد فقط — أضف المزيد لتوسيع خيارات الشراكة") + need_score = 10.0 + else: + need_score = 20.0 + score += need_score + + # 3. Authority matrix configured (0-15) + authority_score = 0.0 + if twin.authority.max_commitment_sar > 0: + authority_score += 5.0 + else: + issues.append("لم يتم تحديد حد أقصى لصلاحيات الذكاء الاصطناعي") + if len(twin.authority.auto_approve) > 0: + authority_score += 5.0 + if len(twin.authority.forbidden) > 0: + authority_score += 5.0 + else: + issues.append("لم يتم تحديد الإجراءات المحظورة — مهم للحماية") + score += authority_score + + # 4. Compliance readiness (0-20) + compliance_score = 0.0 + if twin.pdpl_consent_status == "granted": + compliance_score += 10.0 + else: + issues.append("موافقة نظام حماية البيانات الشخصية (PDPL) غير مكتملة") + if twin.whatsapp_opt_in: + compliance_score += 5.0 + else: + issues.append("لم يتم تفعيل الموافقة على التواصل عبر واتساب") + if twin.email_opt_in: + compliance_score += 5.0 + else: + issues.append("لم يتم تفعيل الموافقة على التواصل عبر البريد الإلكتروني") + score += compliance_score + + # 5. Deal preferences set (0-10) + pref_score = 0.0 + if twin.deal_types_allowed: + pref_score += 5.0 + else: + issues.append("لم يتم تحديد أنواع الصفقات المسموحة") + if twin.red_lines: + pref_score += 5.0 + else: + issues.append("لم يتم تحديد الخطوط الحمراء — يُنصح بتحديدها لحماية مصالحك") + score += pref_score + + # 6. Profile completeness (0-10) + profile_score = 0.0 + if twin.cr_number: + profile_score += 3.0 + else: + issues.append("أضف رقم السجل التجاري لزيادة الموثوقية") + if twin.website: + profile_score += 2.0 + if twin.name_ar: + profile_score += 2.0 + if twin.annual_revenue_sar > 0: + profile_score += 3.0 + score += profile_score + + # Readiness level + if score >= 80: + readiness = "جاهز للصفقات" + readiness_level = "high" + elif score >= 50: + readiness = "يحتاج تحسين بسيط" + readiness_level = "medium" + else: + readiness = "يحتاج اهتمام عاجل" + readiness_level = "low" + + report = { + "twin_id": twin.twin_id, + "company_name": twin.name, + "company_name_ar": twin.name_ar, + "score": round(score, 1), + "max_score": max_score, + "readiness_level": readiness_level, + "readiness_label_ar": readiness, + "breakdown": { + "capabilities": round(cap_score, 1), + "needs": round(need_score, 1), + "authority": round(authority_score, 1), + "compliance": round(compliance_score, 1), + "deal_preferences": round(pref_score, 1), + "profile": round(profile_score, 1), + }, + "issues_ar": issues, + "summary_ar": ( + f"شركة {twin.name_ar or twin.name}: " + f"درجة الجاهزية {score:.0f}/100 — {readiness}. " + + (f"يوجد {len(issues)} ملاحظات تحتاج معالجة." if issues else "الملف مكتمل وجاهز.") + ), + } + + logger.info( + "Deal readiness report for twin %s: score=%.1f level=%s", + twin_id, score, readiness_level, + ) + return report diff --git a/salesflow-saas/backend/app/services/strategic_deals/deal_agent.py b/salesflow-saas/backend/app/services/strategic_deals/deal_agent.py new file mode 100644 index 00000000..e281d25d --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/deal_agent.py @@ -0,0 +1,596 @@ +""" +Deal Agent — Autonomous outreach agent for B2B deal discovery and engagement. +وكيل الصفقات: وكيل ذكي مستقل للتواصل واكتشاف الشراكات +""" + +import json +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import ( + CompanyProfile, StrategicDeal, DealMatch, + DealStatus, DealChannel, MatchStatus, DealType, +) +from app.services.llm.provider import get_llm + +logger = logging.getLogger("dealix.strategic_deals.agent") + + +# ── WhatsApp outreach templates (Arabic) ───────────────────────────────────── + +TEMPLATES = { + "introduction_collaborative": ( + "السلام عليكم {contact_name}\n\n" + "أتمنى تكون بخير وعافية. أنا {sender_name} من {company_name}.\n" + "اطلعت على أعمالكم في مجال {target_industry} وعجبني اللي تقدمونه.\n\n" + "عندنا خبرة في {our_capability} ونشوف فرصة تعاون مثمرة بيننا " + "خصوصاً في مجال {collaboration_area}.\n\n" + "هل ممكن نحدد وقت مناسب نتكلم فيه عن إمكانية التعاون؟\n\n" + "تحياتي" + ), + "introduction_as_ai": ( + "السلام عليكم {contact_name}\n\n" + "أنا مساعد ذكي من شركة {company_name}. فريقنا مهتم بالتعاون معكم " + "بناءً على تحليل التكامل بين خدماتنا.\n\n" + "شركة {company_name} تقدم {our_capability} وشفنا إن عندكم احتياج في " + "هذا المجال.\n\n" + "هل تحبون نرسل لكم تفاصيل أكثر عن فرصة التعاون؟\n\n" + "شكراً لوقتكم" + ), + "follow_up_1": ( + "مرحباً {contact_name}\n\n" + "تابعت معكم بخصوص موضوع التعاون اللي ذكرناه.\n" + "أبشركم جهزنا مقترح مبدئي يوضح كيف ممكن نستفيد من بعض.\n\n" + "هل تفضلون نرسله عبر الإيميل أو نحدد اجتماع قصير؟\n\n" + "تحياتي" + ), + "proposal_summary": ( + "حبيت أشاركك ملخص المقترح:\n\n" + "- نوع التعاون: {deal_type_ar}\n" + "- القيمة المتوقعة: {estimated_value}\n" + "- المدة: {duration}\n" + "- المنافع المشتركة: {mutual_benefits}\n\n" + "المقترح الكامل بالمرفق. ننتظر ملاحظاتكم.\n\n" + "شاكرين لكم" + ), + "negotiation_counter": ( + "شاكرين لكم على الرد والاهتمام {contact_name}.\n\n" + "نقدر وجهة نظركم. بعد دراسة مقترحكم، حبينا نقدم لكم عرض معدل:\n\n" + "{counter_details}\n\n" + "نتمنى يكون العرض مناسب ونتطلع لشراكة ناجحة.\n\n" + "تحياتي" + ), +} + +DEAL_TYPE_AR = { + "partnership": "شراكة استراتيجية", + "distribution": "توزيع", + "franchise": "امتياز تجاري", + "jv": "مشروع مشترك", + "referral": "إحالة", + "acquisition": "استحواذ", + "barter": "مقايضة", +} + + +@dataclass +class OutreachResult: + """Result of an outreach attempt.""" + success: bool = False + channel: str = "" + message_sent: str = "" + response_received: Optional[str] = None + interest_level: Optional[str] = None # high, medium, low, none + next_action: str = "" + next_action_ar: str = "" + error: Optional[str] = None + + +class DealAgent: + """ + Autonomous outreach agent that discovers, contacts, and qualifies B2B partners. + وكيل ذكي مستقل يكتشف ويتواصل ويؤهل الشركاء + """ + + def __init__(self): + self.llm = get_llm() + + # ── Outreach Campaign ──────────────────────────────────────────────────── + + async def run_outreach_campaign( + self, + deal_match_id, + channel: str, + db: AsyncSession, + ) -> OutreachResult: + """ + Execute multi-step outreach: research, craft intro, send, handle response. + تنفيذ حملة تواصل متعددة الخطوات + """ + # Load match and related profiles + match_result = await db.execute(select(DealMatch).where(DealMatch.id == deal_match_id)) + match = match_result.scalar_one_or_none() + if not match: + return OutreachResult(success=False, error="Match not found") + + a_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_a_id)) + company_a = a_result.scalar_one_or_none() + if not company_a: + return OutreachResult(success=False, error="Initiator profile not found") + + target_name = match.company_b_name or "الشركة المستهدفة" + target_industry = "" + target_contact = "" + company_b = None + + if match.company_b_id: + b_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_b_id)) + company_b = b_result.scalar_one_or_none() + if company_b: + target_name = company_b.company_name + target_industry = company_b.industry or "" + target_contact = company_b.whatsapp_number or "" + + # Step 1: Research the target + research = await self._research_target(company_a, company_b, match) + + # Step 2: Craft personalized introduction + style = "as_company" # Default to speaking as the company + intro_message = await self.craft_introduction( + match=match, + channel=channel, + style=style, + db=db, + ) + + # Step 3: Prepare outreach result (actual sending delegated to channel adapters) + # In production, this would call WhatsApp/LinkedIn/Email service + outreach = OutreachResult( + success=True, + channel=channel, + message_sent=intro_message, + next_action="await_response", + next_action_ar="انتظار الرد من الطرف الآخر", + ) + + # Step 4: Update match status + match.status = MatchStatus.OUTREACH_SENT.value + await db.flush() + + # Step 5: Create a strategic deal from this outreach + deal = StrategicDeal( + tenant_id=company_a.tenant_id, + initiator_profile_id=company_a.id, + target_profile_id=match.company_b_id, + target_company_name=target_name, + deal_type=match.deal_type_suggested or DealType.PARTNERSHIP.value, + deal_title=f"شراكة مع {target_name}", + deal_title_ar=f"شراكة مع {target_name}", + our_offer=research.get("our_value_proposition", ""), + our_need=research.get("what_we_need_from_them", ""), + proposed_terms=match.terms_suggested or {}, + status=DealStatus.OUTREACH.value, + channel=channel, + ai_confidence=match.match_score, + negotiation_history=[{ + "round": 0, + "action": "outreach", + "channel": channel, + "message": intro_message[:500], + "timestamp": datetime.now(timezone.utc).isoformat(), + }], + ) + db.add(deal) + await db.flush() + + logger.info( + "Outreach campaign executed for match %s via %s (deal %s created)", + deal_match_id, channel, deal.id, + ) + return outreach + + # ── Craft Introduction ─────────────────────────────────────────────────── + + async def craft_introduction( + self, + match: DealMatch, + channel: str, + style: str, + db: AsyncSession, + ) -> str: + """ + Generate a personalized Arabic introduction message. + إنشاء رسالة تعريفية عربية مخصصة + """ + a_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_a_id)) + company_a = a_result.scalar_one_or_none() + + target_name = match.company_b_name or "الشركة المستهدفة" + target_industry = "" + target_caps = [] + target_needs = [] + + if match.company_b_id: + b_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_b_id)) + company_b = b_result.scalar_one_or_none() + if company_b: + target_name = company_b.company_name + target_industry = company_b.industry or "" + target_caps = company_b.capabilities or [] + target_needs = company_b.needs or [] + elif match.company_b_data: + target_industry = match.company_b_data.get("industry", "") + target_caps = match.company_b_data.get("capabilities", []) + target_needs = match.company_b_data.get("needs", []) + + # Channel-specific length guidance + length_guidance = { + "whatsapp": "اكتب رسالة قصيرة ومباشرة (3-5 أسطر). لا تكتب أكثر من 300 حرف.", + "email": "اكتب رسالة مفصلة ومهنية (8-12 سطر) مع سطر موضوع.", + "linkedin": "اكتب رسالة قصيرة ومهنية (4-6 أسطر).", + "in_person": "جهز نقاط حديث مختصرة (5-7 نقاط).", + } + + style_guidance = { + "as_company": "تكلم باسم الشركة مباشرة (نحن في شركة X...)", + "as_ai": "كن شفافاً أنك مساعد ذكي (أنا مساعد ذكي من شركة X...)", + } + + context = f"""Our company: {company_a.company_name if company_a else 'unknown'} +Our capabilities: {', '.join((company_a.capabilities or [])[:5]) if company_a else 'unknown'} +Target company: {target_name} +Target industry: {target_industry} +Target capabilities: {', '.join(target_caps[:5])} +Target needs: {', '.join(target_needs[:5])} +Match reasons: {', '.join(match.match_reasons or [])} +Match score: {match.match_score} +Suggested deal type: {match.deal_type_suggested}""" + + system_prompt = f"""أنت كاتب رسائل أعمال سعودي محترف. +اكتب رسالة تعريفية للتواصل مع شركة محتملة للتعاون. + +Channel: {channel} +{length_guidance.get(channel, length_guidance['whatsapp'])} + +Style: {style} +{style_guidance.get(style, style_guidance['as_company'])} + +قواعد مهمة: +- ابدأ بالسلام +- اذكر سبب التواصل بوضوح +- أبرز نقطة التكامل بين الشركتين +- اختم بطلب واضح (اجتماع، مكالمة، تفاصيل أكثر) +- لا تبالغ في المدح +- كن مهنياً وودوداً + +Return the message directly as text (not JSON).""" + + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + temperature=0.6, + ) + message = llm_response.content.strip() + + logger.info("Crafted introduction for match %s via %s", match.id, channel) + return message + + # ── Handle Response ────────────────────────────────────────────────────── + + async def handle_response( + self, + deal_id, + message: str, + channel: str, + db: AsyncSession, + ) -> str: + """ + Analyze incoming response and generate appropriate follow-up. + تحليل الرد الوارد وتوليد متابعة مناسبة + """ + deal_result = await db.execute(select(StrategicDeal).where(StrategicDeal.id == deal_id)) + deal = deal_result.scalar_one_or_none() + if not deal: + raise ValueError(f"Deal {deal_id} not found") + + # Load initiator profile + initiator = None + if deal.initiator_profile_id: + init_result = await db.execute( + select(CompanyProfile).where(CompanyProfile.id == deal.initiator_profile_id) + ) + initiator = init_result.scalar_one_or_none() + + history_summary = "" + for h in (deal.negotiation_history or [])[-3:]: + history_summary += f"- {h.get('action', '?')}: {h.get('message', '')[:100]}\n" + + context = f"""Deal: {deal.deal_title} +Our company: {initiator.company_name if initiator else 'unknown'} +Target: {deal.target_company_name or 'unknown'} +Channel: {channel} +Current status: {deal.status} +Recent conversation: +{history_summary} + +Incoming message: {message}""" + + system_prompt = """أنت مساعد أعمال سعودي. حلل الرسالة الواردة وحدد نوعها وقدم رداً مناسباً. + +أولاً حلل الرسالة: +- اهتمام (interest): الطرف الآخر مهتم +- اعتراض (objection): عنده تحفظات +- سؤال (question): يحتاج معلومات إضافية +- رفض (rejection): غير مهتم +- طلب معلومات (request_for_info): يريد تفاصيل أكثر + +ثم اكتب رداً مناسباً: +- إذا مهتم: حدد الخطوة التالية (اجتماع، مقترح) +- إذا متحفظ: عالج التحفظ بلطف +- إذا عنده سؤال: أجب بوضوح +- إذا رافض: اشكره واترك الباب مفتوحاً +- إذا يبي تفاصيل: وعده بإرسالها + +Return JSON: +{ + "response_type": "interest/objection/question/rejection/request_for_info", + "interest_level": "high/medium/low/none", + "response_message": "الرد بالعربي", + "next_action": "schedule_meeting/send_proposal/send_info/follow_up_later/close", + "next_action_ar": "الخطوة التالية بالعربي" +}""" + + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + json_mode=True, + temperature=0.4, + ) + result = llm_response.parse_json() or {} + + response_msg = result.get("response_message", "شكراً لردكم، سنتواصل معكم قريباً.") + interest = result.get("interest_level", "medium") + next_action = result.get("next_action", "follow_up_later") + + # Update deal based on response + if interest == "high" or next_action == "schedule_meeting": + deal.status = DealStatus.NEGOTIATING.value + elif interest == "none" or next_action == "close": + deal.status = DealStatus.CLOSED_LOST.value + deal.closed_at = datetime.now(timezone.utc) + + # Log in negotiation history + history = list(deal.negotiation_history or []) + history.append({ + "round": len(history) + 1, + "action": "response_handling", + "their_message": message[:500], + "our_response": response_msg[:500], + "response_type": result.get("response_type", "unknown"), + "interest_level": interest, + "next_action": next_action, + "channel": channel, + "timestamp": datetime.now(timezone.utc).isoformat(), + }) + deal.negotiation_history = history + await db.flush() + + logger.info( + "Handled response for deal %s: type=%s, interest=%s", + deal_id, result.get("response_type"), interest, + ) + return response_msg + + # ── Generate Proposal ──────────────────────────────────────────────────── + + async def generate_proposal( + self, + deal_id, + db: AsyncSession, + ) -> dict: + """ + Generate a full Arabic business proposal document. + إنشاء مقترح أعمال عربي كامل + """ + deal_result = await db.execute(select(StrategicDeal).where(StrategicDeal.id == deal_id)) + deal = deal_result.scalar_one_or_none() + if not deal: + raise ValueError(f"Deal {deal_id} not found") + + initiator = None + if deal.initiator_profile_id: + init_result = await db.execute( + select(CompanyProfile).where(CompanyProfile.id == deal.initiator_profile_id) + ) + initiator = init_result.scalar_one_or_none() + + target_name = deal.target_company_name or "الطرف الآخر" + target = None + if deal.target_profile_id: + t_result = await db.execute( + select(CompanyProfile).where(CompanyProfile.id == deal.target_profile_id) + ) + target = t_result.scalar_one_or_none() + if target: + target_name = target.company_name + + context = f"""Our company: {initiator.company_name if initiator else 'unknown'} +Our industry: {initiator.industry if initiator else 'unknown'} +Our capabilities: {', '.join((initiator.capabilities or [])[:8]) if initiator else 'unknown'} + +Target company: {target_name} +Target industry: {target.industry if target else 'unknown'} +Target capabilities: {', '.join((target.capabilities or [])[:8]) if target else 'unknown'} +Target needs: {', '.join((target.needs or [])[:8]) if target else 'unknown'} + +Deal: {deal.deal_title} +Deal type: {deal.deal_type} +Our offer: {deal.our_offer or 'TBD'} +Our need: {deal.our_need or 'TBD'} +Proposed terms: {json.dumps(deal.proposed_terms or {}, ensure_ascii=False)} +Estimated value: {deal.estimated_value_sar or 'TBD'} SAR""" + + system_prompt = """أنت كاتب مقترحات أعمال سعودي محترف. +أنشئ مقترح أعمال شامل ومهني باللغة العربية. + +Return JSON: +{ + "title_ar": "عنوان المقترح", + "executive_summary_ar": "الملخص التنفيذي (3-5 جمل)", + "about_us_ar": "نبذة عن شركتنا", + "understanding_your_needs_ar": "فهمنا لاحتياجاتكم", + "proposed_solution_ar": "الحل المقترح", + "our_capabilities_ar": ["قدرة 1", "قدرة 2"], + "mutual_benefits_ar": ["منفعة مشتركة 1", "منفعة مشتركة 2"], + "deal_structure_ar": "هيكل الصفقة", + "financial_overview_ar": "النظرة المالية", + "timeline_ar": [ + {"phase_ar": "المرحلة", "duration_ar": "المدة", "deliverables_ar": "المخرجات"} + ], + "success_metrics_ar": ["مؤشر نجاح 1", "مؤشر نجاح 2"], + "risks_and_mitigations_ar": [ + {"risk_ar": "المخاطرة", "mitigation_ar": "التخفيف"} + ], + "next_steps_ar": ["خطوة 1", "خطوة 2"], + "closing_statement_ar": "الخاتمة" +}""" + + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + json_mode=True, + temperature=0.4, + ) + proposal = llm_response.parse_json() or {} + + logger.info("Generated proposal for deal %s", deal_id) + return proposal + + # ── Discovery Scan ─────────────────────────────────────────────────────── + + async def run_discovery_scan( + self, + profile_id, + deal_type: Optional[str], + db: AsyncSession, + ) -> list[DealMatch]: + """ + Full autonomous scan: analyze profile, find matches, generate outreach plan. + فحص مستقل كامل: تحليل الملف، إيجاد مطابقات، تجهيز خطة تواصل + """ + from app.services.strategic_deals.company_profiler import CompanyProfiler + from app.services.strategic_deals.deal_matcher import DealMatcher + + profiler = CompanyProfiler() + matcher = DealMatcher() + + # Step 1: Enrich profile if needed + prof_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id)) + profile = prof_result.scalar_one_or_none() + if not profile: + raise ValueError(f"Profile {profile_id} not found") + + if not profile.capabilities or len(profile.capabilities) < 2: + logger.info("Discovery scan: enriching profile %s first", profile_id) + await profiler.enrich_profile(profile_id, db) + + # Step 2: Analyze capabilities if thin + if not profile.capabilities or len(profile.capabilities) < 3: + await profiler.analyze_capabilities(profile_id, db) + + # Step 3: Find matches + matches = await matcher.find_matches( + profile_id=profile_id, + deal_type=deal_type, + db=db, + limit=10, + ) + + # Step 4: Generate deal structure suggestions for top matches + for match in matches[:3]: + try: + await matcher.suggest_deal_structure(match.id, db) + except Exception as e: + logger.warning("Could not suggest structure for match %s: %s", match.id, e) + + # Step 5: Generate Arabic summary + if matches: + summary_parts = [f"تم العثور على {len(matches)} فرصة شراكة محتملة:"] + for i, m in enumerate(matches[:5], 1): + target_name = m.company_b_name or "شركة" + if m.company_b_id: + b_res = await db.execute( + select(CompanyProfile).where(CompanyProfile.id == m.company_b_id) + ) + b_prof = b_res.scalar_one_or_none() + if b_prof: + target_name = b_prof.company_name + reasons = ", ".join((m.match_reasons or [])[:2]) + summary_parts.append( + f"{i}. {target_name} (نسبة التوافق: {m.match_score:.0%}) — {reasons}" + ) + summary = "\n".join(summary_parts) + logger.info("Discovery scan summary:\n%s", summary) + + logger.info("Discovery scan complete for profile %s: %d matches", profile_id, len(matches)) + return matches + + # ── Private Helpers ────────────────────────────────────────────────────── + + async def _research_target( + self, + company_a: CompanyProfile, + company_b: Optional[CompanyProfile], + match: DealMatch, + ) -> dict: + """Research the target company to personalize outreach.""" + target_name = company_b.company_name if company_b else (match.company_b_name or "unknown") + target_industry = company_b.industry if company_b else "" + target_caps = ", ".join((company_b.capabilities or [])[:5]) if company_b else "" + target_needs = ", ".join((company_b.needs or [])[:5]) if company_b else "" + + context = f"""Our company: {company_a.company_name} +Our capabilities: {', '.join((company_a.capabilities or [])[:5])} +Our needs: {', '.join((company_a.needs or [])[:5])} + +Target: {target_name} +Industry: {target_industry} +Capabilities: {target_caps} +Needs: {target_needs} +Match score: {match.match_score} +Match reasons: {', '.join(match.match_reasons or [])}""" + + system_prompt = """أنت باحث أعمال. حلل الشركة المستهدفة وجهز نقاط للتواصل. + +Return JSON: +{ + "our_value_proposition": "ما نقدمه لهم بجملة واحدة", + "what_we_need_from_them": "ما نحتاجه منهم بجملة واحدة", + "key_talking_points_ar": ["نقطة حوار 1", "نقطة حوار 2"], + "potential_objections_ar": ["اعتراض محتمل 1"], + "recommended_approach_ar": "النهج الموصى به" +}""" + + try: + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + json_mode=True, + temperature=0.3, + fast=True, + ) + return llm_response.parse_json() or {} + except Exception as e: + logger.warning("Target research failed: %s", e) + return { + "our_value_proposition": "", + "what_we_need_from_them": "", + "key_talking_points_ar": [], + "potential_objections_ar": [], + "recommended_approach_ar": "", + } diff --git a/salesflow-saas/backend/app/services/strategic_deals/deal_matcher.py b/salesflow-saas/backend/app/services/strategic_deals/deal_matcher.py new file mode 100644 index 00000000..bf0ec563 --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/deal_matcher.py @@ -0,0 +1,572 @@ +""" +Deal Matcher — AI-powered B2B matching engine. +محرك المطابقة: يجد الشركاء المثاليين باستخدام الذكاء الاصطناعي +""" + +import json +import logging +from dataclasses import dataclass, field +from typing import Optional + +from sqlalchemy import select, and_, or_, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import ( + CompanyProfile, DealMatch, MatchStatus, DealType, +) +from app.services.llm.provider import get_llm + +logger = logging.getLogger("dealix.strategic_deals.matcher") + +# ── Matching weights ───────────────────────────────────────────────────────── + +MATCH_WEIGHTS = { + "capability_complementarity": 0.30, + "need_alignment": 0.25, + "industry_fit": 0.15, + "geographic_fit": 0.10, + "size_compatibility": 0.10, + "trust_score": 0.10, +} + +# Industry adjacency: industries that typically partner together +INDUSTRY_ADJACENCY = { + "technology": ["consulting", "finance", "healthcare", "education", "retail"], + "construction": ["real_estate", "manufacturing", "logistics", "energy"], + "real_estate": ["construction", "finance", "marketing"], + "retail": ["wholesale", "logistics", "marketing", "technology"], + "wholesale": ["retail", "manufacturing", "logistics"], + "healthcare": ["technology", "consulting", "manufacturing"], + "education": ["technology", "consulting", "media"], + "food_beverage": ["logistics", "retail", "agriculture", "tourism"], + "logistics": ["retail", "wholesale", "manufacturing", "food_beverage"], + "finance": ["technology", "real_estate", "consulting"], + "energy": ["construction", "manufacturing", "technology"], + "tourism": ["food_beverage", "marketing", "media"], + "consulting": ["technology", "finance", "healthcare", "education"], + "marketing": ["technology", "media", "retail", "tourism"], + "agriculture": ["food_beverage", "logistics", "manufacturing"], + "telecom": ["technology", "media", "consulting"], + "media": ["marketing", "technology", "telecom", "tourism"], + "automotive": ["manufacturing", "logistics", "finance"], + "manufacturing": ["construction", "wholesale", "logistics", "energy", "automotive"], + "government": ["technology", "consulting", "construction"], +} + +# Regions that commonly trade together +REGION_PROXIMITY = { + "الرياض": ["القصيم", "المنطقة الشرقية"], + "مكة المكرمة": ["المدينة المنورة", "الباحة", "عسير"], + "المنطقة الشرقية": ["الرياض", "الحدود الشمالية"], + "المدينة المنورة": ["مكة المكرمة", "تبوك"], + "القصيم": ["الرياض", "حائل"], + "عسير": ["مكة المكرمة", "جازان", "نجران", "الباحة"], + "تبوك": ["المدينة المنورة", "الجوف"], + "حائل": ["القصيم", "الحدود الشمالية", "الجوف"], + "الحدود الشمالية": ["حائل", "الجوف", "المنطقة الشرقية"], + "جازان": ["عسير", "نجران"], + "نجران": ["عسير", "جازان"], + "الباحة": ["عسير", "مكة المكرمة"], + "الجوف": ["تبوك", "الحدود الشمالية", "حائل"], +} + + +@dataclass +class MatchScore: + """Detailed breakdown of a match score.""" + total: float = 0.0 + capability_complementarity: float = 0.0 + need_alignment: float = 0.0 + industry_fit: float = 0.0 + geographic_fit: float = 0.0 + size_compatibility: float = 0.0 + trust_score: float = 0.0 + reasons_ar: list[str] = field(default_factory=list) + reasons_en: list[str] = field(default_factory=list) + + +class DealMatcher: + """ + AI-powered B2B matching engine that finds optimal partners. + محرك مطابقة بالذكاء الاصطناعي يجد الشركاء المثاليين + """ + + def __init__(self): + self.llm = get_llm() + + # ── Find Matches ───────────────────────────────────────────────────────── + + async def find_matches( + self, + profile_id, + deal_type: Optional[str], + db: AsyncSession, + limit: int = 10, + ) -> list[DealMatch]: + """ + Score and rank potential matches for a company profile. + تقييم وترتيب المطابقات المحتملة لملف شركة + """ + result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id)) + source = result.scalar_one_or_none() + if not source: + raise ValueError(f"Profile {profile_id} not found") + + # Fetch candidate profiles from the same tenant, excluding self + candidates_q = select(CompanyProfile).where( + CompanyProfile.tenant_id == source.tenant_id, + CompanyProfile.id != profile_id, + CompanyProfile.is_verified == True, # noqa: E712 + ) + candidates_result = await db.execute(candidates_q) + candidates = candidates_result.scalars().all() + + if not candidates: + logger.info("No verified candidate profiles found for tenant %s", source.tenant_id) + return [] + + # Score each candidate + scored: list[tuple[CompanyProfile, MatchScore]] = [] + for candidate in candidates: + match_score = await self.score_match( + company_a=source, + company_b=candidate, + deal_type=deal_type, + ) + if match_score.total >= 0.2: # Minimum threshold + scored.append((candidate, match_score)) + + # Sort by score descending, take top N + scored.sort(key=lambda x: x[1].total, reverse=True) + scored = scored[:limit] + + # Persist matches + matches = [] + for candidate, ms in scored: + match = DealMatch( + tenant_id=source.tenant_id, + company_a_id=source.id, + company_b_id=candidate.id, + match_score=round(ms.total, 4), + match_reasons=ms.reasons_ar, + deal_type_suggested=deal_type or self._suggest_deal_type(source, candidate), + terms_suggested={}, + status=MatchStatus.SUGGESTED.value, + ) + db.add(match) + matches.append(match) + + await db.flush() + for m in matches: + await db.refresh(m) + + logger.info( + "Found %d matches for profile %s (from %d candidates)", + len(matches), profile_id, len(candidates), + ) + return matches + + # ── Detailed Scoring ───────────────────────────────────────────────────── + + async def score_match( + self, + company_a: CompanyProfile, + company_b: CompanyProfile, + deal_type: Optional[str] = None, + ) -> MatchScore: + """ + Compute detailed match score between two companies. + حساب درجة المطابقة التفصيلية بين شركتين + """ + ms = MatchScore() + + # 1. Capability complementarity (0.30): A offers what B needs + cap_score = self._score_overlap( + company_a.capabilities or [], + company_b.needs or [], + ) + ms.capability_complementarity = cap_score + if cap_score > 0.5: + ms.reasons_ar.append( + f"شركة {company_a.company_name} تقدم خدمات تحتاجها شركة {company_b.company_name}" + ) + ms.reasons_en.append( + f"{company_a.company_name} offers what {company_b.company_name} needs" + ) + + # 2. Need alignment (0.25): B offers what A needs + need_score = self._score_overlap( + company_b.capabilities or [], + company_a.needs or [], + ) + ms.need_alignment = need_score + if need_score > 0.5: + ms.reasons_ar.append( + f"شركة {company_b.company_name} تقدم خدمات تحتاجها شركة {company_a.company_name}" + ) + ms.reasons_en.append( + f"{company_b.company_name} offers what {company_a.company_name} needs" + ) + + # 3. Industry fit (0.15): same value chain or adjacent + ind_score = self._score_industry_fit(company_a.industry, company_b.industry) + ms.industry_fit = ind_score + if ind_score > 0.5: + ms.reasons_ar.append("القطاعان متكاملان في سلسلة القيمة") + + # 4. Geographic fit (0.10): same region or complementary + geo_score = self._score_geographic_fit(company_a.region, company_b.region) + ms.geographic_fit = geo_score + if geo_score >= 1.0: + ms.reasons_ar.append(f"الشركتان في نفس المنطقة: {company_a.region}") + elif geo_score >= 0.7: + ms.reasons_ar.append("الشركتان في مناطق متقاربة") + + # 5. Size compatibility (0.10): appropriate size ratio + size_score = self._score_size_compatibility(company_a, company_b) + ms.size_compatibility = size_score + if size_score > 0.7: + ms.reasons_ar.append("حجم الشركتين متناسب للشراكة") + + # 6. Trust score (0.10): verification and history + trust_a = company_a.trust_score or 0.0 + trust_b = company_b.trust_score or 0.0 + ms.trust_score = (trust_a + trust_b) / 2.0 + if ms.trust_score > 0.7: + ms.reasons_ar.append("كلا الشركتين حاصلتان على درجة ثقة عالية") + + # If keyword overlap is low, use LLM for semantic matching + if cap_score < 0.3 and need_score < 0.3: + semantic = await self._semantic_match(company_a, company_b, deal_type) + ms.capability_complementarity = max(ms.capability_complementarity, semantic.get("cap", 0)) + ms.need_alignment = max(ms.need_alignment, semantic.get("need", 0)) + if semantic.get("reason_ar"): + ms.reasons_ar.append(semantic["reason_ar"]) + + # Weighted total + ms.total = ( + ms.capability_complementarity * MATCH_WEIGHTS["capability_complementarity"] + + ms.need_alignment * MATCH_WEIGHTS["need_alignment"] + + ms.industry_fit * MATCH_WEIGHTS["industry_fit"] + + ms.geographic_fit * MATCH_WEIGHTS["geographic_fit"] + + ms.size_compatibility * MATCH_WEIGHTS["size_compatibility"] + + ms.trust_score * MATCH_WEIGHTS["trust_score"] + ) + ms.total = round(min(1.0, ms.total), 4) + + return ms + + # ── Suggest Deal Structure ─────────────────────────────────────────────── + + async def suggest_deal_structure( + self, + match_id, + db: AsyncSession, + ) -> dict: + """ + AI suggests deal type, key terms, pricing, timeline. + الذكاء الاصطناعي يقترح هيكل الصفقة + """ + result = await db.execute(select(DealMatch).where(DealMatch.id == match_id)) + match = result.scalar_one_or_none() + if not match: + raise ValueError(f"Match {match_id} not found") + + # Load both company profiles + a_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_a_id)) + company_a = a_result.scalar_one_or_none() + + company_b = None + if match.company_b_id: + b_result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == match.company_b_id)) + company_b = b_result.scalar_one_or_none() + + company_b_name = company_b.company_name if company_b else (match.company_b_name or "شركة خارجية") + company_b_caps = company_b.capabilities if company_b else [] + company_b_needs = company_b.needs if company_b else [] + + context = f"""Company A: {company_a.company_name} +Industry: {company_a.industry or 'unknown'} +Capabilities: {', '.join(company_a.capabilities or [])} +Needs: {', '.join(company_a.needs or [])} +Revenue SAR: {company_a.annual_revenue_sar or 'unknown'} + +Company B: {company_b_name} +Industry: {(company_b.industry if company_b else 'unknown')} +Capabilities: {', '.join(company_b_caps)} +Needs: {', '.join(company_b_needs)} + +Match score: {match.match_score} +Match reasons: {', '.join(match.match_reasons or [])} +Suggested deal type: {match.deal_type_suggested}""" + + system_prompt = """أنت مستشار صفقات استراتيجية سعودي خبير. اقترح هيكل صفقة مفصل بين الشركتين. + +Return JSON: +{ + "deal_type": "partnership/distribution/franchise/jv/referral/acquisition/barter", + "deal_title_ar": "عنوان الصفقة بالعربي", + "deal_title_en": "Deal title in English", + "proposed_terms": { + "equity_split": "50/50 or other", + "revenue_share": "percentage or fixed", + "territory": "geographic scope", + "exclusivity": true/false, + "duration_months": 12, + "payment_terms": "description", + "kpis": ["key performance indicators"] + }, + "estimated_value_sar": 0, + "timeline": { + "negotiation_weeks": 2, + "due_diligence_weeks": 4, + "launch_weeks": 8 + }, + "mutual_benefits_ar": ["المنفعة المشتركة 1", "المنفعة المشتركة 2"], + "risks_ar": ["المخاطر المحتملة"], + "proposal_summary_ar": "ملخص المقترح بالعربي", + "next_steps_ar": ["الخطوة التالية 1", "الخطوة التالية 2"] +}""" + + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + json_mode=True, + temperature=0.4, + ) + structure = llm_response.parse_json() or {} + + # Persist suggested terms on the match + if structure.get("proposed_terms"): + match.terms_suggested = structure + await db.flush() + + logger.info("Suggested deal structure for match %s", match_id) + return structure + + # ── Barter Chain Discovery ─────────────────────────────────────────────── + + async def find_barter_chains( + self, + profile_id, + db: AsyncSession, + ) -> list[list[dict]]: + """ + Discover multi-party barter opportunities: A->B->C->A circular trades. + اكتشاف فرص المقايضة المتعددة الأطراف: سلاسل تبادل دائرية + شركتك عندها تسويق، الشركة ب تحتاج تسويق وعندها مساحات، + والشركة ج تحتاج مساحات وعندها تطوير برمجي اللي أنت تحتاجه + """ + result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id)) + source = result.scalar_one_or_none() + if not source: + raise ValueError(f"Profile {profile_id} not found") + + source_caps = set(c.lower() for c in (source.capabilities or [])) + source_needs = set(n.lower() for n in (source.needs or [])) + + if not source_caps or not source_needs: + return [] + + # Fetch all profiles in tenant + all_q = select(CompanyProfile).where( + CompanyProfile.tenant_id == source.tenant_id, + CompanyProfile.id != profile_id, + ) + all_result = await db.execute(all_q) + all_profiles = all_result.scalars().all() + + # Build capability/need index + profiles_by_cap: dict[str, list[CompanyProfile]] = {} + profiles_by_need: dict[str, list[CompanyProfile]] = {} + for p in all_profiles: + for cap in (p.capabilities or []): + profiles_by_cap.setdefault(cap.lower(), []).append(p) + for need in (p.needs or []): + profiles_by_need.setdefault(need.lower(), []).append(p) + + chains: list[list[dict]] = [] + + # Find 3-party chains: source->B->C->source + for source_cap in source_caps: + # B needs what source offers + for b_profile in profiles_by_need.get(source_cap, []): + b_caps = set(c.lower() for c in (b_profile.capabilities or [])) + for b_cap in b_caps: + # C needs what B offers and C has what source needs + for c_profile in profiles_by_need.get(b_cap, []): + if c_profile.id == source.id or c_profile.id == b_profile.id: + continue + c_caps = set(c.lower() for c in (c_profile.capabilities or [])) + # Does C offer what source needs? + overlap = c_caps & source_needs + if overlap: + chain = [ + { + "company_id": str(source.id), + "company_name": source.company_name, + "offers": source_cap, + "receives": list(overlap)[0], + }, + { + "company_id": str(b_profile.id), + "company_name": b_profile.company_name, + "offers": b_cap, + "receives": source_cap, + }, + { + "company_id": str(c_profile.id), + "company_name": c_profile.company_name, + "offers": list(overlap)[0], + "receives": b_cap, + }, + ] + chains.append(chain) + if len(chains) >= 10: + break + if len(chains) >= 10: + break + if len(chains) >= 10: + break + if len(chains) >= 10: + break + + # Deduplicate chains by company set + seen_sets: set[frozenset] = set() + unique_chains = [] + for chain in chains: + company_set = frozenset(link["company_id"] for link in chain) + if company_set not in seen_sets: + seen_sets.add(company_set) + unique_chains.append(chain) + + logger.info( + "Found %d barter chains for profile %s", len(unique_chains), profile_id, + ) + return unique_chains + + # ── Private Helpers ────────────────────────────────────────────────────── + + def _score_overlap(self, offers: list[str], needs: list[str]) -> float: + """Score overlap between what one company offers and another needs.""" + if not offers or not needs: + return 0.0 + offers_lower = {o.lower().strip() for o in offers} + needs_lower = {n.lower().strip() for n in needs} + if not needs_lower: + return 0.0 + overlap = offers_lower & needs_lower + # Also check partial substring matches + partial = 0 + for o in offers_lower: + for n in needs_lower: + if n not in overlap and o not in overlap: + if o in n or n in o: + partial += 0.5 + total_matches = len(overlap) + partial + return min(1.0, total_matches / len(needs_lower)) + + def _score_industry_fit(self, ind_a: Optional[str], ind_b: Optional[str]) -> float: + """Score industry compatibility.""" + if not ind_a or not ind_b: + return 0.3 # Unknown = neutral + if ind_a == ind_b: + return 1.0 + adjacent = INDUSTRY_ADJACENCY.get(ind_a, []) + if ind_b in adjacent: + return 0.7 + # Check reverse + adjacent_b = INDUSTRY_ADJACENCY.get(ind_b, []) + if ind_a in adjacent_b: + return 0.7 + return 0.2 + + def _score_geographic_fit(self, region_a: Optional[str], region_b: Optional[str]) -> float: + """Score geographic proximity.""" + if not region_a or not region_b: + return 0.5 # Unknown = neutral + if region_a == region_b: + return 1.0 + nearby = REGION_PROXIMITY.get(region_a, []) + if region_b in nearby: + return 0.7 + return 0.3 + + def _score_size_compatibility( + self, a: CompanyProfile, b: CompanyProfile, + ) -> float: + """Score size compatibility for deals. Very large + very small = low fit.""" + emp_a = float(a.employee_count or 0) + emp_b = float(b.employee_count or 0) + if emp_a == 0 or emp_b == 0: + return 0.5 # Unknown = neutral + ratio = min(emp_a, emp_b) / max(emp_a, emp_b) + # Ratios > 0.1 are generally workable + if ratio >= 0.3: + return 1.0 + elif ratio >= 0.1: + return 0.7 + elif ratio >= 0.01: + return 0.4 + return 0.2 + + def _suggest_deal_type(self, a: CompanyProfile, b: CompanyProfile) -> str: + """Heuristic deal-type suggestion based on profiles.""" + prefs_a = a.deal_preferences or {} + prefs_b = b.deal_preferences or {} + + # Find mutually preferred deal type + all_types = set(list(prefs_a.keys()) + list(prefs_b.keys())) + if all_types: + best_type = max( + all_types, + key=lambda t: (prefs_a.get(t, 0) + prefs_b.get(t, 0)), + ) + return best_type + + # Default based on industry relationship + if a.industry == b.industry: + return DealType.REFERRAL.value + return DealType.PARTNERSHIP.value + + async def _semantic_match( + self, + company_a: CompanyProfile, + company_b: CompanyProfile, + deal_type: Optional[str], + ) -> dict: + """Use LLM for semantic matching when keyword overlap is low.""" + context = f"""Company A: {company_a.company_name} +Capabilities: {', '.join(company_a.capabilities or ['unknown'])} +Needs: {', '.join(company_a.needs or ['unknown'])} +Industry: {company_a.industry or 'unknown'} + +Company B: {company_b.company_name} +Capabilities: {', '.join(company_b.capabilities or ['unknown'])} +Needs: {', '.join(company_b.needs or ['unknown'])} +Industry: {company_b.industry or 'unknown'} + +Deal type: {deal_type or 'any'}""" + + system_prompt = """أنت محلل مطابقة بين الشركات. قيم مدى تكامل هاتين الشركتين. + +Return JSON: +{ + "cap": 0.0 to 1.0 (capability complementarity), + "need": 0.0 to 1.0 (need alignment), + "reason_ar": "سبب التكامل بالعربي أو null إذا لا يوجد تكامل" +}""" + + try: + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + json_mode=True, + temperature=0.2, + fast=True, + ) + result = llm_response.parse_json() + return result if result else {"cap": 0, "need": 0, "reason_ar": None} + except Exception as e: + logger.warning("Semantic match failed: %s", e) + return {"cap": 0, "need": 0, "reason_ar": None} diff --git a/salesflow-saas/backend/app/services/strategic_deals/deal_negotiator.py b/salesflow-saas/backend/app/services/strategic_deals/deal_negotiator.py new file mode 100644 index 00000000..97c841c1 --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/deal_negotiator.py @@ -0,0 +1,479 @@ +""" +Deal Negotiator — Autonomous AI negotiator for B2B deals. +المفاوض الذكي: مفاوض آلي بالذكاء الاصطناعي للصفقات بين الشركات +""" + +import json +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import ( + StrategicDeal, CompanyProfile, DealStatus, DealType, +) +from app.services.llm.provider import get_llm + +logger = logging.getLogger("dealix.strategic_deals.negotiator") + + +# ── Models ─────────────────────────────────────────────────────────────────── + + +class NegotiationStrategy(BaseModel): + """Strategy configuration for autonomous negotiation.""" + target_terms: dict = {} # Ideal outcome + acceptable_range: dict = {} # Min/max for each variable + walk_away_point: dict = {} # Absolute limits / deal breakers + priorities: list[str] = [] # Ordered from most to least important + style: str = "collaborative" # collaborative, competitive, accommodating + + +@dataclass +class NegotiationRound: + """Result of a single negotiation round.""" + round_number: int = 0 + action: str = "" # opening_offer, counter_offer, acceptance, rejection, escalation + our_terms: dict = field(default_factory=dict) + their_terms: dict = field(default_factory=dict) + message_ar: str = "" + message_en: str = "" + concessions_made: list[str] = field(default_factory=list) + concessions_gained: list[str] = field(default_factory=list) + within_range: bool = True + confidence: float = 0.0 + timestamp: str = "" + + +# ── Escalation thresholds ──────────────────────────────────────────────────── + +ESCALATION_VALUE_SAR = 500_000 # Deals above this need human oversight +MAX_AUTO_ROUNDS = 5 # After this many rounds, escalate +STALL_THRESHOLD = 3 # Same terms repeated this many times = stall + + +class DealNegotiator: + """ + Autonomous AI negotiator that handles B2B deal negotiations. + Respects Saudi business culture: relationship-first, patience, mutual respect. + مفاوض ذكي يحترم ثقافة الأعمال السعودية + """ + + def __init__(self): + self.llm = get_llm() + + # ── Start Negotiation ──────────────────────────────────────────────────── + + async def start_negotiation( + self, + deal_id, + strategy: NegotiationStrategy, + db: AsyncSession, + ) -> NegotiationRound: + """ + Generate opening offer based on strategy and Saudi negotiation culture. + إنشاء العرض الأولي بناءً على الاستراتيجية وثقافة التفاوض السعودية + """ + deal = await self._load_deal(deal_id, db) + + initiator = await self._load_profile(deal.initiator_profile_id, db) + target_name = deal.target_company_name or "الطرف الآخر" + if deal.target_profile_id: + target = await self._load_profile(deal.target_profile_id, db) + target_name = target.company_name if target else target_name + + context = f"""Deal: {deal.deal_title} +Deal type: {deal.deal_type} +Our company: {initiator.company_name} +Target company: {target_name} +Our offer: {deal.our_offer or 'not specified'} +Our need: {deal.our_need or 'not specified'} +Strategy style: {strategy.style} +Target terms: {json.dumps(strategy.target_terms, ensure_ascii=False)} +Priorities: {', '.join(strategy.priorities)}""" + + style_guidance = { + "collaborative": "ابدأ بعرض عادل ومتوازن يظهر الرغبة في شراكة طويلة المدى", + "competitive": "ابدأ بعرض طموح لكن معقول مع ترك مساحة للتفاوض", + "accommodating": "ابدأ بعرض سخي يظهر حسن النية والرغبة في بناء علاقة", + } + + system_prompt = f"""أنت مفاوض أعمال سعودي محترف. أنشئ عرضاً أولياً للصفقة. + +التوجيه: {style_guidance.get(strategy.style, style_guidance['collaborative'])} + +Important Saudi negotiation culture: +- Start with relationship building (سلامات واستفسار عن الأحوال) +- Show respect for the other party +- Be patient, don't rush to numbers +- Present win-win framing + +Return JSON: +{{ + "opening_terms": {{"key": "value for each negotiable item"}}, + "message_ar": "رسالة العرض الأولي بالعربي (تبدأ بالسلام والتحية)", + "message_en": "Opening message in English", + "rationale_ar": "مبررات العرض", + "confidence": 0.0 to 1.0 +}}""" + + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + json_mode=True, + temperature=0.4, + ) + result = llm_response.parse_json() or {} + + now_str = datetime.now(timezone.utc).isoformat() + round_data = NegotiationRound( + round_number=1, + action="opening_offer", + our_terms=result.get("opening_terms", strategy.target_terms), + their_terms={}, + message_ar=result.get("message_ar", ""), + message_en=result.get("message_en", ""), + concessions_made=[], + concessions_gained=[], + within_range=True, + confidence=result.get("confidence", 0.5), + timestamp=now_str, + ) + + # Update deal + deal.proposed_terms = round_data.our_terms + deal.status = DealStatus.NEGOTIATING.value + deal.ai_confidence = round_data.confidence + history = list(deal.negotiation_history or []) + history.append({ + "round": round_data.round_number, + "action": round_data.action, + "our_terms": round_data.our_terms, + "their_terms": round_data.their_terms, + "message_ar": round_data.message_ar, + "timestamp": now_str, + }) + deal.negotiation_history = history + await db.flush() + + logger.info("Started negotiation for deal %s (round 1)", deal_id) + return round_data + + # ── Handle Counter-Offer ───────────────────────────────────────────────── + + async def handle_counter_offer( + self, + deal_id, + their_terms: dict, + db: AsyncSession, + ) -> NegotiationRound: + """ + Analyze a counter-offer and generate a response. + تحليل عرض مضاد وتوليد رد مناسب + """ + deal = await self._load_deal(deal_id, db) + history = list(deal.negotiation_history or []) + round_num = len(history) + 1 + + # Get the latest strategy from proposed terms + our_latest = deal.proposed_terms or {} + + context = f"""Deal: {deal.deal_title} +Deal type: {deal.deal_type} +Our latest terms: {json.dumps(our_latest, ensure_ascii=False)} +Their counter-offer: {json.dumps(their_terms, ensure_ascii=False)} +Negotiation history (rounds): {len(history)} +Estimated value SAR: {deal.estimated_value_sar or 'unknown'}""" + + system_prompt = """أنت مفاوض أعمال سعودي محترف. الطرف الآخر قدم عرضاً مضاداً. + +حلل العرض وقرر: +1. هل العرض مقبول؟ +2. هل نحتاج عرض مضاد؟ +3. هل يجب رفع الموضوع لإنسان؟ + +Saudi culture: never be aggressive. Show appreciation for their offer before countering. +Handle common responses: "غالي" (too expensive), "نبي نفكر" (need to think), "عندنا عرض ثاني" (we have another offer) + +Return JSON: +{ + "action": "accept/counter/reject/escalate", + "counter_terms": {"key": "value"}, + "message_ar": "الرد بالعربي", + "message_en": "Response in English", + "concessions_made": ["what we gave up"], + "concessions_gained": ["what we got"], + "within_acceptable_range": true/false, + "confidence": 0.0 to 1.0, + "analysis_ar": "تحليل العرض المضاد" +}""" + + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + json_mode=True, + temperature=0.3, + ) + result = llm_response.parse_json() or {} + + action = result.get("action", "counter") + now_str = datetime.now(timezone.utc).isoformat() + + round_data = NegotiationRound( + round_number=round_num, + action=action, + our_terms=result.get("counter_terms", our_latest), + their_terms=their_terms, + message_ar=result.get("message_ar", ""), + message_en=result.get("message_en", ""), + concessions_made=result.get("concessions_made", []), + concessions_gained=result.get("concessions_gained", []), + within_range=result.get("within_acceptable_range", True), + confidence=result.get("confidence", 0.5), + timestamp=now_str, + ) + + # Update deal state + if action == "accept": + deal.agreed_terms = their_terms + deal.status = DealStatus.TERM_SHEET.value + elif action == "reject": + deal.status = DealStatus.CLOSED_LOST.value + deal.closed_at = datetime.now(timezone.utc) + else: + deal.proposed_terms = round_data.our_terms + + deal.ai_confidence = round_data.confidence + history.append({ + "round": round_data.round_number, + "action": action, + "our_terms": round_data.our_terms, + "their_terms": their_terms, + "message_ar": round_data.message_ar, + "timestamp": now_str, + }) + deal.negotiation_history = history + await db.flush() + + logger.info("Handled counter-offer for deal %s (round %d, action=%s)", deal_id, round_num, action) + return round_data + + # ── Generate Negotiation Response ──────────────────────────────────────── + + async def generate_response( + self, + deal_id, + message: str, + db: AsyncSession, + ) -> str: + """ + Generate a culturally appropriate Arabic/English negotiation response. + توليد رد تفاوضي مناسب ثقافياً بالعربي أو الإنجليزي + """ + deal = await self._load_deal(deal_id, db) + history = deal.negotiation_history or [] + + # Summarize negotiation context + history_summary = "" + for h in history[-3:]: # Last 3 rounds + history_summary += f"Round {h.get('round', '?')}: {h.get('action', '?')} - {h.get('message_ar', '')[:100]}\n" + + context = f"""Deal: {deal.deal_title} +Deal type: {deal.deal_type} +Current status: {deal.status} +Our proposed terms: {json.dumps(deal.proposed_terms or {}, ensure_ascii=False)} +Recent history: +{history_summary} + +Incoming message from counter-party: {message}""" + + system_prompt = """أنت مفاوض أعمال سعودي محترف. رد على رسالة الطرف الآخر بشكل مناسب. + +Rules: +- إذا الرسالة بالعربي، رد بالعربي +- إذا الرسالة بالإنجليزي، رد بالإنجليزي +- كن محترماً وودوداً دائماً +- لا تكن عدوانياً أبداً +- حافظ على العلاقة حتى لو الصفقة لم تنجح + +Handle: +- "غالي" → أظهر المرونة واعرض بدائل +- "نبي نفكر" → أعطهم وقت مع اقتراح موعد متابعة +- "عندنا عرض ثاني" → أبرز المميزات الفريدة بدون تقليل المنافسين +- "ما يناسبنا" → اسأل عن التفاصيل واعرض تعديلات + +Return the response message directly as text (not JSON).""" + + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + temperature=0.5, + ) + response_text = llm_response.content.strip() + + # Log the exchange in negotiation history + history = list(deal.negotiation_history or []) + history.append({ + "round": len(history) + 1, + "action": "response", + "their_message": message[:500], + "our_response": response_text[:500], + "timestamp": datetime.now(timezone.utc).isoformat(), + }) + deal.negotiation_history = history + await db.flush() + + logger.info("Generated negotiation response for deal %s", deal_id) + return response_text + + # ── Should Escalate? ───────────────────────────────────────────────────── + + async def should_escalate( + self, + deal_id, + db: AsyncSession, + ) -> bool: + """ + Determine if a human should take over the negotiation. + تحديد ما إذا كان يجب تصعيد التفاوض لإنسان + """ + deal = await self._load_deal(deal_id, db) + history = deal.negotiation_history or [] + round_count = len(history) + + # Rule 1: High-value deals + value = float(deal.estimated_value_sar or 0) + if value > ESCALATION_VALUE_SAR: + logger.info("Escalation: deal %s value (%.0f SAR) exceeds threshold", deal_id, value) + return True + + # Rule 2: Too many rounds without resolution + if round_count >= MAX_AUTO_ROUNDS: + logger.info("Escalation: deal %s reached %d rounds", deal_id, round_count) + return True + + # Rule 3: Stalled negotiation (same terms repeating) + if round_count >= STALL_THRESHOLD: + recent_terms = [ + json.dumps(h.get("our_terms", {}), sort_keys=True) + for h in history[-STALL_THRESHOLD:] + ] + if len(set(recent_terms)) == 1: + logger.info("Escalation: deal %s stalled for %d rounds", deal_id, STALL_THRESHOLD) + return True + + # Rule 4: Low confidence + if deal.ai_confidence is not None and deal.ai_confidence < 0.3: + logger.info("Escalation: deal %s AI confidence too low (%.2f)", deal_id, deal.ai_confidence) + return True + + # Rule 5: Counter-party explicitly requested human contact + if history: + last_msg = (history[-1].get("their_message", "") or "").lower() + human_keywords = [ + "أبي أكلم شخص", "أبي أكلم المدير", "ابي اتكلم مع انسان", + "speak to someone", "talk to a person", "human", "manager", + "مدير", "مسؤول", + ] + for kw in human_keywords: + if kw in last_msg: + logger.info("Escalation: deal %s counter-party requested human", deal_id) + return True + + return False + + # ── Generate Term Sheet ────────────────────────────────────────────────── + + async def generate_term_sheet( + self, + deal_id, + db: AsyncSession, + ) -> dict: + """ + Generate a formal Arabic term sheet from agreed terms. + إنشاء ورقة شروط رسمية بالعربي من الشروط المتفق عليها + """ + deal = await self._load_deal(deal_id, db) + initiator = await self._load_profile(deal.initiator_profile_id, db) + + target_name = deal.target_company_name or "الطرف الثاني" + target_cr = "" + if deal.target_profile_id: + target = await self._load_profile(deal.target_profile_id, db) + if target: + target_name = target.company_name + target_cr = target.cr_number or "" + + terms = deal.agreed_terms or deal.proposed_terms or {} + + context = f"""Parties: +- Party A: {initiator.company_name} (CR: {initiator.cr_number or 'N/A'}) +- Party B: {target_name} (CR: {target_cr or 'N/A'}) + +Deal: {deal.deal_title} +Deal type: {deal.deal_type} +Agreed terms: {json.dumps(terms, ensure_ascii=False)} +Estimated value: {deal.estimated_value_sar or 'TBD'} SAR +Our offer: {deal.our_offer or 'N/A'} +Our need: {deal.our_need or 'N/A'}""" + + system_prompt = """أنت مستشار قانوني سعودي متخصص في صياغة أوراق الشروط. +أنشئ ورقة شروط رسمية باللغة العربية. + +Return JSON: +{ + "title_ar": "عنوان ورقة الشروط", + "date": "التاريخ", + "parties": [ + {"name": "اسم الطرف", "role": "الطرف الأول/الطرف الثاني", "cr": "رقم السجل التجاري"} + ], + "preamble_ar": "مقدمة ورقة الشروط", + "scope_ar": "نطاق الاتفاقية", + "terms": [ + {"title_ar": "عنوان البند", "description_ar": "تفاصيل البند"} + ], + "obligations_party_a_ar": ["التزامات الطرف الأول"], + "obligations_party_b_ar": ["التزامات الطرف الثاني"], + "financial_terms_ar": "الشروط المالية", + "duration_ar": "مدة الاتفاقية", + "termination_ar": "شروط الإنهاء", + "confidentiality_ar": "شرط السرية", + "dispute_resolution_ar": "حل النزاعات", + "governing_law_ar": "القانون الحاكم: أنظمة المملكة العربية السعودية", + "next_steps_ar": ["الخطوات التالية"] +}""" + + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + json_mode=True, + temperature=0.2, + ) + term_sheet = llm_response.parse_json() or {} + + # Update deal status + if deal.status == DealStatus.NEGOTIATING.value: + deal.status = DealStatus.TERM_SHEET.value + await db.flush() + + logger.info("Generated term sheet for deal %s", deal_id) + return term_sheet + + # ── Helpers ────────────────────────────────────────────────────────────── + + async def _load_deal(self, deal_id, db: AsyncSession) -> StrategicDeal: + result = await db.execute(select(StrategicDeal).where(StrategicDeal.id == deal_id)) + deal = result.scalar_one_or_none() + if not deal: + raise ValueError(f"Deal {deal_id} not found") + return deal + + async def _load_profile(self, profile_id, db: AsyncSession) -> Optional[CompanyProfile]: + if not profile_id: + return None + result = await db.execute(select(CompanyProfile).where(CompanyProfile.id == profile_id)) + return result.scalar_one_or_none() diff --git a/salesflow-saas/backend/app/services/strategic_deals/deal_room.py b/salesflow-saas/backend/app/services/strategic_deals/deal_room.py new file mode 100644 index 00000000..2d5ce9b1 --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/deal_room.py @@ -0,0 +1,674 @@ +""" +Deal Room — Central workspace for managing an active B2B deal through all stages. +غرفة الصفقة: مساحة العمل المركزية لإدارة صفقة B2B عبر جميع المراحل +""" + +import logging +import uuid +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import StrategicDeal, CompanyProfile, DealStatus +from app.services.llm.provider import get_llm + +logger = logging.getLogger("dealix.strategic_deals.deal_room") + + +# ── Room Stages ───────────────────────────────────────────────────────────── + +ROOM_STAGES = [ + "discovery", + "qualification", + "proposal", + "negotiation", + "legal", + "approval", + "closed_won", + "closed_lost", +] + +STAGE_LABELS_AR = { + "discovery": "اكتشاف", + "qualification": "تأهيل", + "proposal": "مقترح", + "negotiation": "تفاوض", + "legal": "مراجعة قانونية", + "approval": "موافقة", + "closed_won": "تمت بنجاح", + "closed_lost": "لم تتم", +} + + +# ── Pydantic Models ───────────────────────────────────────────────────────── + + +class ConcessionRecord(BaseModel): + """Record of a single concession given or received.""" + what: str + value_sar: float = 0.0 + direction: str = "given" # given or received + timestamp: str = "" + rationale: str = "" + + +class ApprovalRequest(BaseModel): + """An approval request within a deal room.""" + approval_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + action: str + details: str = "" + requested_by: str = "ai_agent" + requested_at: str = "" + status: str = "pending" # pending, granted, denied + decided_by: str = "" + decided_at: str = "" + notes: str = "" + + +class AuditEntry(BaseModel): + """Immutable audit log entry.""" + timestamp: str + actor: str # user_id or "ai_agent" + action: str + details: str = "" + metadata: dict = Field(default_factory=dict) + + +class RoomMessage(BaseModel): + """A message within the deal room conversation.""" + message_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + direction: str # inbound or outbound + channel: str # email, whatsapp, internal + content: str + sender: str = "" + timestamp: str = "" + metadata: dict = Field(default_factory=dict) + + +class DealRoom(BaseModel): + """ + Central workspace for managing a B2B deal. + غرفة الصفقة: مساحة العمل المركزية لصفقة B2B + """ + room_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + deal_id: str + tenant_id: str + + # Parties + our_twin_id: str = "" + their_profile: dict = Field(default_factory=dict) + + # Deal context + deal_type: str = "" + hypothesis: str = "" # Why this deal makes sense (Arabic) + mutual_value: dict = Field( + default_factory=lambda: {"us": [], "them": []}, + ) + + # Negotiation state + current_offer: dict = Field(default_factory=dict) + their_last_response: dict = Field(default_factory=dict) + concessions_made: list[ConcessionRecord] = Field(default_factory=list) + concessions_received: list[ConcessionRecord] = Field(default_factory=list) + batna: dict = Field(default_factory=dict) # Best alternative if deal fails + walk_away_threshold: dict = Field(default_factory=dict) + + # Conversation + messages: list[RoomMessage] = Field(default_factory=list) + channel: str = "email" + + # Status + stage: str = "discovery" + blockers: list[str] = Field(default_factory=list) + next_action: str = "" + next_action_ar: str = "" + + # Governance + approvals_pending: list[ApprovalRequest] = Field(default_factory=list) + approvals_granted: list[ApprovalRequest] = Field(default_factory=list) + red_line_violations: list[dict] = Field(default_factory=list) + audit_log: list[AuditEntry] = Field(default_factory=list) + + # Metadata + created_at: str = "" + updated_at: str = "" + + +# ── Service ───────────────────────────────────────────────────────────────── + + +class DealRoomService: + """ + Manages DealRoom lifecycle: creation, stage transitions, messaging, governance. + إدارة دورة حياة غرفة الصفقة: الإنشاء، والانتقال بين المراحل، والرسائل، والحوكمة + """ + + def __init__(self): + self.llm = get_llm() + + # ── Create Room ───────────────────────────────────────────────────────── + + async def create_room( + self, + deal_id: str, + our_twin_id: str, + their_profile: dict, + db: AsyncSession, + ) -> DealRoom: + """ + Create a new deal room linked to a StrategicDeal. + إنشاء غرفة صفقة جديدة مرتبطة بصفقة استراتيجية + """ + deal_result = await db.execute( + select(StrategicDeal).where(StrategicDeal.id == deal_id) + ) + deal = deal_result.scalar_one_or_none() + if not deal: + raise ValueError(f"الصفقة غير موجودة: {deal_id}") + + now_iso = datetime.now(timezone.utc).isoformat() + room = DealRoom( + deal_id=str(deal.id), + tenant_id=str(deal.tenant_id), + our_twin_id=our_twin_id, + their_profile=their_profile, + deal_type=deal.deal_type or "", + channel=deal.channel or "email", + stage="discovery", + next_action="research_target", + next_action_ar="بحث عن الطرف الآخر وتحليل احتياجاته", + created_at=now_iso, + updated_at=now_iso, + audit_log=[ + AuditEntry( + timestamp=now_iso, + actor="ai_agent", + action="room_created", + details=f"غرفة صفقة جديدة — نوع: {deal.deal_type or 'غير محدد'}", + ), + ], + ) + + # Persist room data on the deal + history = list(deal.negotiation_history or []) + history.append({ + "round": 0, + "action": "room_created", + "room_id": room.room_id, + "timestamp": now_iso, + }) + deal.negotiation_history = history + + # Store room in proposed_terms as a nested structure + existing_terms = dict(deal.proposed_terms or {}) + existing_terms["_deal_room"] = room.model_dump(mode="json") + deal.proposed_terms = existing_terms + await db.flush() + + logger.info("Created deal room %s for deal %s", room.room_id, deal_id) + return room + + # ── Load Room ─────────────────────────────────────────────────────────── + + async def _load_room(self, room_id: str, db: AsyncSession) -> tuple[DealRoom, StrategicDeal]: + """Load a DealRoom and its parent StrategicDeal.""" + # Scan deals for the room + all_deals = await db.execute(select(StrategicDeal)) + for deal in all_deals.scalars(): + terms = deal.proposed_terms or {} + room_data = terms.get("_deal_room") + if room_data and room_data.get("room_id") == room_id: + return DealRoom(**room_data), deal + raise ValueError(f"غرفة الصفقة غير موجودة: {room_id}") + + async def _persist_room(self, room: DealRoom, deal: StrategicDeal, db: AsyncSession): + """Persist the room state back onto the deal.""" + room.updated_at = datetime.now(timezone.utc).isoformat() + existing_terms = dict(deal.proposed_terms or {}) + existing_terms["_deal_room"] = room.model_dump(mode="json") + deal.proposed_terms = existing_terms + await db.flush() + + # ── Update Stage ──────────────────────────────────────────────────────── + + async def update_stage( + self, + room_id: str, + new_stage: str, + reason: str, + db: AsyncSession, + ): + """ + Transition the deal room to a new stage with audit logging. + نقل غرفة الصفقة إلى مرحلة جديدة مع تسجيل في سجل المراجعة + """ + if new_stage not in ROOM_STAGES: + raise ValueError(f"مرحلة غير صالحة: {new_stage}. المراحل المتاحة: {', '.join(ROOM_STAGES)}") + + room, deal = await self._load_room(room_id, db) + old_stage = room.stage + + # Validate forward-only transition (except to closed_lost which can happen from any stage) + if new_stage != "closed_lost": + old_idx = ROOM_STAGES.index(old_stage) if old_stage in ROOM_STAGES else 0 + new_idx = ROOM_STAGES.index(new_stage) + if new_idx < old_idx: + raise ValueError( + f"لا يمكن الرجوع من {STAGE_LABELS_AR.get(old_stage, old_stage)} " + f"إلى {STAGE_LABELS_AR.get(new_stage, new_stage)}" + ) + + room.stage = new_stage + now_iso = datetime.now(timezone.utc).isoformat() + room.audit_log.append( + AuditEntry( + timestamp=now_iso, + actor="ai_agent", + action="stage_changed", + details=f"انتقال من {STAGE_LABELS_AR.get(old_stage, old_stage)} إلى {STAGE_LABELS_AR.get(new_stage, new_stage)}: {reason}", + metadata={"old_stage": old_stage, "new_stage": new_stage}, + ) + ) + + # Sync deal status + stage_to_status = { + "discovery": DealStatus.DISCOVERY.value, + "qualification": DealStatus.DISCOVERY.value, + "proposal": DealStatus.OUTREACH.value, + "negotiation": DealStatus.NEGOTIATING.value, + "legal": DealStatus.TERM_SHEET.value, + "approval": DealStatus.DUE_DILIGENCE.value, + "closed_won": DealStatus.CLOSED_WON.value, + "closed_lost": DealStatus.CLOSED_LOST.value, + } + mapped_status = stage_to_status.get(new_stage) + if mapped_status: + deal.status = mapped_status + if new_stage in ("closed_won", "closed_lost"): + deal.closed_at = datetime.now(timezone.utc) + + await self._persist_room(room, deal, db) + logger.info("Room %s stage: %s -> %s (%s)", room_id, old_stage, new_stage, reason) + + # ── Add Message ───────────────────────────────────────────────────────── + + async def add_message( + self, + room_id: str, + message: str, + direction: str, + channel: str, + db: AsyncSession, + ): + """ + Record a message in the deal room conversation. + تسجيل رسالة في محادثة غرفة الصفقة + """ + room, deal = await self._load_room(room_id, db) + now_iso = datetime.now(timezone.utc).isoformat() + + room.messages.append( + RoomMessage( + direction=direction, + channel=channel, + content=message, + sender="ai_agent" if direction == "outbound" else "counterparty", + timestamp=now_iso, + ) + ) + + if direction == "inbound": + room.their_last_response = { + "content": message, + "channel": channel, + "timestamp": now_iso, + } + + room.audit_log.append( + AuditEntry( + timestamp=now_iso, + actor="ai_agent" if direction == "outbound" else "counterparty", + action=f"message_{direction}", + details=message[:200], + metadata={"channel": channel}, + ) + ) + + await self._persist_room(room, deal, db) + logger.info("Added %s message to room %s via %s", direction, room_id, channel) + + # ── Record Concession ─────────────────────────────────────────────────── + + async def record_concession( + self, + room_id: str, + what: str, + value: float, + db: AsyncSession, + ): + """ + Record a concession made during negotiation. + تسجيل تنازل تم خلال التفاوض + """ + room, deal = await self._load_room(room_id, db) + now_iso = datetime.now(timezone.utc).isoformat() + + record = ConcessionRecord( + what=what, + value_sar=value, + direction="given", + timestamp=now_iso, + ) + room.concessions_made.append(record) + + room.audit_log.append( + AuditEntry( + timestamp=now_iso, + actor="ai_agent", + action="concession_made", + details=f"تنازل: {what} (قيمة: {value:,.0f} ريال)", + metadata={"value_sar": value}, + ) + ) + + await self._persist_room(room, deal, db) + logger.info("Recorded concession in room %s: %s (%.0f SAR)", room_id, what, value) + + # ── Check Red Lines ───────────────────────────────────────────────────── + + async def check_red_lines( + self, + room_id: str, + proposed_terms: dict, + db: AsyncSession, + ) -> list[str]: + """ + Check proposed terms against the company's red lines. + التحقق من الشروط المقترحة مقابل الخطوط الحمراء للشركة + """ + room, deal = await self._load_room(room_id, db) + + # Load the company twin to get red lines + from app.services.strategic_deals.company_twin import CompanyTwinBuilder + builder = CompanyTwinBuilder() + twin = None + + if room.our_twin_id: + twin = await builder.get_twin_by_id(room.our_twin_id, db) + + if not twin: + # Try loading by company_id from the deal initiator + if deal.initiator_profile_id: + twin = await builder.get_twin(str(deal.initiator_profile_id), db) + + red_lines = twin.red_lines if twin else [] + if not red_lines: + return [] + + violations: list[str] = [] + terms_text = str(proposed_terms).lower() + + # Static keyword check + keyword_checks = { + "حصرية": "exclusivity", + "حقوق ملكية": "equity", + "ضمان": "guarantee", + "تعويض": "compensation", + "غرامة": "penalty", + } + + for red_line in red_lines: + red_line_lower = red_line.lower() + # Direct keyword match + if red_line_lower in terms_text: + violations.append(f"خط أحمر: {red_line}") + continue + # Check Arabic keywords + for ar_kw, en_kw in keyword_checks.items(): + if ar_kw in red_line_lower and en_kw in terms_text: + violations.append(f"خط أحمر: {red_line}") + break + + # If there are potential concerns, use LLM for deeper analysis + if not violations and red_lines: + system_prompt = """أنت مراجع عقود سعودي. تحقق من الشروط المقترحة مقابل الخطوط الحمراء. + +أعد النتائج بصيغة JSON: +{ + "violations": ["وصف الانتهاك 1", "وصف الانتهاك 2"], + "warnings": ["تحذير 1"] +} + +إذا لم يكن هناك انتهاكات، أعد قوائم فارغة.""" + + context = ( + f"الخطوط الحمراء:\n" + "\n".join(f"- {rl}" for rl in red_lines) + + f"\n\nالشروط المقترحة:\n{str(proposed_terms)}" + ) + + try: + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + json_mode=True, + temperature=0.1, + ) + result = llm_response.parse_json() + if result and result.get("violations"): + violations.extend(result["violations"]) + except Exception as exc: + logger.warning("LLM red-line check failed: %s", exc) + + # Record violations + if violations: + now_iso = datetime.now(timezone.utc).isoformat() + for v in violations: + room.red_line_violations.append({ + "violation": v, + "proposed_terms": proposed_terms, + "timestamp": now_iso, + }) + room.audit_log.append( + AuditEntry( + timestamp=now_iso, + actor="ai_agent", + action="red_line_violation", + details=f"تم اكتشاف {len(violations)} انتهاك للخطوط الحمراء", + metadata={"violations": violations}, + ) + ) + await self._persist_room(room, deal, db) + + logger.info("Red line check for room %s: %d violations", room_id, len(violations)) + return violations + + # ── Request Approval ──────────────────────────────────────────────────── + + async def request_approval( + self, + room_id: str, + action: str, + details: str, + db: AsyncSession, + ) -> str: + """ + Create an approval request that pauses AI action until a human decides. + إنشاء طلب موافقة يوقف عمل الذكاء الاصطناعي حتى يقرر إنسان + """ + room, deal = await self._load_room(room_id, db) + now_iso = datetime.now(timezone.utc).isoformat() + + approval = ApprovalRequest( + action=action, + details=details, + requested_at=now_iso, + ) + room.approvals_pending.append(approval) + + room.blockers.append(f"بانتظار موافقة على: {action}") + room.audit_log.append( + AuditEntry( + timestamp=now_iso, + actor="ai_agent", + action="approval_requested", + details=f"طلب موافقة: {action} — {details}", + metadata={"approval_id": approval.approval_id}, + ) + ) + + await self._persist_room(room, deal, db) + logger.info("Approval requested in room %s: %s (id=%s)", room_id, action, approval.approval_id) + return approval.approval_id + + # ── Grant Approval ────────────────────────────────────────────────────── + + async def grant_approval( + self, + room_id: str, + approval_id: str, + user_id: str, + db: AsyncSession, + ): + """ + Grant a pending approval request. + منح موافقة على طلب معلق + """ + room, deal = await self._load_room(room_id, db) + now_iso = datetime.now(timezone.utc).isoformat() + + granted = None + remaining_pending = [] + for req in room.approvals_pending: + if req.approval_id == approval_id: + req.status = "granted" + req.decided_by = user_id + req.decided_at = now_iso + granted = req + else: + remaining_pending.append(req) + + if not granted: + raise ValueError(f"طلب الموافقة غير موجود: {approval_id}") + + room.approvals_pending = remaining_pending + room.approvals_granted.append(granted) + + # Remove related blocker + blocker_prefix = f"بانتظار موافقة على: {granted.action}" + room.blockers = [b for b in room.blockers if b != blocker_prefix] + + room.audit_log.append( + AuditEntry( + timestamp=now_iso, + actor=user_id, + action="approval_granted", + details=f"تمت الموافقة على: {granted.action}", + metadata={"approval_id": approval_id}, + ) + ) + + await self._persist_room(room, deal, db) + logger.info("Approval %s granted by %s in room %s", approval_id, user_id, room_id) + + # ── Deal Summary ──────────────────────────────────────────────────────── + + async def get_deal_summary( + self, + room_id: str, + db: AsyncSession, + ) -> dict: + """ + Generate an Arabic summary of the deal room status. + إنشاء ملخص عربي لحالة غرفة الصفقة + """ + room, deal = await self._load_room(room_id, db) + + # Gather data for LLM summary + msg_count = len(room.messages) + inbound = sum(1 for m in room.messages if m.direction == "inbound") + outbound = msg_count - inbound + concessions_given = len(room.concessions_made) + concessions_got = len(room.concessions_received) + total_concession_value = sum(c.value_sar for c in room.concessions_made) + pending_approvals = len(room.approvals_pending) + violations = len(room.red_line_violations) + + stage_ar = STAGE_LABELS_AR.get(room.stage, room.stage) + their_name = room.their_profile.get("company_name", room.their_profile.get("name", "الطرف الآخر")) + + summary = { + "room_id": room.room_id, + "deal_id": room.deal_id, + "stage": room.stage, + "stage_ar": stage_ar, + "their_name": their_name, + "deal_type": room.deal_type, + "channel": room.channel, + "statistics": { + "total_messages": msg_count, + "inbound_messages": inbound, + "outbound_messages": outbound, + "concessions_given": concessions_given, + "concessions_received": concessions_got, + "total_concession_value_sar": total_concession_value, + "pending_approvals": pending_approvals, + "red_line_violations": violations, + }, + "blockers": room.blockers, + "next_action": room.next_action, + "next_action_ar": room.next_action_ar, + "current_offer": room.current_offer, + "their_last_response": room.their_last_response, + "summary_ar": ( + f"صفقة مع {their_name} — المرحلة: {stage_ar}\n" + f"عدد الرسائل: {msg_count} ({inbound} واردة، {outbound} صادرة)\n" + f"التنازلات المقدمة: {concessions_given} (بقيمة {total_concession_value:,.0f} ريال)\n" + + (f"موافقات معلقة: {pending_approvals}\n" if pending_approvals else "") + + (f"تحذير: {violations} انتهاك للخطوط الحمراء\n" if violations else "") + + (f"الخطوة التالية: {room.next_action_ar}" if room.next_action_ar else "") + ), + } + + logger.info("Generated deal summary for room %s", room_id) + return summary + + # ── Get Rooms ─────────────────────────────────────────────────────────── + + async def get_rooms( + self, + tenant_id: str, + stage: Optional[str] = None, + db: AsyncSession = None, + ) -> list[DealRoom]: + """ + List all deal rooms for a tenant, optionally filtered by stage. + عرض جميع غرف الصفقات لمستأجر معين مع إمكانية الفلترة بالمرحلة + """ + query = select(StrategicDeal).where( + StrategicDeal.tenant_id == tenant_id + ) + result = await db.execute(query) + deals = result.scalars().all() + + rooms: list[DealRoom] = [] + for deal in deals: + terms = deal.proposed_terms or {} + room_data = terms.get("_deal_room") + if not room_data: + continue + try: + room = DealRoom(**room_data) + if stage and room.stage != stage: + continue + rooms.append(room) + except Exception as exc: + logger.warning("Failed to deserialize room from deal %s: %s", deal.id, exc) + + logger.info( + "Retrieved %d deal rooms for tenant %s (stage=%s)", + len(rooms), tenant_id, stage or "all", + ) + return rooms diff --git a/salesflow-saas/backend/app/services/strategic_deals/deal_taxonomy.py b/salesflow-saas/backend/app/services/strategic_deals/deal_taxonomy.py new file mode 100644 index 00000000..9d69aa23 --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/deal_taxonomy.py @@ -0,0 +1,573 @@ +""" +Deal Taxonomy — Complete taxonomy of 15 B2B deal types with templates and qualification flows. +تصنيف الصفقات: 15 نوعاً من صفقات الشراكات بين الشركات مع قوالب وأسئلة تأهيل +""" + +import logging +from typing import Optional + +from pydantic import BaseModel, Field + +logger = logging.getLogger("dealix.strategic_deals.taxonomy") + + +# ── Taxonomy Schema ───────────────────────────────────────────────────────── + + +class DealTypeSpec(BaseModel): + """Full specification for a deal type in the taxonomy.""" + id: str + name: str + name_ar: str + description: str + description_ar: str + qualification_questions: list[str] # Arabic questions + typical_terms: list[str] + risk_level: str # low, medium, high + approval_level: str # mode_0 through mode_4 + need_categories: list[str] # Which need categories this deal type addresses + example_ar: str # Real-world Saudi example + + +# ── The 15-Type Taxonomy ──────────────────────────────────────────────────── + +DEAL_TAXONOMY: dict[str, dict] = { + "service_barter": { + "name": "Service-for-Service Exchange", + "name_ar": "تبادل خدمات", + "description": "Exchange services of equivalent value without cash transactions", + "description_ar": "تبادل خدمات بقيمة متساوية بين شركتين بدون تدفقات نقدية", + "qualification_questions": [ + "ما الخدمة التي تقدمونها للتبادل؟", + "ما القيمة التقديرية لهذه الخدمة بالريال السعودي؟", + "ما الخدمة التي تحتاجونها بالمقابل؟", + "ما المدة المتوقعة لهذا التبادل؟", + "هل لديكم خبرة سابقة في تبادل الخدمات؟", + ], + "typical_terms": [ + "duration", + "scope", + "quality_sla", + "cancellation", + "value_equivalence_method", + "dispute_resolution", + ], + "risk_level": "low", + "approval_level": "mode_2", + "need_categories": ["marketing", "technology", "delivery"], + "example_ar": "شركة تسويق تقدم حملات رقمية لشركة برمجيات مقابل تطوير موقع إلكتروني", + }, + "referral_partnership": { + "name": "Referral Partnership", + "name_ar": "شراكة إحالة", + "description": "Earn commission by referring qualified leads to each other", + "description_ar": "كسب عمولة من خلال إحالة عملاء مؤهلين بين الشركتين", + "qualification_questions": [ + "ما نوع العملاء الذين تحيلونهم عادة؟", + "ما نسبة العمولة المتوقعة؟", + "كيف يتم تتبع الإحالات؟", + "ما هو متوسط حجم الصفقة لعملائكم؟", + ], + "typical_terms": [ + "commission_rate", + "tracking_method", + "payment_schedule", + "exclusivity", + "minimum_referrals", + "non_compete", + ], + "risk_level": "low", + "approval_level": "mode_2", + "need_categories": ["sales", "marketing"], + "example_ar": "مكتب محاماة يحيل عملاءه لشركة محاسبة مقابل 10% من قيمة أول عقد", + }, + "co_selling": { + "name": "Co-Selling Agreement", + "name_ar": "بيع مشترك", + "description": "Joint sales efforts targeting shared opportunities", + "description_ar": "جهود بيع مشتركة لاستهداف فرص مشتركة بين الشركتين", + "qualification_questions": [ + "ما المنتجات أو الخدمات التي ستباع بشكل مشترك؟", + "كيف سيتم تقسيم الإيرادات؟", + "من يقود عملية البيع؟", + "ما القطاعات المستهدفة؟", + "هل لديكم فريق مبيعات مخصص لهذا الغرض؟", + ], + "typical_terms": [ + "revenue_split", + "lead_ownership", + "territory", + "sales_process", + "brand_usage", + "training_requirements", + ], + "risk_level": "medium", + "approval_level": "mode_3", + "need_categories": ["sales", "distribution"], + "example_ar": "شركة برمجيات وشركة استشارات يبيعون حلولاً متكاملة لقطاع الصحة", + }, + "co_marketing": { + "name": "Co-Marketing Campaign", + "name_ar": "تسويق مشترك", + "description": "Joint marketing campaigns sharing costs and audiences", + "description_ar": "حملات تسويقية مشتركة مع تقاسم التكاليف والجمهور المستهدف", + "qualification_questions": [ + "ما القنوات التسويقية المستهدفة؟", + "ما الميزانية المتوقعة من كل طرف؟", + "من الجمهور المستهدف المشترك؟", + "ما مؤشرات النجاح المتفق عليها؟", + ], + "typical_terms": [ + "budget_split", + "channels", + "brand_guidelines", + "content_approval", + "lead_sharing", + "duration", + ], + "risk_level": "low", + "approval_level": "mode_2", + "need_categories": ["marketing"], + "example_ar": "شركتا تقنية تشتركان في رعاية مؤتمر قطاع التجزئة وتتقاسمان العملاء المحتملين", + }, + "subcontracting": { + "name": "Subcontracting Agreement", + "name_ar": "عقد باطن (مقاولة فرعية)", + "description": "Outsource specific project scope to a specialized partner", + "description_ar": "إسناد جزء من نطاق المشروع لشريك متخصص كمقاول فرعي", + "qualification_questions": [ + "ما نطاق العمل المطلوب إسناده؟", + "ما المهارات والشهادات المطلوبة؟", + "ما الجدول الزمني للتسليم؟", + "هل المشروع حكومي أو خاص؟", + "ما شروط الضمان والجودة؟", + ], + "typical_terms": [ + "scope_of_work", + "payment_milestones", + "quality_standards", + "liability", + "insurance", + "confidentiality", + "penalties", + ], + "risk_level": "medium", + "approval_level": "mode_3", + "need_categories": ["delivery", "talent"], + "example_ar": "شركة مقاولات كبرى تسند أعمال الكهرباء لشركة متخصصة في مشروع حكومي", + }, + "white_label": { + "name": "White-Label / Private Label", + "name_ar": "علامة بيضاء", + "description": "Provide products or services under the partner's brand", + "description_ar": "تقديم منتجات أو خدمات تحت العلامة التجارية للشريك", + "qualification_questions": [ + "ما المنتج أو الخدمة المراد تقديمها تحت علامتهم؟", + "ما مستوى التخصيص المطلوب؟", + "كيف سيتم التسعير والهوامش؟", + "ما متطلبات الجودة والدعم الفني؟", + ], + "typical_terms": [ + "branding_rights", + "customization_scope", + "pricing_structure", + "minimum_volume", + "exclusivity", + "support_sla", + "ip_ownership", + ], + "risk_level": "medium", + "approval_level": "mode_3", + "need_categories": ["technology", "delivery"], + "example_ar": "شركة برمجيات سعودية توفر نظام CRM تحت العلامة التجارية لشركة اتصالات", + }, + "reseller": { + "name": "Reseller Agreement", + "name_ar": "اتفاقية موزع معتمد", + "description": "Authorized resale of products or services with margin", + "description_ar": "إعادة بيع منتجات أو خدمات الشريك بصفة موزع معتمد مع هامش ربح", + "qualification_questions": [ + "ما المنتجات المراد توزيعها؟", + "ما المنطقة الجغرافية المستهدفة؟", + "هل التوزيع حصري أم غير حصري؟", + "ما هامش الربح المتوقع؟", + "ما حجم المبيعات المتوقع سنوياً؟", + ], + "typical_terms": [ + "territory", + "exclusivity", + "margin_structure", + "minimum_purchase", + "payment_terms", + "marketing_support", + "training", + "return_policy", + ], + "risk_level": "medium", + "approval_level": "mode_3", + "need_categories": ["distribution", "sales"], + "example_ar": "شركة سعودية توزع حلول أمن سيبراني لشركة أمريكية في منطقة الخليج", + }, + "strategic_alliance": { + "name": "Strategic Alliance", + "name_ar": "تحالف استراتيجي", + "description": "Long-term strategic collaboration without equity exchange", + "description_ar": "تعاون استراتيجي طويل الأمد بدون تبادل حصص ملكية", + "qualification_questions": [ + "ما الأهداف الاستراتيجية المشتركة؟", + "ما مدة التحالف المتوقعة؟", + "كيف ستتم الحوكمة واتخاذ القرارات؟", + "ما الموارد التي سيساهم بها كل طرف؟", + "هل هناك اتفاقيات عدم منافسة؟", + ], + "typical_terms": [ + "strategic_objectives", + "governance_structure", + "resource_commitments", + "non_compete", + "exit_terms", + "ip_sharing", + "confidentiality", + ], + "risk_level": "high", + "approval_level": "mode_4", + "need_categories": ["capital", "distribution", "technology"], + "example_ar": "شركة لوجستية وشركة تقنية يتحالفان لتقديم حلول سلسلة إمداد ذكية للسوق السعودي", + }, + "channel_partnership": { + "name": "Channel Partnership", + "name_ar": "شراكة قنوات توزيع", + "description": "Leverage partner's sales channels for distribution", + "description_ar": "الاستفادة من قنوات بيع الشريك لتوزيع منتجاتك وخدماتك", + "qualification_questions": [ + "ما القنوات التي يمتلكها الشريك؟", + "ما حجم قاعدة عملائهم؟", + "كيف سيتم تقسيم المسؤوليات؟", + "ما الدعم المطلوب للقناة (تدريب، مواد تسويقية)؟", + ], + "typical_terms": [ + "channel_type", + "commission_structure", + "training_requirements", + "marketing_support", + "performance_targets", + "reporting_frequency", + ], + "risk_level": "medium", + "approval_level": "mode_3", + "need_categories": ["distribution", "sales"], + "example_ar": "شركة SaaS تستخدم شبكة استشاري إداريين لبيع منتجها في المملكة", + }, + "joint_venture": { + "name": "Joint Venture", + "name_ar": "مشروع مشترك", + "description": "Create a new entity jointly owned by both parties", + "description_ar": "إنشاء كيان جديد مملوك بشكل مشترك بين الطرفين", + "qualification_questions": [ + "ما هدف المشروع المشترك؟", + "ما نسبة مساهمة كل طرف؟", + "ما الشكل القانوني المقترح (شركة ذات مسؤولية محدودة، شراكة)؟", + "من سيتولى الإدارة اليومية؟", + "ما استراتيجية الخروج؟", + "كيف ستوزع الأرباح والخسائر؟", + ], + "typical_terms": [ + "equity_split", + "capital_contributions", + "governance", + "management_structure", + "profit_distribution", + "exit_strategy", + "non_compete", + "dispute_resolution", + ], + "risk_level": "high", + "approval_level": "mode_4", + "need_categories": ["capital", "technology", "distribution"], + "example_ar": "مستثمر سعودي وشركة تقنية أجنبية ينشئون شركة مشتركة لتقديم حلول الذكاء الاصطناعي محلياً", + }, + "acquisition_scouting": { + "name": "Acquisition Scouting", + "name_ar": "استكشاف استحواذ", + "description": "Identify and qualify potential acquisition targets", + "description_ar": "تحديد وتأهيل الشركات المرشحة للاستحواذ", + "qualification_questions": [ + "ما القطاع المستهدف للاستحواذ؟", + "ما الحجم المثالي للشركة المستهدفة (إيرادات، موظفين)؟", + "ما الميزانية المتاحة للاستحواذ؟", + "هل تبحثون عن استحواذ كامل أو حصة جزئية؟", + "ما الأصول الاستراتيجية المطلوبة (تقنية، عملاء، تراخيص)؟", + ], + "typical_terms": [ + "target_criteria", + "valuation_method", + "due_diligence_scope", + "exclusivity_period", + "advisory_fees", + "confidentiality", + ], + "risk_level": "high", + "approval_level": "mode_4", + "need_categories": ["capital", "technology"], + "example_ar": "مجموعة سعودية تبحث عن شركات تقنية ناشئة للاستحواذ بميزانية 5-20 مليون ريال", + }, + "investment_intro": { + "name": "Investment Introduction", + "name_ar": "تقديم فرصة استثمارية", + "description": "Connect companies with investors or investment opportunities", + "description_ar": "ربط الشركات بمستثمرين أو فرص استثمارية مناسبة", + "qualification_questions": [ + "هل تبحثون عن استثمار أم مستثمر؟", + "ما حجم التمويل المطلوب أو المتاح؟", + "ما مرحلة نمو الشركة؟", + "ما العائد المتوقع على الاستثمار؟", + "هل لديكم عرض تقديمي (Pitch Deck) جاهز؟", + ], + "typical_terms": [ + "investment_size", + "valuation", + "equity_offered", + "use_of_funds", + "board_representation", + "anti_dilution", + "introducer_fee", + ], + "risk_level": "high", + "approval_level": "mode_4", + "need_categories": ["capital"], + "example_ar": "شركة ناشئة سعودية تبحث عن جولة تمويل Series A بقيمة 10 مليون ريال", + }, + "vendor_replacement": { + "name": "Vendor Replacement", + "name_ar": "استبدال مورد", + "description": "Replace an existing vendor with a better-fit partner", + "description_ar": "استبدال مورد حالي بشريك أفضل من حيث الجودة أو السعر أو الخدمة", + "qualification_questions": [ + "ما الخدمة أو المنتج الذي يقدمه المورد الحالي؟", + "ما أسباب الرغبة في التغيير؟", + "ما معايير اختيار المورد الجديد؟", + "ما الميزانية المتاحة؟", + "ما الجدول الزمني المطلوب للانتقال؟", + ], + "typical_terms": [ + "transition_plan", + "pricing_comparison", + "service_level_agreement", + "contract_duration", + "penalty_clauses", + "data_migration", + ], + "risk_level": "medium", + "approval_level": "mode_3", + "need_categories": ["delivery", "technology"], + "example_ar": "مستشفى يبحث عن مورد جديد لمستلزمات طبية بعد انتهاء عقد المورد الحالي", + }, + "capability_gap_fill": { + "name": "Capability Gap Fill", + "name_ar": "سد فجوة القدرات", + "description": "Partner with a company to fill a specific capability gap", + "description_ar": "التعاون مع شركة متخصصة لسد فجوة في قدرات شركتك", + "qualification_questions": [ + "ما الفجوة التي تحتاجون سدها؟", + "هل هي فجوة مؤقتة أم دائمة؟", + "ما مستوى التخصص المطلوب؟", + "هل تفضلون شريكاً محلياً أم دولياً؟", + "ما ميزانية سد هذه الفجوة؟", + ], + "typical_terms": [ + "gap_definition", + "duration", + "knowledge_transfer", + "performance_metrics", + "pricing", + "confidentiality", + "training_commitment", + ], + "risk_level": "low", + "approval_level": "mode_2", + "need_categories": ["talent", "technology", "delivery"], + "example_ar": "شركة مقاولات تتعاون مع شركة تصميم معماري لتقديم عروض متكاملة", + }, + "tender_consortium": { + "name": "Tender Consortium", + "name_ar": "تحالف مناقصات", + "description": "Form a consortium to jointly bid on large tenders", + "description_ar": "تشكيل تحالف للتقدم بعرض مشترك في المناقصات الكبرى", + "qualification_questions": [ + "ما المناقصة أو المشروع المستهدف؟", + "ما الجهة المالكة للمناقصة؟", + "ما التخصصات المطلوبة لتكوين التحالف؟", + "ما الموعد النهائي لتقديم العرض؟", + "هل لديكم خبرة سابقة في المناقصات الحكومية؟", + "ما نسبة المحتوى المحلي المطلوبة؟", + ], + "typical_terms": [ + "scope_allocation", + "revenue_split", + "lead_partner", + "joint_liability", + "bid_bond", + "performance_bond", + "local_content", + "governance", + ], + "risk_level": "high", + "approval_level": "mode_4", + "need_categories": ["delivery", "capital", "talent"], + "example_ar": "ثلاث شركات سعودية تتحالف للتقدم لمناقصة مشروع بنية تحتية حكومي بقيمة 50 مليون ريال", + }, +} + +# ── Mapping from need categories to deal types ────────────────────────────── + +_NEED_TO_DEAL_MAP: dict[str, list[str]] = {} +for _deal_id, _spec in DEAL_TAXONOMY.items(): + for _cat in _spec["need_categories"]: + _NEED_TO_DEAL_MAP.setdefault(_cat, []).append(_deal_id) + + +# ── Service ───────────────────────────────────────────────────────────────── + + +class DealTaxonomyService: + """ + Provides lookup and intelligence over the 15-type deal taxonomy. + خدمة تصنيف الصفقات: بحث واقتراحات ذكية لأنواع الصفقات الخمسة عشر + """ + + @staticmethod + def get_deal_type(type_id: str) -> Optional[DealTypeSpec]: + """Return full spec for a deal type, or None if not found.""" + raw = DEAL_TAXONOMY.get(type_id) + if not raw: + return None + return DealTypeSpec(id=type_id, **raw) + + @staticmethod + def get_all_types() -> list[DealTypeSpec]: + """Return all 15 deal types as structured specs.""" + return [ + DealTypeSpec(id=type_id, **spec) + for type_id, spec in DEAL_TAXONOMY.items() + ] + + @staticmethod + def get_types_for_need(need_category: str) -> list[str]: + """ + Return deal type IDs that address a given need category. + إرجاع أنواع الصفقات التي تلبي فئة احتياج معينة + """ + return _NEED_TO_DEAL_MAP.get(need_category, []) + + @staticmethod + def get_qualification_questions(type_id: str, language: str = "ar") -> list[str]: + """ + Return qualification questions for a deal type. + إرجاع أسئلة التأهيل لنوع صفقة معين + """ + spec = DEAL_TAXONOMY.get(type_id) + if not spec: + return [] + questions = spec["qualification_questions"] + if language == "ar": + return questions + # English placeholders — in production these would be translated + return [f"[Q{i+1}] {q}" for i, q in enumerate(questions)] + + @staticmethod + def get_typical_terms(type_id: str) -> list[str]: + """Return typical negotiation terms for a deal type.""" + spec = DEAL_TAXONOMY.get(type_id) + if not spec: + return [] + return spec["typical_terms"] + + @staticmethod + def suggest_deal_type( + capability_category: str, + need_category: str, + ) -> str: + """ + Suggest the best deal type given a capability and a need. + اقتراح أفضل نوع صفقة بناءً على القدرة والاحتياج + """ + # Priority matrix: (capability_cat, need_cat) -> preferred deal type + priority_map: dict[tuple[str, str], str] = { + ("service", "marketing"): "co_marketing", + ("service", "sales"): "co_selling", + ("service", "delivery"): "subcontracting", + ("service", "technology"): "capability_gap_fill", + ("product", "distribution"): "reseller", + ("product", "sales"): "channel_partnership", + ("expertise", "talent"): "capability_gap_fill", + ("expertise", "technology"): "white_label", + ("capacity", "delivery"): "subcontracting", + ("capacity", "capital"): "joint_venture", + ("distribution", "marketing"): "co_marketing", + ("distribution", "sales"): "channel_partnership", + ("distribution", "distribution"): "reseller", + ("technology", "technology"): "white_label", + ("technology", "capital"): "investment_intro", + } + + specific = priority_map.get((capability_category, need_category)) + if specific: + logger.info( + "Suggested deal type %s for capability=%s, need=%s", + specific, capability_category, need_category, + ) + return specific + + # Fallback: find deal types matching the need category + candidates = _NEED_TO_DEAL_MAP.get(need_category, []) + if candidates: + # Prefer lower-risk options first + risk_order = {"low": 0, "medium": 1, "high": 2} + candidates_sorted = sorted( + candidates, + key=lambda t: risk_order.get(DEAL_TAXONOMY[t]["risk_level"], 1), + ) + result = candidates_sorted[0] + logger.info( + "Fallback deal type %s for capability=%s, need=%s", + result, capability_category, need_category, + ) + return result + + logger.info( + "No specific deal type found for capability=%s, need=%s; defaulting to referral_partnership", + capability_category, need_category, + ) + return "referral_partnership" + + @staticmethod + def get_risk_level(type_id: str) -> str: + """Return the risk level for a deal type.""" + spec = DEAL_TAXONOMY.get(type_id) + return spec["risk_level"] if spec else "medium" + + @staticmethod + def get_approval_level(type_id: str) -> str: + """Return the minimum operating mode required for this deal type.""" + spec = DEAL_TAXONOMY.get(type_id) + return spec["approval_level"] if spec else "mode_3" + + @staticmethod + def search_types(query: str) -> list[DealTypeSpec]: + """ + Search deal types by keyword (English or Arabic). + بحث في أنواع الصفقات بكلمة مفتاحية + """ + query_lower = query.lower().strip() + results = [] + for type_id, spec in DEAL_TAXONOMY.items(): + searchable = " ".join([ + type_id, + spec["name"].lower(), + spec["name_ar"], + spec["description"].lower(), + spec["description_ar"], + ]) + if query_lower in searchable: + results.append(DealTypeSpec(id=type_id, **spec)) + return results diff --git a/salesflow-saas/backend/app/services/strategic_deals/ecosystem_mapper.py b/salesflow-saas/backend/app/services/strategic_deals/ecosystem_mapper.py new file mode 100644 index 00000000..8e48ac55 --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/ecosystem_mapper.py @@ -0,0 +1,568 @@ +""" +Ecosystem Mapper — Maps and analyzes B2B partner ecosystems in the Saudi market. +خريطة المنظومة: رسم وتحليل منظومة الشركاء في السوق السعودي +""" + +import json +import logging +import uuid +from collections import defaultdict +from typing import Optional + +from pydantic import BaseModel, Field +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import CompanyProfile +from app.services.llm.provider import get_llm + +logger = logging.getLogger("dealix.strategic_deals.ecosystem_mapper") + +# ── Entity type definitions ───────────────────────────────────────────────── + +ENTITY_TYPES = { + "agency": "وكالة", + "integrator": "مُدمج أنظمة", + "reseller": "موزع معتمد", + "consultant": "مستشار", + "distributor": "موزع", + "supplier": "مورد", + "customer": "عميل", + "competitor": "منافس", +} + +LINK_TYPES = ("partner", "competitor", "vendor", "client", "referral", "subsidiary") + +# ── Capability clusters for gap analysis ──────────────────────────────────── + +CAPABILITY_CLUSTERS = { + "تقنية": ["تطوير برمجيات", "حوسبة سحابية", "أمن سيبراني", "ذكاء اصطناعي", "تحليل بيانات"], + "تسويق": ["تسويق رقمي", "إعلان", "علاقات عامة", "إدارة محتوى", "سوشل ميديا"], + "عمليات": ["لوجستيات", "سلسلة إمداد", "إدارة مخازن", "نقل", "توزيع"], + "مالية": ["محاسبة", "تدقيق", "استشارات مالية", "تمويل", "إدارة مخاطر"], + "موارد بشرية": ["توظيف", "تدريب", "تطوير مهني", "رواتب", "شؤون موظفين"], + "قانونية": ["استشارات قانونية", "عقود", "ملكية فكرية", "امتثال", "تراخيص"], + "مبيعات": ["مبيعات مباشرة", "مبيعات قنوات", "تطوير أعمال", "إدارة حسابات", "عروض أسعار"], +} + + +# ── Models ────────────────────────────────────────────────────────────────── + + +class EcosystemEntity(BaseModel): + """A node in the ecosystem graph representing a company or organization.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + name_ar: str = "" + entity_type: str = "partner" # agency, integrator, reseller, consultant, distributor + industry: str = "" + city: str = "" + capabilities: list[str] = Field(default_factory=list) + relationship_strength: float = Field(0.0, ge=0.0, le=1.0) + partner_potential: float = Field(0.0, ge=0.0, le=1.0) + profile_id: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "name": "DataSphere Solutions", + "name_ar": "حلول داتا سفير", + "entity_type": "integrator", + "industry": "technology", + "city": "الرياض", + "capabilities": ["حوسبة سحابية", "أمن سيبراني"], + "relationship_strength": 0.8, + "partner_potential": 0.75, + } + } + + +class EcosystemLink(BaseModel): + """An edge in the ecosystem graph representing a relationship.""" + source_id: str + target_id: str + link_type: str = "partner" # partner, competitor, vendor, client + strength: float = Field(0.5, ge=0.0, le=1.0) + description_ar: str = "" + + +# ── Ecosystem Mapper Engine ───────────────────────────────────────────────── + + +class EcosystemMapper: + """ + Builds, analyzes, and visualizes B2B ecosystem maps. + Identifies gaps, suggests partners, and monitors ecosystem health. + بناء وتحليل وعرض خرائط منظومة الأعمال — تحديد الفجوات واقتراح الشركاء + """ + + def __init__(self): + self.llm = get_llm() + + # ── Build Map ─────────────────────────────────────────────────────────── + + async def build_map( + self, + tenant_id: str, + db: AsyncSession, + ) -> dict: + """ + Build a complete ecosystem map from company profiles and deal history. + بناء خريطة منظومة كاملة من ملفات الشركات وتاريخ الصفقات + """ + result = await db.execute( + select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id) + ) + profiles = result.scalars().all() + + if not profiles: + logger.info("No profiles found for tenant %s", tenant_id) + return {"entities": [], "links": [], "stats": {}} + + entities: list[EcosystemEntity] = [] + links: list[EcosystemLink] = [] + + # Build entity nodes from profiles + entity_map: dict[str, EcosystemEntity] = {} + for profile in profiles: + entity_type = self._infer_entity_type(profile) + entity = EcosystemEntity( + name=profile.company_name or "", + name_ar=profile.company_name_ar if hasattr(profile, "company_name_ar") else "", + entity_type=entity_type, + industry=profile.industry or "", + city=profile.region or "", + capabilities=[c for c in (profile.capabilities or [])], + relationship_strength=float(profile.trust_score or 0.5), + partner_potential=0.0, + profile_id=str(profile.id), + ) + entities.append(entity) + entity_map[str(profile.id)] = entity + + # Infer links based on capability/need overlap and industry relationships + profile_list = list(profiles) + for i, prof_a in enumerate(profile_list): + for prof_b in profile_list[i + 1:]: + link_type, strength = self._infer_link(prof_a, prof_b) + if strength >= 0.2: + entity_a = entity_map.get(str(prof_a.id)) + entity_b = entity_map.get(str(prof_b.id)) + if entity_a and entity_b: + link = EcosystemLink( + source_id=entity_a.id, + target_id=entity_b.id, + link_type=link_type, + strength=round(strength, 4), + description_ar=self._link_description( + prof_a.company_name, prof_b.company_name, link_type + ), + ) + links.append(link) + + # Compute partner potential for each entity + for entity in entities: + incoming = [lk for lk in links if lk.target_id == entity.id] + outgoing = [lk for lk in links if lk.source_id == entity.id] + partner_links = [ + lk for lk in incoming + outgoing + if lk.link_type in ("partner", "referral") + ] + if partner_links: + entity.partner_potential = round( + sum(lk.strength for lk in partner_links) / len(partner_links), 4 + ) + + stats = { + "total_entities": len(entities), + "total_links": len(links), + "entity_types": defaultdict(int), + "link_types": defaultdict(int), + "avg_relationship_strength": 0.0, + } + for e in entities: + stats["entity_types"][e.entity_type] += 1 + for lk in links: + stats["link_types"][lk.link_type] += 1 + if entities: + stats["avg_relationship_strength"] = round( + sum(e.relationship_strength for e in entities) / len(entities), 4 + ) + stats["entity_types"] = dict(stats["entity_types"]) + stats["link_types"] = dict(stats["link_types"]) + + logger.info( + "Built ecosystem map for tenant %s: %d entities, %d links", + tenant_id, len(entities), len(links), + ) + + return { + "entities": [e.model_dump() for e in entities], + "links": [lk.model_dump() for lk in links], + "stats": stats, + } + + # ── Find Gaps ─────────────────────────────────────────────────────────── + + async def find_gaps( + self, + tenant_id: str, + db: AsyncSession, + ) -> list[dict]: + """ + Identify underserved areas in the ecosystem where partners are missing. + تحديد المناطق غير المخدومة في المنظومة حيث ينقص الشركاء + """ + result = await db.execute( + select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id) + ) + profiles = result.scalars().all() + + if not profiles: + return [] + + # Collect all capabilities and all needs across the ecosystem + all_capabilities: set[str] = set() + all_needs: set[str] = set() + for profile in profiles: + for cap in (profile.capabilities or []): + all_capabilities.add(cap.lower().strip()) + for need in (profile.needs or []): + all_needs.add(need.lower().strip()) + + # Gaps: needs that no one in the ecosystem can fulfill + unmet_needs = all_needs - all_capabilities + + # Cluster-level gaps: entire capability clusters with low coverage + cluster_gaps: list[dict] = [] + for cluster_name, cluster_caps in CAPABILITY_CLUSTERS.items(): + cluster_lower = {c.lower() for c in cluster_caps} + covered = cluster_lower & all_capabilities + coverage = len(covered) / len(cluster_lower) if cluster_lower else 0 + if coverage < 0.3: + cluster_gaps.append({ + "gap_type": "cluster", + "cluster_name_ar": cluster_name, + "coverage": round(coverage, 4), + "missing_capabilities": list(cluster_lower - all_capabilities), + "recommendation_ar": f"المنظومة تفتقر لشركاء في مجال {cluster_name} — التغطية {coverage:.0%} فقط", + }) + + # Individual unmet needs + individual_gaps = [ + { + "gap_type": "unmet_need", + "need": need, + "recommendation_ar": f"لا يوجد شريك يقدم: {need}", + } + for need in sorted(unmet_needs)[:20] + ] + + gaps = cluster_gaps + individual_gaps + + logger.info( + "Found %d ecosystem gaps for tenant %s (%d cluster, %d individual)", + len(gaps), tenant_id, len(cluster_gaps), len(individual_gaps), + ) + return gaps + + # ── Suggest Partners ──────────────────────────────────────────────────── + + async def suggest_partners( + self, + gap_type: str, + tenant_id: str, + db: AsyncSession, + ) -> list[EcosystemEntity]: + """ + Suggest potential partners to fill an ecosystem gap. + اقتراح شركاء محتملين لسد فجوة في المنظومة + """ + gaps = await self.find_gaps(tenant_id, db) + + matching_gaps = [g for g in gaps if g.get("gap_type") == gap_type] + if not matching_gaps: + matching_gaps = gaps[:3] + + gap_summary = json.dumps(matching_gaps[:5], ensure_ascii=False) + + context = f"""فجوات المنظومة: +{gap_summary} + +نوع الفجوة المطلوب: {gap_type}""" + + system_prompt = """أنت مستشار تطوير أعمال سعودي. بناءً على فجوات المنظومة، اقترح شركاء محتملين. + +Return JSON: +{ + "suggestions": [ + { + "name": "اسم النوع المقترح بالإنجليزي", + "name_ar": "اسم النوع المقترح بالعربي", + "entity_type": "agency/integrator/reseller/consultant/distributor", + "industry": "القطاع", + "capabilities": ["قدرة ١", "قدرة ٢"], + "rationale_ar": "سبب الاقتراح بالعربي", + "partner_potential": 0.0 to 1.0 + } + ] +}""" + + try: + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + json_mode=True, + temperature=0.4, + ) + result = llm_response.parse_json() or {} + suggestions_data = result.get("suggestions", []) + except Exception as exc: + logger.warning("LLM partner suggestion failed: %s", exc) + suggestions_data = [ + { + "name": f"Partner for {gap_type}", + "name_ar": f"شريك لسد فجوة {gap_type}", + "entity_type": "consultant", + "industry": "consulting", + "capabilities": [g.get("need", "") for g in matching_gaps if g.get("need")], + "rationale_ar": "اقتراح تلقائي بناءً على الفجوات المكتشفة", + "partner_potential": 0.5, + } + ] + + entities: list[EcosystemEntity] = [] + for s in suggestions_data: + entity = EcosystemEntity( + name=s.get("name", ""), + name_ar=s.get("name_ar", ""), + entity_type=s.get("entity_type", "consultant"), + industry=s.get("industry", ""), + capabilities=s.get("capabilities", []), + relationship_strength=0.0, + partner_potential=min(1.0, max(0.0, float(s.get("partner_potential", 0.5)))), + ) + entities.append(entity) + + logger.info( + "Suggested %d partners for gap '%s' in tenant %s", + len(entities), gap_type, tenant_id, + ) + return entities + + # ── Get Clusters ──────────────────────────────────────────────────────── + + async def get_clusters( + self, + tenant_id: str, + db: AsyncSession, + ) -> list[dict]: + """ + Identify clusters of related entities in the ecosystem. + تحديد تجمعات الكيانات المترابطة في المنظومة + """ + eco_map = await self.build_map(tenant_id, db) + entities = eco_map.get("entities", []) + links = eco_map.get("links", []) + + if not entities: + return [] + + # Group entities by industry + industry_groups: dict[str, list[dict]] = defaultdict(list) + for entity in entities: + industry_groups[entity.get("industry", "other")].append(entity) + + clusters: list[dict] = [] + for industry, members in industry_groups.items(): + if not members: + continue + + member_ids = {m["id"] for m in members} + internal_links = [ + lk for lk in links + if lk.get("source_id") in member_ids and lk.get("target_id") in member_ids + ] + external_links = [ + lk for lk in links + if (lk.get("source_id") in member_ids) != (lk.get("target_id") in member_ids) + ] + + avg_strength = 0.0 + if internal_links: + avg_strength = sum(lk.get("strength", 0) for lk in internal_links) / len(internal_links) + + all_caps: set[str] = set() + for m in members: + all_caps.update(m.get("capabilities", [])) + + clusters.append({ + "cluster_name": industry, + "cluster_name_ar": ENTITY_TYPES.get(industry, industry), + "member_count": len(members), + "internal_links": len(internal_links), + "external_links": len(external_links), + "avg_internal_strength": round(avg_strength, 4), + "capabilities": sorted(all_caps), + "members": [{"id": m["id"], "name": m["name"]} for m in members], + }) + + clusters.sort(key=lambda c: c["member_count"], reverse=True) + + logger.info("Identified %d clusters for tenant %s", len(clusters), tenant_id) + return clusters + + # ── Ecosystem Health ──────────────────────────────────────────────────── + + async def get_ecosystem_health( + self, + tenant_id: str, + db: AsyncSession, + ) -> dict: + """ + Calculate ecosystem health metrics: coverage, concentration, resilience. + حساب مؤشرات صحة المنظومة: التغطية والتركيز والمرونة + """ + eco_map = await self.build_map(tenant_id, db) + entities = eco_map.get("entities", []) + links = eco_map.get("links", []) + gaps = await self.find_gaps(tenant_id, db) + + total_entities = len(entities) + total_links = len(links) + total_gaps = len(gaps) + + if total_entities == 0: + return { + "overall_score": 0.0, + "coverage": 0.0, + "concentration_risk": 1.0, + "resilience": 0.0, + "diversity": 0.0, + "gap_count": 0, + "recommendations_ar": ["لا توجد بيانات كافية لتحليل صحة المنظومة"], + } + + # Coverage: ratio of cluster gaps (lower = better coverage) + cluster_gaps = [g for g in gaps if g.get("gap_type") == "cluster"] + total_clusters = len(CAPABILITY_CLUSTERS) + coverage = 1.0 - (len(cluster_gaps) / total_clusters) if total_clusters > 0 else 0.0 + + # Concentration risk: how dependent the ecosystem is on few entities + type_counts = defaultdict(int) + for e in entities: + type_counts[e.get("entity_type", "unknown")] += 1 + max_type_share = max(type_counts.values()) / total_entities if total_entities > 0 else 1.0 + concentration_risk = max_type_share + + # Diversity: number of distinct entity types / total possible + diversity = len(type_counts) / len(ENTITY_TYPES) if ENTITY_TYPES else 0.0 + + # Resilience: avg links per entity (more links = more resilient) + avg_links = total_links / total_entities if total_entities > 0 else 0.0 + resilience = min(1.0, avg_links / 3.0) # 3+ links per entity = max resilience + + # Overall health score + overall = round( + coverage * 0.35 + + (1.0 - concentration_risk) * 0.25 + + resilience * 0.25 + + diversity * 0.15, + 4, + ) + + # Generate recommendations + recommendations_ar: list[str] = [] + if coverage < 0.5: + recommendations_ar.append("تغطية المنظومة ضعيفة — يُنصح بإضافة شركاء في القطاعات الناقصة") + if concentration_risk > 0.6: + recommendations_ar.append("تركيز عالٍ على نوع واحد من الشركاء — يُنصح بالتنويع") + if resilience < 0.4: + recommendations_ar.append("مرونة المنظومة منخفضة — يُنصح بتعزيز الروابط بين الشركاء") + if diversity < 0.5: + recommendations_ar.append("تنوع أنواع الشركاء محدود — يُنصح بإضافة أنواع جديدة") + if total_gaps > 10: + recommendations_ar.append(f"يوجد {total_gaps} فجوة في المنظومة — يُنصح بمعالجة الفجوات الحرجة أولاً") + if not recommendations_ar: + recommendations_ar.append("المنظومة في حالة صحية جيدة — استمر في المراقبة الدورية") + + health = { + "overall_score": overall, + "coverage": round(coverage, 4), + "concentration_risk": round(concentration_risk, 4), + "resilience": round(resilience, 4), + "diversity": round(diversity, 4), + "gap_count": total_gaps, + "total_entities": total_entities, + "total_links": total_links, + "entity_type_distribution": dict(type_counts), + "recommendations_ar": recommendations_ar, + } + + logger.info( + "Ecosystem health for tenant %s: overall=%.2f coverage=%.2f risk=%.2f", + tenant_id, overall, coverage, concentration_risk, + ) + return health + + # ── Private Helpers ───────────────────────────────────────────────────── + + def _infer_entity_type(self, profile: CompanyProfile) -> str: + """Infer entity type from company profile characteristics.""" + caps = {c.lower() for c in (profile.capabilities or [])} + industry = (profile.industry or "").lower() + + if industry == "consulting" or "استشارات" in caps: + return "consultant" + if "توزيع" in caps or "distribution" in industry: + return "distributor" + if "تكامل" in caps or "integration" in industry or "تكامل أنظمة" in caps: + return "integrator" + if "إعادة بيع" in caps or "reselling" in industry: + return "reseller" + if industry in ("marketing", "media") or "تسويق" in caps: + return "agency" + return "partner" + + def _infer_link( + self, prof_a: CompanyProfile, prof_b: CompanyProfile, + ) -> tuple[str, float]: + """Infer the link type and strength between two profiles.""" + caps_a = {c.lower() for c in (prof_a.capabilities or [])} + caps_b = {c.lower() for c in (prof_b.capabilities or [])} + needs_a = {n.lower() for n in (prof_a.needs or [])} + needs_b = {n.lower() for n in (prof_b.needs or [])} + + # Check if they are in the same industry (potential competitors) + same_industry = (prof_a.industry or "") == (prof_b.industry or "") and prof_a.industry + + # Check vendor/client: A offers what B needs + a_serves_b = len(caps_a & needs_b) + b_serves_a = len(caps_b & needs_a) + + if a_serves_b > 0 and b_serves_a > 0: + # Mutual exchange = partnership + strength = min(1.0, (a_serves_b + b_serves_a) / max(len(needs_a | needs_b), 1) * 2) + return "partner", round(strength, 4) + elif a_serves_b > 0: + strength = min(1.0, a_serves_b / max(len(needs_b), 1)) + return "vendor", round(strength, 4) + elif b_serves_a > 0: + strength = min(1.0, b_serves_a / max(len(needs_a), 1)) + return "client", round(strength, 4) + elif same_industry and caps_a & caps_b: + overlap = len(caps_a & caps_b) / max(len(caps_a | caps_b), 1) + return "competitor", round(overlap, 4) + else: + return "partner", 0.1 + + def _link_description(self, name_a: str, name_b: str, link_type: str) -> str: + """Generate Arabic description for a link.""" + descriptions = { + "partner": f"{name_a} و{name_b} شركاء محتملون", + "competitor": f"{name_a} و{name_b} في نفس المجال التنافسي", + "vendor": f"{name_a} مورد محتمل لـ{name_b}", + "client": f"{name_a} عميل محتمل لـ{name_b}", + "referral": f"{name_a} و{name_b} في شبكة إحالات مشتركة", + } + return descriptions.get(link_type, f"علاقة بين {name_a} و{name_b}") diff --git a/salesflow-saas/backend/app/services/strategic_deals/operating_modes.py b/salesflow-saas/backend/app/services/strategic_deals/operating_modes.py new file mode 100644 index 00000000..a57847a9 --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/operating_modes.py @@ -0,0 +1,429 @@ +""" +Operating Modes — Five levels of AI autonomy for deal management. +أوضاع التشغيل: خمسة مستويات لصلاحيات الذكاء الاصطناعي في إدارة الصفقات +""" + +import enum +import logging +from typing import Optional + +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import CompanyProfile + +logger = logging.getLogger("dealix.strategic_deals.operating_modes") + + +# ── Operating Modes ───────────────────────────────────────────────────────── + + +class OperatingMode(int, enum.Enum): + """ + Five escalating levels of AI autonomy. + خمسة مستويات تصاعدية لاستقلالية الذكاء الاصطناعي + """ + MANUAL = 0 # AI analyzes, human does everything / الذكاء الاصطناعي يحلل والإنسان ينفذ + DRAFT = 1 # AI writes drafts, human sends / الذكاء الاصطناعي يكتب والإنسان يرسل + ASSISTED = 2 # AI sends approved templates via email / الذكاء الاصطناعي يرسل قوالب معتمدة بالإيميل + NEGOTIATION = 3 # AI negotiates within defined gates / الذكاء الاصطناعي يفاوض ضمن حدود محددة + STRATEGIC = 4 # Full workflow with mandatory escalation for commitments / سير عمل كامل مع تصعيد إلزامي للالتزامات + + +MODE_LABELS_AR = { + OperatingMode.MANUAL: "يدوي", + OperatingMode.DRAFT: "مسودات", + OperatingMode.ASSISTED: "مساعد", + OperatingMode.NEGOTIATION: "تفاوض", + OperatingMode.STRATEGIC: "استراتيجي", +} + +MODE_DESCRIPTIONS_AR = { + OperatingMode.MANUAL: "الذكاء الاصطناعي يحلل ويقترح فقط — أنت تنفذ كل شيء", + OperatingMode.DRAFT: "الذكاء الاصطناعي يكتب المسودات — أنت تراجع وترسل", + OperatingMode.ASSISTED: "الذكاء الاصطناعي يرسل القوالب المعتمدة عبر البريد الإلكتروني تلقائياً", + OperatingMode.NEGOTIATION: "الذكاء الاصطناعي يتفاوض ضمن الحدود المحددة — يصعّد عند الحاجة", + OperatingMode.STRATEGIC: "سير عمل كامل مع تصعيد إلزامي لأي التزام مالي أو قانوني", +} + + +# ── Mode Policy ───────────────────────────────────────────────────────────── + + +class ModePolicy(BaseModel): + """Policy governing what an AI agent can do in a given operating mode.""" + mode: int # OperatingMode value + allowed_channels: list[str] = Field(default_factory=list) + allowed_actions: list[str] = Field(default_factory=list) + auto_send: bool = False + auto_negotiate: bool = False + escalation_triggers: list[str] = Field(default_factory=list) + max_auto_commitment_sar: float = 0.0 + + # Labels + label_ar: str = "" + description_ar: str = "" + + +# ── Predefined Policies ──────────────────────────────────────────────────── + + +MODE_POLICIES: dict[OperatingMode, ModePolicy] = { + OperatingMode.MANUAL: ModePolicy( + mode=OperatingMode.MANUAL.value, + allowed_channels=[], + allowed_actions=[ + "analyze", + "suggest", + "draft", + "score_match", + "generate_report", + ], + auto_send=False, + auto_negotiate=False, + escalation_triggers=["all"], + max_auto_commitment_sar=0, + label_ar="يدوي", + description_ar="الذكاء الاصطناعي يحلل ويقترح فقط — أنت تنفذ كل شيء", + ), + OperatingMode.DRAFT: ModePolicy( + mode=OperatingMode.DRAFT.value, + allowed_channels=[], + allowed_actions=[ + "analyze", + "suggest", + "draft", + "score_match", + "generate_report", + "craft_introduction", + "draft_proposal", + "draft_counter_offer", + ], + auto_send=False, + auto_negotiate=False, + escalation_triggers=["all"], + max_auto_commitment_sar=0, + label_ar="مسودات", + description_ar="الذكاء الاصطناعي يكتب المسودات — أنت تراجع وترسل", + ), + OperatingMode.ASSISTED: ModePolicy( + mode=OperatingMode.ASSISTED.value, + allowed_channels=["email"], + allowed_actions=[ + "analyze", + "suggest", + "draft", + "score_match", + "generate_report", + "craft_introduction", + "draft_proposal", + "send_template", + "send_follow_up", + "schedule_reminder", + ], + auto_send=True, + auto_negotiate=False, + escalation_triggers=[ + "reply_received", + "objection", + "pricing_question", + "meeting_request", + "negative_sentiment", + ], + max_auto_commitment_sar=0, + label_ar="مساعد", + description_ar="الذكاء الاصطناعي يرسل القوالب المعتمدة عبر البريد الإلكتروني تلقائياً", + ), + OperatingMode.NEGOTIATION: ModePolicy( + mode=OperatingMode.NEGOTIATION.value, + allowed_channels=["email", "whatsapp"], + allowed_actions=[ + "analyze", + "suggest", + "draft", + "score_match", + "generate_report", + "craft_introduction", + "draft_proposal", + "send_template", + "send_follow_up", + "schedule_reminder", + "send_custom_message", + "handle_response", + "counter_offer", + "negotiate_terms", + "record_concession", + ], + auto_send=True, + auto_negotiate=True, + escalation_triggers=[ + "pricing_change", + "exclusivity", + "equity", + "legal_terms", + "value_above_threshold", + "human_requested", + "stall_detected", + ], + max_auto_commitment_sar=50_000, + label_ar="تفاوض", + description_ar="الذكاء الاصطناعي يتفاوض ضمن الحدود المحددة — يصعّد عند الحاجة", + ), + OperatingMode.STRATEGIC: ModePolicy( + mode=OperatingMode.STRATEGIC.value, + allowed_channels=["email", "whatsapp"], + allowed_actions=[ + "analyze", + "suggest", + "draft", + "score_match", + "generate_report", + "craft_introduction", + "draft_proposal", + "send_template", + "send_follow_up", + "schedule_reminder", + "send_custom_message", + "handle_response", + "counter_offer", + "negotiate_terms", + "record_concession", + "request_approval", + "generate_term_sheet", + "run_discovery_scan", + "run_outreach_campaign", + ], + auto_send=True, + auto_negotiate=True, + escalation_triggers=[ + "commitment", + "exclusivity", + "equity", + "legal", + "data_sharing", + "ip_licensing", + "territory_change", + "value_above_threshold", + "human_requested", + ], + max_auto_commitment_sar=100_000, + label_ar="استراتيجي", + description_ar="سير عمل كامل مع تصعيد إلزامي لأي التزام مالي أو قانوني", + ), +} + + +# ── Mode Enforcer ─────────────────────────────────────────────────────────── + + +class ModeEnforcer: + """ + Enforces operating mode policies before any AI action is executed. + يفرض سياسات وضع التشغيل قبل تنفيذ أي إجراء للذكاء الاصطناعي + """ + + @staticmethod + async def check_action( + mode: OperatingMode, + action: str, + deal_value: float, + db: AsyncSession, + ) -> tuple[bool, str]: + """ + Check whether an action is allowed under the current operating mode. + Returns (allowed, reason_ar). + + التحقق مما إذا كان الإجراء مسموحاً في وضع التشغيل الحالي + يرجع (مسموح، السبب_بالعربي) + """ + policy = MODE_POLICIES.get(mode) + if not policy: + return False, f"وضع التشغيل غير معروف: {mode}" + + # Check if action is in allowed list + if action not in policy.allowed_actions: + mode_label = MODE_LABELS_AR.get(mode, str(mode)) + return False, ( + f"الإجراء '{action}' غير مسموح في وضع '{mode_label}'. " + f"الإجراءات المتاحة: {', '.join(policy.allowed_actions)}" + ) + + # Check if deal value exceeds auto-commitment threshold + if deal_value > 0 and deal_value > policy.max_auto_commitment_sar: + return False, ( + f"قيمة الصفقة ({deal_value:,.0f} ريال) تتجاوز الحد الأقصى للالتزام التلقائي " + f"({policy.max_auto_commitment_sar:,.0f} ريال). يلزم تصعيد للإنسان." + ) + + # Check escalation triggers + escalation_actions = { + "counter_offer": ["pricing_change"], + "negotiate_terms": ["pricing_change", "legal_terms"], + "send_custom_message": [], + "handle_response": ["reply_received"], + "generate_term_sheet": ["legal_terms", "commitment"], + "run_outreach_campaign": [], + } + + action_triggers = escalation_actions.get(action, []) + for trigger in action_triggers: + if trigger in policy.escalation_triggers: + if not policy.auto_negotiate: + mode_label = MODE_LABELS_AR.get(mode, str(mode)) + return False, ( + f"الإجراء '{action}' يستلزم تصعيداً بسبب: {trigger}. " + f"وضع '{mode_label}' لا يسمح بالتفاوض التلقائي." + ) + + logger.info( + "Action '%s' allowed in mode %s (deal_value=%.0f SAR)", + action, mode.name, deal_value, + ) + return True, "مسموح" + + @staticmethod + async def check_channel( + mode: OperatingMode, + channel: str, + ) -> tuple[bool, str]: + """ + Check whether a communication channel is allowed under the current mode. + التحقق مما إذا كانت قناة الاتصال مسموحة في الوضع الحالي + """ + policy = MODE_POLICIES.get(mode) + if not policy: + return False, f"وضع التشغيل غير معروف: {mode}" + + if not policy.allowed_channels: + mode_label = MODE_LABELS_AR.get(mode, str(mode)) + return False, f"وضع '{mode_label}' لا يسمح بأي قناة اتصال. الإرسال يتم يدوياً." + + if channel not in policy.allowed_channels: + mode_label = MODE_LABELS_AR.get(mode, str(mode)) + return False, ( + f"القناة '{channel}' غير مسموحة في وضع '{mode_label}'. " + f"القنوات المتاحة: {', '.join(policy.allowed_channels)}" + ) + + return True, "مسموح" + + @staticmethod + async def get_current_mode( + tenant_id: str, + db: AsyncSession, + ) -> OperatingMode: + """ + Get the current operating mode for a tenant. + الحصول على وضع التشغيل الحالي للمستأجر + """ + # Mode is stored in the tenant's first company profile deal_preferences + result = await db.execute( + select(CompanyProfile).where( + CompanyProfile.tenant_id == tenant_id + ).limit(1) + ) + profile = result.scalar_one_or_none() + if not profile: + logger.info("No profile found for tenant %s, defaulting to MANUAL", tenant_id) + return OperatingMode.MANUAL + + prefs = profile.deal_preferences or {} + mode_value = prefs.get("_operating_mode", OperatingMode.MANUAL.value) + + try: + return OperatingMode(mode_value) + except ValueError: + logger.warning("Invalid operating mode %s for tenant %s, defaulting to MANUAL", mode_value, tenant_id) + return OperatingMode.MANUAL + + @staticmethod + async def set_mode( + tenant_id: str, + mode: OperatingMode, + db: AsyncSession, + ): + """ + Set the operating mode for a tenant. + تعيين وضع التشغيل للمستأجر + """ + result = await db.execute( + select(CompanyProfile).where( + CompanyProfile.tenant_id == tenant_id + ).limit(1) + ) + profile = result.scalar_one_or_none() + if not profile: + raise ValueError(f"لا يوجد ملف شركة للمستأجر: {tenant_id}") + + prefs = dict(profile.deal_preferences or {}) + old_mode = prefs.get("_operating_mode", OperatingMode.MANUAL.value) + prefs["_operating_mode"] = mode.value + profile.deal_preferences = prefs + await db.flush() + + old_label = MODE_LABELS_AR.get(OperatingMode(old_mode), str(old_mode)) + new_label = MODE_LABELS_AR.get(mode, str(mode)) + logger.info( + "Operating mode for tenant %s changed: %s -> %s", + tenant_id, old_label, new_label, + ) + + @staticmethod + def get_mode_policy(mode: OperatingMode) -> ModePolicy: + """ + Get the policy for a specific operating mode. + الحصول على سياسة وضع تشغيل محدد + """ + policy = MODE_POLICIES.get(mode) + if not policy: + raise ValueError(f"وضع التشغيل غير معروف: {mode}") + return policy + + @staticmethod + def get_all_modes() -> list[dict]: + """ + List all operating modes with their labels and descriptions. + عرض جميع أوضاع التشغيل مع التسميات والأوصاف + """ + return [ + { + "mode": mode.value, + "name": mode.name, + "label_ar": MODE_LABELS_AR[mode], + "description_ar": MODE_DESCRIPTIONS_AR[mode], + "auto_send": MODE_POLICIES[mode].auto_send, + "auto_negotiate": MODE_POLICIES[mode].auto_negotiate, + "max_auto_commitment_sar": MODE_POLICIES[mode].max_auto_commitment_sar, + "allowed_channels": MODE_POLICIES[mode].allowed_channels, + } + for mode in OperatingMode + ] + + @staticmethod + async def should_escalate( + mode: OperatingMode, + trigger: str, + deal_value: float, + ) -> tuple[bool, str]: + """ + Determine if a specific trigger requires human escalation. + تحديد ما إذا كان المحفز يستلزم تصعيداً للإنسان + """ + policy = MODE_POLICIES.get(mode) + if not policy: + return True, "وضع التشغيل غير معروف — يجب التصعيد" + + # "all" trigger means everything escalates + if "all" in policy.escalation_triggers: + return True, f"وضع '{MODE_LABELS_AR.get(mode, '')}' يتطلب تصعيد كل الإجراءات" + + if trigger in policy.escalation_triggers: + return True, f"المحفز '{trigger}' يتطلب تصعيداً في وضع '{MODE_LABELS_AR.get(mode, '')}'" + + if deal_value > policy.max_auto_commitment_sar > 0: + return True, ( + f"قيمة الصفقة ({deal_value:,.0f} ريال) تتجاوز الحد ({policy.max_auto_commitment_sar:,.0f} ريال)" + ) + + return False, "لا يلزم تصعيد" diff --git a/salesflow-saas/backend/app/services/strategic_deals/portfolio_intelligence.py b/salesflow-saas/backend/app/services/strategic_deals/portfolio_intelligence.py new file mode 100644 index 00000000..4d04c0a1 --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/portfolio_intelligence.py @@ -0,0 +1,573 @@ +""" +Portfolio Intelligence — AI-driven insights across the deal portfolio. +ذكاء المحفظة: رؤى مدعومة بالذكاء الاصطناعي عبر محفظة الصفقات +""" + +import json +import logging +from collections import defaultdict +from typing import Optional + +from pydantic import BaseModel, Field +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import CompanyProfile, DealMatch, StrategicDeal +from app.services.llm.provider import get_llm + +logger = logging.getLogger("dealix.strategic_deals.portfolio_intelligence") + +# ── Vertical definitions (Saudi market) ───────────────────────────────────── + +VERTICALS = { + "technology": "تقنية المعلومات", + "construction": "مقاولات وبناء", + "real_estate": "عقارات", + "retail": "تجارة تجزئة", + "wholesale": "تجارة جملة", + "healthcare": "رعاية صحية", + "education": "تعليم وتدريب", + "food_beverage": "أغذية ومشروبات", + "logistics": "نقل ولوجستيات", + "finance": "خدمات مالية", + "energy": "طاقة", + "tourism": "سياحة وضيافة", + "consulting": "استشارات", + "marketing": "تسويق وإعلان", + "manufacturing": "صناعة", + "telecom": "اتصالات", + "media": "إعلام وترفيه", + "agriculture": "زراعة", + "automotive": "سيارات", + "government": "قطاع حكومي", +} + +DEAL_TYPE_LABELS = { + "partnership": "شراكة", + "distribution": "توزيع", + "franchise": "امتياز", + "jv": "مشروع مشترك", + "referral": "إحالة", + "acquisition": "استحواذ", + "barter": "مقايضة", + "reseller": "إعادة بيع", +} + + +# ── Models ────────────────────────────────────────────────────────────────── + + +class PortfolioInsight(BaseModel): + """A single intelligence insight derived from portfolio analysis.""" + insight_type: str # top_vertical, best_deal_type, best_partner_archetype, gap, productization + title: str = "" + title_ar: str = "" + data: dict = Field(default_factory=dict) + confidence: float = Field(0.5, ge=0.0, le=1.0) + recommendation: str = "" + recommendation_ar: str = "" + + class Config: + json_schema_extra = { + "example": { + "insight_type": "top_vertical", + "title": "Technology is the best-performing vertical", + "title_ar": "قطاع التقنية هو الأفضل أداءً", + "data": {"vertical": "technology", "deal_count": 15, "avg_score": 0.82}, + "confidence": 0.85, + "recommendation_ar": "زيادة التركيز على صفقات قطاع التقنية", + } + } + + +# ── Portfolio Intelligence Engine ─────────────────────────────────────────── + + +class PortfolioIntelligence: + """ + Analyzes the entire deal portfolio to surface actionable insights. + Identifies top verticals, best deal structures, gaps, and productization opportunities. + يحلل محفظة الصفقات بالكامل لاستخراج رؤى قابلة للتنفيذ + """ + + def __init__(self): + self.llm = get_llm() + + # ── Full Analysis ─────────────────────────────────────────────────────── + + async def analyze( + self, + tenant_id: str, + period: str = "quarterly", + db: AsyncSession = None, + ) -> list[PortfolioInsight]: + """ + Run a complete portfolio analysis and return all insights. + تحليل شامل للمحفظة واستخراج جميع الرؤى + """ + if db is None: + raise ValueError("Database session is required") + + insights: list[PortfolioInsight] = [] + + # Run all analysis types in sequence + verticals = await self.get_top_verticals(tenant_id, db) + if verticals: + top = verticals[0] + insights.append(PortfolioInsight( + insight_type="top_vertical", + title=f"Top vertical: {top.get('vertical', 'unknown')}", + title_ar=f"القطاع الأفضل: {top.get('vertical_ar', 'غير محدد')}", + data=top, + confidence=min(0.95, top.get("deal_count", 0) / 20), + recommendation=f"Increase focus on {top.get('vertical', '')} deals", + recommendation_ar=f"زيادة التركيز على صفقات قطاع {top.get('vertical_ar', '')}", + )) + + deal_types = await self.get_best_deal_types(tenant_id, db) + if deal_types: + best = deal_types[0] + insights.append(PortfolioInsight( + insight_type="best_deal_type", + title=f"Best deal type: {best.get('deal_type', 'unknown')}", + title_ar=f"أفضل نوع صفقة: {best.get('deal_type_ar', 'غير محدد')}", + data=best, + confidence=min(0.90, best.get("count", 0) / 15), + recommendation=f"Prioritize {best.get('deal_type', '')} deals", + recommendation_ar=f"إعطاء الأولوية لصفقات {best.get('deal_type_ar', '')}", + )) + + archetypes = await self.get_best_partner_archetypes(tenant_id, db) + if archetypes: + best_arch = archetypes[0] + insights.append(PortfolioInsight( + insight_type="best_partner_archetype", + title=f"Best partner type: {best_arch.get('archetype', 'unknown')}", + title_ar=f"أفضل نوع شريك: {best_arch.get('archetype_ar', 'غير محدد')}", + data=best_arch, + confidence=min(0.85, best_arch.get("count", 0) / 10), + recommendation_ar=f"البحث عن شركاء من نوع {best_arch.get('archetype_ar', '')}", + )) + + gaps = await self.get_repeated_gaps(tenant_id, db) + for gap in gaps[:3]: + insights.append(PortfolioInsight( + insight_type="repeated_gap", + title=f"Repeated gap: {gap.get('gap', '')}", + title_ar=f"فجوة متكررة: {gap.get('gap', '')}", + data=gap, + confidence=min(0.80, gap.get("frequency", 0) / 5), + recommendation_ar=f"سد فجوة: {gap.get('gap', '')} — تكررت {gap.get('frequency', 0)} مرات", + )) + + products = await self.get_productization_candidates(tenant_id, db) + for prod in products[:2]: + insights.append(PortfolioInsight( + insight_type="productization", + title=f"Productization candidate: {prod.get('capability', '')}", + title_ar=f"فرصة تحويل لمنتج: {prod.get('capability', '')}", + data=prod, + confidence=min(0.75, prod.get("demand_count", 0) / 8), + recommendation_ar=f"تحويل «{prod.get('capability', '')}» إلى منتج قابل للبيع", + )) + + # Sort by confidence descending + insights.sort(key=lambda i: i.confidence, reverse=True) + + logger.info( + "Portfolio analysis for tenant %s (%s): %d insights", + tenant_id, period, len(insights), + ) + return insights + + # ── Top Verticals ─────────────────────────────────────────────────────── + + async def get_top_verticals( + self, + tenant_id: str, + db: AsyncSession, + ) -> list[dict]: + """ + Identify the highest-performing industry verticals by deal volume and score. + تحديد القطاعات الصناعية الأفضل أداءً حسب حجم الصفقات والتقييم + """ + result = await db.execute( + select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id) + ) + profiles = result.scalars().all() + + # Count deals and avg scores per industry + industry_stats: dict[str, dict] = defaultdict( + lambda: {"deal_count": 0, "total_score": 0.0, "total_revenue": 0.0, "companies": 0} + ) + + for profile in profiles: + industry = profile.industry or "other" + industry_stats[industry]["companies"] += 1 + industry_stats[industry]["total_revenue"] += float(profile.annual_revenue_sar or 0) + industry_stats[industry]["total_score"] += float(profile.trust_score or 0) + + # Get match counts per industry + matches_result = await db.execute( + select(DealMatch).where(DealMatch.tenant_id == tenant_id) + ) + matches = matches_result.scalars().all() + + profile_industry: dict[str, str] = {} + for p in profiles: + profile_industry[str(p.id)] = p.industry or "other" + + for match in matches: + industry_a = profile_industry.get(str(match.company_a_id), "other") + industry_stats[industry_a]["deal_count"] += 1 + + # Build ranked list + verticals: list[dict] = [] + for industry, stats in industry_stats.items(): + companies = stats["companies"] + avg_score = stats["total_score"] / companies if companies > 0 else 0 + verticals.append({ + "vertical": industry, + "vertical_ar": VERTICALS.get(industry, industry), + "deal_count": stats["deal_count"], + "company_count": companies, + "avg_trust_score": round(avg_score, 4), + "total_revenue_sar": round(stats["total_revenue"], 2), + "performance_score": round( + stats["deal_count"] * 0.4 + avg_score * 0.3 + min(companies / 10, 1) * 0.3, 4 + ), + }) + + verticals.sort(key=lambda v: v["performance_score"], reverse=True) + + logger.info("Top verticals for tenant %s: %d industries analyzed", tenant_id, len(verticals)) + return verticals + + # ── Best Deal Types ───────────────────────────────────────────────────── + + async def get_best_deal_types( + self, + tenant_id: str, + db: AsyncSession, + ) -> list[dict]: + """ + Determine which deal types yield the best results. + تحديد أنواع الصفقات الأكثر نجاحاً + """ + matches_result = await db.execute( + select(DealMatch).where(DealMatch.tenant_id == tenant_id) + ) + matches = matches_result.scalars().all() + + type_stats: dict[str, dict] = defaultdict( + lambda: {"count": 0, "total_score": 0.0, "accepted": 0} + ) + + for match in matches: + deal_type = match.deal_type_suggested or "unknown" + type_stats[deal_type]["count"] += 1 + type_stats[deal_type]["total_score"] += float(match.match_score or 0) + if match.status in ("accepted", "signed", "active"): + type_stats[deal_type]["accepted"] += 1 + + deal_types: list[dict] = [] + for dt, stats in type_stats.items(): + count = stats["count"] + avg_score = stats["total_score"] / count if count > 0 else 0 + acceptance_rate = stats["accepted"] / count if count > 0 else 0 + + deal_types.append({ + "deal_type": dt, + "deal_type_ar": DEAL_TYPE_LABELS.get(dt, dt), + "count": count, + "avg_match_score": round(avg_score, 4), + "acceptance_rate": round(acceptance_rate, 4), + "effectiveness_score": round( + avg_score * 0.4 + acceptance_rate * 0.4 + min(count / 20, 1) * 0.2, 4 + ), + }) + + deal_types.sort(key=lambda d: d["effectiveness_score"], reverse=True) + + logger.info("Best deal types for tenant %s: %d types analyzed", tenant_id, len(deal_types)) + return deal_types + + # ── Best Partner Archetypes ───────────────────────────────────────────── + + async def get_best_partner_archetypes( + self, + tenant_id: str, + db: AsyncSession, + ) -> list[dict]: + """ + Identify the most successful partner archetypes (size, industry, type). + تحديد أنماط الشركاء الأكثر نجاحاً (الحجم، القطاع، النوع) + """ + result = await db.execute( + select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id) + ) + profiles = result.scalars().all() + + matches_result = await db.execute( + select(DealMatch).where(DealMatch.tenant_id == tenant_id) + ) + matches = matches_result.scalars().all() + + # Build profile lookup + profile_map: dict[str, CompanyProfile] = {} + for p in profiles: + profile_map[str(p.id)] = p + + # Analyze successful matches to derive archetypes + archetype_stats: dict[str, dict] = defaultdict( + lambda: {"count": 0, "total_score": 0.0, "examples": []} + ) + + for match in matches: + partner_id = str(match.company_b_id) if match.company_b_id else None + if not partner_id or partner_id not in profile_map: + continue + + partner = profile_map[partner_id] + emp_count = int(partner.employee_count or 0) + + if emp_count > 500: + size_bucket = "enterprise" + size_ar = "مؤسسة كبيرة" + elif emp_count > 50: + size_bucket = "mid_market" + size_ar = "سوق متوسط" + elif emp_count > 10: + size_bucket = "smb" + size_ar = "أعمال صغيرة ومتوسطة" + else: + size_bucket = "startup" + size_ar = "شركة ناشئة" + + archetype_key = f"{partner.industry or 'unknown'}_{size_bucket}" + archetype_stats[archetype_key]["count"] += 1 + archetype_stats[archetype_key]["total_score"] += float(match.match_score or 0) + archetype_stats[archetype_key]["industry"] = partner.industry or "unknown" + archetype_stats[archetype_key]["size"] = size_bucket + archetype_stats[archetype_key]["size_ar"] = size_ar + archetype_stats[archetype_key]["industry_ar"] = VERTICALS.get(partner.industry or "", partner.industry or "") + if len(archetype_stats[archetype_key]["examples"]) < 3: + archetype_stats[archetype_key]["examples"].append(partner.company_name) + + archetypes: list[dict] = [] + for key, stats in archetype_stats.items(): + count = stats["count"] + avg_score = stats["total_score"] / count if count > 0 else 0 + archetype_label = f"{stats.get('industry_ar', '')} - {stats.get('size_ar', '')}" + + archetypes.append({ + "archetype": key, + "archetype_ar": archetype_label, + "industry": stats.get("industry", ""), + "size": stats.get("size", ""), + "count": count, + "avg_match_score": round(avg_score, 4), + "examples": stats.get("examples", []), + "score": round(avg_score * 0.6 + min(count / 10, 1) * 0.4, 4), + }) + + archetypes.sort(key=lambda a: a["score"], reverse=True) + + logger.info("Partner archetypes for tenant %s: %d archetypes", tenant_id, len(archetypes)) + return archetypes + + # ── Repeated Gaps ─────────────────────────────────────────────────────── + + async def get_repeated_gaps( + self, + tenant_id: str, + db: AsyncSession, + ) -> list[dict]: + """ + Find needs that repeatedly appear but are never fulfilled in the portfolio. + اكتشاف الاحتياجات التي تتكرر ولا يتم تلبيتها في المحفظة + """ + result = await db.execute( + select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id) + ) + profiles = result.scalars().all() + + all_needs: dict[str, int] = defaultdict(int) + all_caps: set[str] = set() + + for profile in profiles: + for need in (profile.needs or []): + all_needs[need.lower().strip()] += 1 + for cap in (profile.capabilities or []): + all_caps.add(cap.lower().strip()) + + # Gaps: needs that appear multiple times but nobody offers + gaps: list[dict] = [] + for need, frequency in sorted(all_needs.items(), key=lambda x: x[1], reverse=True): + if need not in all_caps and frequency >= 2: + gaps.append({ + "gap": need, + "frequency": frequency, + "severity": "high" if frequency >= 5 else ("medium" if frequency >= 3 else "low"), + "severity_ar": "عالية" if frequency >= 5 else ("متوسطة" if frequency >= 3 else "منخفضة"), + "recommendation_ar": f"البحث عن شريك يقدم «{need}» — مطلوب من {frequency} شركة", + }) + + logger.info("Repeated gaps for tenant %s: %d gaps found", tenant_id, len(gaps)) + return gaps + + # ── Productization Candidates ─────────────────────────────────────────── + + async def get_productization_candidates( + self, + tenant_id: str, + db: AsyncSession, + ) -> list[dict]: + """ + Identify capabilities with high demand that could become standalone products. + تحديد القدرات ذات الطلب العالي التي يمكن تحويلها لمنتجات مستقلة + """ + result = await db.execute( + select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id) + ) + profiles = result.scalars().all() + + # Count how many companies need each capability vs how many offer it + cap_supply: dict[str, int] = defaultdict(int) + cap_demand: dict[str, int] = defaultdict(int) + + for profile in profiles: + for cap in (profile.capabilities or []): + cap_supply[cap.lower().strip()] += 1 + for need in (profile.needs or []): + cap_demand[need.lower().strip()] += 1 + + candidates: list[dict] = [] + for capability, demand_count in cap_demand.items(): + supply_count = cap_supply.get(capability, 0) + if demand_count >= 3 and supply_count <= 1: + demand_supply_ratio = demand_count / max(supply_count, 1) + candidates.append({ + "capability": capability, + "demand_count": demand_count, + "supply_count": supply_count, + "demand_supply_ratio": round(demand_supply_ratio, 2), + "market_potential": "عالي" if demand_supply_ratio > 5 else ("متوسط" if demand_supply_ratio > 2 else "منخفض"), + "recommendation_ar": ( + f"فرصة لتحويل «{capability}» إلى منتج — " + f"مطلوب من {demand_count} شركة ومتوفر عند {supply_count} فقط" + ), + }) + + candidates.sort(key=lambda c: c["demand_supply_ratio"], reverse=True) + + logger.info( + "Productization candidates for tenant %s: %d candidates", + tenant_id, len(candidates), + ) + return candidates + + # ── Quarterly Report ──────────────────────────────────────────────────── + + async def generate_quarterly_report( + self, + tenant_id: str, + db: AsyncSession, + ) -> str: + """ + Generate a comprehensive Arabic quarterly portfolio intelligence report. + إنشاء تقرير ذكاء محفظة ربع سنوي شامل بالعربي + """ + insights = await self.analyze(tenant_id, period="quarterly", db=db) + verticals = await self.get_top_verticals(tenant_id, db) + deal_types = await self.get_best_deal_types(tenant_id, db) + gaps = await self.get_repeated_gaps(tenant_id, db) + products = await self.get_productization_candidates(tenant_id, db) + + # Build context for LLM + context_parts = [ + f"عدد الرؤى المستخرجة: {len(insights)}", + f"القطاعات الأفضل أداءً: {json.dumps(verticals[:5], ensure_ascii=False)}", + f"أنواع الصفقات الأنجح: {json.dumps(deal_types[:5], ensure_ascii=False)}", + f"الفجوات المتكررة: {json.dumps(gaps[:5], ensure_ascii=False)}", + f"فرص التحويل لمنتجات: {json.dumps(products[:5], ensure_ascii=False)}", + ] + + top_insights = [] + for ins in insights[:5]: + top_insights.append(f"- {ins.title_ar} (ثقة: {ins.confidence:.0%}): {ins.recommendation_ar}") + + context_parts.append(f"أبرز الرؤى:\n" + "\n".join(top_insights)) + + context = "\n\n".join(context_parts) + + system_prompt = """أنت محلل استراتيجي سعودي خبير. اكتب تقرير ذكاء محفظة ربع سنوي شامل بالعربي. + +يجب أن يشمل التقرير: +١. ملخص تنفيذي +٢. أداء القطاعات — أي القطاعات تحقق أفضل النتائج +٣. تحليل أنواع الصفقات — أي الهياكل أنجح +٤. الفجوات الاستراتيجية — ما ينقص المنظومة +٥. فرص التحويل لمنتجات — خدمات يمكن تعبئتها كمنتجات +٦. التوصيات الاستراتيجية — ٣-٥ توصيات محددة +٧. خطة العمل للربع القادم + +اكتب بأسلوب تنفيذي رسمي مناسب لمجلس الإدارة. استخدم الأرقام والنسب.""" + + try: + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + temperature=0.3, + ) + report = llm_response.content.strip() + except Exception as exc: + logger.error("Quarterly report generation failed: %s", exc) + # Build a structured fallback report + report_parts = [ + "تقرير ذكاء المحفظة — الربع الحالي", + "=" * 40, + "", + "ملخص تنفيذي:", + f"تم تحليل المحفظة واستخراج {len(insights)} رؤية استراتيجية.", + "", + ] + + if verticals: + report_parts.append("القطاعات الأفضل أداءً:") + for v in verticals[:3]: + report_parts.append( + f" - {v.get('vertical_ar', '')}: " + f"{v.get('deal_count', 0)} صفقة، " + f"تقييم {v.get('avg_trust_score', 0):.2f}" + ) + report_parts.append("") + + if deal_types: + report_parts.append("أنواع الصفقات الأنجح:") + for dt in deal_types[:3]: + report_parts.append( + f" - {dt.get('deal_type_ar', '')}: " + f"{dt.get('count', 0)} صفقة، " + f"فعالية {dt.get('effectiveness_score', 0):.2f}" + ) + report_parts.append("") + + if gaps: + report_parts.append("الفجوات المتكررة:") + for g in gaps[:3]: + report_parts.append(f" - {g.get('gap', '')}: تكررت {g.get('frequency', 0)} مرات") + report_parts.append("") + + if products: + report_parts.append("فرص التحويل لمنتجات:") + for p in products[:3]: + report_parts.append( + f" - {p.get('capability', '')}: " + f"الطلب {p.get('demand_count', 0)} / العرض {p.get('supply_count', 0)}" + ) + + report = "\n".join(report_parts) + + logger.info("Generated quarterly report for tenant %s", tenant_id) + return report diff --git a/salesflow-saas/backend/app/services/strategic_deals/roi_engine.py b/salesflow-saas/backend/app/services/strategic_deals/roi_engine.py new file mode 100644 index 00000000..c5bb9f01 --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/roi_engine.py @@ -0,0 +1,484 @@ +""" +ROI Engine — Return on Investment calculator for strategic B2B initiatives. +محرك العائد على الاستثمار: حاسبة العائد على الاستثمار للمبادرات الاستراتيجية +""" + +import json +import logging +import math +from typing import Optional + +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import CompanyProfile +from app.services.llm.provider import get_llm + +logger = logging.getLogger("dealix.strategic_deals.roi_engine") + +# ── Initiative type benchmarks (Saudi market) ─────────────────────────────── + +INITIATIVE_BENCHMARKS = { + "partnership": { + "avg_roi_pct": 0.45, + "avg_payback_months": 8, + "cac_reduction_range": (0.10, 0.30), + "margin_impact_range": (0.02, 0.08), + }, + "acquisition": { + "avg_roi_pct": 0.25, + "avg_payback_months": 24, + "cac_reduction_range": (0.15, 0.40), + "margin_impact_range": (0.05, 0.15), + }, + "channel_expansion": { + "avg_roi_pct": 0.60, + "avg_payback_months": 6, + "cac_reduction_range": (0.05, 0.20), + "margin_impact_range": (0.01, 0.05), + }, + "market_entry": { + "avg_roi_pct": 0.30, + "avg_payback_months": 18, + "cac_reduction_range": (0.00, 0.10), + "margin_impact_range": (0.03, 0.10), + }, + "digital_transformation": { + "avg_roi_pct": 0.55, + "avg_payback_months": 12, + "cac_reduction_range": (0.20, 0.50), + "margin_impact_range": (0.05, 0.12), + }, + "product_launch": { + "avg_roi_pct": 0.40, + "avg_payback_months": 10, + "cac_reduction_range": (0.00, 0.15), + "margin_impact_range": (0.05, 0.20), + }, + "referral_program": { + "avg_roi_pct": 0.80, + "avg_payback_months": 3, + "cac_reduction_range": (0.30, 0.60), + "margin_impact_range": (0.01, 0.03), + }, +} + + +# ── Models ────────────────────────────────────────────────────────────────── + + +class ROICalculation(BaseModel): + """Complete ROI analysis for a strategic initiative.""" + initiative_type: str + investment_sar: float = 0.0 + projected_return_sar: float = 0.0 + roi_percentage: float = 0.0 + payback_months: int = 0 + cac_reduction: float = Field(0.0, ge=0.0, le=1.0) + distribution_value: float = 0.0 + margin_impact: float = 0.0 + risk_adjusted_roi: float = 0.0 + confidence: float = Field(0.5, ge=0.0, le=1.0) + breakdown: dict = Field(default_factory=dict) + summary_ar: str = "" + + class Config: + json_schema_extra = { + "example": { + "initiative_type": "partnership", + "investment_sar": 100_000, + "projected_return_sar": 250_000, + "roi_percentage": 150.0, + "payback_months": 6, + "cac_reduction": 0.20, + "risk_adjusted_roi": 97.5, + "confidence": 0.75, + "summary_ar": "شراكة مع عائد متوقع ٢٥٠ ألف ريال واسترداد خلال ٦ أشهر", + } + } + + +# ── ROI Engine ────────────────────────────────────────────────────────────── + + +class ROIEngine: + """ + Calculates, compares, and projects ROI for strategic B2B initiatives. + يحسب ويقارن ويتوقع العائد على الاستثمار للمبادرات الاستراتيجية + """ + + def __init__(self): + self.llm = get_llm() + self._active_calculations: dict[str, list[ROICalculation]] = {} + + # ── Calculate ROI ─────────────────────────────────────────────────────── + + async def calculate( + self, + initiative_type: str, + params: dict, + db: AsyncSession, + ) -> ROICalculation: + """ + Calculate detailed ROI for a strategic initiative. + حساب العائد على الاستثمار التفصيلي لمبادرة استراتيجية + """ + benchmark = INITIATIVE_BENCHMARKS.get(initiative_type, {}) + + investment = float(params.get("investment_sar", 0)) + if investment <= 0: + raise ValueError("investment_sar must be positive") + + projected_return = float(params.get("projected_return_sar", 0)) + monthly_return = float(params.get("monthly_return_sar", 0)) + duration_months = int(params.get("duration_months", 12)) + risk_factor = min(1.0, max(0.0, float(params.get("risk_factor", 0.3)))) + discount_rate = float(params.get("annual_discount_rate", 0.08)) + + # If projected_return not given, estimate from monthly + if projected_return <= 0 and monthly_return > 0: + projected_return = monthly_return * duration_months + + # If still zero, use benchmark + if projected_return <= 0: + avg_roi = benchmark.get("avg_roi_pct", 0.30) + projected_return = investment * (1 + avg_roi) + + # Core ROI + roi_percentage = ((projected_return - investment) / investment) * 100 if investment > 0 else 0.0 + + # Payback period + if monthly_return > 0: + payback_months = max(1, math.ceil(investment / monthly_return)) + elif projected_return > investment and duration_months > 0: + monthly_est = (projected_return - investment) / duration_months + payback_months = max(1, math.ceil(investment / monthly_est)) if monthly_est > 0 else duration_months + else: + payback_months = benchmark.get("avg_payback_months", 12) + + # CAC reduction estimate + cac_range = benchmark.get("cac_reduction_range", (0.0, 0.15)) + cac_reduction = float(params.get("cac_reduction", (cac_range[0] + cac_range[1]) / 2)) + cac_reduction = min(1.0, max(0.0, cac_reduction)) + + # Margin impact + margin_range = benchmark.get("margin_impact_range", (0.01, 0.05)) + margin_impact = float(params.get("margin_impact", (margin_range[0] + margin_range[1]) / 2)) + + # Distribution / channel value + distribution_value = float(params.get("distribution_value_sar", 0)) + if distribution_value <= 0 and initiative_type in ("channel_expansion", "partnership", "referral_program"): + distribution_value = projected_return * 0.2 + + # Risk-adjusted ROI — discount by risk factor + risk_adjusted_roi = roi_percentage * (1 - risk_factor) + + # NPV-based confidence: higher NPV relative to investment = higher confidence + monthly_discount = discount_rate / 12 + npv = 0.0 + if monthly_return > 0: + for month in range(1, duration_months + 1): + npv += monthly_return / ((1 + monthly_discount) ** month) + else: + monthly_est = projected_return / max(duration_months, 1) + for month in range(1, duration_months + 1): + npv += monthly_est / ((1 + monthly_discount) ** month) + + npv -= investment + npv_ratio = npv / investment if investment > 0 else 0 + confidence = min(0.95, max(0.1, 0.5 + npv_ratio * 0.3)) + + # Detailed breakdown + breakdown = { + "gross_return_sar": round(projected_return, 2), + "net_return_sar": round(projected_return - investment, 2), + "npv_sar": round(npv, 2), + "monthly_return_sar": round(monthly_return or projected_return / max(duration_months, 1), 2), + "duration_months": duration_months, + "risk_factor": risk_factor, + "discount_rate": discount_rate, + "cac_savings_sar": round(investment * cac_reduction, 2), + "distribution_value_sar": round(distribution_value, 2), + "benchmark_avg_roi_pct": benchmark.get("avg_roi_pct", 0) * 100, + "vs_benchmark": "أعلى من المتوسط" if roi_percentage > benchmark.get("avg_roi_pct", 0) * 100 else "أقل من المتوسط", + } + + # Generate Arabic summary + summary_ar = await self._generate_summary( + initiative_type, investment, projected_return, + roi_percentage, payback_months, risk_adjusted_roi, confidence, + ) + + calc = ROICalculation( + initiative_type=initiative_type, + investment_sar=round(investment, 2), + projected_return_sar=round(projected_return, 2), + roi_percentage=round(roi_percentage, 2), + payback_months=payback_months, + cac_reduction=round(cac_reduction, 4), + distribution_value=round(distribution_value, 2), + margin_impact=round(margin_impact, 4), + risk_adjusted_roi=round(risk_adjusted_roi, 2), + confidence=round(confidence, 4), + breakdown=breakdown, + summary_ar=summary_ar, + ) + + # Store for tenant dashboard + tenant_id = params.get("tenant_id", "default") + self._active_calculations.setdefault(tenant_id, []).append(calc) + + logger.info( + "ROI calculated: type=%s investment=%.0f return=%.0f roi=%.1f%% payback=%dm", + initiative_type, investment, projected_return, roi_percentage, payback_months, + ) + return calc + + # ── Compare Initiatives ───────────────────────────────────────────────── + + async def compare_initiatives( + self, + calculations: list[ROICalculation], + ) -> dict: + """ + Rank and compare multiple initiatives by risk-adjusted ROI. + ترتيب ومقارنة عدة مبادرات حسب العائد المعدل بالمخاطر + """ + if not calculations: + return {"ranked": [], "summary_ar": "لا توجد مبادرات للمقارنة"} + + ranked = [] + for calc in calculations: + ranked.append({ + "initiative_type": calc.initiative_type, + "investment_sar": calc.investment_sar, + "projected_return_sar": calc.projected_return_sar, + "roi_percentage": calc.roi_percentage, + "risk_adjusted_roi": calc.risk_adjusted_roi, + "payback_months": calc.payback_months, + "confidence": calc.confidence, + "cac_reduction": calc.cac_reduction, + "margin_impact": calc.margin_impact, + "npv_sar": calc.breakdown.get("npv_sar", 0), + }) + + # Sort by risk-adjusted ROI descending + ranked.sort(key=lambda x: x["risk_adjusted_roi"], reverse=True) + + for i, item in enumerate(ranked): + item["rank"] = i + 1 + + best = ranked[0] + summary_ar = ( + f"تم مقارنة {len(ranked)} مبادرة. " + f"الأفضل: {best['initiative_type']} بعائد معدل {best['risk_adjusted_roi']:.1f}% " + f"واسترداد خلال {best['payback_months']} شهر " + f"بدرجة ثقة {best['confidence']:.0%}." + ) + + total_investment = sum(c.investment_sar for c in calculations) + total_return = sum(c.projected_return_sar for c in calculations) + portfolio_roi = ((total_return - total_investment) / total_investment * 100) if total_investment > 0 else 0 + + logger.info( + "Compared %d initiatives. Best: %s (adj ROI=%.1f%%)", + len(ranked), best["initiative_type"], best["risk_adjusted_roi"], + ) + + return { + "ranked": ranked, + "portfolio_investment_sar": round(total_investment, 2), + "portfolio_return_sar": round(total_return, 2), + "portfolio_roi_pct": round(portfolio_roi, 2), + "summary_ar": summary_ar, + } + + # ── Annual Projection ─────────────────────────────────────────────────── + + async def project_annual( + self, + tenant_id: str, + db: AsyncSession, + ) -> dict: + """ + Project annual returns across all active initiatives for a tenant. + إسقاط العوائد السنوية لجميع المبادرات النشطة للمستأجر + """ + calculations = self._active_calculations.get(tenant_id, []) + + if not calculations: + return { + "tenant_id": tenant_id, + "total_investment_sar": 0, + "projected_annual_return_sar": 0, + "weighted_roi_pct": 0, + "avg_payback_months": 0, + "monthly_projections": [], + "summary_ar": "لا توجد مبادرات نشطة لهذا المستأجر", + } + + total_investment = sum(c.investment_sar for c in calculations) + total_return = sum(c.projected_return_sar for c in calculations) + weighted_roi = ((total_return - total_investment) / total_investment * 100) if total_investment > 0 else 0 + avg_payback = sum(c.payback_months for c in calculations) / len(calculations) + total_cac_savings = sum(c.investment_sar * c.cac_reduction for c in calculations) + + # Monthly projection across all initiatives + monthly_projections = [] + for month in range(1, 13): + month_return = 0.0 + for calc in calculations: + if month >= calc.payback_months: + monthly_est = calc.breakdown.get("monthly_return_sar", 0) + month_return += monthly_est + else: + ramp_ratio = month / max(calc.payback_months, 1) + monthly_est = calc.breakdown.get("monthly_return_sar", 0) * ramp_ratio + month_return += monthly_est + + monthly_projections.append({ + "month": month, + "projected_return_sar": round(month_return, 2), + "cumulative_sar": round( + sum(p["projected_return_sar"] for p in monthly_projections) + month_return, 2 + ), + }) + + summary_ar = ( + f"إجمالي الاستثمار: {total_investment:,.0f} ريال | " + f"العائد السنوي المتوقع: {total_return:,.0f} ريال | " + f"العائد على الاستثمار: {weighted_roi:.1f}% | " + f"متوسط فترة الاسترداد: {avg_payback:.0f} شهر | " + f"وفورات تكلفة الاستحواذ: {total_cac_savings:,.0f} ريال" + ) + + logger.info( + "Annual projection for tenant %s: investment=%.0f return=%.0f roi=%.1f%%", + tenant_id, total_investment, total_return, weighted_roi, + ) + + return { + "tenant_id": tenant_id, + "total_investment_sar": round(total_investment, 2), + "projected_annual_return_sar": round(total_return, 2), + "weighted_roi_pct": round(weighted_roi, 2), + "avg_payback_months": round(avg_payback, 1), + "total_cac_savings_sar": round(total_cac_savings, 2), + "initiative_count": len(calculations), + "monthly_projections": monthly_projections, + "summary_ar": summary_ar, + } + + # ── ROI Dashboard ─────────────────────────────────────────────────────── + + async def get_roi_dashboard( + self, + tenant_id: str, + db: AsyncSession, + ) -> dict: + """ + Get a comprehensive ROI dashboard for all active initiatives. + الحصول على لوحة معلومات شاملة للعائد على الاستثمار لجميع المبادرات النشطة + """ + calculations = self._active_calculations.get(tenant_id, []) + projection = await self.project_annual(tenant_id, db) + + # Group by initiative type + by_type: dict[str, list[ROICalculation]] = {} + for calc in calculations: + by_type.setdefault(calc.initiative_type, []).append(calc) + + type_summaries = [] + for init_type, calcs in by_type.items(): + total_inv = sum(c.investment_sar for c in calcs) + total_ret = sum(c.projected_return_sar for c in calcs) + avg_roi = sum(c.roi_percentage for c in calcs) / len(calcs) + avg_conf = sum(c.confidence for c in calcs) / len(calcs) + + type_summaries.append({ + "initiative_type": init_type, + "count": len(calcs), + "total_investment_sar": round(total_inv, 2), + "total_return_sar": round(total_ret, 2), + "avg_roi_pct": round(avg_roi, 2), + "avg_confidence": round(avg_conf, 4), + }) + + type_summaries.sort(key=lambda x: x["avg_roi_pct"], reverse=True) + + # Top performers + top_performers = sorted(calculations, key=lambda c: c.risk_adjusted_roi, reverse=True)[:5] + top_list = [ + { + "initiative_type": c.initiative_type, + "investment_sar": c.investment_sar, + "roi_pct": c.roi_percentage, + "risk_adjusted_roi": c.risk_adjusted_roi, + "payback_months": c.payback_months, + } + for c in top_performers + ] + + dashboard = { + "tenant_id": tenant_id, + "initiative_count": len(calculations), + "projection": projection, + "by_type": type_summaries, + "top_performers": top_list, + "health": { + "avg_roi_pct": round( + sum(c.roi_percentage for c in calculations) / max(len(calculations), 1), 2 + ), + "avg_confidence": round( + sum(c.confidence for c in calculations) / max(len(calculations), 1), 4 + ), + "total_at_risk_sar": round( + sum(c.investment_sar * (1 - c.confidence) for c in calculations), 2 + ), + }, + } + + logger.info("ROI dashboard for tenant %s: %d initiatives", tenant_id, len(calculations)) + return dashboard + + # ── Private Helpers ───────────────────────────────────────────────────── + + async def _generate_summary( + self, + initiative_type: str, + investment: float, + projected_return: float, + roi_pct: float, + payback_months: int, + risk_adjusted_roi: float, + confidence: float, + ) -> str: + """Generate an Arabic summary for an ROI calculation.""" + context = f"""نوع المبادرة: {initiative_type} +الاستثمار: {investment:,.0f} ريال +العائد المتوقع: {projected_return:,.0f} ريال +العائد على الاستثمار: {roi_pct:.1f}% +فترة الاسترداد: {payback_months} شهر +العائد المعدل بالمخاطر: {risk_adjusted_roi:.1f}% +درجة الثقة: {confidence:.0%}""" + + system_prompt = """أنت محلل مالي سعودي. اكتب ملخصاً موجزاً بالعربي (٢-٣ جمل) يشرح العائد على الاستثمار لهذه المبادرة. +اذكر إذا كان العائد جيداً أو ضعيفاً مقارنة بالسوق وأعطِ توصية مختصرة. +اكتب الملخص مباشرة بدون JSON.""" + + try: + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + temperature=0.3, + ) + return llm_response.content.strip() + except Exception as exc: + logger.warning("LLM summary generation failed: %s", exc) + verdict = "عائد جيد" if roi_pct > 30 else ("عائد متوسط" if roi_pct > 10 else "عائد ضعيف") + return ( + f"مبادرة {initiative_type}: استثمار {investment:,.0f} ريال " + f"بعائد متوقع {projected_return:,.0f} ريال ({roi_pct:.1f}%). " + f"فترة الاسترداد {payback_months} شهر. التقييم: {verdict}." + ) diff --git a/salesflow-saas/backend/app/services/strategic_deals/strategic_simulator.py b/salesflow-saas/backend/app/services/strategic_deals/strategic_simulator.py new file mode 100644 index 00000000..7cf89c07 --- /dev/null +++ b/salesflow-saas/backend/app/services/strategic_deals/strategic_simulator.py @@ -0,0 +1,596 @@ +""" +Strategic Simulator — Monte Carlo-style scenario modeling for B2B deals. +المحاكي الاستراتيجي: نمذجة سيناريوهات بأسلوب مونت كارلو للصفقات بين الشركات +""" + +import json +import logging +import uuid +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.strategic_deal import CompanyProfile +from app.services.llm.provider import get_llm + +logger = logging.getLogger("dealix.strategic_deals.strategic_simulator") + +# ── Scenario type definitions ─────────────────────────────────────────────── + +SCENARIO_TYPES = { + "partnership": "شراكة استراتيجية", + "acquisition": "استحواذ", + "channel_expansion": "توسع قنوات التوزيع", + "market_entry": "دخول سوق جديد", + "joint_venture": "مشروع مشترك", + "franchise": "امتياز تجاري", + "divestiture": "تصفية أصول", +} + +# ── Default assumptions by scenario type ──────────────────────────────────── + +DEFAULT_ASSUMPTIONS = { + "partnership": { + "revenue_share_pct": 0.15, + "setup_cost_sar": 50_000, + "ramp_months": 3, + "success_probability": 0.65, + "annual_growth_pct": 0.10, + }, + "acquisition": { + "premium_pct": 0.25, + "integration_cost_pct": 0.15, + "synergy_savings_pct": 0.10, + "ramp_months": 12, + "success_probability": 0.50, + "annual_growth_pct": 0.15, + }, + "channel_expansion": { + "channel_setup_sar": 100_000, + "per_channel_cost_sar": 25_000, + "channels_count": 3, + "revenue_per_channel_sar": 200_000, + "ramp_months": 6, + "success_probability": 0.70, + }, + "market_entry": { + "entry_cost_sar": 500_000, + "first_year_revenue_sar": 300_000, + "market_share_target": 0.05, + "ramp_months": 12, + "success_probability": 0.45, + "annual_growth_pct": 0.20, + }, + "joint_venture": { + "equity_split": 0.50, + "total_investment_sar": 1_000_000, + "projected_revenue_sar": 2_000_000, + "ramp_months": 9, + "success_probability": 0.55, + "annual_growth_pct": 0.12, + }, + "franchise": { + "franchise_fee_sar": 200_000, + "royalty_pct": 0.06, + "unit_revenue_sar": 500_000, + "units_count": 2, + "ramp_months": 6, + "success_probability": 0.60, + }, + "divestiture": { + "asset_value_sar": 1_000_000, + "discount_pct": 0.10, + "transaction_cost_pct": 0.05, + "timeline_months": 6, + "success_probability": 0.75, + }, +} + + +# ── Models ────────────────────────────────────────────────────────────────── + + +class StrategicScenario(BaseModel): + """A fully modeled strategic scenario with financial projections.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + name: str = "" + name_ar: str = "" + scenario_type: str = "partnership" + parties: list[str] = Field(default_factory=list) + assumptions: dict = Field(default_factory=dict) + upside: dict = Field(default_factory=dict) + downside: dict = Field(default_factory=dict) + timeline_months: int = 12 + probability: float = Field(0.5, ge=0.0, le=1.0) + net_value_sar: float = 0.0 + recommendation: str = "" + recommendation_ar: str = "" + created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + + class Config: + json_schema_extra = { + "example": { + "name": "Partnership with LogiPrime", + "name_ar": "شراكة مع لوجي برايم", + "scenario_type": "partnership", + "parties": ["شركتنا", "لوجي برايم"], + "probability": 0.65, + "net_value_sar": 750_000, + } + } + + +# ── Strategic Simulator Engine ────────────────────────────────────────────── + + +class StrategicSimulator: + """ + Simulates strategic scenarios, comparing outcomes and generating + Arabic-language recommendations for Saudi B2B decision-makers. + يحاكي السيناريوهات الاستراتيجية ويقارن النتائج ويولد توصيات بالعربي + """ + + def __init__(self): + self.llm = get_llm() + self._scenarios: dict[str, StrategicScenario] = {} + + # ── Simulate ──────────────────────────────────────────────────────────── + + async def simulate( + self, + scenario_type: str, + params: dict, + twin_id: Optional[str], + db: AsyncSession, + ) -> StrategicScenario: + """ + Run a full strategic simulation for a given scenario type. + تشغيل محاكاة استراتيجية كاملة لنوع سيناريو معين + """ + if scenario_type not in SCENARIO_TYPES: + raise ValueError( + f"Unknown scenario type '{scenario_type}'. " + f"Valid types: {', '.join(SCENARIO_TYPES.keys())}" + ) + + # Load acquirer profile if twin_id provided + acquirer_name = params.get("acquirer_name", "الشركة") + acquirer_revenue = float(params.get("acquirer_revenue_sar", 0)) + if twin_id: + result = await db.execute( + select(CompanyProfile).where(CompanyProfile.id == twin_id) + ) + twin = result.scalar_one_or_none() + if twin: + acquirer_name = twin.company_name or acquirer_name + acquirer_revenue = float(twin.annual_revenue_sar or acquirer_revenue) + + # Merge defaults with user-provided params + defaults = DEFAULT_ASSUMPTIONS.get(scenario_type, {}).copy() + assumptions = {**defaults, **params.get("assumptions", {})} + + # Compute financials based on scenario type + upside, downside, net_value, timeline = self._compute_financials( + scenario_type, assumptions, acquirer_revenue, + ) + + probability = min(1.0, max(0.0, float( + assumptions.get("success_probability", + defaults.get("success_probability", 0.5)) + ))) + + # Build scenario + parties = params.get("parties", [acquirer_name]) + scenario = StrategicScenario( + name=params.get("name", f"{scenario_type} scenario"), + name_ar=params.get("name_ar", SCENARIO_TYPES.get(scenario_type, scenario_type)), + scenario_type=scenario_type, + parties=parties, + assumptions=assumptions, + upside=upside, + downside=downside, + timeline_months=timeline, + probability=probability, + net_value_sar=round(net_value, 2), + ) + + # Generate Arabic recommendation via LLM + recommendation = await self._generate_scenario_recommendation(scenario) + scenario.recommendation = recommendation + scenario.recommendation_ar = recommendation + + self._scenarios[scenario.id] = scenario + + logger.info( + "Simulated scenario '%s' (type=%s): net_value=%.0f SAR, probability=%.0%%", + scenario.name, scenario_type, net_value, probability * 100, + ) + return scenario + + # ── Compare Scenarios ─────────────────────────────────────────────────── + + async def compare_scenarios( + self, + scenarios: list[StrategicScenario], + db: AsyncSession, + ) -> dict: + """ + Rank and compare multiple scenarios by expected value and risk. + ترتيب ومقارنة عدة سيناريوهات حسب القيمة المتوقعة والمخاطر + """ + if not scenarios: + return {"ranked": [], "summary_ar": "لا توجد سيناريوهات للمقارنة"} + + ranked = [] + for s in scenarios: + expected_value = s.net_value_sar * s.probability + risk_adjusted = expected_value * (1.0 - (1.0 - s.probability) * 0.5) + ranked.append({ + "id": s.id, + "name": s.name, + "name_ar": s.name_ar, + "scenario_type": s.scenario_type, + "net_value_sar": s.net_value_sar, + "probability": s.probability, + "expected_value_sar": round(expected_value, 2), + "risk_adjusted_value_sar": round(risk_adjusted, 2), + "timeline_months": s.timeline_months, + "upside_total": sum( + float(v) for v in s.upside.values() if isinstance(v, (int, float)) + ), + "downside_total": sum( + float(v) for v in s.downside.values() if isinstance(v, (int, float)) + ), + }) + + ranked.sort(key=lambda x: x["risk_adjusted_value_sar"], reverse=True) + + # Add rank + for i, item in enumerate(ranked): + item["rank"] = i + 1 + + # Generate comparison summary + best = ranked[0] + worst = ranked[-1] + + summary_ar = ( + f"تم مقارنة {len(ranked)} سيناريو. " + f"الأفضل: {best['name_ar']} بقيمة متوقعة {best['expected_value_sar']:,.0f} ريال " + f"واحتمالية نجاح {best['probability']:.0%}. " + ) + if len(ranked) > 1: + summary_ar += ( + f"الأقل جاذبية: {worst['name_ar']} بقيمة متوقعة " + f"{worst['expected_value_sar']:,.0f} ريال." + ) + + logger.info("Compared %d scenarios. Best: %s", len(ranked), best["name"]) + + return { + "ranked": ranked, + "best_scenario_id": best["id"], + "summary_ar": summary_ar, + } + + # ── Sensitivity Analysis ──────────────────────────────────────────────── + + async def sensitivity_analysis( + self, + scenario_id: str, + variable: str, + value_range: list[float], + db: AsyncSession, + ) -> list[dict]: + """ + Run sensitivity analysis on a single variable across a range of values. + تحليل الحساسية لمتغير واحد عبر نطاق من القيم + """ + base_scenario = self._scenarios.get(scenario_id) + if not base_scenario: + raise ValueError(f"Scenario {scenario_id} not found") + + if not value_range: + base_val = float(base_scenario.assumptions.get(variable, 1.0)) + value_range = [ + round(base_val * 0.5, 4), + round(base_val * 0.75, 4), + round(base_val, 4), + round(base_val * 1.25, 4), + round(base_val * 1.5, 4), + ] + + results: list[dict] = [] + for val in value_range: + modified_assumptions = base_scenario.assumptions.copy() + modified_assumptions[variable] = val + + upside, downside, net_value, timeline = self._compute_financials( + base_scenario.scenario_type, modified_assumptions, 0, + ) + + expected = net_value * base_scenario.probability + results.append({ + "variable": variable, + "value": val, + "net_value_sar": round(net_value, 2), + "expected_value_sar": round(expected, 2), + "upside_revenue": upside.get("revenue_gain_sar", 0), + "downside_cost": downside.get("total_cost_sar", 0), + "delta_from_base": round(net_value - base_scenario.net_value_sar, 2), + }) + + logger.info( + "Sensitivity analysis for scenario %s on '%s': %d data points", + scenario_id, variable, len(results), + ) + return results + + # ── Generate Recommendation ───────────────────────────────────────────── + + async def generate_recommendation( + self, + scenario_id: str, + db: AsyncSession, + ) -> str: + """ + Generate a detailed Arabic strategic recommendation for a scenario. + إنشاء توصية استراتيجية تفصيلية بالعربي لسيناريو محدد + """ + scenario = self._scenarios.get(scenario_id) + if not scenario: + raise ValueError(f"Scenario {scenario_id} not found") + + recommendation = await self._generate_scenario_recommendation(scenario) + scenario.recommendation = recommendation + scenario.recommendation_ar = recommendation + + logger.info("Generated recommendation for scenario %s", scenario_id) + return recommendation + + # ── Private: Compute Financials ───────────────────────────────────────── + + def _compute_financials( + self, + scenario_type: str, + assumptions: dict, + acquirer_revenue: float, + ) -> tuple[dict, dict, float, int]: + """Compute upside, downside, net value, and timeline from assumptions.""" + + if scenario_type == "partnership": + rev_share = float(assumptions.get("revenue_share_pct", 0.15)) + setup = float(assumptions.get("setup_cost_sar", 50_000)) + ramp = int(assumptions.get("ramp_months", 3)) + growth = float(assumptions.get("annual_growth_pct", 0.10)) + base_rev = acquirer_revenue if acquirer_revenue > 0 else 1_000_000 + + annual_gain = base_rev * rev_share + three_year = annual_gain * (1 + growth) + annual_gain * (1 + growth) ** 2 + annual_gain * (1 + growth) ** 3 + + upside = { + "revenue_gain_sar": round(annual_gain, 2), + "three_year_revenue_sar": round(three_year, 2), + "reach_expansion_pct": round(rev_share * 100, 1), + "capacity_gain_pct": round(rev_share * 50, 1), + } + downside = { + "setup_cost_sar": setup, + "annual_management_sar": round(setup * 0.3, 2), + "total_cost_sar": round(setup + setup * 0.3 * 3, 2), + "operational_burden": "متوسط", + "risk_level": "منخفض", + } + net_value = three_year - downside["total_cost_sar"] + timeline = ramp + 12 + + elif scenario_type == "acquisition": + premium = float(assumptions.get("premium_pct", 0.25)) + integration_cost = float(assumptions.get("integration_cost_pct", 0.15)) + synergy = float(assumptions.get("synergy_savings_pct", 0.10)) + target_value = float(assumptions.get("target_value_sar", acquirer_revenue * 0.3)) + ramp = int(assumptions.get("ramp_months", 12)) + + acquisition_price = target_value * (1 + premium) + integration = target_value * integration_cost + annual_synergy = target_value * synergy + + upside = { + "revenue_gain_sar": round(target_value, 2), + "annual_synergy_sar": round(annual_synergy, 2), + "three_year_synergy_sar": round(annual_synergy * 3, 2), + "market_share_gain_pct": round(target_value / max(acquirer_revenue, 1) * 100, 1), + } + downside = { + "acquisition_price_sar": round(acquisition_price, 2), + "integration_cost_sar": round(integration, 2), + "total_cost_sar": round(acquisition_price + integration, 2), + "operational_burden": "عالي", + "risk_level": "عالي", + } + net_value = upside["three_year_synergy_sar"] + target_value - downside["total_cost_sar"] + timeline = ramp + 24 + + elif scenario_type == "channel_expansion": + channel_setup = float(assumptions.get("channel_setup_sar", 100_000)) + per_channel = float(assumptions.get("per_channel_cost_sar", 25_000)) + channels = int(assumptions.get("channels_count", 3)) + rev_per_channel = float(assumptions.get("revenue_per_channel_sar", 200_000)) + ramp = int(assumptions.get("ramp_months", 6)) + + total_setup = channel_setup + per_channel * channels + annual_rev = rev_per_channel * channels + + upside = { + "revenue_gain_sar": round(annual_rev, 2), + "reach_expansion_pct": round(channels * 15, 1), + "channels_added": channels, + } + downside = { + "setup_cost_sar": round(total_setup, 2), + "annual_ops_sar": round(per_channel * channels * 0.5, 2), + "total_cost_sar": round(total_setup + per_channel * channels * 0.5, 2), + "operational_burden": "متوسط", + "risk_level": "منخفض", + } + net_value = annual_rev * 2 - downside["total_cost_sar"] + timeline = ramp + 12 + + elif scenario_type == "market_entry": + entry_cost = float(assumptions.get("entry_cost_sar", 500_000)) + first_year = float(assumptions.get("first_year_revenue_sar", 300_000)) + growth = float(assumptions.get("annual_growth_pct", 0.20)) + ramp = int(assumptions.get("ramp_months", 12)) + + three_year_rev = first_year + first_year * (1 + growth) + first_year * (1 + growth) ** 2 + + upside = { + "revenue_gain_sar": round(first_year, 2), + "three_year_revenue_sar": round(three_year_rev, 2), + "market_share_target_pct": float(assumptions.get("market_share_target", 0.05)) * 100, + } + downside = { + "entry_cost_sar": round(entry_cost, 2), + "annual_ops_sar": round(entry_cost * 0.2, 2), + "total_cost_sar": round(entry_cost + entry_cost * 0.2 * 2, 2), + "operational_burden": "عالي", + "risk_level": "عالي", + } + net_value = three_year_rev - downside["total_cost_sar"] + timeline = ramp + 24 + + elif scenario_type == "joint_venture": + equity = float(assumptions.get("equity_split", 0.50)) + investment = float(assumptions.get("total_investment_sar", 1_000_000)) + projected = float(assumptions.get("projected_revenue_sar", 2_000_000)) + ramp = int(assumptions.get("ramp_months", 9)) + + our_share = projected * equity + our_cost = investment * equity + + upside = { + "revenue_gain_sar": round(our_share, 2), + "equity_value_sar": round(our_share * 3, 2), + "reach_expansion_pct": round(equity * 100, 1), + } + downside = { + "investment_sar": round(our_cost, 2), + "annual_ops_sar": round(our_cost * 0.1, 2), + "total_cost_sar": round(our_cost + our_cost * 0.1 * 2, 2), + "operational_burden": "عالي", + "risk_level": "متوسط", + } + net_value = our_share * 2 - downside["total_cost_sar"] + timeline = ramp + 18 + + elif scenario_type == "franchise": + fee = float(assumptions.get("franchise_fee_sar", 200_000)) + royalty = float(assumptions.get("royalty_pct", 0.06)) + unit_rev = float(assumptions.get("unit_revenue_sar", 500_000)) + units = int(assumptions.get("units_count", 2)) + ramp = int(assumptions.get("ramp_months", 6)) + + annual_royalty = unit_rev * units * royalty + total_fees = fee * units + + upside = { + "revenue_gain_sar": round(annual_royalty + total_fees, 2), + "annual_royalty_sar": round(annual_royalty, 2), + "franchise_fees_sar": round(total_fees, 2), + "units_count": units, + } + downside = { + "setup_cost_sar": round(fee * 0.3 * units, 2), + "support_cost_sar": round(unit_rev * 0.02 * units, 2), + "total_cost_sar": round(fee * 0.3 * units + unit_rev * 0.02 * units * 3, 2), + "operational_burden": "متوسط", + "risk_level": "منخفض", + } + net_value = (annual_royalty * 3 + total_fees) - downside["total_cost_sar"] + timeline = ramp + 12 + + elif scenario_type == "divestiture": + asset_val = float(assumptions.get("asset_value_sar", 1_000_000)) + discount = float(assumptions.get("discount_pct", 0.10)) + tx_cost = float(assumptions.get("transaction_cost_pct", 0.05)) + ramp = int(assumptions.get("timeline_months", 6)) + + proceeds = asset_val * (1 - discount) + costs = asset_val * tx_cost + + upside = { + "proceeds_sar": round(proceeds, 2), + "cash_freed_sar": round(proceeds - costs, 2), + "operational_relief": "تخفيف عبء تشغيلي", + } + downside = { + "transaction_cost_sar": round(costs, 2), + "discount_loss_sar": round(asset_val * discount, 2), + "total_cost_sar": round(costs + asset_val * discount, 2), + "operational_burden": "منخفض", + "risk_level": "منخفض", + } + net_value = proceeds - costs + timeline = ramp + + else: + upside = {"revenue_gain_sar": 0} + downside = {"total_cost_sar": 0} + net_value = 0 + timeline = 12 + + return upside, downside, round(net_value, 2), timeline + + # ── Private: Generate Recommendation ──────────────────────────────────── + + async def _generate_scenario_recommendation( + self, scenario: StrategicScenario, + ) -> str: + """Generate an Arabic recommendation for a scenario using LLM.""" + type_ar = SCENARIO_TYPES.get(scenario.scenario_type, scenario.scenario_type) + + context = f"""نوع السيناريو: {type_ar} +الأطراف: {', '.join(scenario.parties)} +الافتراضات: {json.dumps(scenario.assumptions, ensure_ascii=False)} +الجانب الإيجابي: {json.dumps(scenario.upside, ensure_ascii=False)} +الجانب السلبي: {json.dumps(scenario.downside, ensure_ascii=False)} +المدة الزمنية: {scenario.timeline_months} شهر +احتمالية النجاح: {scenario.probability:.0%} +صافي القيمة: {scenario.net_value_sar:,.0f} ريال سعودي""" + + system_prompt = """أنت مستشار استراتيجي سعودي خبير. اكتب توصية تنفيذية واضحة بالعربي. + +يجب أن تشمل: +١. ملخص تنفيذي في سطرين +٢. المبرر الاستراتيجي +٣. المخاطر الرئيسية وطرق التخفيف +٤. التوصية النهائية (تنفيذ / تأجيل / رفض) مع المبررات +٥. الخطوات التالية إذا كانت التوصية بالتنفيذ + +اكتب بأسلوب مهني رسمي مناسب لعرضه على الإدارة التنفيذية.""" + + try: + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=context, + temperature=0.3, + ) + return llm_response.content.strip() + except Exception as exc: + logger.warning("LLM recommendation generation failed: %s", exc) + if scenario.net_value_sar > 0 and scenario.probability >= 0.5: + verdict = "يُنصح بالتنفيذ" + elif scenario.net_value_sar > 0: + verdict = "يُنصح بمزيد من الدراسة قبل التنفيذ" + else: + verdict = "لا يُنصح بالتنفيذ في الوقت الحالي" + + return ( + f"توصية — {type_ar}\n" + f"صافي القيمة المتوقعة: {scenario.net_value_sar:,.0f} ريال\n" + f"احتمالية النجاح: {scenario.probability:.0%}\n" + f"المدة الزمنية: {scenario.timeline_months} شهر\n" + f"القرار: {verdict}" + ) diff --git a/salesflow-saas/backend/app/services/whatsapp_brain.py b/salesflow-saas/backend/app/services/whatsapp_brain.py new file mode 100644 index 00000000..d11cb6c4 --- /dev/null +++ b/salesflow-saas/backend/app/services/whatsapp_brain.py @@ -0,0 +1,342 @@ +""" +WhatsApp AI Brain — Dealix AI Revenue OS +Central intelligence for the Dealix WhatsApp number. +Handles: sales, support, marketer support, deals, and general inquiries. +Connected to backend data for contextual, intelligent responses. +""" +import logging +import re +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class ConversationMode(str, Enum): + SALES = "sales" + SUPPORT = "support" + MARKETER = "marketer" + DEALS = "deals" + GENERAL = "general" + + +class CallerProfile(BaseModel): + phone: str + name: str = "زائر" + caller_type: str = "unknown" # client, marketer, lead, unknown + tenant_id: str = "" + subscription_plan: str = "" + commission_balance: float = 0.0 + lead_score: int = 0 + language: str = "ar" + + +class ConversationEntry(BaseModel): + role: str # user, assistant + content: str + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +ARABIC_MARKERS = ["وش", "كيف", "أبي", "ليش", "هلا", "مرحبا", "السلام", "شكرا", "طيب"] +INTENT_KEYWORDS = { + "greeting": ["هلا", "مرحبا", "السلام عليكم", "أهلاً", "hi", "hello", "hey"], + "pricing": ["سعر", "كم", "باقة", "اشتراك", "price", "cost", "plan", "pricing"], + "demo": ["عرض", "demo", "تجربة", "شرح", "وريني"], + "support": ["مشكلة", "ما يشتغل", "خطأ", "bug", "help", "مساعدة", "دعم"], + "complaint": ["شكوى", "زعلان", "سيء", "complaint", "unhappy"], + "partnership": ["شراكة", "partner", "تعاون", "صفقة", "deal"], + "commission": ["عمولة", "commission", "أرباح", "دفعة", "payout"], + "feature": ["ميزة", "feature", "يقدر", "يدعم", "فيه"], + "competitor": ["zoho", "salesforce", "hubspot", "pipedrive", "منافس"], + "cancel": ["إلغاء", "cancel", "أوقف", "stop"], +} + + +class WhatsAppBrain: + """Central brain for Dealix WhatsApp — routes and responds intelligently.""" + + def __init__(self): + self._conversations: dict[str, list[ConversationEntry]] = {} + from app.services.whatsapp_knowledge import DealixKnowledge + self.knowledge = DealixKnowledge + + async def handle_incoming( + self, phone: str, message: str, db: Any = None + ) -> str: + caller = await self.identify_caller(phone, db) + language = self._detect_language(message) + caller.language = language + intent = self._detect_intent(message) + history = self._get_history(phone) + mode = self._route_conversation(intent, caller) + + self._add_to_history(phone, "user", message) + + handlers = { + ConversationMode.SALES: self._handle_sales, + ConversationMode.SUPPORT: self._handle_support, + ConversationMode.MARKETER: self._handle_marketer, + ConversationMode.DEALS: self._handle_deals, + ConversationMode.GENERAL: self._handle_general, + } + handler = handlers.get(mode, self._handle_general) + response = await handler(message, caller, intent, history, db) + + self._add_to_history(phone, "assistant", response) + logger.info( + f"[WhatsAppBrain] {phone} mode={mode.value} intent={intent} " + f"caller={caller.caller_type} lang={language}" + ) + return response + + async def identify_caller(self, phone: str, db: Any = None) -> CallerProfile: + profile = CallerProfile(phone=phone) + if not db: + return profile + try: + from sqlalchemy import select, or_ + from app.models.lead import Lead + from app.models.user import User + from app.models.affiliate import AffiliateMarketer + + clean_phone = phone.replace("+", "").replace(" ", "") + + # Check if affiliate marketer + result = await db.execute( + select(AffiliateMarketer).where( + AffiliateMarketer.phone.contains(clean_phone[-9:]) + ).limit(1) + ) + marketer = result.scalar_one_or_none() + if marketer: + profile.caller_type = "marketer" + profile.name = marketer.full_name or "مسوّق" + profile.tenant_id = str(marketer.tenant_id) if hasattr(marketer, 'tenant_id') else "" + return profile + + # Check if existing user/client + result = await db.execute( + select(User).where(User.phone.contains(clean_phone[-9:])).limit(1) + ) + user = result.scalar_one_or_none() + if user: + profile.caller_type = "client" + profile.name = user.full_name or "عميل" + profile.tenant_id = str(user.tenant_id) if hasattr(user, 'tenant_id') else "" + return profile + + # Check if known lead + result = await db.execute( + select(Lead).where(Lead.phone.contains(clean_phone[-9:])).limit(1) + ) + lead = result.scalar_one_or_none() + if lead: + profile.caller_type = "lead" + profile.name = lead.name or "عميل محتمل" + profile.lead_score = lead.score or 0 + profile.tenant_id = str(lead.tenant_id) if hasattr(lead, 'tenant_id') else "" + return profile + + except Exception as e: + logger.warning(f"Error identifying caller {phone}: {e}") + + return profile + + def _detect_language(self, message: str) -> str: + arabic_chars = len(re.findall(r'[\u0600-\u06FF]', message)) + latin_chars = len(re.findall(r'[a-zA-Z]', message)) + return "ar" if arabic_chars >= latin_chars else "en" + + def _detect_intent(self, message: str) -> str: + msg_lower = message.lower() + for intent, keywords in INTENT_KEYWORDS.items(): + if any(kw in msg_lower for kw in keywords): + return intent + return "general" + + def _route_conversation(self, intent: str, caller: CallerProfile) -> ConversationMode: + if caller.caller_type == "marketer" or intent == "commission": + return ConversationMode.MARKETER + if caller.caller_type == "client" and intent in ("support", "complaint", "cancel"): + return ConversationMode.SUPPORT + if intent in ("partnership",): + return ConversationMode.DEALS + if intent in ("pricing", "demo", "feature", "competitor"): + return ConversationMode.SALES + if caller.caller_type == "client": + return ConversationMode.SUPPORT + return ConversationMode.SALES if caller.caller_type == "unknown" else ConversationMode.GENERAL + + def _get_history(self, phone: str) -> list[ConversationEntry]: + return self._conversations.get(phone, [])[-10:] + + def _add_to_history(self, phone: str, role: str, content: str) -> None: + if phone not in self._conversations: + self._conversations[phone] = [] + self._conversations[phone].append(ConversationEntry(role=role, content=content)) + if len(self._conversations[phone]) > 50: + self._conversations[phone] = self._conversations[phone][-50:] + + async def _handle_sales( + self, message: str, caller: CallerProfile, intent: str, history: list, db: Any + ) -> str: + lang = caller.language + + if intent == "greeting": + name_part = f" {caller.name}" if caller.name != "زائر" else "" + if lang == "ar": + return ( + f"أهلاً وسهلاً{name_part}! 👋\n" + f"أنا مساعد ديلكس الذكي.\n\n" + f"أقدر أساعدك في:\n" + f"• معرفة مميزات Dealix\n" + f"• الأسعار والباقات\n" + f"• حجز عرض توضيحي\n" + f"• أي سؤال ثاني\n\n" + f"كيف أقدر أساعدك؟" + ) + return ( + f"Hello{name_part}! 👋\n" + f"I'm the Dealix AI assistant.\n\n" + f"I can help with:\n" + f"• Dealix features\n• Pricing\n• Book a demo\n\nHow can I help?" + ) + + if intent == "pricing": + pricing_text = self.knowledge.get_pricing_text(lang) + suffix = "\nكل الباقات فيها تجربة مجانية ١٤ يوم بدون بطاقة.\nتبي تجرب؟" if lang == "ar" else "\nAll plans include a 14-day free trial. Want to try?" + return f"{pricing_text}\n{suffix}" + + if intent == "demo": + if lang == "ar": + return ( + "ممتاز! يسعدنا نعرض لك Dealix 🎉\n\n" + "العرض يستغرق ١٥ دقيقة فقط.\n" + "أرسل لي اسمك ورقم جوالك وأرتب لك الموعد." + ) + return "Great! We'd love to show you Dealix 🎉\nThe demo takes just 15 minutes.\nSend your name and phone, and I'll set it up." + + if intent == "competitor": + for comp in ["zoho", "salesforce", "hubspot"]: + if comp in message.lower(): + resp = self.knowledge.get_competitor_response(comp) + if resp: + return resp + if lang == "ar": + return "Dealix الوحيد المصمم للسوق السعودي: عربي أولاً، واتساب مدمج، AI يفهم سعودي. تبي أوريك المقارنة؟" + return "Dealix is the only CRM built for Saudi: Arabic-first, WhatsApp native, Saudi-aware AI. Want to see the comparison?" + + if intent == "feature": + for key, feat in self.knowledge.FEATURES.items(): + if any(word in message for word in feat["name_ar"].split()): + points = "\n".join(f"✅ {p}" for p in feat["selling_points_ar"]) + return f"*{feat['name_ar']}*\n{feat['desc_ar']}\n\n{points}" + + # Check objections + for obj_type, obj_data in self.knowledge.OBJECTION_RESPONSES.items(): + triggers = {"expensive": ["غالي", "مكلف"], "need_to_think": ["أفكر", "بشوف"], "too_complex": ["صعب", "معقد"], "small_team": ["صغير", "وحدي"]} + if obj_type in triggers and any(t in message for t in triggers[obj_type]): + return obj_data.get(lang, obj_data["ar"]) + + # FAQ search + faq_answer = self.knowledge.search_faq(message) + if faq_answer: + return faq_answer + + if lang == "ar": + return "شكراً لتواصلك! 🙏\nأقدر أساعدك بأي سؤال عن Dealix — الأسعار، المميزات، أو حجز عرض توضيحي.\nوش تحب تعرف؟" + return "Thanks for reaching out! 🙏\nI can help with pricing, features, or booking a demo.\nWhat would you like to know?" + + async def _handle_support( + self, message: str, caller: CallerProfile, intent: str, history: list, db: Any + ) -> str: + name = caller.name or "عميل" + if intent == "complaint": + return ( + f"أستاذ/ة {name}، نعتذر عن أي إزعاج 🙏\n" + f"فريق الدعم المتخصص بيتواصل معك خلال ساعة.\n" + f"لو تقدر توصف المشكلة بالتفصيل، بيساعدنا نحلها أسرع." + ) + if intent == "cancel": + return ( + f"أستاذ/ة {name}، نأسف إنك تفكر بالإلغاء 😔\n" + f"قبل ما نلغي، ممكن أعرف السبب؟ يمكن نقدر نساعدك.\n" + f"لو تبي، أقدر أحولك لمدير حسابك مباشرة." + ) + return ( + f"أهلاً {name}! 👋\n" + f"كيف أقدر أساعدك اليوم؟\n\n" + f"لو عندك مشكلة تقنية، وصّف لي المشكلة وبأساعدك فوراً.\n" + f"لو تحتاج شي ما أقدر أحله، بأحولك لفريق الدعم المتخصص." + ) + + async def _handle_marketer( + self, message: str, caller: CallerProfile, intent: str, history: list, db: Any + ) -> str: + name = caller.name or "مسوّق" + if intent == "commission": + return ( + f"أهلاً {name}! 🌟\n\n" + f"للاطلاع على عمولاتك وأدائك، ادخل لوحة التحكم من:\n" + f"dealix.sa/dashboard\n\n" + f"لو عندك سؤال عن العمولات أو المدفوعات، أنا هنا أساعدك." + ) + + # Search marketer FAQ + for faq in self.knowledge.MARKETER_FAQ: + if any(word in message for word in faq["q_ar"].split() if len(word) > 2): + return faq["a_ar"] + + return ( + f"أهلاً {name}! مسوّقنا المميز 🌟\n\n" + f"كيف أقدر أساعدك اليوم؟\n" + f"• استفسار عن العمولات\n" + f"• مساعدة تقنية\n" + f"• نصائح للتسويق\n" + f"• أي سؤال ثاني" + ) + + async def _handle_deals( + self, message: str, caller: CallerProfile, intent: str, history: list, db: Any + ) -> str: + return ( + "أهلاً! 🤝\n\n" + "Dealix يدعم ١٥ نوع صفقة استراتيجية:\n" + "• شراكات وتبادل خدمات\n" + "• توزيع وreseller\n" + "• مشاريع مشتركة\n" + "• فرص استحواذ\n\n" + "حدثني أكثر عن شركتك ووش تبحث عنه، وبأساعدك نلقى أفضل فرصة." + ) + + async def _handle_general( + self, message: str, caller: CallerProfile, intent: str, history: list, db: Any + ) -> str: + faq_answer = self.knowledge.search_faq(message) + if faq_answer: + return faq_answer + lang = caller.language + if lang == "ar": + return ( + "أهلاً وسهلاً! 👋\n" + "أنا مساعد ديلكس — نظام المبيعات الذكي للسعودية.\n\n" + "أقدر أساعدك في:\n" + "١. معرفة مميزات Dealix\n" + "٢. الأسعار والباقات\n" + "٣. حجز عرض توضيحي\n" + "٤. الدعم الفني\n" + "٥. برنامج التسويق بالعمولة\n\n" + "أختر رقم أو اكتب سؤالك مباشرة." + ) + return ( + "Hello! 👋\nI'm the Dealix assistant — the smart sales system for Saudi Arabia.\n\n" + "I can help with:\n1. Features\n2. Pricing\n3. Book a demo\n4. Support\n5. Affiliate program\n\n" + "Pick a number or type your question." + ) + + +# Global singleton +whatsapp_brain = WhatsAppBrain() diff --git a/salesflow-saas/backend/app/services/whatsapp_knowledge.py b/salesflow-saas/backend/app/services/whatsapp_knowledge.py new file mode 100644 index 00000000..ad5302fa --- /dev/null +++ b/salesflow-saas/backend/app/services/whatsapp_knowledge.py @@ -0,0 +1,221 @@ +""" +WhatsApp Knowledge Base — Dealix AI Revenue OS +Complete knowledge the WhatsApp brain uses to respond intelligently. +""" +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +class DealixKnowledge: + """Everything the WhatsApp brain needs to know.""" + + FEATURES = { + "whatsapp_crm": { + "name_ar": "إدارة العملاء عبر الواتساب", + "name_en": "WhatsApp CRM", + "desc_ar": "تواصل مع عملاءك مباشرة من الواتساب مع تتبع كامل للمحادثات", + "selling_points_ar": [ + "رد تلقائي ذكي بالعربي", + "تتبع كل محادثة", + "إشعارات فورية عند رد العميل", + ], + }, + "ai_scoring": { + "name_ar": "تقييم عملاء بالذكاء الاصطناعي", + "name_en": "AI Lead Scoring", + "desc_ar": "النظام يقيّم كل عميل من ٠ لـ ١٠٠ ويقولك مين الأهم", + "selling_points_ar": [ + "تقييم تلقائي مع كل تفاعل", + "يفهم المحادثات العربية", + "توصيات متابعة بالعربي", + ], + }, + "pipeline": { + "name_ar": "مسار صفقات بصري", + "name_en": "Visual Pipeline", + "desc_ar": "شوف كل صفقاتك بنظرة واحدة وحركها بالسحب", + "selling_points_ar": ["Kanban بصري", "٥ مراحل", "drag-and-drop"], + }, + "cpq": { + "name_ar": "عروض أسعار احترافية", + "name_en": "Quotes & Proposals", + "desc_ar": "أنشئ عروض أسعار بالعربي مع ضريبة القيمة المضافة تلقائياً", + "selling_points_ar": ["ضريبة ١٥٪ تلقائي", "إرسال بالواتساب", "تتبع القبول"], + }, + "pdpl": { + "name_ar": "حماية البيانات PDPL", + "name_en": "PDPL Compliance", + "desc_ar": "متوافق مع نظام حماية البيانات الشخصية السعودي", + "selling_points_ar": ["موافقات تلقائية", "حقوق بيانات", "audit trail"], + }, + "deal_exchange": { + "name_ar": "صفقات استراتيجية", + "name_en": "Strategic Deals", + "desc_ar": "اكتشف شركاء وصفقات متبادلة — تبادل خدمات، شراكات، توزيع", + "selling_points_ar": ["١٥ نوع صفقة", "مطابقة ذكية", "مفاوض AI"], + }, + } + + PRICING = { + "all_in_one": { + "name_ar": "Dealix All-in-One", + "name_en": "Dealix All-in-One", + "price_monthly": 1499, + "price_yearly": 14999, + "trial_days": 7, + "users_included": 20, + "extra_user_price": 99, + "features_ar": [ + "كل المميزات مفتوحة — بدون استثناء", + "٧ أدمغة AI لكل قناة (واتساب، إيميل، لينكدإن، إنستقرام، تيكتوك، تويتر، سناب)", + "صفقات استراتيجية — ١٥ نوع صفقة", + "مفاوض AI بالعربي", + "Company Twin — نموذج شركتك الرقمي", + "رصد استحواذات + محاكي نمو", + "تقييم عملاء AI + تنبؤات مبيعات", + "مسار صفقات بصري + عروض أسعار CPQ", + "تسلسلات متعددة القنوات", + "PDPL كامل + حوكمة", + "٢٠ مستخدم + ٩٩ ر.س لكل مستخدم إضافي", + "دعم عربي + إنجليزي", + "٧ أيام تجربة مجانية — بدون بطاقة", + ], + "best_for_ar": "كل الشركات — من الصغيرة للكبيرة", + "guarantee_ar": "استرداد كامل خلال ٣٠ يوم إذا لم يعجبك", + }, + } + + OBJECTION_RESPONSES = { + "expensive": { + "ar": "أفهم — لكن ٥٩ ر.س أقل من فاتورة كابتشينو أسبوعية. وصفقة وحدة ضايعة بسبب عدم المتابعة تكلف أضعاف. جرّبه مجاناً ١٤ يوم وشوف بنفسك.", + "en": "I understand — but 59 SAR is less than weekly cappuccinos. One lost deal due to poor follow-up costs much more. Try it free for 14 days.", + }, + "already_have_crm": { + "ar": "ممتاز! وش تستخدم حالياً؟ كثير من عملاءنا انتقلوا من أنظمة أجنبية لأن Dealix مصمم للسوق السعودي — عربي أولاً، واتساب مدمج، PDPL جاهز.", + "en": "Great! What are you using? Many clients switched because Dealix is built for Saudi — Arabic-first, WhatsApp native, PDPL ready.", + }, + "need_to_think": { + "ar": "أكيد، خذ وقتك. بس حبيت أذكرك إن التجربة مجانية ١٤ يوم بدون بطاقة — تقدر تجرب وتقرر بعدها.", + "en": "Sure, take your time. Just remember — 14-day free trial, no credit card needed.", + }, + "too_complex": { + "ar": "بالعكس! Dealix مصمم ليكون بسيط جداً — أغلب العملاء يبدون يستخدمونه بأقل من ٥ دقائق. وعندنا دعم بالعربي يساعدك.", + "en": "Actually the opposite! Most clients start using it in under 5 minutes. And we have Arabic support.", + }, + "small_team": { + "ar": "حتى لو شخص واحد! باقة المبتدئ ٥٩ ر.س تكفي. والنظام يساعدك تتابع عملاءك بدون ما تحتاج فريق كبير.", + "en": "Even for one person! Starter plan at 59 SAR is enough. The system helps you follow up without needing a big team.", + }, + "no_budget": { + "ar": "أفهم. التجربة مجانية ١٤ يوم — جربها وشوف كم صفقة تقدر تكسب. الاستثمار يرجع لك أضعاف.", + "en": "I understand. 14-day free trial — try it and see how many deals you can win. The ROI speaks for itself.", + }, + "competitor_better": { + "ar": "كل نظام له مميزاته. لكن Dealix الوحيد المصمم للسعودية: عربي أولاً، واتساب مدمج، AI يفهم سعودي. تبي أوريك المقارنة؟", + "en": "Every system has its strengths. But Dealix is the only one built for Saudi: Arabic-first, WhatsApp native, Saudi-aware AI. Want to see the comparison?", + }, + "not_now": { + "ar": "تمام! أقدر أرسل لك ملخص سريع عن Dealix وتشوفه لما يناسبك. وش إيميلك؟", + "en": "No problem! I can send you a quick summary to review when it suits you. What's your email?", + }, + } + + COMPETITOR_CARDS = { + "zoho": { + "name": "Zoho CRM", + "we_win": [ + "عربي أولاً (مو ترجمة)", "واتساب مدمج (مو إضافة)", + "AI يفهم اللهجة السعودية", "PDPL مدمج بالنظام", + "صفقات استراتيجية (لا يوجد عندهم)", "دعم سعودي مباشر", + ], + "they_win": ["نظام أكبر وأقدم", "تكاملات أكثر", "سيرفرات سعودية"], + "response_ar": "Zoho نظام ممتاز ومعروف. لكن الفرق إن Dealix مبني من الأساس للسوق السعودي — مو ترجمة لنظام أجنبي. واتساب عندنا مدمج، الذكاء الاصطناعي يفهم عربي، وPDPL جاهز. وبسعر مقارب.", + }, + "salesforce": { + "name": "Salesforce", + "we_win": [ + "عربي بالكامل", "سعر أقل ١٠ مرات", "واتساب مدمج", + "بسيط وسريع (مو ٦ أشهر تطبيق)", "PDPL جاهز", + ], + "they_win": ["أكبر نظام CRM بالعالم", "آلاف التكاملات", "enterprise-grade"], + "response_ar": "Salesforce نظام عملاق — لكن يحتاج ٦ أشهر تطبيق ومئات الآلاف. Dealix يشتغل بأقل من ٥ دقائق، عربي بالكامل، وبسعر يبدأ من ٥٩ ر.س. للشركات السعودية الصغيرة والمتوسطة، Dealix الخيار الأذكى.", + }, + "hubspot": { + "name": "HubSpot", + "we_win": [ + "عربي أولاً", "واتساب مدمج", "AI عربي", + "سعر أقل بكثير", "PDPL مدمج", "صفقات استراتيجية", + ], + "they_win": ["marketing hub قوي", "content management", "brand أكبر"], + "response_ar": "HubSpot ممتاز للتسويق الرقمي. لكن للمبيعات في السوق السعودي، Dealix أقوى: واتساب مدمج، AI يفهم عربي، وPDPL جاهز. وبسعر أقل بكثير.", + }, + } + + FAQ = [ + {"q_ar": "كم سعر Dealix؟", "a_ar": "يبدأ من ٥٩ ر.س/شهر. الاحترافي ١٤٩ ر.س، المؤسسي ٢٢٥ ر.س. وفيه تجربة مجانية ١٤ يوم."}, + {"q_ar": "هل يدعم الواتساب؟", "a_ar": "نعم! واتساب مدمج بالنظام — ترسل وتستقبل وتتابع كل المحادثات من مكان واحد."}, + {"q_ar": "هل يدعم العربي؟", "a_ar": "نعم! Dealix مبني عربي أولاً — الواجهة والتقارير والذكاء الاصطناعي كلها بالعربي."}, + {"q_ar": "هل هو آمن؟", "a_ar": "نعم. متوافق مع نظام حماية البيانات PDPL، تشفير SSL، وسيرفرات سعودية."}, + {"q_ar": "هل فيه تجربة مجانية؟", "a_ar": "نعم! ١٤ يوم تجربة مجانية كاملة — بدون بطاقة ائتمانية."}, + {"q_ar": "كيف أبدأ؟", "a_ar": "ادخل dealix.sa واضغط 'ابدأ مجاناً'. التسجيل يأخذ أقل من دقيقة."}, + {"q_ar": "هل يناسب شركتي الصغيرة؟", "a_ar": "أكيد! باقة المبتدئ ٥٩ ر.س مصممة للشركات الصغيرة. حتى لو شخص واحد."}, + {"q_ar": "هل يدعم الإنجليزي بعد؟", "a_ar": "نعم! تقدر تبدل بين العربي والإنجليزي بضغطة زر."}, + {"q_ar": "كيف أتواصل مع الدعم؟", "a_ar": "واتساب أو إيميل support@dealix.sa — نرد خلال ٤ ساعات عمل."}, + {"q_ar": "هل فيه تطبيق جوال؟", "a_ar": "الموقع متجاوب ويشتغل بشكل ممتاز على الجوال. تطبيق مخصص قريباً إن شاء الله."}, + ] + + MARKETER_FAQ = [ + {"q_ar": "كيف أسجل كمسوّق؟", "a_ar": "ادخل dealix.sa/marketers واضغط 'سجّل كمسوّق'. التسجيل مجاني ويتفعل فوراً."}, + {"q_ar": "كم العمولة؟", "a_ar": "٢٠٪ من اشتراك كل عميل (٣٠٠ ر.س/شهر) مستمرة لمدة ٦ أشهر. قادة الفرق يحصلون على override ٧٪ لمدة ١٢ شهر."}, + {"q_ar": "متى تنزل العمولة؟", "a_ar": "كل يوم أحد تتحول العمولات لحسابك البنكي أو STC Pay."}, + {"q_ar": "كم مدة العمولة المستمرة؟", "a_ar": "المسوّقين: ٦ أشهر من تاريخ اشتراك العميل. مدراء التسويق: ١٢ شهر override من فريقهم."}, + {"q_ar": "كيف أتابع أدائي؟", "a_ar": "من لوحة التحكم تشوف كل شي: عملاء، عمولات، مستواك، وروابط التتبع."}, + {"q_ar": "هل فيه حد أقصى للعمولة؟", "a_ar": "لا! ما فيه حد — كل ما زاد عدد العملاء زادت عمولتك."}, + ] + + @classmethod + def get_pricing_text(cls, language: str = "ar") -> str: + plan = cls.PRICING["all_in_one"] + if language == "ar": + features = "\n".join(f" ✅ {f}" for f in plan["features_ar"][:6]) + return ( + f"🔵 *{plan['name_ar']}*\n\n" + f"💰 {plan['price_monthly']} ر.س/شهر\n" + f"💰 {plan['price_yearly']} ر.س/سنة (وفّر شهرين)\n" + f"👥 {plan['users_included']} مستخدم + {plan['extra_user_price']} ر.س/إضافي\n\n" + f"كل المميزات مفتوحة:\n{features}\n\n" + f"🎁 ٧ أيام تجربة مجانية — بدون بطاقة\n" + f"🔒 استرداد كامل خلال ٣٠ يوم" + ) + features = "\n".join(f" ✅ {f}" for f in [ + "7 AI brains", "15 deal types", "Arabic AI negotiator", + "PDPL compliance", "Visual pipeline", "20 users included", + ]) + return ( + f"🔵 *{plan['name_en']}*\n\n" + f"💰 {plan['price_monthly']} SAR/mo\n" + f"💰 {plan['price_yearly']} SAR/yr (save 2 months)\n\n" + f"Everything included:\n{features}\n\n" + f"🎁 7-day free trial — no credit card\n" + f"🔒 30-day money-back guarantee" + ) + + @classmethod + def search_faq(cls, query: str) -> Optional[str]: + query_lower = query.lower() + for faq in cls.FAQ: + if any(word in faq["q_ar"] for word in query_lower.split() if len(word) > 2): + return faq["a_ar"] + return None + + @classmethod + def get_competitor_response(cls, competitor: str) -> Optional[str]: + card = cls.COMPETITOR_CARDS.get(competitor.lower()) + return card["response_ar"] if card else None + + @classmethod + def get_objection_response(cls, objection_type: str, language: str = "ar") -> Optional[str]: + obj = cls.OBJECTION_RESPONSES.get(objection_type) + return obj[language] if obj else None diff --git a/salesflow-saas/frontend/public/dealix-presentations/investor-deck.md b/salesflow-saas/frontend/public/dealix-presentations/investor-deck.md new file mode 100644 index 00000000..cfa207c1 --- /dev/null +++ b/salesflow-saas/frontend/public/dealix-presentations/investor-deck.md @@ -0,0 +1,217 @@ +# Dealix — Investor Pitch Deck +# ديلكس — عرض المستثمرين + +--- + +## Slide 1: Cover + +# Dealix | ديلكس +### نظام المبيعات الذكي للسعودية +### The Smart Sales System for Saudi Arabia + +**AI-Powered Revenue + Partnership + Strategic Deal OS** + +--- + +## Slide 2: Problem | المشكلة + +### الشركات السعودية تخسر ملايين بسبب: + +| المشكلة | الأثر | +|---------|-------| +| **٧٠٪ من العملاء المحتملين يضيعون** | بسبب عدم المتابعة | +| **فوضى الواتساب** | رسائل ضايعة، ما تعرف مين رد | +| **لا يوجد CRM عربي ذكي** | الأنظمة الأجنبية مو مصممة للسوق السعودي | +| **فرص شراكات ضائعة** | لا يوجد نظام يكتشف ويفاوض الصفقات تلقائياً | +| **غرامات PDPL** | حتى ٥ مليون ر.س لكل مخالفة | + +**السوق يحتاج نظام مبني للسعودية، عربي أولاً، ذكي فعلاً.** + +--- + +## Slide 3: Solution | الحل + +### Dealix = 4 طبقات متكاملة + +``` +Layer 3: Strategic Growth OS + → رصد استحواذات، خريطة شركاء، محاكاة ROI + +Layer 2: Deal Exchange OS + → مطابقة شركاء، تبادل خدمات، غرف صفقات + +Layer 1: Sales OS + → ليدات، تواصل واتساب ذكي، عروض أسعار، pipeline + +Layer 0: Core Platform + → Company Twin، حوكمة، PDPL، ذاكرة تجارية +``` + +**أول منصة سعودية تجمع بين المبيعات والشراكات والنمو الاستراتيجي.** + +--- + +## Slide 4: Market | السوق + +### سوق CRM السعودي + +| المقياس | الرقم | +|---------|-------| +| **حجم السوق ٢٠٢٤** | $652M | +| **المتوقع ٢٠٣٣** | $1.46B | +| **معدل النمو** | 9.4% CAGR | +| **سوق AI السعودي ٢٠٢٦** | $680M | +| **المتوقع ٢٠٣١** | $2.8B (32.9% CAGR) | +| **عدد الشركات المسجلة** | 1.2M+ | +| **استخدام WhatsApp** | 85%+ (30M+ مستخدم) | + +### رؤية 2030 تدفع التحول الرقمي +- استثمار Salesforce $500M في الرياض +- AWS + Humain: 150,000 AI accelerator في الرياض +- SDAIA تقود حوكمة الذكاء الاصطناعي + +--- + +## Slide 5: Product | المنتج + +### ميزات رئيسية + +| الميزة | الوصف | +|--------|-------| +| 🤖 **AI عربي** | NLP يفهم اللهجة السعودية + تقييم ذكي للعملاء | +| 📱 **واتساب ذكي** | بوت يبيع ويدعم ويتفاوض بالعربي | +| 🔄 **صفقات استراتيجية** | مطابقة شركاء + تبادل خدمات + مفاوض AI | +| 📊 **Pipeline بصري** | Kanban مع drag-and-drop | +| 💰 **عروض أسعار** | CPQ مع ضريبة القيمة المضافة تلقائياً | +| 🛡️ **PDPL مدمج** | موافقات + حقوق بيانات + audit trail | +| 📈 **تنبؤات مبيعات** | AI forecasting بالعربي | +| 🌍 **ثنائي اللغة** | عربي/إنجليزي بتبديل فوري | + +--- + +## Slide 6: Technology | التقنية + +### البنية التقنية + +- **Backend**: FastAPI + Python 3.12 (async) +- **Frontend**: Next.js 15 + React 19 + TypeScript +- **Database**: PostgreSQL 16 + Redis + pgvector +- **AI**: Groq + OpenAI + Arabic NLP (camel-tools) +- **Orchestration**: Hermes + OpenClaw 2026.4.11 +- **Channels**: WhatsApp Business API + Email + LinkedIn +- **Infrastructure**: Docker + Nginx + Celery + +### AI Stack +- Arabic NLP with Saudi dialect detection +- AI Lead Scoring (0-100, 4 dimensions) +- Conversation Intelligence (Arabic dialogue analysis) +- Autonomous Sales Agent (WhatsApp qualification bot) +- Strategic Deal Negotiator (multi-round Arabic negotiation) + +--- + +## Slide 7: Business Model | نموذج العمل + +### SaaS Subscription + +| الباقة | السعر/شهر | المستهدف | +|--------|-----------|----------| +| **Starter** | 59 ر.س | شركات صغيرة (1-3 مستخدمين) | +| **Professional** | 149 ر.س | شركات متوسطة (1-10 مستخدمين) | +| **Enterprise** | 225 ر.س | شركات كبيرة (لا محدود) | + +### مصادر إيراد إضافية +- **Success Fees**: 1-3% من قيمة الصفقات المكتملة عبر Deal Exchange +- **Marketplace**: عمولة على شراكات ناجحة +- **API Access**: للمطورين والتكاملات +- **Premium Support**: دعم مخصص للمؤسسات + +--- + +## Slide 8: Go-to-Market | خطة الذهاب للسوق + +### Phase A: Real Estate (الآن) +- 20,000+ وكالة عقارية في الرياض +- متوسط قيمة الصفقة: 500K-5M ر.س +- WhatsApp = قناة التواصل الأساسية + +### Phase B: Healthcare (شهر 3-6) +- عيادات وأسنان ومراكز طبية +- حجوزات + متابعة مرضى + +### Phase C: Contracting (شهر 6-12) +- مقاولات وخدمات +- عروض أسعار + متابعة مشاريع + +### استراتيجية الاكتساب +- Cold outreach (email-first) +- Content marketing (Arabic SEO) +- WhatsApp viral (referral program) +- LinkedIn thought leadership +- Saudi Chamber partnerships + +--- + +## Slide 9: Competition | المنافسة + +### لماذا Dealix يتفوق + +| الميزة | Dealix | Salesforce | Zoho | HubSpot | +|--------|--------|------------|------|---------| +| عربي أولاً | ✅ | ❌ | ⚠️ | ❌ | +| واتساب مدمج | ✅ | ❌ | ⚠️ | ❌ | +| AI عربي | ✅ | ❌ | ❌ | ❌ | +| PDPL مدمج | ✅ | ❌ | ❌ | ❌ | +| صفقات استراتيجية | ✅ | ❌ | ❌ | ❌ | +| السعر/مستخدم/شهر | 59 ر.س | 656 ر.س | 52 ر.س | 562 ر.س | +| سيرفر سعودي | ✅ | ❌ | ✅ | ❌ | + +**لا يوجد منافس يجمع: AI عربي + WhatsApp أساسي + PDPL + صفقات استراتيجية** + +--- + +## Slide 10: Revenue Projections | التوقعات المالية + +| الفترة | عملاء | MRR | ARR | +|--------|-------|-----|-----| +| شهر 1 | 5 | 745 ر.س | - | +| شهر 3 | 25 | 3,725 ر.س | - | +| شهر 6 | 100 | 14,900 ر.س | 178K ر.س | +| سنة 1 | 300 | 44,700 ر.س | 536K ر.س | +| سنة 2 | 1,000 | 149,000 ر.س | 1.79M ر.س | +| سنة 3 | 5,000 | 745,000 ر.س | 8.94M ر.س | + +--- + +## Slide 11: Ask | الطلب + +### نبحث عن: جولة Pre-Seed / Seed + +| البند | المبلغ | +|-------|--------| +| **المطلوب** | 2-5M ر.س | +| **التقييم** | 15-25M ر.س (pre-money) | +| **الاستخدام** | التطوير (40%) + التسويق (30%) + التشغيل (20%) + احتياطي (10%) | + +### المعالم (12 شهر) +- 1,000 عميل مدفوع +- 1.79M ر.س ARR +- 3 قطاعات مغطاة +- Deal Exchange OS مُطلق +- فريق 8-12 شخص + +--- + +## Slide 12: Contact | تواصل + +### Dealix | ديلكس +- **الموقع**: dealix.sa +- **الإيميل**: invest@dealix.sa +- **الواتساب**: +966 5X XXX XXXX +- **LinkedIn**: linkedin.com/company/dealix-sa + +> "نبني أول نظام تجاري ذكي مصمم للسعودية" + +--- + +*صنع بحب في السعودية 🇸🇦* diff --git a/salesflow-saas/frontend/public/robots.txt b/salesflow-saas/frontend/public/robots.txt new file mode 100644 index 00000000..480aa9f4 --- /dev/null +++ b/salesflow-saas/frontend/public/robots.txt @@ -0,0 +1,10 @@ +User-agent: * +Allow: / +Disallow: /api/ +Disallow: /dashboard/ +Disallow: /settings/ + +Sitemap: https://dealix.sa/sitemap.xml + +# Dealix - نظام المبيعات الذكي للسعودية +# https://dealix.sa diff --git a/salesflow-saas/frontend/public/sitemap.xml b/salesflow-saas/frontend/public/sitemap.xml new file mode 100644 index 00000000..e6c2d9d6 --- /dev/null +++ b/salesflow-saas/frontend/public/sitemap.xml @@ -0,0 +1,9 @@ + + + https://dealix.sa/1.0weekly + https://dealix.sa/login0.8 + https://dealix.sa/register0.8 + https://dealix.sa/marketers0.7 + https://dealix.sa/terms0.3 + https://dealix.sa/privacy0.3 + diff --git a/salesflow-saas/frontend/src/app/dashboard/page.tsx b/salesflow-saas/frontend/src/app/dashboard/page.tsx index fc7247ae..afc12c32 100644 --- a/salesflow-saas/frontend/src/app/dashboard/page.tsx +++ b/salesflow-saas/frontend/src/app/dashboard/page.tsx @@ -44,6 +44,9 @@ import { IntelligenceDashboard } from "../../components/dealix/intelligence-dash import { LeadGeneratorView } from "../../components/dealix/lead-generator-view"; import { SalesOsView } from "../../components/dealix/sales-os-view"; import { FullOpsView } from "../../components/dealix/full-ops-view"; +import { PipelineKanban } from "../../components/dealix/pipeline-kanban"; +import { UnifiedInbox } from "../../components/dealix/unified-inbox"; +import { LeadScoreCard } from "../../components/dealix/lead-score-card"; export default function DashboardPage() { const auth = useRequireAuth(); @@ -78,6 +81,9 @@ export default function DashboardPage() { { id: "scripts", label: "سكربتات المبيعات", icon: Phone }, { id: "agreements", label: "الاتفاقيات واHR", icon: FileSignature }, { id: "guarantee", label: "الضمان الذهبي", icon: ShieldCheck }, + { id: "pipeline", label: "مسار الصفقات", icon: Target }, + { id: "inbox", label: "صندوق الوارد الموحد", icon: Bell }, + { id: "scoring", label: "تقييم العملاء AI", icon: Zap }, { id: "onboarding", label: "تأهيل المسوق", icon: BookOpen }, ]; @@ -117,6 +123,12 @@ export default function DashboardPage() { return ; case "guarantee": return ; + case "pipeline": + return ; + case "inbox": + return ; + case "scoring": + return ; case "onboarding": return ; default: diff --git a/salesflow-saas/frontend/src/app/error.tsx b/salesflow-saas/frontend/src/app/error.tsx new file mode 100644 index 00000000..5a694147 --- /dev/null +++ b/salesflow-saas/frontend/src/app/error.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; + +interface ErrorPageProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function ErrorPage({ error, reset }: ErrorPageProps) { + const [showDetails, setShowDetails] = useState(false); + const isDev = process.env.NODE_ENV === 'development'; + + useEffect(() => { + console.error('[Dealix Error]', error); + }, [error]); + + return ( +
+
+ + +
+ + + +
+ +

+ حدث خطأ غير متوقع +

+

+ An unexpected error occurred +

+
+ + + Try Again + + + {isDev && ( + + + {showDetails && ( + + {error.message} + {error.stack && `\n\n${error.stack}`} + {error.digest && `\n\nDigest: ${error.digest}`} + + )} + + )} +
+ ); +} diff --git a/salesflow-saas/frontend/src/app/not-found.tsx b/salesflow-saas/frontend/src/app/not-found.tsx new file mode 100644 index 00000000..a962ac3d --- /dev/null +++ b/salesflow-saas/frontend/src/app/not-found.tsx @@ -0,0 +1,52 @@ +'use client'; + +import Link from 'next/link'; +import { motion } from 'framer-motion'; + +export default function NotFound() { + return ( +
+ {/* Decorative glow */} +
+ + + 404 + + + +

+ الصفحة غير موجودة +

+

+ Page Not Found +

+
+ + + + العودة للرئيسية + + + + + +
+ ); +} diff --git a/salesflow-saas/frontend/src/app/page.tsx b/salesflow-saas/frontend/src/app/page.tsx index 3a49782f..4d0a0024 100644 --- a/salesflow-saas/frontend/src/app/page.tsx +++ b/salesflow-saas/frontend/src/app/page.tsx @@ -1,5 +1,5 @@ -import { DealixPublicSite } from "../components/dealix/dealix-public-site"; +import { PremiumLanding } from "../components/dealix/premium-landing"; export default function HomePage() { - return ; + return ; } diff --git a/salesflow-saas/frontend/src/app/privacy/page.tsx b/salesflow-saas/frontend/src/app/privacy/page.tsx new file mode 100644 index 00000000..06b637a7 --- /dev/null +++ b/salesflow-saas/frontend/src/app/privacy/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import Link from 'next/link'; +import { motion } from 'framer-motion'; + +const LAST_UPDATED = '2026-03-01'; + +const sections = [ + { title: 'نظرة عامة', body: 'تلتزم Dealix بحماية خصوصية مستخدميها وفقاً لنظام حماية البيانات الشخصية (PDPL) الصادر بالمرسوم الملكي رقم (م/19) بتاريخ 1443/2/9هـ. توضح هذه السياسة كيفية جمع واستخدام ومعالجة بياناتك الشخصية.', pdpl: false }, + { title: 'البيانات التي نجمعها', body: 'نجمع بيانات التسجيل (الاسم، البريد الإلكتروني، رقم الجوال)، بيانات الشركة (الاسم، السجل التجاري، المجال)، بيانات الاستخدام (سجلات الدخول، النشاط)، وبيانات الاتصال (رسائل واتساب، بريد إلكتروني) بموافقة صريحة.', pdpl: false }, + { title: 'الأساس القانوني للمعالجة (PDPL)', body: 'نعالج بياناتك بناءً على: (أ) موافقتك الصريحة، (ب) تنفيذ العقد المبرم معك، (ج) الالتزام بمتطلبات نظامية. يحق لك سحب موافقتك في أي وقت دون المساس بمشروعية المعالجة التي تمت قبل السحب.', pdpl: true }, + { title: 'حقوق صاحب البيانات (PDPL)', body: 'وفقاً لنظام PDPL، يحق لك: الوصول إلى بياناتك الشخصية، تصحيح البيانات غير الدقيقة، طلب حذف بياناتك، الحصول على نسخة من بياناتك بصيغة قابلة للقراءة، الاعتراض على المعالجة، وتقييد معالجة بياناتك.', pdpl: true }, + { title: 'نقل البيانات خارج المملكة (PDPL)', body: 'لا يتم نقل بياناتك الشخصية خارج المملكة العربية السعودية إلا وفقاً لمتطلبات المادة 29 من نظام PDPL وبعد التأكد من توفر مستوى حماية كافٍ في الدولة المستقبلة أو الحصول على موافقتك الصريحة.', pdpl: true }, + { title: 'ملفات تعريف الارتباط', body: 'نستخدم ملفات تعريف الارتباط (Cookies) لتحسين تجربتك. تشمل: ملفات ضرورية لتشغيل المنصة، ملفات تحليلية لفهم الاستخدام، وملفات تفضيلات لحفظ إعداداتك. يمكنك التحكم في إعدادات الملفات من خلال المتصفح.', pdpl: false }, + { title: 'الاحتفاظ بالبيانات (PDPL)', body: 'نحتفظ ببياناتك طوال مدة اشتراكك وفترة إضافية لا تتجاوز 12 شهراً بعد إلغاء الحساب للأغراض القانونية. يتم حذف البيانات تلقائياً بعد انتهاء فترة الاحتفاظ ما لم يكن هناك التزام نظامي يقتضي خلاف ذلك.', pdpl: true }, + { title: 'أمن البيانات', body: 'نتخذ إجراءات أمنية تقنية وتنظيمية لحماية بياناتك تشمل: التشفير أثناء النقل والتخزين (TLS 1.3, AES-256)، التحكم في الوصول، المراقبة المستمرة، والنسخ الاحتياطي المنتظم.', pdpl: false }, + { title: 'الإبلاغ عن الانتهاكات (PDPL)', body: 'في حال حدوث أي انتهاك لبياناتك الشخصية، سنقوم بإخطارك والجهة المختصة خلال 72 ساعة وفقاً لمتطلبات نظام PDPL. سنوضح طبيعة الانتهاك والإجراءات المتخذة والتوصيات لتقليل الأثر.', pdpl: true }, +]; + +export default function PrivacyPage() { + return ( +
+ + + + + + رجوع + + +

سياسة الخصوصية

+

آخر تحديث: {LAST_UPDATED}

+ +
+ {sections.map((s, i) => ( +
+
+

{s.title}

+ {s.pdpl && ( + + PDPL + + )} +
+

{s.body}

+
+ ))} +
+ + {/* DPO Contact */} +
+

التواصل مع مسؤول حماية البيانات (DPO)

+

+ لأي استفسارات تتعلق بخصوصية بياناتك أو لممارسة حقوقك وفقاً لنظام PDPL، يمكنك التواصل مع مسؤول حماية البيانات: +

+
+

البريد الإلكتروني: dpo@dealix.sa

+

الهاتف: +966 11 XXX XXXX

+

العنوان: الرياض، المملكة العربية السعودية

+
+
+
+
+ ); +} diff --git a/salesflow-saas/frontend/src/app/settings/page.tsx b/salesflow-saas/frontend/src/app/settings/page.tsx new file mode 100644 index 00000000..a9eac5a3 --- /dev/null +++ b/salesflow-saas/frontend/src/app/settings/page.tsx @@ -0,0 +1,504 @@ +'use client'; + +import { useState, type ReactNode } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useI18n } from '@/i18n'; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +type TabId = 'account' | 'company' | 'team' | 'billing' | 'integrations' | 'notifications'; + +interface Tab { + id: TabId; + labelAr: string; + labelEn: string; + icon: ReactNode; +} + +interface TeamMember { + id: string; + name: string; + email: string; + role: 'owner' | 'manager' | 'agent'; + avatar?: string; +} + +/* ------------------------------------------------------------------ */ +/* Static data */ +/* ------------------------------------------------------------------ */ + +const tabs: Tab[] = [ + { id: 'account', labelAr: 'الحساب', labelEn: 'Account', icon: }, + { id: 'company', labelAr: 'الشركة', labelEn: 'Company', icon: }, + { id: 'team', labelAr: 'الفريق', labelEn: 'Team', icon: }, + { id: 'billing', labelAr: 'الفوترة', labelEn: 'Billing', icon: }, + { id: 'integrations', labelAr: 'التكاملات', labelEn: 'Integrations', icon: }, + { id: 'notifications', labelAr: 'الإشعارات', labelEn: 'Notifications', icon: }, +]; + +const mockTeam: TeamMember[] = [ + { id: '1', name: 'أحمد الغامدي', email: 'ahmed@dealix.sa', role: 'owner' }, + { id: '2', name: 'سارة العتيبي', email: 'sara@dealix.sa', role: 'manager' }, + { id: '3', name: 'خالد المالكي', email: 'khaled@dealix.sa', role: 'agent' }, +]; + +const roleLabels: Record = { + owner: { ar: 'مالك', en: 'Owner', color: 'text-amber-400 bg-amber-400/10 border-amber-400/30' }, + manager: { ar: 'مدير', en: 'Manager', color: 'text-primary bg-primary/10 border-primary/30' }, + agent: { ar: 'وكيل', en: 'Agent', color: 'text-slate-300 bg-white/5 border-white/10' }, +}; + +const notificationEvents = [ + { id: 'new_lead', labelAr: 'عميل محتمل جديد', labelEn: 'New Lead' }, + { id: 'deal_won', labelAr: 'صفقة مكسوبة', labelEn: 'Deal Won' }, + { id: 'deal_lost', labelAr: 'صفقة خاسرة', labelEn: 'Deal Lost' }, + { id: 'message', labelAr: 'رسالة جديدة', labelEn: 'New Message' }, + { id: 'task_due', labelAr: 'مهمة مستحقة', labelEn: 'Task Due' }, + { id: 'approval', labelAr: 'طلب موافقة', labelEn: 'Approval Request' }, +]; + +const channels = ['email', 'whatsapp', 'sms', 'push'] as const; + +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + +export default function SettingsPage() { + const { isArabic } = useI18n(); + const [activeTab, setActiveTab] = useState('account'); + + const label = (ar: string, en: string) => (isArabic ? ar : en); + + return ( +
+
+

+ {label('الإعدادات', 'Settings')} +

+ +
+ {/* Tab nav -- right side in RTL */} + + + {/* Content */} +
+ + + {activeTab === 'account' && } + {activeTab === 'company' && } + {activeTab === 'team' && } + {activeTab === 'billing' && } + {activeTab === 'integrations' && } + {activeTab === 'notifications' && } + + +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Shared */ +/* ------------------------------------------------------------------ */ + +type L = (ar: string, en: string) => string; + +function Section({ title, children, onSave, label }: { title: string; children: ReactNode; onSave?: () => void; label: L }) { + return ( +
+

{title}

+
{children}
+ {onSave && ( +
+ +
+ )} +
+ ); +} + +function Field({ label: fieldLabel, children }: { label: string; children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function TextInput({ placeholder, defaultValue, dir }: { placeholder?: string; defaultValue?: string; dir?: string }) { + return ( + + ); +} + +function SelectInput({ options, defaultValue }: { options: { value: string; label: string }[]; defaultValue?: string }) { + return ( + + ); +} + +function Toggle({ defaultChecked = false }: { defaultChecked?: boolean }) { + const [on, setOn] = useState(defaultChecked); + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Tabs */ +/* ------------------------------------------------------------------ */ + +function AccountTab({ label }: { label: L }) { + return ( +
{}} label={label}> +
+ + + + + + + + + + + + + + + +
+
+ ); +} + +function CompanyTab({ label }: { label: L }) { + return ( +
{}} label={label}> +
+ + + + + + + + + + + + +
+ {/* Logo upload placeholder */} +
+ +
+
+ + + + {label('اسحب الملف أو اضغط للرفع', 'Drag & drop or click to upload')} +
+
+
+
+ ); +} + +function TeamTab({ label }: { label: L }) { + return ( + <> +
+
+ {mockTeam.map((m) => { + const rl = roleLabels[m.role]; + return ( +
+
+
+ {m.name.charAt(0)} +
+
+

{m.name}

+

{m.email}

+
+
+
+ {m.role === 'owner' ? ( + + {label(rl.ar, rl.en)} + + ) : ( + + )} +
+
+ ); + })} +
+ +
+ + ); +} + +function BillingTab({ label }: { label: L }) { + return ( + <> + {/* Current Plan */} +
+
+
+

{label('الباقة الاحترافية', 'Professional Plan')}

+

{label('١٤٩ ر.س / شهرياً', 'SAR 149 / month')}

+
+ +
+
+ + {/* Payment method */} +
+
+
VISA
+
+

**** **** **** 4242

+

{label('تنتهي ١٢/٢٧', 'Expires 12/27')}

+
+
+
+ + {/* Invoice history */} +
+
+ {[ + { date: '2026-03-01', amount: '149', status: 'paid' }, + { date: '2026-02-01', amount: '149', status: 'paid' }, + { date: '2026-01-01', amount: '149', status: 'paid' }, + ].map((inv, i) => ( +
+ {inv.date} + {inv.amount} {label('ر.س', 'SAR')} + {label('مدفوعة', 'Paid')} +
+ ))} +
+
+ + ); +} + +function IntegrationsTab({ label }: { label: L }) { + const integrations = [ + { name: 'WhatsApp', icon: '💬', connected: true, descAr: 'متصل — رقم +966 50 XXX XXXX', descEn: 'Connected — +966 50 XXX XXXX' }, + { name: label('البريد SMTP', 'Email SMTP'), icon: '📧', connected: false, descAr: 'غير متصل', descEn: 'Not connected' }, + ]; + + return ( + <> +
+
+ {integrations.map((intg, i) => ( +
+
+ {intg.icon} +
+

{intg.name}

+

{label(intg.descAr, intg.descEn)}

+
+
+ + {intg.connected ? label('متصل', 'Connected') : label('غير متصل', 'Disconnected')} + +
+ ))} +
+
+ + {/* API Key */} +
+
+ + +
+

+ {label('لا تشارك مفتاح API مع أي شخص. يمكنك إعادة توليده من هنا.', 'Never share your API key. You can regenerate it here.')} +

+
+ + ); +} + +function NotificationsTab({ label }: { label: L }) { + return ( +
{}} label={label}> + {/* Channel headers */} +
+ + {channels.map((ch) => ( + {ch} + ))} +
+
+ {notificationEvents.map((evt) => ( +
+ {label(evt.labelAr, evt.labelEn)} + {channels.map((ch) => ( +
+ {ch} + +
+ ))} +
+ ))} +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Icons */ +/* ------------------------------------------------------------------ */ + +function UserIcon() { + return ( + + + + ); +} + +function BuildingIcon() { + return ( + + + + ); +} + +function UsersIcon() { + return ( + + + + ); +} + +function CreditCardIcon() { + return ( + + + + ); +} + +function PuzzleIcon() { + return ( + + + + ); +} + +function BellIcon() { + return ( + + + + ); +} diff --git a/salesflow-saas/frontend/src/app/terms/page.tsx b/salesflow-saas/frontend/src/app/terms/page.tsx new file mode 100644 index 00000000..f278a45d --- /dev/null +++ b/salesflow-saas/frontend/src/app/terms/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import Link from 'next/link'; +import { motion } from 'framer-motion'; + +const LAST_UPDATED = '2026-03-01'; + +const sections = [ + { title: 'المقدمة', body: 'مرحباً بكم في منصة Dealix ("المنصة"). باستخدامك للمنصة، فإنك توافق على الالتزام بهذه الشروط والأحكام. يرجى قراءتها بعناية قبل استخدام خدماتنا.' }, + { title: 'تعريفات', body: '"المنصة" تعني تطبيق Dealix وجميع خدماته. "المستخدم" يعني أي شخص أو كيان يستخدم المنصة. "الخدمات" تشمل جميع الميزات والأدوات المتاحة عبر المنصة بما في ذلك إدارة العملاء والصفقات والتواصل.' }, + { title: 'الأهلية', body: 'يجب أن يكون عمرك 18 عاماً على الأقل لاستخدام المنصة. باستخدامك للمنصة، تؤكد أنك تملك الأهلية القانونية لإبرام هذه الاتفاقية وأنك مفوّض من قبل الشركة التي تمثلها.' }, + { title: 'الحساب والأمان', body: 'أنت مسؤول عن الحفاظ على سرية بيانات حسابك وكلمة المرور. يجب إخطارنا فوراً عند اكتشاف أي استخدام غير مصرح به لحسابك. لا تتحمل Dealix مسؤولية أي خسارة ناتجة عن استخدام غير مصرح به.' }, + { title: 'الاستخدام المقبول', body: 'تلتزم باستخدام المنصة للأغراض التجارية المشروعة فقط. يُحظر استخدام المنصة في أي نشاط مخالف للأنظمة السعودية أو لإرسال رسائل غير مرغوبة (spam) أو لجمع بيانات بطرق غير مشروعة.' }, + { title: 'حماية البيانات', body: 'نلتزم بنظام حماية البيانات الشخصية (PDPL) في المملكة العربية السعودية. تتم معالجة البيانات وفقاً لسياسة الخصوصية الخاصة بنا وبموافقة صريحة من أصحاب البيانات.' }, + { title: 'الملكية الفكرية', body: 'جميع حقوق الملكية الفكرية للمنصة وبرامجها وتصاميمها وعلاماتها التجارية مملوكة لشركة Dealix. لا يحق لك نسخ أو تعديل أو توزيع أي جزء من المنصة دون إذن كتابي مسبق.' }, + { title: 'الإنهاء', body: 'يحق لنا تعليق أو إنهاء حسابك في حال مخالفة هذه الشروط. يمكنك إلغاء حسابك في أي وقت من خلال إعدادات الحساب. عند الإنهاء، سيتم حذف بياناتك وفقاً لسياسة الاحتفاظ بالبيانات.' }, + { title: 'القانون الحاكم', body: 'تخضع هذه الشروط لأنظمة المملكة العربية السعودية. أي نزاع ينشأ عن استخدام المنصة يخضع لاختصاص المحاكم المختصة في المملكة العربية السعودية.' }, +]; + +export default function TermsPage() { + return ( +
+ + + + + + رجوع + + +

الشروط والأحكام

+

آخر تحديث: {LAST_UPDATED}

+ +
+ {sections.map((s, i) => ( +
+

{s.title}

+

{s.body}

+
+ ))} +
+
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/capabilities-showcase.tsx b/salesflow-saas/frontend/src/components/dealix/capabilities-showcase.tsx new file mode 100644 index 00000000..fb603f74 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/capabilities-showcase.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { motion } from "framer-motion"; +import { + MessageCircle, Mail, Linkedin, Instagram, Music2, Twitter, + Brain, Shield, Handshake, TrendingUp, Globe, Zap, + Building2, Users, BarChart3, Bot, Lock, Sparkles, +} from "lucide-react"; + +const fadeUp = { + hidden: { opacity: 0, y: 30 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.6 } }, +}; + +const stagger = { + visible: { transition: { staggerChildren: 0.1 } }, +}; + +const UNIQUE_CAPABILITIES = [ + { + icon: Brain, + title_ar: "٧ أدمغة ذكية لكل قناة", + title_en: "7 AI Brains — One Per Channel", + desc_ar: "كل قناة عندها عقل خاص: واتساب، إيميل، لينكدإن، إنستقرام، تيكتوك، تويتر، سناب — كلهم مربوطين بالباك إند ويعرفون عميلك", + desc_en: "Each channel has its own AI brain: WhatsApp, Email, LinkedIn, Instagram, TikTok, Twitter, Snapchat — all connected to your CRM data", + badge_ar: "لا يوجد منافس يقدم هذا", + badge_en: "No competitor offers this", + color: "from-cyan-500 to-blue-600", + }, + { + icon: Handshake, + title_ar: "محرك صفقات استراتيجية", + title_en: "Strategic Deal Exchange Engine", + desc_ar: "النظام يفهم شركتك ويبحث عن شركاء مناسبين — تبادل خدمات، شراكات، توزيع، استحواذ — ١٥ نوع صفقة", + desc_en: "The system understands your company and finds matching partners — barter, partnerships, distribution, acquisition — 15 deal types", + badge_ar: "أول نظام بالعالم يفعل هذا", + badge_en: "World's first system to do this", + color: "from-emerald-500 to-teal-600", + }, + { + icon: Bot, + title_ar: "مفاوض AI بالعربي", + title_en: "Arabic AI Negotiator", + desc_ar: "يتفاوض بالنيابة عنك بالعربي — يفهم الثقافة السعودية، يحفظ ماء الوجه، ويعرف متى يصعّد للبشر", + desc_en: "Negotiates on your behalf in Arabic — understands Saudi culture, saves face, knows when to escalate to humans", + badge_ar: "حصري لـ Dealix", + badge_en: "Dealix exclusive", + color: "from-purple-500 to-indigo-600", + }, + { + icon: Shield, + title_ar: "حماية PDPL مدمجة", + title_en: "Built-in PDPL Protection", + desc_ar: "النظام يفحص الموافقة قبل كل رسالة — حماية بياناتك وبيانات عملائك حسب نظام حماية البيانات السعودي", + desc_en: "System checks consent before every message — protects your data and clients' data per Saudi PDPL law", + badge_ar: "غرامة ٥ مليون ر.س — نحميك", + badge_en: "SAR 5M fine — we protect you", + color: "from-red-500 to-orange-600", + }, + { + icon: TrendingUp, + title_ar: "محاكي نمو استراتيجي", + title_en: "Strategic Growth Simulator", + desc_ar: "حاكي أي سيناريو: شراكة، استحواذ، توسع — شوف العائد والمخاطر قبل ما تقرر", + desc_en: "Simulate any scenario: partnership, acquisition, expansion — see ROI and risks before deciding", + badge_ar: "مستوى enterprise", + badge_en: "Enterprise-grade", + color: "from-amber-500 to-yellow-600", + }, + { + icon: Globe, + title_ar: "عربي أولاً — ثنائي اللغة", + title_en: "Arabic-First — Bilingual", + desc_ar: "مبني عربي من الأساس مع AI يفهم اللهجة السعودية — مو ترجمة لنظام أجنبي", + desc_en: "Built Arabic from the ground up with Saudi-dialect AI — not a translation of a foreign system", + badge_ar: "الوحيد بالسوق", + badge_en: "Only one in market", + color: "from-green-500 to-emerald-600", + }, +]; + +const CHANNEL_ICONS = [ + { icon: MessageCircle, name: "WhatsApp", color: "#25D366" }, + { icon: Mail, name: "Email", color: "#EA4335" }, + { icon: Linkedin, name: "LinkedIn", color: "#0A66C2" }, + { icon: Instagram, name: "Instagram", color: "#E4405F" }, + { icon: Music2, name: "TikTok", color: "#000000" }, + { icon: Twitter, name: "X/Twitter", color: "#1DA1F2" }, +]; + +const COMPARISON_DATA = [ + { feature_ar: "أدمغة AI لكل قناة", dealix: true, salesforce: false, zoho: false, hubspot: false }, + { feature_ar: "صفقات استراتيجية تلقائية", dealix: true, salesforce: false, zoho: false, hubspot: false }, + { feature_ar: "مفاوض AI بالعربي", dealix: true, salesforce: false, zoho: false, hubspot: false }, + { feature_ar: "واتساب مدمج بالنظام", dealix: true, salesforce: false, zoho: false, hubspot: false }, + { feature_ar: "PDPL مدمج", dealix: true, salesforce: false, zoho: false, hubspot: false }, + { feature_ar: "عربي أولاً (مو ترجمة)", dealix: true, salesforce: false, zoho: true, hubspot: false }, + { feature_ar: "محاكي نمو استراتيجي", dealix: true, salesforce: false, zoho: false, hubspot: false }, + { feature_ar: "تسلسلات متعددة القنوات", dealix: true, salesforce: true, zoho: true, hubspot: true }, +]; + +export function CapabilitiesShowcase() { + return ( +
+
+ {/* Header */} + + + ما يميز Dealix عن كل المنافسين + +

+ قدرات لا توجد في أي نظام آخر +

+

+ Dealix ليس مجرد CRM — هو نظام تجاري ذكي يبيع ويتفاوض ويبني شراكات بالنيابة عنك +

+
+ + {/* Channel Icons Row */} + + {CHANNEL_ICONS.map((ch) => ( + +
+ +
+ {ch.name} +
+ ))} +
+ + {/* Unique Capabilities Grid */} + + {UNIQUE_CAPABILITIES.map((cap, i) => ( + +
+ +
+

{cap.title_ar}

+

{cap.desc_ar}

+ + {cap.badge_ar} + +
+ ))} +
+ + {/* Comparison Table */} + +

+ لماذا Dealix هو الخيار الأذكى؟ +

+
+ + + + + + + + + + + + {COMPARISON_DATA.map((row, i) => ( + + + + + + + + ))} + +
الميزةDealixSalesforceZohoHubSpot
{row.feature_ar}{row.dealix ? "✅" : "❌"}{row.salesforce ? "✅" : "❌"}{row.zoho ? "✅" : "❌"}{row.hubspot ? "✅" : "❌"}
+
+

+ Dealix يتفوق في ٧ من ٨ مقارنات — والباقي متساوي +

+
+ + {/* Bottom CTA */} + + +

+ جاهز تشوف قدرات لا توجد في أي مكان ثاني؟ +

+

+ ١٤ يوم تجربة مجانية — بدون بطاقة — كل المميزات مفتوحة +

+ +
+
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/command-palette.tsx b/salesflow-saas/frontend/src/components/dealix/command-palette.tsx new file mode 100644 index 00000000..d0b1577e --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/command-palette.tsx @@ -0,0 +1,289 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { clsx } from 'clsx'; +import { + Search, Plus, MessageSquare, BarChart3, Settings, + Users, Briefcase, ArrowRight, Clock, Inbox, + LayoutDashboard, UserPlus, CheckSquare, Megaphone, +} from 'lucide-react'; +import { useI18n } from '@/i18n'; + +type CommandCategory = 'recent' | 'navigation' | 'actions' | 'contacts' | 'deals'; + +interface CommandItem { + id: string; + label: string; + labelAr: string; + category: CommandCategory; + icon: typeof Search; + keywords: string[]; + onSelect?: () => void; +} + +interface CommandPaletteProps { + open: boolean; + onClose: () => void; + onSelect?: (item: CommandItem) => void; +} + +const backdropVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, +}; + +const panelVariants = { + hidden: { opacity: 0, scale: 0.96, y: -8 }, + visible: { opacity: 1, scale: 1, y: 0 }, + exit: { opacity: 0, scale: 0.96, y: -8 }, +}; + +function buildItems(t: (k: string) => string): CommandItem[] { + return [ + { id: 'nav-dashboard', label: 'Dashboard', labelAr: t('dashboard.tabs.overview'), category: 'navigation', icon: LayoutDashboard, keywords: ['home', 'لوحة', 'loha', 'dashboard'] }, + { id: 'nav-pipeline', label: 'Pipeline', labelAr: t('dashboard.tabs.pipeline'), category: 'navigation', icon: Briefcase, keywords: ['deals', 'مسار', 'masar', 'pipeline', 'صفقات'] }, + { id: 'nav-inbox', label: 'Inbox', labelAr: t('dashboard.tabs.inbox'), category: 'navigation', icon: Inbox, keywords: ['messages', 'صندوق', 'sandoq', 'inbox', 'رسائل'] }, + { id: 'nav-analytics', label: 'Analytics', labelAr: t('dashboard.tabs.analytics'), category: 'navigation', icon: BarChart3, keywords: ['reports', 'تحليلات', 'tahlilat', 'analytics', 'تقارير'] }, + { id: 'nav-leads', label: 'Leads', labelAr: t('dashboard.tabs.leads'), category: 'navigation', icon: Users, keywords: ['clients', 'عملاء', '3omala', 'leads'] }, + { id: 'nav-settings', label: 'Settings', labelAr: t('dashboard.tabs.settings'), category: 'navigation', icon: Settings, keywords: ['config', 'إعدادات', 'e3dadat', 'settings'] }, + { id: 'nav-marketers', label: 'Marketers', labelAr: t('commandPalette.actions.goToMarketers'), category: 'navigation', icon: Megaphone, keywords: ['affiliate', 'مسوقين', 'msawqin', 'marketers'] }, + { id: 'act-new-deal', label: 'Create New Deal', labelAr: t('commandPalette.actions.newDeal'), category: 'actions', icon: Plus, keywords: ['new', 'deal', 'صفقة', 'safqa', 'جديد', 'jadid', 'create'] }, + { id: 'act-new-contact', label: 'Add Contact', labelAr: t('commandPalette.actions.newContact'), category: 'actions', icon: UserPlus, keywords: ['contact', 'add', 'إضافة', 'edafa', 'جهة', 'jiha'] }, + { id: 'act-new-task', label: 'Create Task', labelAr: t('commandPalette.actions.newTask'), category: 'actions', icon: CheckSquare, keywords: ['task', 'مهمة', 'muhimma', 'todo'] }, + { id: 'act-send-msg', label: 'Send Message', labelAr: t('commandPalette.actions.sendMessage'), category: 'actions', icon: MessageSquare, keywords: ['message', 'رسالة', 'risala', 'whatsapp', 'واتساب'] }, + ]; +} + +function fuzzyMatch(query: string, item: CommandItem, isArabic: boolean): boolean { + const q = query.toLowerCase(); + const haystack = [ + item.label.toLowerCase(), + item.labelAr, + ...item.keywords.map((k) => k.toLowerCase()), + ].join(' '); + return haystack.includes(q); +} + +function CommandPalette({ open, onClose, onSelect }: CommandPaletteProps) { + const { t, dir, isArabic } = useI18n(); + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + const allItems = useMemo(() => buildItems(t), [t]); + + const filtered = useMemo(() => { + if (!query.trim()) return allItems.slice(0, 8); + return allItems.filter((item) => fuzzyMatch(query, item, isArabic)); + }, [query, allItems, isArabic]); + + const grouped = useMemo(() => { + const map = new Map(); + for (const item of filtered) { + const list = map.get(item.category) ?? []; + list.push(item); + map.set(item.category, list); + } + return map; + }, [filtered]); + + const flatItems = useMemo(() => filtered, [filtered]); + + useEffect(() => { + if (open) { + setQuery(''); + setActiveIndex(0); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open]); + + useEffect(() => { + setActiveIndex(0); + }, [query]); + + const handleSelect = useCallback( + (item: CommandItem) => { + onSelect?.(item); + item.onSelect?.(); + onClose(); + }, + [onSelect, onClose], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((i) => (i + 1) % flatItems.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((i) => (i - 1 + flatItems.length) % flatItems.length); + } else if (e.key === 'Enter' && flatItems[activeIndex]) { + e.preventDefault(); + handleSelect(flatItems[activeIndex]); + } else if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }, + [flatItems, activeIndex, handleSelect, onClose], + ); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, onClose]); + + const categoryLabel = (cat: CommandCategory) => + t(`commandPalette.categories.${cat}`); + + return ( + + {open && ( +
+
+ )} +
+ ); +} + +function useCommandPalette() { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setOpen((prev) => !prev); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + return { open, setOpen, onClose: () => setOpen(false) }; +} + +export { CommandPalette, useCommandPalette }; +export type { CommandPaletteProps, CommandItem }; diff --git a/salesflow-saas/frontend/src/components/dealix/cookie-consent.tsx b/salesflow-saas/frontend/src/components/dealix/cookie-consent.tsx new file mode 100644 index 00000000..d468840b --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/cookie-consent.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import Link from 'next/link'; +import { useI18n } from '@/i18n'; + +const STORAGE_KEY = 'dealix-cookie-consent'; + +export function CookieConsent() { + const { isArabic } = useI18n(); + const [visible, setVisible] = useState(false); + + const label = (ar: string, en: string) => (isArabic ? ar : en); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) { + // Small delay so it doesn't appear on first paint + const timer = setTimeout(() => setVisible(true), 1500); + return () => clearTimeout(timer); + } + }, []); + + function handleAccept() { + localStorage.setItem(STORAGE_KEY, 'accepted'); + setVisible(false); + } + + function handleReject() { + localStorage.setItem(STORAGE_KEY, 'rejected'); + setVisible(false); + } + + return ( + + {visible && ( + +
+
+ {/* Text */} +
+

+ {label( + 'نستخدم ملفات تعريف الارتباط لتحسين تجربتك وتحليل استخدام المنصة وفقاً لنظام حماية البيانات الشخصية (PDPL).', + 'We use cookies to improve your experience and analyze platform usage in compliance with PDPL.' + )} +

+ + {label('المزيد من المعلومات', 'More Information')} + +
+ + {/* Buttons */} +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/dealix-3d-logo.tsx b/salesflow-saas/frontend/src/components/dealix/dealix-3d-logo.tsx new file mode 100644 index 00000000..0e3fbda4 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/dealix-3d-logo.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { Suspense, useRef, useMemo, useState, useEffect } from 'react'; +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { Float, MeshTransmissionMaterial, Environment } from '@react-three/drei'; +import * as THREE from 'three'; +import { clsx } from 'clsx'; + +function useIsMobile() { + const [mobile, setMobile] = useState(false); + useEffect(() => { + const check = () => setMobile(window.innerWidth < 768); + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); + return mobile; +} + +function HandShape({ position, rotation, color }: { + position: [number, number, number]; + rotation: [number, number, number]; + color: string; +}) { + const group = useRef(null); + + return ( + + {/* Palm */} + + + + + {/* Fingers - four cylinders */} + {[0, 1, 2, 3].map((i) => ( + + + + + ))} + {/* Thumb */} + + + + + + ); +} + +function GlowSphere() { + const ref = useRef(null); + + useFrame(({ clock }) => { + if (!ref.current) return; + const s = 1 + Math.sin(clock.elapsedTime * 2) * 0.15; + ref.current.scale.setScalar(s); + }); + + return ( + + + + + ); +} + +function Particles({ count }: { count: number }) { + const ref = useRef(null); + + const positions = useMemo(() => { + const arr = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + arr[i * 3] = (Math.random() - 0.5) * 3; + arr[i * 3 + 1] = (Math.random() - 0.5) * 3; + arr[i * 3 + 2] = (Math.random() - 0.5) * 3; + } + return arr; + }, [count]); + + useFrame(({ clock }) => { + if (!ref.current) return; + ref.current.rotation.y = clock.elapsedTime * 0.05; + ref.current.rotation.x = Math.sin(clock.elapsedTime * 0.03) * 0.1; + }); + + return ( + + + + + + + ); +} + +function HandshakeScene({ isMobile }: { isMobile: boolean }) { + const groupRef = useRef(null); + const mouse = useRef({ x: 0, y: 0 }); + + const { viewport } = useThree(); + + useEffect(() => { + const handle = (e: MouseEvent) => { + mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1; + mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1; + }; + window.addEventListener('mousemove', handle); + return () => window.removeEventListener('mousemove', handle); + }, []); + + useFrame(({ clock }) => { + if (!groupRef.current) return; + groupRef.current.rotation.y = clock.elapsedTime * 0.15 + mouse.current.x * 0.3; + groupRef.current.rotation.x = Math.sin(clock.elapsedTime * 0.2) * 0.05 + mouse.current.y * 0.15; + }); + + return ( + + {/* Left hand reaching right */} + + {/* Right hand reaching left */} + + {/* Glow at handshake point */} + + {/* Particles */} + + + ); +} + +function LoadingShimmer() { + return ( +
+
+
+
+
+
+
+ ); +} + +interface DealixLogo3DProps { + size?: number; + className?: string; +} + +function DealixLogo3D({ size = 300, className }: DealixLogo3DProps) { + const isMobile = useIsMobile(); + + return ( +
+ }> + + + + + + + + + + + {/* Ambient glow behind the canvas */} +
+
+ ); +} + +export { DealixLogo3D }; +export type { DealixLogo3DProps }; diff --git a/salesflow-saas/frontend/src/components/dealix/lead-score-card.tsx b/salesflow-saas/frontend/src/components/dealix/lead-score-card.tsx new file mode 100644 index 00000000..48ae70ee --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/lead-score-card.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { motion, useMotionValue, useTransform, animate } from "framer-motion"; +import { TrendingUp, Sparkles, UserCheck, MousePointerClick, Target } from "lucide-react"; + +/* ───────────── types ───────────── */ +interface BreakdownItem { + key: string; + label: string; + value: number; // 0-25 + icon: typeof TrendingUp; +} + +interface LeadScoreData { + score: number; // 0-100 + breakdown: BreakdownItem[]; + recommendation: string; +} + +/* ───────────── helpers ───────────── */ +function getGrade(score: number): string { + if (score >= 90) return "A+"; + if (score >= 80) return "A"; + if (score >= 70) return "B"; + if (score >= 55) return "C"; + if (score >= 40) return "D"; + return "F"; +} + +function getScoreColor(score: number): string { + if (score >= 75) return "#10b981"; // green + if (score >= 50) return "#eab308"; // yellow + return "#ef4444"; // red +} + +function getGradientId(score: number): string { + return `score-gradient-${score}`; +} + +/* ───────────── sample data ───────────── */ +const sampleData: LeadScoreData = { + score: 78, + breakdown: [ + { key: "engagement", label: "التفاعل", value: 22, icon: MousePointerClick }, + { key: "profile", label: "الملف الشخصي", value: 18, icon: UserCheck }, + { key: "behavior", label: "السلوك", value: 20, icon: TrendingUp }, + { key: "intent", label: "نية الشراء", value: 18, icon: Target }, + ], + recommendation: "عميل واعد — تابع خلال ٢٤ ساعة", +}; + +/* ───────────── circular ring ───────────── */ +function ScoreRing({ + score, + size = 160, + strokeWidth = 10, +}: { + score: number; + size?: number; + strokeWidth?: number; +}) { + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const motionProgress = useMotionValue(0); + const strokeDashoffset = useTransform( + motionProgress, + (v) => circumference - (v / 100) * circumference + ); + const displayScore = useMotionValue(0); + const [displayed, setDisplayed] = useState(0); + + useEffect(() => { + const anim = animate(motionProgress, score, { duration: 1.4, ease: "easeOut" }); + const anim2 = animate(displayScore, score, { + duration: 1.4, + ease: "easeOut", + onUpdate: (v) => setDisplayed(Math.round(v)), + }); + return () => { + anim.stop(); + anim2.stop(); + }; + }, [score, motionProgress, displayScore]); + + const color = getScoreColor(score); + const grade = getGrade(score); + const gradientId = getGradientId(score); + + return ( +
+ + + + + + + + + {/* background ring */} + + {/* progress ring */} + + + + {/* center content */} +
+ + {displayed} + + + {grade} + +
+
+ ); +} + +/* ───────────── breakdown bar ───────────── */ +function BreakdownBar({ + item, + delay, +}: { + item: BreakdownItem; + delay: number; +}) { + const Icon = item.icon; + const pct = (item.value / 25) * 100; + const color = getScoreColor(item.value * 4); // scale 0-25 -> 0-100 + + return ( + +
+ + {item.value}/٢٥ + +
+ {item.label} +
+ +
+
+
+
+ +
+
+ ); +} + +/* ───────────── full variant ───────────── */ +export function LeadScoreCard({ + data = sampleData, + variant = "full", +}: { + data?: LeadScoreData; + variant?: "full" | "compact"; +}) { + if (variant === "compact") { + return ; + } + + return ( + + {/* header */} +
+
+ +
+

تقييم العميل الذكي

+
+ + {/* ring */} +
+ +
+ + {/* breakdown */} +
+ {data.breakdown.map((item, i) => ( + + ))} +
+ + {/* recommendation */} + +
+ + توصية الذكاء الاصطناعي +
+

{data.recommendation}

+
+
+ ); +} + +/* ───────────── compact variant ───────────── */ +function LeadScoreCompact({ data }: { data: LeadScoreData }) { + const color = getScoreColor(data.score); + const grade = getGrade(data.score); + + return ( + + {/* mini ring */} + + + {/* info */} +
+
+ + {data.score} + + + {grade} + +
+

{data.recommendation}

+
+ + {/* mini bars */} +
+ {data.breakdown.map((item) => ( + + ))} +
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/marketers-page.tsx b/salesflow-saas/frontend/src/components/dealix/marketers-page.tsx new file mode 100644 index 00000000..c3c00f24 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/marketers-page.tsx @@ -0,0 +1,385 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { motion, useInView } from 'framer-motion'; +import { clsx } from 'clsx'; +import { + Zap, Wrench, HeadphonesIcon, Eye, + UserPlus, Share2, Coins, + Award, ChevronDown, ChevronUp, + LayoutDashboard, Link2, FileText, BarChart3, + Star, Quote, Phone, Mail, User, +} from 'lucide-react'; +import { useI18n } from '@/i18n'; + +/* ---------- Animation Helpers ---------- */ +const fadeUp = { + hidden: { opacity: 0, y: 24 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.5 } }, +}; + +const stagger = { + hidden: {}, + visible: { transition: { staggerChildren: 0.1 } }, +}; + +function Section({ children, className }: { children: React.ReactNode; className?: string }) { + const ref = useRef(null); + const inView = useInView(ref, { once: true, margin: '-60px' }); + return ( + + {children} + + ); +} + +function GlassCard({ children, className }: { children: React.ReactNode; className?: string }) { + return ( + + {children} + + ); +} + +/* ---------- FAQ Accordion ---------- */ +function FaqItem({ question, answer }: { question: string; answer: string }) { + const [open, setOpen] = useState(false); + return ( +
+ + +

{answer}

+
+
+ ); +} + +/* ---------- Main ---------- */ +function MarketersPage() { + const { t, dir, isArabic } = useI18n(); + const [form, setForm] = useState({ name: '', phone: '', email: '' }); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + await new Promise((r) => setTimeout(r, 1500)); + setSubmitting(false); + setSubmitted(true); + }; + + const benefits = [ + { icon: Zap, title: 'benefitInstantCommission', desc: 'benefitInstantCommissionDesc' }, + { icon: Wrench, title: 'benefitProTools', desc: 'benefitProToolsDesc' }, + { icon: HeadphonesIcon, title: 'benefitSupport', desc: 'benefitSupportDesc' }, + { icon: Eye, title: 'benefitTransparency', desc: 'benefitTransparencyDesc' }, + ]; + const steps = [ + { icon: UserPlus, title: 'step1Title', desc: 'step1Desc' }, + { icon: Share2, title: 'step2Title', desc: 'step2Desc' }, + { icon: Coins, title: 'step3Title', desc: 'step3Desc' }, + ]; + const tiers = [ + { key: 'tierBronze', desc: 'tierBronzeDesc', pct: '10%', color: 'from-amber-700 to-amber-900', badge: 'bg-amber-700/40 text-amber-300' }, + { key: 'tierSilver', desc: 'tierSilverDesc', pct: '15%', color: 'from-slate-400 to-slate-600', badge: 'bg-slate-500/40 text-slate-200' }, + { key: 'tierGold', desc: 'tierGoldDesc', pct: '20%', color: 'from-amber-400 to-yellow-500', badge: 'bg-amber-400/30 text-amber-200' }, + ]; + const tools = [ + { icon: LayoutDashboard, key: 'toolDashboard' }, { icon: Link2, key: 'toolLinks' }, + { icon: FileText, key: 'toolTemplates' }, { icon: BarChart3, key: 'toolReports' }, + ]; + const faqs = Array.from({ length: 5 }, (_, i) => ({ q: t(`marketersPage.faq${i + 1}Q`), a: t(`marketersPage.faq${i + 1}A`) })); + + return ( +
+ {/* ===== HERO ===== */} +
+
+
+
+ + {t('marketersPage.heroTitle')} + + + {t('marketersPage.heroSubtitle')} + +
+
+ + {/* ===== STATS BAR ===== */} +
+
+ + {[ + { label: t('marketersPage.statsAvgCommission'), value: isArabic ? '٤,٢٠٠ ر.س' : 'SAR 4,200' }, + { label: t('marketersPage.statsActiveMarketers'), value: isArabic ? '+١٢٠' : '120+' }, + { label: t('marketersPage.statsTotalPaid'), value: isArabic ? '+٢.٥ مليون ر.س' : 'SAR 2.5M+' }, + ].map((stat) => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+
+
+ + {/* ===== BENEFITS ===== */} +
+
+ + {t('marketersPage.benefitsTitle')} + +
+ {benefits.map((b) => ( + +
+ +
+

{t(`marketersPage.${b.title}`)}

+

{t(`marketersPage.${b.desc}`)}

+
+ ))} +
+
+
+ + {/* ===== HOW IT WORKS ===== */} +
+
+ + {t('marketersPage.howItWorksTitle')} + +
+ {steps.map((s, i) => ( + +
+ +
+ {i + 1} +

{t(`marketersPage.${s.title}`)}

+

{t(`marketersPage.${s.desc}`)}

+
+ ))} +
+
+
+ + {/* ===== COMMISSION TIERS ===== */} +
+
+ {t('marketersPage.tiersTitle')} +
+ {tiers.map((tier) => ( + +
+
+ {t(`marketersPage.${tier.key}`)} +

{tier.pct}

+

{t('marketersPage.tierCommission')}

+

{t(`marketersPage.${tier.desc}`)}

+
+ + ))} +
+
+
+ + {/* ===== TESTIMONIALS ===== */} +
+
+ + {t('marketersPage.testimonialsTitle')} + +
+ {[1, 2].map((n) => ( + + +

{t(`marketersPage.testimonial${n}Text`)}

+
+
+ {t(`marketersPage.testimonial${n}Name`).charAt(0)} +
+
+

{t(`marketersPage.testimonial${n}Name`)}

+
+ + {t(`marketersPage.testimonial${n}Role`)} +
+
+
+
+ ))} +
+
+
+ + {/* ===== TOOLS PREVIEW ===== */} +
+
+ + {t('marketersPage.toolsTitle')} + + + {tools.map((tl) => ( +
+ +

{t(`marketersPage.${tl.key}`)}

+
+ ))} +
+
+
+ + {/* ===== FAQ ===== */} +
+
+ + {t('marketersPage.faqTitle')} + + + {faqs.map((faq, i) => ( + + ))} + +
+
+ + {/* ===== CTA + FORM ===== */} +
+
+ + {t('marketersPage.ctaTitle')} + + + {submitted ? ( + + +

{t('marketersPage.formSuccess')}

+
+ ) : ( + +
+ + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder={t('marketersPage.formNamePlaceholder')} + className={clsx( + 'w-full rounded-xl bg-white/5 border border-white/10 ps-10 pe-4 py-3', + 'text-sm text-white placeholder:text-slate-500', + 'focus:outline-none focus:ring-2 focus:ring-teal-400/50 focus:border-transparent', + 'transition-all', + )} + /> +
+
+ + setForm((f) => ({ ...f, phone: e.target.value }))} + placeholder={t('marketersPage.formPhonePlaceholder')} + className={clsx( + 'w-full rounded-xl bg-white/5 border border-white/10 ps-10 pe-4 py-3', + 'text-sm text-white placeholder:text-slate-500 text-start', + 'focus:outline-none focus:ring-2 focus:ring-teal-400/50 focus:border-transparent', + 'transition-all', + )} + /> +
+
+ + setForm((f) => ({ ...f, email: e.target.value }))} + placeholder={t('marketersPage.formEmailPlaceholder')} + className={clsx( + 'w-full rounded-xl bg-white/5 border border-white/10 ps-10 pe-4 py-3', + 'text-sm text-white placeholder:text-slate-500 text-start', + 'focus:outline-none focus:ring-2 focus:ring-teal-400/50 focus:border-transparent', + 'transition-all', + )} + /> +
+ + {submitting ? t('marketersPage.formSubmitting') : t('marketersPage.ctaButton')} + +
+ )} +
+
+
+ ); +} + +export { MarketersPage }; diff --git a/salesflow-saas/frontend/src/components/dealix/notification-bell.tsx b/salesflow-saas/frontend/src/components/dealix/notification-bell.tsx new file mode 100644 index 00000000..f2d54df3 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/notification-bell.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useI18n } from '@/i18n'; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +type NotificationType = 'new_lead' | 'deal_won' | 'deal_lost' | 'message' | 'task_due' | 'approval_needed'; + +interface Notification { + id: string; + type: NotificationType; + titleAr: string; + titleEn: string; + timeAgo: string; + read: boolean; +} + +/* ------------------------------------------------------------------ */ +/* Mock data */ +/* ------------------------------------------------------------------ */ + +const typeConfig: Record = { + new_lead: { icon: '👤', color: 'bg-blue-500/20 text-blue-400' }, + deal_won: { icon: '🎉', color: 'bg-emerald-500/20 text-emerald-400' }, + deal_lost: { icon: '📉', color: 'bg-red-500/20 text-red-400' }, + message: { icon: '💬', color: 'bg-primary/20 text-primary' }, + task_due: { icon: '⏰', color: 'bg-amber-500/20 text-amber-400' }, + approval_needed: { icon: '✅', color: 'bg-purple-500/20 text-purple-400' }, +}; + +const initialNotifications: Notification[] = [ + { id: '1', type: 'new_lead', titleAr: 'عميل محتمل جديد: محمد السالم', titleEn: 'New lead: Mohammed Al-Salem', timeAgo: '2m', read: false }, + { id: '2', type: 'deal_won', titleAr: 'تم كسب صفقة عقار الرياض — ٥٠٠,٠٠٠ ر.س', titleEn: 'Deal won: Riyadh Property — SAR 500,000', timeAgo: '15m', read: false }, + { id: '3', type: 'message', titleAr: 'رسالة جديدة من أحمد الغامدي', titleEn: 'New message from Ahmed Al-Ghamdi', timeAgo: '1h', read: false }, + { id: '4', type: 'task_due', titleAr: 'مهمة مستحقة: متابعة عميل شركة النور', titleEn: 'Task due: Follow up with Al-Nour Co.', timeAgo: '2h', read: true }, + { id: '5', type: 'approval_needed', titleAr: 'طلب موافقة على خصم ١٥٪', titleEn: 'Discount approval request: 15%', timeAgo: '3h', read: true }, + { id: '6', type: 'deal_lost', titleAr: 'صفقة خاسرة: مشروع جدة', titleEn: 'Deal lost: Jeddah Project', timeAgo: '5h', read: true }, +]; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function NotificationBell() { + const { isArabic } = useI18n(); + const [open, setOpen] = useState(false); + const [notifications, setNotifications] = useState(initialNotifications); + const ref = useRef(null); + + const unreadCount = notifications.filter((n) => !n.read).length; + const label = (ar: string, en: string) => (isArabic ? ar : en); + + // Close on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, []); + + function markAllRead() { + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + } + + function markRead(id: string) { + setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n))); + } + + return ( +
+ {/* Bell button */} + + + {/* Dropdown */} + + {open && ( + + {/* Header */} +
+

{label('الإشعارات', 'Notifications')}

+ {unreadCount > 0 && ( + + )} +
+ + {/* List */} +
+ {notifications.length === 0 ? ( +
+

{label('لا توجد إشعارات جديدة', 'No new notifications')}

+
+ ) : ( + notifications.map((n) => { + const cfg = typeConfig[n.type]; + return ( + + ); + }) + )} +
+ + {/* Footer */} +
+ +
+
+ )} +
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/onboarding-flow.tsx b/salesflow-saas/frontend/src/components/dealix/onboarding-flow.tsx new file mode 100644 index 00000000..68e8dcd0 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/onboarding-flow.tsx @@ -0,0 +1,377 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { clsx } from 'clsx'; +import { + UserCircle, Building2, CheckCircle2, PartyPopper, + Import, MessageCircle, GitBranch, Users, + ChevronLeft, ChevronRight, Briefcase, Sparkles, +} from 'lucide-react'; +import { useI18n } from '@/i18n'; + +/* ---------- Types ---------- */ +type Phase = 'welcome' | 'firstValue' | 'checklist'; +type Role = 'salesManager' | 'salesRep' | 'executive' | 'other'; +type Industry = 'realEstate' | 'automotive' | 'healthcare' | 'services' | 'other'; + +interface OnboardingFlowProps { + onComplete?: () => void; + className?: string; +} + +/* ---------- Animation ---------- */ +const slideVariants = { + enter: (dir: number) => ({ x: dir > 0 ? 80 : -80, opacity: 0 }), + center: { x: 0, opacity: 1 }, + exit: (dir: number) => ({ x: dir > 0 ? -80 : 80, opacity: 0 }), +}; + +/* ---------- Phase 1: Welcome ---------- */ +function WelcomePhase({ + role, + setRole, + industry, + setIndustry, + onNext, +}: { + role: Role | null; + setRole: (r: Role) => void; + industry: Industry | null; + setIndustry: (i: Industry) => void; + onNext: () => void; +}) { + const { t, isArabic } = useI18n(); + const [step, setStep] = useState<'role' | 'industry'>('role'); + + const roles: { key: Role; icon: typeof UserCircle }[] = [ + { key: 'salesManager', icon: UserCircle }, { key: 'salesRep', icon: Briefcase }, + { key: 'executive', icon: Building2 }, { key: 'other', icon: Users }, + ]; + const industries: { key: Industry; label: string }[] = [ + { key: 'realEstate', label: t('onboarding.industryRealEstate') }, { key: 'automotive', label: t('onboarding.industryAutomotive') }, + { key: 'healthcare', label: t('onboarding.industryHealthcare') }, { key: 'services', label: t('onboarding.industryServices') }, + { key: 'other', label: t('onboarding.industryOther') }, + ]; + const roleLabels: Record = { + salesManager: t('onboarding.roleSalesManager'), salesRep: t('onboarding.roleSalesRep'), + executive: t('onboarding.roleExecutive'), other: t('onboarding.roleOther'), + }; + + return ( +
+ +

{t('onboarding.welcomeTitle')}

+

{t('onboarding.welcomeSubtitle')}

+ + + {step === 'role' ? ( + +

{t('onboarding.roleQuestion')}

+
+ {roles.map((r) => { + const selected = role === r.key; + return ( + + ); + })} +
+
+ ) : ( + +

{t('onboarding.industryQuestion')}

+
+ {industries.map((ind) => ( + + ))} +
+ +
+ )} +
+
+ ); +} + +/* ---------- Phase 2: First Value ---------- */ +function FirstValuePhase({ onNext }: { onNext: () => void }) { + const { t, isArabic } = useI18n(); + const [created, setCreated] = useState(false); + + const handleCreate = () => { + setCreated(true); + setTimeout(onNext, 1800); + }; + + return ( +
+

{t('onboarding.firstValueTitle')}

+

{t('onboarding.firstValueSubtitle')}

+ + + {!created ? ( + +
+
+ +
+ {t('onboarding.sampleDealName')} +
+
+
+ +
+ {isArabic ? 'ر.س' : 'SAR'} {t('onboarding.sampleDealValue')} +
+
+
+ +
+ {t('onboarding.sampleContactName')} — {t('onboarding.sampleCompany')} +
+
+
+ + + {t('onboarding.createDeal')} + +
+ ) : ( + + + + +

{t('onboarding.celebration')}

+

{t('onboarding.dealCreated')}

+
+ )} +
+
+ ); +} + +/* ---------- Phase 3: Checklist ---------- */ +interface ChecklistItem { + key: string; + label: string; + icon: typeof Import; + done: boolean; +} + +function ChecklistPhase({ onComplete }: { onComplete?: () => void }) { + const { t } = useI18n(); + + const [items, setItems] = useState([ + { key: 'import', label: t('onboarding.checkImportContacts'), icon: Import, done: false }, { key: 'whatsapp', label: t('onboarding.checkConnectWhatsApp'), icon: MessageCircle, done: false }, + { key: 'pipeline', label: t('onboarding.checkSetupPipeline'), icon: GitBranch, done: false }, { key: 'team', label: t('onboarding.checkInviteTeam'), icon: Users, done: false }, + ]); + + const doneCount = items.filter((i) => i.done).length; + const progress = Math.round((doneCount / items.length) * 100); + + const toggleItem = useCallback((key: string) => { + setItems((prev) => + prev.map((item) => + item.key === key ? { ...item, done: !item.done } : item, + ), + ); + }, []); + + return ( +
+

{t('onboarding.checklistTitle')}

+ + {/* Progress */} +
+
+ +
+ + {progress}% {t('onboarding.checklistProgress')} + +
+ + {/* Items */} +
    + {items.map((item) => { + const Icon = item.icon; + return ( + toggleItem(item.key)} + className={clsx('flex items-center gap-3 px-4 py-3 rounded-xl border transition-all duration-200 cursor-pointer', + item.done ? 'bg-teal-500/10 border-teal-500/25' : 'bg-white/5 border-white/10 hover:bg-white/[0.08]')}> + {item.done ? :
    } + + {item.label} + + ); + })} +
+ + {progress === 100 && ( + + + {t('common.getStarted')} + + + )} +
+ ); +} + +/* ---------- Main Onboarding Flow ---------- */ +function OnboardingFlow({ onComplete, className }: OnboardingFlowProps) { + const { dir } = useI18n(); + const [phase, setPhase] = useState('welcome'); + const [direction, setDirection] = useState(1); + const [role, setRole] = useState(null); + const [industry, setIndustry] = useState(null); + + const goTo = useCallback((next: Phase) => { + const order: Phase[] = ['welcome', 'firstValue', 'checklist']; + setDirection(order.indexOf(next) > order.indexOf(phase) ? 1 : -1); + setPhase(next); + }, [phase]); + + const phases: Phase[] = ['welcome', 'firstValue', 'checklist']; + const currentIdx = phases.indexOf(phase); + + return ( +
+ {/* Phase indicators */} +
+ {phases.map((p, i) => ( +
+ ))} +
+ + {/* Content */} + + + {phase === 'welcome' && ( + goTo('firstValue')} + /> + )} + {phase === 'firstValue' && ( + goTo('checklist')} /> + )} + {phase === 'checklist' && ( + + )} + + +
+ ); +} + +export { OnboardingFlow }; +export type { OnboardingFlowProps }; diff --git a/salesflow-saas/frontend/src/components/dealix/pipeline-kanban.tsx b/salesflow-saas/frontend/src/components/dealix/pipeline-kanban.tsx new file mode 100644 index 00000000..666ba729 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/pipeline-kanban.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { useState } from "react"; +import { motion, AnimatePresence, Reorder } from "framer-motion"; +import { + GripVertical, + Building2, + User, + Clock, + ChevronDown, + ChevronUp, + TrendingUp, + X, +} from "lucide-react"; + +/* ───────────── types ───────────── */ +interface Deal { + id: string; + company: string; + value: number; + rep: string; + daysInStage: number; + note?: string; + probability: number; +} + +interface Stage { + id: string; + label: string; + color: string; // tailwind ring/border colour + headerBg: string; // gradient header + dotColor: string; + deals: Deal[]; +} + +/* ───────────── sample data ───────────── */ +const initialStages: Stage[] = [ + { + id: "new", + label: "جديد", + color: "border-blue-500", + headerBg: "from-blue-600 to-blue-400", + dotColor: "bg-blue-500", + deals: [ + { id: "d1", company: "شركة الأفق التقنية", value: 45_000, rep: "سالم", daysInStage: 2, probability: 10, note: "تواصل أولي عبر واتساب" }, + { id: "d2", company: "مؤسسة الوفاء", value: 22_000, rep: "نورة", daysInStage: 5, probability: 15 }, + ], + }, + { + id: "negotiation", + label: "تفاوض", + color: "border-yellow-500", + headerBg: "from-yellow-500 to-amber-400", + dotColor: "bg-yellow-500", + deals: [ + { id: "d3", company: "مجموعة الرواد", value: 125_000, rep: "فهد", daysInStage: 8, probability: 45, note: "اجتماع مع المدير التنفيذي يوم الأحد" }, + ], + }, + { + id: "proposal", + label: "عرض سعر", + color: "border-orange-500", + headerBg: "from-orange-500 to-orange-400", + dotColor: "bg-orange-500", + deals: [ + { id: "d4", company: "مصنع الشرق", value: 310_000, rep: "سالم", daysInStage: 3, probability: 60 }, + { id: "d5", company: "حلول البيانات", value: 88_000, rep: "نورة", daysInStage: 12, probability: 55, note: "بانتظار موافقة المشتريات" }, + ], + }, + { + id: "won", + label: "فوز", + color: "border-emerald-500", + headerBg: "from-emerald-500 to-green-400", + dotColor: "bg-emerald-500", + deals: [ + { id: "d6", company: "شركة النخبة", value: 200_000, rep: "فهد", daysInStage: 0, probability: 100 }, + ], + }, + { + id: "lost", + label: "خسارة", + color: "border-red-500", + headerBg: "from-red-500 to-rose-400", + dotColor: "bg-red-500", + deals: [ + { id: "d7", company: "مؤسسة السلام", value: 60_000, rep: "نورة", daysInStage: 0, probability: 0, note: "اختاروا منافس أرخص" }, + ], + }, +]; + +const fmt = (n: number) => + new Intl.NumberFormat("ar-SA", { maximumFractionDigits: 0 }).format(n); + +/* ───────────── progress dots ───────────── */ +const stageOrder = ["new", "negotiation", "proposal", "won", "lost"]; +function ProgressDots({ stageId }: { stageId: string }) { + const idx = stageOrder.indexOf(stageId); + const isLost = stageId === "lost"; + return ( +
+ {stageOrder.slice(0, 4).map((_, i) => ( + + ))} +
+ ); +} + +/* ───────────── deal card ───────────── */ +function DealCard({ deal, stageId }: { deal: Deal; stageId: string }) { + const [expanded, setExpanded] = useState(false); + + return ( + + {/* drag handle */} + + + {/* header */} +
+
+
+ +

{deal.company}

+
+

+ {fmt(deal.value)} ر.س +

+
+ +
+ + {/* meta row */} +
+ + + {deal.rep} + + + + {deal.daysInStage} يوم + + + + {deal.probability}٪ + +
+ + + + {/* expanded details */} + + {expanded && ( + +
+ {deal.note &&

{deal.note}

} +
+ + +
+
+
+ )} +
+
+ ); +} + +/* ───────────── empty state ───────────── */ +function EmptyColumn() { + return ( +
+

لا توجد صفقات

+
+ ); +} + +/* ───────────── column ───────────── */ +function StageColumn({ stage }: { stage: Stage }) { + const total = stage.deals.reduce((s, d) => s + d.value, 0); + const [deals, setDeals] = useState(stage.deals); + + return ( +
+ {/* header */} +
+
+ + {deals.length} + +

{stage.label}

+
+

+ {fmt(total)} ر.س +

+
+ + {/* cards */} +
+ {deals.length === 0 ? ( + + ) : ( + + {deals.map((deal) => ( + + + + ))} + + )} +
+
+ ); +} + +/* ───────────── main component ───────────── */ +export function PipelineKanban() { + const [stages] = useState(initialStages); + + return ( + + {/* title bar */} +
+
+

خط الصفقات

+

+ إجمالي: {fmt(stages.flatMap((s) => s.deals).reduce((a, d) => a + d.value, 0))} ر.س +  |  {stages.flatMap((s) => s.deals).length} صفقة +

+
+ +
+ + {/* kanban board */} +
+ {stages.map((stage, i) => ( + + + + ))} +
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/premium-landing.tsx b/salesflow-saas/frontend/src/components/dealix/premium-landing.tsx new file mode 100644 index 00000000..23ae7832 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/premium-landing.tsx @@ -0,0 +1,474 @@ +"use client"; + +import { useRef } from "react"; +import { motion, useInView } from "framer-motion"; +import { + Zap, + MessageSquare, + BarChart3, + FileText, + ShieldCheck, + BrainCircuit, + ChevronLeft, + Play, + CheckCircle2, + ArrowLeft, + Star, + Users, + Trophy, + Rocket, + AlertTriangle, + Clock, + XCircle, + Building2, +} from "lucide-react"; + +/* ───────────── animation helpers ───────────── */ +const fadeUp = { + hidden: { opacity: 0, y: 28 }, + visible: (i: number = 0) => ({ + opacity: 1, + y: 0, + transition: { delay: i * 0.1, duration: 0.55, ease: "easeOut" }, + }), +}; + +const stagger = { + visible: { transition: { staggerChildren: 0.1 } }, +}; + +function Section({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + const ref = useRef(null); + const inView = useInView(ref, { once: true, margin: "-60px" }); + return ( + + {children} + + ); +} + +/* ───────────── data ───────────── */ +const painPoints = [ + { icon: AlertTriangle, title: "بيانات مبعثرة", desc: "معلومات العملاء موزعة بين إكسل وواتساب وأوراق" }, + { icon: Clock, title: "وقت ضائع", desc: "فريق المبيعات يقضي ٦٠٪ من وقته في مهام يدوية" }, + { icon: XCircle, title: "صفقات تضيع", desc: "عدم متابعة العملاء المحتملين في الوقت المناسب" }, + { icon: BarChart3, title: "لا تقارير واضحة", desc: "صعوبة قياس أداء الفريق واتخاذ قرارات مبنية على بيانات" }, +]; + +const features = [ + { icon: MessageSquare, title: "واتساب ذكي", desc: "تواصل تلقائي مع العملاء عبر واتساب مع ردود الذكاء الاصطناعي", color: "text-green-400 bg-green-400/10" }, + { icon: BrainCircuit, title: "تقييم عملاء AI", desc: "تقييم تلقائي لكل عميل محتمل بناءً على سلوكه واهتمامه", color: "text-teal-400 bg-teal-400/10" }, + { icon: BarChart3, title: "Pipeline بصري", desc: "تتبع جميع الصفقات بلوحة كانبان تفاعلية مع drag & drop", color: "text-blue-400 bg-blue-400/10" }, + { icon: FileText, title: "عروض أسعار", desc: "أنشئ عروض أسعار احترافية بضغطة زر مع حسابات تلقائية", color: "text-orange-400 bg-orange-400/10" }, + { icon: ShieldCheck, title: "متوافق مع PDPL", desc: "حماية بيانات العملاء وفق نظام حماية البيانات الشخصية السعودي", color: "text-purple-400 bg-purple-400/10" }, + { icon: Zap, title: "تقارير ذكية", desc: "تحليلات فورية ولوحات بيانات تفاعلية لاتخاذ قرارات أسرع", color: "text-amber-400 bg-amber-400/10" }, +]; + +const steps = [ + { num: "١", title: "سجّل", desc: "أنشئ حسابك في أقل من دقيقتين وابدأ فوراً" }, + { num: "٢", title: "أضف عملاءك", desc: "استورد بياناتك من إكسل أو أضفها يدوياً بسهولة" }, + { num: "٣", title: "ابدأ البيع", desc: "دع الذكاء الاصطناعي يساعدك في إتمام المزيد من الصفقات" }, +]; + +const pricingPlans = [ + { + name: "Starter", + nameAr: "الأساسية", + price: "٥٩", + period: "شهرياً", + features: ["٣ مستخدمين", "٥٠٠ عميل محتمل", "واتساب أساسي", "تقارير أساسية", "دعم بالإيميل"], + cta: "ابدأ مجاناً", + highlighted: false, + }, + { + name: "Professional", + nameAr: "الاحترافية", + price: "١٤٩", + period: "شهرياً", + features: ["١٠ مستخدمين", "عملاء غير محدودين", "واتساب + إيميل + SMS", "تقييم AI للعملاء", "Pipeline بصري", "عروض أسعار", "تقارير متقدمة", "دعم أولوية"], + cta: "ابدأ التجربة المجانية", + highlighted: true, + badge: "الأكثر شعبية", + }, + { + name: "Enterprise", + nameAr: "المؤسسية", + price: "٢٢٥", + period: "شهرياً", + features: ["مستخدمين غير محدودين", "كل مميزات الاحترافية", "PDPL كامل", "API مفتوح", "مدير حساب مخصص", "تدريب الفريق", "SLA ٩٩.٩٪"], + cta: "تواصل معنا", + highlighted: false, + }, +]; + +/* ───────────── 3D Logo placeholder ───────────── */ +function DealixLogo3D() { + return ( + +
+
+
+ + DEALIX +
+
+ + ); +} + +/* ───────────── main component ───────────── */ +export function PremiumLanding() { + return ( +
+ {/* ── mesh background ── */} +
+
+
+
+ {/* mesh dots */} +
+
+ + {/* ═══════════ NAV ═══════════ */} + + + {/* ═══════════ HERO ═══════════ */} +
+
+ {/* left: 3D logo */} + + + + + {/* right: text */} +
+ + نظام المبيعات الذكي +
+ للسعودية +
+ + وحّد فريق مبيعاتك مع واتساب، أتمت المتابعة بالذكاء الاصطناعي، وتابع كل صفقة من البداية للإغلاق + + + + + +
+
+ + {/* stats bar */} + + {[ + { label: "شركة سعودية", value: "+٥٠٠" }, + { label: "رضا العملاء", value: "٩٥٪" }, + { label: "صفقة مغلقة", value: "+١٠٠٠" }, + ].map((s, i) => ( +
+

{s.value}

+

{s.label}

+
+ ))} +
+
+ + {/* ═══════════ PAIN POINTS ═══════════ */} +
+ + مشاكل يعاني منها كل مدير مبيعات + + + هل تواجه هذه التحديات في فريقك؟ Dealix صُمم لحلها + +
+ {painPoints.map((p, i) => ( + +
+ +
+

{p.title}

+

{p.desc}

+
+ ))} +
+
+ + {/* ═══════════ FEATURES ═══════════ */} +
+ + كل ما يحتاجه فريق المبيعات + + + أدوات متكاملة مصممة خصيصاً للسوق السعودي + +
+ {features.map((f, i) => ( + +
+ +
+

{f.title}

+

{f.desc}

+
+ ))} +
+
+ + {/* ═══════════ HOW IT WORKS ═══════════ */} +
+ + ابدأ في ٣ خطوات بسيطة + +
+ {/* connecting line */} +
+ +
+ {steps.map((s, i) => ( + +
+ {s.num} +
+

{s.title}

+

{s.desc}

+
+ ))} +
+
+
+ + {/* ═══════════ SOCIAL PROOF ═══════════ */} +
+ + شركات سعودية تثق بـ Dealix + + + {/* logos row */} + + {["الأفق التقنية", "مجموعة الرواد", "حلول البيانات", "شركة النخبة", "مصنع الشرق"].map((name, i) => ( +
+ + {name} +
+ ))} +
+ + {/* testimonial */} + +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+

+ “Dealix غيّر طريقة عمل فريق المبيعات عندنا بالكامل. من أول شهر زادت مبيعاتنا ٤٠٪ وصار عندنا رؤية واضحة لكل صفقة.” +

+
+

عبدالله الشمري

+

مدير المبيعات — شركة الأفق التقنية

+
+
+
+ + {/* ═══════════ PRICING ═══════════ */} +
+ + أسعار بسيطة وشفافة + + + ابدأ مجاناً لمدة ١٤ يوم — بدون بطاقة ائتمانية + + +
+ {pricingPlans.map((plan, i) => ( + + {plan.badge && ( + + {plan.badge} + + )} + +

{plan.nameAr}

+

{plan.name}

+ +
+ {plan.price} + ر.س / {plan.period} +
+ +
    + {plan.features.map((f, j) => ( +
  • + + {f} +
  • + ))} +
+ + +
+ ))} +
+
+ + {/* ═══════════ FINAL CTA ═══════════ */} +
+ + +

+ جاهز تنقل مبيعاتك للمستوى التالي؟ +

+

+ انضم لأكثر من ٥٠٠ شركة سعودية حققت نمو في المبيعات مع Dealix +

+ +

١٤ يوم تجربة مجانية — بدون بطاقة

+
+
+ + {/* ═══════════ FOOTER ═══════════ */} +
+
+
+ {/* logo */} +
+
+ +
+
+ DEALIX +

نظام المبيعات الذكي للسعودية

+
+
+ + {/* links */} + + + {/* social placeholders */} +
+ {["X", "in", "yt"].map((s, i) => ( + + ))} +
+
+ +
+

جميع الحقوق محفوظة Dealix {new Date().getFullYear()}

+

صنع بـ ❤️ في السعودية

+
+
+
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/sales-workspace.tsx b/salesflow-saas/frontend/src/components/dealix/sales-workspace.tsx new file mode 100644 index 00000000..14752e24 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/sales-workspace.tsx @@ -0,0 +1,364 @@ +'use client'; + +import { useMemo } from 'react'; +import { motion } from 'framer-motion'; +import { clsx } from 'clsx'; +import { + Users, CalendarPlus, Briefcase, TrendingUp, Clock, Zap, + CheckCircle2, Circle, AlertTriangle, MessageSquare, Phone, + ArrowUpRight, Sparkles, ChevronLeft, ChevronRight, + FileText, InboxIcon, +} from 'lucide-react'; +import { useI18n } from '@/i18n'; +import { KpiCard } from '@/components/ui/kpi-card'; +import { EmptyState } from '@/components/ui/empty-state'; + +/* ---------- Types ---------- */ +interface Task { + id: string; + title: string; + dueStatus: 'overdue' | 'today' | 'upcoming'; + time?: string; +} + +interface Deal { + id: string; + name: string; + value: number; + stage: string; + stageColor: string; +} + +interface Activity { + id: string; + type: 'message' | 'call' | 'dealUpdate' | 'noteAdded'; + text: string; + time: string; +} + +interface AiInsight { + id: string; + type: 'followUp' | 'closing' | 'risk'; + count: number; +} + +interface SalesWorkspaceProps { + userName?: string; + kpis?: { + totalLeads: number; + newToday: number; + openDeals: number; + wonValue: number; + conversionRate: number; + responseTime: number; + }; + tasks?: Task[]; + deals?: Deal[]; + activities?: Activity[]; + insights?: AiInsight[]; + className?: string; +} + +/* ---------- Demo data ---------- */ +const demoKpis = { + totalLeads: 1247, + newToday: 18, + openDeals: 43, + wonValue: 892500, + conversionRate: 34, + responseTime: 12, +}; + +const demoTasks: Task[] = [ + { id: '1', title: 'متابعة أحمد الشمري — عرض عقار', dueStatus: 'overdue', time: 'أمس' }, + { id: '2', title: 'اتصال مع نورة — عرض سعر', dueStatus: 'today', time: '2:00 م' }, + { id: '3', title: 'إرسال عقد لشركة المستقبل', dueStatus: 'today', time: '4:30 م' }, + { id: '4', title: 'جدولة عرض تقديمي', dueStatus: 'upcoming', time: 'غداً' }, +]; + +const demoDeals: Deal[] = [ + { id: '1', name: 'صفقة أبراج الرياض', value: 2500000, stage: 'تفاوض', stageColor: 'bg-amber-500' }, + { id: '2', name: 'مشروع المجمع التجاري', value: 1800000, stage: 'عرض سعر', stageColor: 'bg-teal-500' }, + { id: '3', name: 'فيلا حي النرجس', value: 950000, stage: 'مؤهّل', stageColor: 'bg-blue-500' }, + { id: '4', name: 'مكاتب طريق الملك', value: 780000, stage: 'تفاوض', stageColor: 'bg-amber-500' }, + { id: '5', name: 'شقق حي الملقا', value: 650000, stage: 'عرض سعر', stageColor: 'bg-teal-500' }, +]; + +const demoActivities: Activity[] = [ + { id: '1', type: 'message', text: 'رسالة من أحمد: "ابي تفاصيل العرض"', time: 'منذ 5 دقائق' }, + { id: '2', type: 'call', text: 'مكالمة مع نورة — 8 دقائق', time: 'منذ 30 دقيقة' }, + { id: '3', type: 'dealUpdate', text: 'صفقة أبراج الرياض انتقلت لمرحلة التفاوض', time: 'منذ ساعة' }, + { id: '4', type: 'noteAdded', text: 'ملاحظة على فيلا النرجس: العميل يبي جراج إضافي', time: 'منذ 2 ساعة' }, +]; + +const demoInsights: AiInsight[] = [ + { id: '1', type: 'followUp', count: 3 }, + { id: '2', type: 'closing', count: 2 }, + { id: '3', type: 'risk', count: 1 }, +]; + +/* ---------- Sub-components ---------- */ +const stagger = { + hidden: {}, + visible: { transition: { staggerChildren: 0.06 } }, +}; + +const fadeUp = { + hidden: { opacity: 0, y: 12 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.35 } }, +}; + +function GlassCard({ children, className }: { children: React.ReactNode; className?: string }) { + return ( + + {children} + + ); +} + +function SectionHeader({ icon: Icon, title }: { icon: typeof Users; title: string }) { + return ( +
+ +

{title}

+
+ ); +} + +const activityIcons: Record = { + message: MessageSquare, + call: Phone, + dealUpdate: ArrowUpRight, + noteAdded: FileText, +}; + +const taskStatusStyles: Record = { + overdue: { dot: 'bg-rose-500', text: 'text-rose-400' }, + today: { dot: 'bg-amber-500', text: 'text-amber-400' }, + upcoming: { dot: 'bg-slate-500', text: 'text-slate-400' }, +}; + +const insightIcons: Record = { + followUp: { icon: Clock, color: 'text-amber-400' }, + closing: { icon: TrendingUp, color: 'text-emerald-400' }, + risk: { icon: AlertTriangle, color: 'text-rose-400' }, +}; + +/* ---------- Main ---------- */ +function SalesWorkspace({ + userName, + kpis: kpisProp, + tasks: tasksProp, + deals: dealsProp, + activities: activitiesProp, + insights: insightsProp, + className, +}: SalesWorkspaceProps) { + const { t, dir, locale, isArabic } = useI18n(); + + const kpis = kpisProp ?? demoKpis; + const tasks = tasksProp ?? demoTasks; + const deals = dealsProp ?? demoDeals; + const activities = activitiesProp ?? demoActivities; + const insights = insightsProp ?? demoInsights; + + const greeting = useMemo(() => { + const hour = new Date().getHours(); + const base = hour < 17 ? t('workspace.greeting') : t('workspace.greetingEvening'); + return userName ? `${base}، ${userName}` : base; + }, [t, userName]); + + const formatCurrency = (val: number) => + new Intl.NumberFormat(locale === 'ar' ? 'ar-SA' : 'en-US', { + style: 'currency', + currency: 'SAR', + maximumFractionDigits: 0, + }).format(val); + + const kpiDefs = [ + { key: 'totalLeads', value: kpis.totalLeads, label: t('dashboard.kpis.totalLeads'), icon: Users, trend: { direction: 'up' as const, percentage: 12 }, sparkline: [30, 42, 38, 55, 52, 68, 62] }, + { key: 'newToday', value: kpis.newToday, label: t('dashboard.kpis.newToday'), icon: CalendarPlus, trend: { direction: 'up' as const, percentage: 8 }, sparkline: [5, 8, 12, 9, 15, 11, 18] }, + { key: 'openDeals', value: kpis.openDeals, label: t('dashboard.kpis.openDeals'), icon: Briefcase, trend: { direction: 'up' as const, percentage: 5 }, sparkline: [28, 35, 31, 40, 38, 42, 43] }, + { key: 'wonValue', value: kpis.wonValue, label: t('dashboard.kpis.wonValue'), prefix: isArabic ? 'ر.س' : 'SAR', trend: { direction: 'up' as const, percentage: 22 }, sparkline: [400, 520, 480, 650, 720, 810, 892] }, + { key: 'conversionRate', value: kpis.conversionRate, label: t('dashboard.kpis.conversionRate'), suffix: '%', trend: { direction: 'down' as const, percentage: 3 }, sparkline: [38, 36, 35, 37, 34, 33, 34] }, + { key: 'responseTime', value: kpis.responseTime, label: t('dashboard.kpis.responseTime'), suffix: t('workspace.kpiResponseUnit'), trend: { direction: 'up' as const, percentage: 15 }, sparkline: [20, 18, 15, 14, 13, 12, 12] }, + ]; + + const insightLabel = (i: AiInsight) => { + const labels: Record = { + followUp: t('workspace.aiInsightFollowUp'), + closing: t('workspace.aiInsightClosing'), + risk: t('workspace.aiInsightRisk'), + }; + return `${i.count} ${labels[i.type]}`; + }; + + return ( + + {/* Greeting */} + + {greeting} + + + {/* KPI Bar */} + + {kpiDefs.map((k) => ( + + ))} + + + {/* 3-column body */} +
+ {/* LEFT: Tasks */} + + + {tasks.length === 0 ? ( + + ) : ( +
    + {tasks.map((task) => { + const style = taskStatusStyles[task.dueStatus]; + return ( +
  • + +
    +

    {task.title}

    +

    {task.time}

    +
    +
  • + ); + })} +
+ )} +
+ + {/* CENTER: Hot Deals */} + + + {deals.length === 0 ? ( + + ) : ( +
+ {deals.map((deal, idx) => ( +
+ + {idx + 1} + +
+

{deal.name}

+
+ + {deal.stage} +
+
+ + {formatCurrency(deal.value)} + +
+ ))} +
+ )} +
+ + {/* RIGHT: Activity */} + + + {activities.length === 0 ? ( + + ) : ( +
    + {activities.map((act) => { + const Icon = activityIcons[act.type]; + return ( +
  • +
    + +
    +
    +

    {act.text}

    +

    {act.time}

    +
    +
  • + ); + })} +
+ )} +
+
+ + {/* AI Insights */} + + +
+ {insights.map((insight) => { + const { icon: Icon, color } = insightIcons[insight.type]; + return ( + + + {insightLabel(insight)} + {isArabic ? ( + + ) : ( + + )} + + ); + })} +
+
+
+ ); +} + +export { SalesWorkspace }; +export type { SalesWorkspaceProps, Task, Deal, Activity, AiInsight }; diff --git a/salesflow-saas/frontend/src/components/dealix/search-panel.tsx b/salesflow-saas/frontend/src/components/dealix/search-panel.tsx new file mode 100644 index 00000000..b4c70534 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/search-panel.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useI18n } from '@/i18n'; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +type ResultCategory = 'leads' | 'deals' | 'contacts' | 'companies'; + +interface SearchResult { + id: string; + category: ResultCategory; + name: string; + nameEn: string; + lastActivity: string; + lastActivityEn: string; +} + +/* ------------------------------------------------------------------ */ +/* Mock data */ +/* ------------------------------------------------------------------ */ + +const categoryConfig: Record = { + leads: { labelAr: 'عملاء محتملين', labelEn: 'Leads', icon: '👤', color: 'text-blue-400 bg-blue-400/10 border-blue-400/30' }, + deals: { labelAr: 'صفقات', labelEn: 'Deals', icon: '💼', color: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30' }, + contacts: { labelAr: 'جهات اتصال', labelEn: 'Contacts', icon: '📇', color: 'text-purple-400 bg-purple-400/10 border-purple-400/30' }, + companies: { labelAr: 'شركات', labelEn: 'Companies', icon: '🏢', color: 'text-amber-400 bg-amber-400/10 border-amber-400/30' }, +}; + +const allResults: SearchResult[] = [ + { id: '1', category: 'leads', name: 'محمد السالم', nameEn: 'Mohammed Al-Salem', lastActivity: 'رسالة منذ ساعتين', lastActivityEn: 'Message 2h ago' }, + { id: '2', category: 'leads', name: 'فهد العتيبي', nameEn: 'Fahd Al-Otaibi', lastActivity: 'مكالمة منذ يوم', lastActivityEn: 'Call 1d ago' }, + { id: '3', category: 'deals', name: 'صفقة عقار الرياض', nameEn: 'Riyadh Property Deal', lastActivity: 'تحديث المرحلة منذ ٣ ساعات', lastActivityEn: 'Stage update 3h ago' }, + { id: '4', category: 'deals', name: 'مشروع جدة التجاري', nameEn: 'Jeddah Commercial Project', lastActivity: 'عرض سعر منذ يومين', lastActivityEn: 'Quote sent 2d ago' }, + { id: '5', category: 'contacts', name: 'أحمد الغامدي', nameEn: 'Ahmed Al-Ghamdi', lastActivity: 'آخر تواصل منذ أسبوع', lastActivityEn: 'Last contact 1w ago' }, + { id: '6', category: 'contacts', name: 'نورة الحربي', nameEn: 'Noura Al-Harbi', lastActivity: 'اجتماع أمس', lastActivityEn: 'Meeting yesterday' }, + { id: '7', category: 'companies', name: 'شركة البناء المتقدم', nameEn: 'Advanced Construction Co.', lastActivity: '٣ صفقات نشطة', lastActivityEn: '3 active deals' }, + { id: '8', category: 'companies', name: 'مجموعة النور القابضة', nameEn: 'Al-Nour Holding Group', lastActivity: 'عميل منذ ٦ أشهر', lastActivityEn: 'Client for 6 months' }, +]; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function SearchPanel({ open, onClose }: { open: boolean; onClose: () => void }) { + const { isArabic } = useI18n(); + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const [recentSearches, setRecentSearches] = useState(() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('dealix-recent-searches'); + return saved ? JSON.parse(saved) : []; + } + return []; + }); + const inputRef = useRef(null); + const listRef = useRef(null); + + const label = (ar: string, en: string) => (isArabic ? ar : en); + + // Filter results + const filtered = query.trim().length > 0 + ? allResults.filter((r) => + r.name.toLowerCase().includes(query.toLowerCase()) || + r.nameEn.toLowerCase().includes(query.toLowerCase()) + ) + : []; + + // Group by category + const grouped = filtered.reduce>((acc, r) => { + if (!acc[r.category]) acc[r.category] = []; + acc[r.category].push(r); + return acc; + }, {} as Record); + + const flatResults = Object.values(grouped).flat(); + + // Focus input when opened + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 100); + setQuery(''); + setSelectedIndex(0); + } + }, [open]); + + // Save recent search + const saveRecent = useCallback((term: string) => { + if (!term.trim()) return; + const updated = [term, ...recentSearches.filter((s) => s !== term)].slice(0, 5); + setRecentSearches(updated); + if (typeof window !== 'undefined') { + localStorage.setItem('dealix-recent-searches', JSON.stringify(updated)); + } + }, [recentSearches]); + + // Keyboard nav + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, flatResults.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter' && flatResults[selectedIndex]) { + saveRecent(query); + // Would navigate to result in real app + onClose(); + } else if (e.key === 'Escape') { + onClose(); + } + } + + // Scroll selected into view + useEffect(() => { + const el = listRef.current?.querySelector(`[data-index="${selectedIndex}"]`); + el?.scrollIntoView({ block: 'nearest' }); + }, [selectedIndex]); + + return ( + + {open && ( + <> + {/* Backdrop */} + + + {/* Panel */} + +
+ {/* Search input */} +
+ + + + { setQuery(e.target.value); setSelectedIndex(0); }} + onKeyDown={handleKeyDown} + placeholder={label('ابحث في العملاء، الصفقات، الشركات...', 'Search leads, deals, companies...')} + className="flex-1 bg-transparent text-white placeholder-slate-500 text-sm focus:outline-none" + /> + + ESC + +
+ + {/* Results area */} +
+ {query.trim().length === 0 ? ( + /* Recent searches */ +
+ {recentSearches.length > 0 ? ( + <> +

+ {label('عمليات بحث سابقة', 'Recent Searches')} +

+ {recentSearches.map((s, i) => ( + + ))} + + ) : ( +

+ {label('اكتب للبحث...', 'Type to search...')} +

+ )} +
+ ) : flatResults.length === 0 ? ( + /* Empty state */ +
+ + + +

{label('لا توجد نتائج', 'No results found')}

+

{label('جرب كلمات بحث مختلفة', 'Try different search terms')}

+
+ ) : ( + /* Grouped results */ +
+ {(Object.keys(grouped) as ResultCategory[]).map((cat) => { + const cfg = categoryConfig[cat]; + return ( +
+

+ {label(cfg.labelAr, cfg.labelEn)} +

+ {grouped[cat].map((r) => { + const globalIdx = flatResults.indexOf(r); + const isSelected = globalIdx === selectedIndex; + return ( + + ); + })} +
+ ); + })} +
+ )} +
+ + {/* Footer hint */} +
+ + ↑↓ + {label('تنقل', 'Navigate')} + + + + {label('فتح', 'Open')} + + + ESC + {label('إغلاق', 'Close')} + +
+
+
+ + )} +
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/stats-counter.tsx b/salesflow-saas/frontend/src/components/dealix/stats-counter.tsx new file mode 100644 index 00000000..8ca24736 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/stats-counter.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback } from 'react'; +import { motion, useSpring, useTransform, useInView } from 'framer-motion'; +import { clsx } from 'clsx'; + +type NumberLocale = 'ar' | 'en'; + +interface StatsCounterProps { + target: number; + label: string; + prefix?: string; + suffix?: string; + currency?: boolean; + locale?: NumberLocale; + duration?: number; + className?: string; +} + +function formatNumber(value: number, locale: NumberLocale, currency: boolean): string { + const opts: Intl.NumberFormatOptions = currency + ? { style: 'currency', currency: 'SAR', maximumFractionDigits: 0 } + : { maximumFractionDigits: 0 }; + + const loc = locale === 'ar' ? 'ar-SA' : 'en-SA'; + return new Intl.NumberFormat(loc, opts).format(value); +} + +function AnimatedNumber({ + target, + locale, + currency, + duration, +}: { + target: number; + locale: NumberLocale; + currency: boolean; + duration: number; +}) { + const ref = useRef(null); + const isInView = useInView(ref, { once: true, margin: '-50px' }); + + const springValue = useSpring(0, { + stiffness: 50, + damping: 20, + duration: duration * 1000, + }); + + const display = useTransform(springValue, (v) => formatNumber(Math.round(v), locale, currency)); + + useEffect(() => { + if (isInView) { + springValue.set(target); + } + }, [isInView, target, springValue]); + + useEffect(() => { + const unsubscribe = display.on('change', (v) => { + if (ref.current) { + ref.current.textContent = v; + } + }); + return unsubscribe; + }, [display]); + + return 0; +} + +function StatsCounter({ + target, + label, + prefix, + suffix, + currency = false, + locale = 'ar', + duration = 2, + className, +}: StatsCounterProps) { + return ( +
+
+ {prefix && {prefix}} + + {suffix && {suffix}} +
+

{label}

+
+ ); +} + +interface StatsGridProps { + stats: StatsCounterProps[]; + className?: string; +} + +function StatsGrid({ stats, className }: StatsGridProps) { + return ( +
+ {stats.map((stat) => ( + + + + ))} +
+ ); +} + +export { StatsCounter, StatsGrid }; +export type { StatsCounterProps, StatsGridProps, NumberLocale }; diff --git a/salesflow-saas/frontend/src/components/dealix/unified-inbox.tsx b/salesflow-saas/frontend/src/components/dealix/unified-inbox.tsx new file mode 100644 index 00000000..bf745476 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/unified-inbox.tsx @@ -0,0 +1,450 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + Search, + Send, + Paperclip, + ArrowRight, + Phone, + MoreVertical, + Sparkles, + Check, + CheckCheck, + MessageSquare, + Mail, + Smartphone, +} from "lucide-react"; + +/* ───────────── types ───────────── */ +type Channel = "whatsapp" | "email" | "sms"; +type FilterTab = "all" | "whatsapp" | "email" | "sms"; + +interface Message { + id: string; + text: string; + sent: boolean; // true = we sent, false = received + time: string; + read?: boolean; +} + +interface Conversation { + id: string; + name: string; + avatar: string; // initials + avatarColor: string; + channel: Channel; + lastMessage: string; + time: string; + unread: number; + messages: Message[]; +} + +/* ───────────── channel config ───────────── */ +const channelConfig: Record = { + whatsapp: { icon: MessageSquare, color: "text-green-400 bg-green-400/20", label: "واتساب" }, + email: { icon: Mail, color: "text-blue-400 bg-blue-400/20", label: "إيميل" }, + sms: { icon: Smartphone, color: "text-purple-400 bg-purple-400/20", label: "رسائل" }, +}; + +const filterTabs: { key: FilterTab; label: string }[] = [ + { key: "all", label: "الكل" }, + { key: "whatsapp", label: "واتساب" }, + { key: "email", label: "إيميل" }, + { key: "sms", label: "رسائل" }, +]; + +/* ───────────── sample data ───────────── */ +const sampleConversations: Conversation[] = [ + { + id: "c1", + name: "أحمد الغامدي", + avatar: "أغ", + avatarColor: "bg-green-600", + channel: "whatsapp", + lastMessage: "تمام، أرسل لي العرض على الإيميل", + time: "١٠:٣٢", + unread: 2, + messages: [ + { id: "m1", text: "السلام عليكم، عندكم حل CRM يدعم العربي؟", sent: false, time: "١٠:١٥" }, + { id: "m2", text: "وعليكم السلام أحمد! أكيد، Dealix مصمم بالكامل للسوق السعودي", sent: true, time: "١٠:١٨", read: true }, + { id: "m3", text: "كم السعر للباقة الاحترافية؟", sent: false, time: "١٠:٢٠" }, + { id: "m4", text: "١٤٩ ر.س شهرياً مع تجربة مجانية ١٤ يوم", sent: true, time: "١٠:٢٥", read: true }, + { id: "m5", text: "تمام، أرسل لي العرض على الإيميل", sent: false, time: "١٠:٣٢" }, + ], + }, + { + id: "c2", + name: "سارة المطيري", + avatar: "سم", + avatarColor: "bg-blue-600", + channel: "email", + lastMessage: "شكراً على العرض التقديمي، سأرجع لكم بعد الاجتماع", + time: "أمس", + unread: 0, + messages: [ + { id: "m6", text: "مرحباً، أرغب بمعرفة المزيد عن خدمات تقييم العملاء بالذكاء الاصطناعي", sent: false, time: "أمس ٠٩:٠٠" }, + { id: "m7", text: "أهلاً سارة! نظام تقييم العملاء يعتمد على ٤ محاور: التفاعل، الملف الشخصي، السلوك، ونية الشراء", sent: true, time: "أمس ٠٩:٤٥", read: true }, + { id: "m8", text: "ممتاز، هل يمكنكم تقديم عرض لفريق من ١٥ شخص؟", sent: false, time: "أمس ١١:٣٠" }, + { id: "m9", text: "بالتأكيد! أرفقت عرض الأسعار للباقة المؤسسية", sent: true, time: "أمس ١٤:٠٠", read: true }, + { id: "m10", text: "شكراً على العرض التقديمي، سأرجع لكم بعد الاجتماع", sent: false, time: "أمس ١٦:٢٠" }, + ], + }, + { + id: "c3", + name: "خالد العتيبي", + avatar: "خع", + avatarColor: "bg-purple-600", + channel: "sms", + lastMessage: "موعدنا يوم الأحد الساعة ١١ صباحاً", + time: "١٢:٠٠", + unread: 1, + messages: [ + { id: "m11", text: "خالد، تذكير بموعد العرض التقديمي", sent: true, time: "١١:٣٠", read: true }, + { id: "m12", text: "موعدنا يوم الأحد الساعة ١١ صباحاً", sent: false, time: "١٢:٠٠" }, + ], + }, + { + id: "c4", + name: "منيرة القحطاني", + avatar: "مق", + avatarColor: "bg-amber-600", + channel: "whatsapp", + lastMessage: "ودي أجرب النظام قبل ما نقرر", + time: "٠٩:١٥", + unread: 3, + messages: [ + { id: "m13", text: "مرحباً، محتاجين نظام CRM لشركة عقارية", sent: false, time: "٠٨:٤٥" }, + { id: "m14", text: "أهلاً منيرة! Dealix يخدم أكثر من ٥٠ شركة عقارية في المملكة", sent: true, time: "٠٩:٠٠", read: true }, + { id: "m15", text: "ودي أجرب النظام قبل ما نقرر", sent: false, time: "٠٩:١٥" }, + ], + }, +]; + +/* ───────────── typing indicator ───────────── */ +function TypingIndicator() { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); +} + +/* ───────────── conversation list item ───────────── */ +function ConversationItem({ + convo, + isActive, + onClick, +}: { + convo: Conversation; + isActive: boolean; + onClick: () => void; +}) { + const ch = channelConfig[convo.channel]; + const Icon = ch.icon; + + return ( + + {/* avatar */} +
+ {convo.avatar} + + + +
+ + {/* text */} +
+
+ {convo.time} +

{convo.name}

+
+

{convo.lastMessage}

+
+ + {/* unread badge */} + {convo.unread > 0 && ( + + {convo.unread} + + )} +
+ ); +} + +/* ───────────── chat panel ───────────── */ +function ChatPanel({ + convo, + onBack, +}: { + convo: Conversation; + onBack: () => void; +}) { + const [messages, setMessages] = useState(convo.messages); + const [input, setInput] = useState(""); + const [showTyping, setShowTyping] = useState(false); + const scrollRef = useRef(null); + const ch = channelConfig[convo.channel]; + + useEffect(() => { + setMessages(convo.messages); + }, [convo.id, convo.messages]); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); + }, [messages, showTyping]); + + const handleSend = () => { + if (!input.trim()) return; + const newMsg: Message = { + id: `m-${Date.now()}`, + text: input, + sent: true, + time: new Date().toLocaleTimeString("ar-SA", { hour: "2-digit", minute: "2-digit" }), + read: false, + }; + setMessages((prev) => [...prev, newMsg]); + setInput(""); + setShowTyping(true); + setTimeout(() => { + setShowTyping(false); + setMessages((prev) => [ + ...prev, + { + id: `m-${Date.now()}-r`, + text: "شكراً لتواصلك! سأرد عليك في أقرب وقت", + sent: false, + time: new Date().toLocaleTimeString("ar-SA", { hour: "2-digit", minute: "2-digit" }), + }, + ]); + }, 2000); + }; + + return ( +
+ {/* chat header */} +
+
+ + +
+
+
+

{convo.name}

+ + {ch.label} + +
+
+ {convo.avatar} +
+ {/* back button mobile */} + +
+
+ + {/* messages */} +
+ {messages.map((msg) => ( + +
+

{msg.text}

+
+ {msg.time} + {msg.sent && + (msg.read ? ( + + ) : ( + + ))} +
+
+
+ ))} + + + {showTyping && ( + + + + )} + +
+ + {/* AI suggestion chip */} +
+ +
+ + {/* input bar */} +
+ + setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSend()} + placeholder="اكتب رسالتك..." + className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm placeholder:text-white/30 focus:outline-none focus:border-teal-500/50 transition-colors" + /> + +
+
+ ); +} + +/* ───────────── main component ───────────── */ +export function UnifiedInbox() { + const [activeId, setActiveId] = useState(null); + const [filter, setFilter] = useState("all"); + const [search, setSearch] = useState(""); + + const filtered = sampleConversations.filter((c) => { + if (filter !== "all" && c.channel !== filter) return false; + if (search && !c.name.includes(search) && !c.lastMessage.includes(search)) return false; + return true; + }); + + const activeConvo = sampleConversations.find((c) => c.id === activeId) ?? null; + + return ( + + {/* ─── right panel: conversation list ─── */} +
+ {/* search */} +
+
+ + setSearch(e.target.value)} + placeholder="بحث..." + className="w-full bg-white/5 border border-white/10 rounded-xl pr-10 pl-4 py-2 text-sm placeholder:text-white/30 focus:outline-none focus:border-teal-500/40 transition-colors" + /> +
+
+ + {/* filter tabs */} +
+ {filterTabs.map((tab) => ( + + ))} +
+ + {/* list */} +
+ + {filtered.map((convo, i) => ( + + setActiveId(convo.id)} + /> + + ))} + + + {filtered.length === 0 && ( +
+ لا توجد محادثات +
+ )} +
+
+ + {/* ─── left panel: chat thread ─── */} +
+ {activeConvo ? ( + setActiveId(null)} /> + ) : ( +
+
+ +
+

اختر محادثة للبدء

+
+ )} +
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/ui/badge.tsx b/salesflow-saas/frontend/src/components/ui/badge.tsx new file mode 100644 index 00000000..3835526c --- /dev/null +++ b/salesflow-saas/frontend/src/components/ui/badge.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { type ReactNode } from 'react'; +import { motion } from 'framer-motion'; +import { clsx } from 'clsx'; + +type BadgeVariant = 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'live'; + +interface BadgeProps { + variant?: BadgeVariant; + dot?: boolean; + children: ReactNode; + className?: string; +} + +const variantStyles: Record = { + success: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', + warning: 'bg-amber-500/15 text-amber-400 border-amber-500/30', + danger: 'bg-red-500/15 text-red-400 border-red-500/30', + info: 'bg-blue-500/15 text-blue-400 border-blue-500/30', + neutral: 'bg-slate-500/15 text-slate-400 border-slate-500/30', + live: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', +}; + +const dotColors: Record = { + success: 'bg-emerald-400', + warning: 'bg-amber-400', + danger: 'bg-red-400', + info: 'bg-blue-400', + neutral: 'bg-slate-400', + live: 'bg-emerald-400', +}; + +function Badge({ variant = 'neutral', dot = false, children, className }: BadgeProps) { + return ( + + {dot && ( + + {variant === 'live' && ( + + )} + + + )} + {children} + + ); +} + +export { Badge }; +export type { BadgeProps, BadgeVariant }; diff --git a/salesflow-saas/frontend/src/components/ui/button.tsx b/salesflow-saas/frontend/src/components/ui/button.tsx new file mode 100644 index 00000000..ba7861b5 --- /dev/null +++ b/salesflow-saas/frontend/src/components/ui/button.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'; +import { motion, type HTMLMotionProps } from 'framer-motion'; +import { clsx } from 'clsx'; +import { Loader2 } from 'lucide-react'; + +type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'gold'; +type ButtonSize = 'sm' | 'md' | 'lg'; + +interface ButtonProps + extends Omit, 'children' | 'disabled'>, + Pick, 'disabled' | 'type' | 'form'> { + variant?: ButtonVariant; + size?: ButtonSize; + loading?: boolean; + icon?: ReactNode; + iconPosition?: 'start' | 'end'; + fullWidth?: boolean; + children: ReactNode; +} + +const variantStyles: Record = { + primary: clsx( + 'bg-gradient-to-l from-teal-500 to-emerald-600 text-white', + 'hover:shadow-[0_0_20px_rgba(20,184,166,0.4)]', + 'active:from-teal-600 active:to-emerald-700', + 'disabled:from-slate-600 disabled:to-slate-700 disabled:text-slate-400', + ), + secondary: clsx( + 'border border-teal-500/50 text-teal-400 bg-transparent', + 'hover:bg-teal-500/10 hover:border-teal-400', + 'hover:shadow-[0_0_15px_rgba(20,184,166,0.2)]', + 'active:bg-teal-500/20', + 'disabled:border-slate-600 disabled:text-slate-500', + ), + ghost: clsx( + 'text-slate-300 bg-transparent', + 'hover:bg-white/5 hover:text-white', + 'active:bg-white/10', + 'disabled:text-slate-600', + ), + danger: clsx( + 'bg-gradient-to-l from-red-500 to-rose-600 text-white', + 'hover:shadow-[0_0_20px_rgba(239,68,68,0.4)]', + 'active:from-red-600 active:to-rose-700', + 'disabled:from-slate-600 disabled:to-slate-700 disabled:text-slate-400', + ), + gold: clsx( + 'bg-gradient-to-l from-amber-400 to-yellow-500 text-slate-900 font-semibold', + 'hover:shadow-[0_0_20px_rgba(251,191,36,0.4)]', + 'active:from-amber-500 active:to-yellow-600', + 'disabled:from-slate-600 disabled:to-slate-700 disabled:text-slate-400', + ), +}; + +const sizeStyles: Record = { + sm: 'h-8 text-sm ps-3 pe-3 gap-1.5 rounded-md', + md: 'h-10 text-base ps-5 pe-5 gap-2 rounded-lg', + lg: 'h-12 text-lg ps-7 pe-7 gap-2.5 rounded-xl', +}; + +const DealixButton = forwardRef( + ( + { + variant = 'primary', + size = 'md', + loading = false, + icon, + iconPosition = 'start', + fullWidth = false, + disabled, + children, + className, + ...props + }, + ref, + ) => { + const isDisabled = disabled || loading; + + return ( + + {loading ? ( + + ) : ( + icon && iconPosition === 'start' && {icon} + )} + {children} + {!loading && icon && iconPosition === 'end' && ( + {icon} + )} + + ); + }, +); + +DealixButton.displayName = 'DealixButton'; + +export { DealixButton as Button }; +export type { ButtonProps, ButtonVariant, ButtonSize }; diff --git a/salesflow-saas/frontend/src/components/ui/card.tsx b/salesflow-saas/frontend/src/components/ui/card.tsx new file mode 100644 index 00000000..0e9df010 --- /dev/null +++ b/salesflow-saas/frontend/src/components/ui/card.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { forwardRef, type ReactNode, type HTMLAttributes } from 'react'; +import { motion, type HTMLMotionProps } from 'framer-motion'; +import { clsx } from 'clsx'; + +type CardVariant = 'default' | 'gradient' | 'elevated' | 'feature'; + +interface CardProps extends Omit, 'children'> { + variant?: CardVariant; + header?: ReactNode; + footer?: ReactNode; + badge?: ReactNode; + noPadding?: boolean; + children: ReactNode; +} + +const variantStyles: Record = { + default: clsx( + 'bg-white/5 backdrop-blur-xl', + 'border border-white/10', + ), + gradient: clsx( + 'bg-gradient-to-bl from-teal-500/10 via-slate-900/80 to-slate-900/90', + 'backdrop-blur-xl border border-teal-500/20', + ), + elevated: clsx( + 'bg-slate-800/80 backdrop-blur-xl', + 'border border-white/10', + 'shadow-xl shadow-black/20', + ), + feature: clsx( + 'bg-gradient-to-bl from-teal-500/15 via-emerald-500/5 to-transparent', + 'backdrop-blur-xl border border-teal-400/20', + ), +}; + +const Card = forwardRef( + ( + { + variant = 'default', + header, + footer, + badge, + noPadding = false, + children, + className, + ...props + }, + ref, + ) => { + return ( + + {badge && ( +
{badge}
+ )} + + {header && ( +
+ {header} +
+ )} + +
{children}
+ + {footer && ( +
+ {footer} +
+ )} +
+ ); + }, +); + +Card.displayName = 'Card'; + +interface CardTitleProps extends HTMLAttributes { + children: ReactNode; +} + +function CardTitle({ children, className, ...props }: CardTitleProps) { + return ( +

+ {children} +

+ ); +} + +function CardDescription({ children, className, ...props }: HTMLAttributes) { + return ( +

+ {children} +

+ ); +} + +export { Card, CardTitle, CardDescription }; +export type { CardProps, CardVariant }; diff --git a/salesflow-saas/frontend/src/components/ui/command-input.tsx b/salesflow-saas/frontend/src/components/ui/command-input.tsx new file mode 100644 index 00000000..857fa520 --- /dev/null +++ b/salesflow-saas/frontend/src/components/ui/command-input.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { clsx } from 'clsx'; +import { Search } from 'lucide-react'; +import { useI18n } from '@/i18n'; + +interface CommandInputProps extends Omit, 'type'> { + onCommandClick?: () => void; +} + +const CommandInput = forwardRef( + ({ onCommandClick, className, placeholder, ...props }, ref) => { + const { t, dir } = useI18n(); + + const resolvedPlaceholder = placeholder ?? t('commandPalette.placeholder'); + + return ( + + ); + }, +); + +CommandInput.displayName = 'CommandInput'; + +export { CommandInput }; +export type { CommandInputProps }; diff --git a/salesflow-saas/frontend/src/components/ui/empty-state.tsx b/salesflow-saas/frontend/src/components/ui/empty-state.tsx new file mode 100644 index 00000000..c3de80b2 --- /dev/null +++ b/salesflow-saas/frontend/src/components/ui/empty-state.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { type ReactNode } from 'react'; +import { motion } from 'framer-motion'; +import { clsx } from 'clsx'; +import { useI18n } from '@/i18n'; +import { type LucideIcon } from 'lucide-react'; + +interface EmptyStateProps { + icon: LucideIcon; + title: string; + description?: string; + actionLabel?: string; + onAction?: () => void; + className?: string; +} + +function EmptyState({ + icon: Icon, + title, + description, + actionLabel, + onAction, + className, +}: EmptyStateProps) { + const { dir } = useI18n(); + + return ( + +
+ +
+ +

+ {title} +

+ + {description && ( +

+ {description} +

+ )} + + {actionLabel && onAction && ( + + {actionLabel} + + )} +
+ ); +} + +export { EmptyState }; +export type { EmptyStateProps }; diff --git a/salesflow-saas/frontend/src/components/ui/index.ts b/salesflow-saas/frontend/src/components/ui/index.ts new file mode 100644 index 00000000..94781bc8 --- /dev/null +++ b/salesflow-saas/frontend/src/components/ui/index.ts @@ -0,0 +1,29 @@ +export { Button } from './button'; +export type { ButtonProps, ButtonVariant, ButtonSize } from './button'; + +export { Card, CardTitle, CardDescription } from './card'; +export type { CardProps, CardVariant } from './card'; + +export { Input } from './input'; +export type { InputProps, InputType } from './input'; + +export { Modal } from './modal'; +export type { ModalProps, ModalSize } from './modal'; + +export { Badge } from './badge'; +export type { BadgeProps, BadgeVariant } from './badge'; + +export { Sidebar, useSidebar } from './sidebar'; +export type { SidebarProps, NavItem, NavSection } from './sidebar'; + +export { KpiCard } from './kpi-card'; +export type { KpiCardProps } from './kpi-card'; + +export { EmptyState } from './empty-state'; +export type { EmptyStateProps } from './empty-state'; + +export { CommandInput } from './command-input'; +export type { CommandInputProps } from './command-input'; + +export { ToastProvider, useToast } from './toast'; +export type { ToastType, Toast, ToastContextType } from './toast'; diff --git a/salesflow-saas/frontend/src/components/ui/input.tsx b/salesflow-saas/frontend/src/components/ui/input.tsx new file mode 100644 index 00000000..416054fe --- /dev/null +++ b/salesflow-saas/frontend/src/components/ui/input.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { + forwardRef, + useState, + useId, + type InputHTMLAttributes, + type TextareaHTMLAttributes, +} from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { clsx } from 'clsx'; +import { Search, Eye, EyeOff } from 'lucide-react'; + +type InputType = 'text' | 'email' | 'phone' | 'password' | 'search' | 'textarea'; + +interface InputProps + extends Omit, 'type' | 'size'> { + inputType?: InputType; + label?: string; + error?: string; + rows?: number; +} + +const baseStyles = clsx( + 'w-full bg-white/5 backdrop-blur-sm text-white placeholder-transparent', + 'border border-white/10 rounded-lg', + 'transition-all duration-200', + 'focus:outline-none focus:ring-2 focus:ring-teal-400/50 focus:border-teal-400', + 'disabled:opacity-50 disabled:cursor-not-allowed', + 'text-base ps-4 pe-4 pt-5 pb-2', + 'peer', +); + +const labelStyles = clsx( + 'absolute text-sm text-slate-400 duration-200 transform', + 'top-4 start-4 z-10 origin-[right]', + 'peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0', + 'peer-focus:scale-75 peer-focus:-translate-y-2.5', + 'peer-[:not(:placeholder-shown)]:scale-75 peer-[:not(:placeholder-shown)]:-translate-y-2.5', + 'pointer-events-none', +); + +const errorLabelStyles = 'text-red-400'; + +const DealixInput = forwardRef( + ({ inputType = 'text', label, error, className, rows = 4, id, ...props }, ref) => { + const generatedId = useId(); + const inputId = id ?? generatedId; + const [showPassword, setShowPassword] = useState(false); + + const errorId = error ? `${inputId}-error` : undefined; + + const wrapperClass = 'relative w-full'; + + if (inputType === 'textarea') { + return ( +
+