diff --git a/salesflow-saas/backend/app/api/v1/proposals.py b/salesflow-saas/backend/app/api/v1/proposals.py index cffa7739..46a61f9e 100644 --- a/salesflow-saas/backend/app/api/v1/proposals.py +++ b/salesflow-saas/backend/app/api/v1/proposals.py @@ -58,16 +58,32 @@ class AcceptRequest(BaseModel): notes: str = "" -class AIProposalRequest(BaseModel): - deal_title: str - client_name: str - client_company: str = "" - industry: str = "services" - deal_value: float = 0.0 - currency: str = "SAR" - requirements: str = "" - language: str = "ar" - extra_context: str = "" +# ── Helpers ────────────────────────────────────── + +async def _fetch_proposal(db: AsyncSession, proposal_id: UUID, tenant_id) -> Proposal: + result = await db.execute( + select(Proposal).where(Proposal.id == proposal_id, Proposal.tenant_id == tenant_id) + ) + p = result.scalar_one_or_none() + if not p: + raise HTTPException(status_code=404, detail="عرض السعر غير موجود") + return p + + +def _proposal_dict(p: Proposal) -> dict: + return { + "id": str(p.id), "tenant_id": str(p.tenant_id), + "deal_id": str(p.deal_id) if p.deal_id else None, + "lead_id": str(p.lead_id) if p.lead_id else None, + "title": p.title, "content": p.content, + "total_amount": str(p.total_amount) if p.total_amount else "0", + "currency": p.currency, "status": p.status, + "valid_until": p.valid_until.isoformat() if p.valid_until else None, + "sent_at": p.sent_at.isoformat() if p.sent_at else None, + "viewed_at": p.viewed_at.isoformat() if p.viewed_at else None, + "created_at": p.created_at.isoformat() if p.created_at else None, + "updated_at": p.updated_at.isoformat() if p.updated_at else None, + } # ── Endpoints ──────────────────────────────────── @@ -114,19 +130,7 @@ async def create_proposal( ): """Create a new proposal/quote.""" engine = QuoteEngine(db) - quote_data = QuoteCreate( - tenant_id=str(current_user.tenant_id), - deal_id=data.deal_id, - lead_id=data.lead_id, - title=data.title, - currency=data.currency, - industry=data.industry, - validity_days=data.validity_days, - vat_registration_number=data.vat_registration_number, - client_name=data.client_name, - client_company=data.client_company, - notes_ar=data.notes_ar, - ) + quote_data = QuoteCreate(tenant_id=str(current_user.tenant_id), **data.model_dump()) return await engine.create_quote(quote_data) @@ -176,15 +180,7 @@ async def get_proposal( db: AsyncSession = Depends(get_db), ): """Get full proposal details.""" - result = await db.execute( - select(Proposal).where( - Proposal.id == proposal_id, - Proposal.tenant_id == current_user.tenant_id, - ) - ) - proposal = result.scalar_one_or_none() - if not proposal: - raise HTTPException(status_code=404, detail="عرض السعر غير موجود") + proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id) return _proposal_dict(proposal) @@ -196,15 +192,7 @@ async def update_proposal( db: AsyncSession = Depends(get_db), ): """Update proposal metadata.""" - result = await db.execute( - select(Proposal).where( - Proposal.id == proposal_id, - Proposal.tenant_id == current_user.tenant_id, - ) - ) - proposal = result.scalar_one_or_none() - if not proposal: - raise HTTPException(status_code=404, detail="عرض السعر غير موجود") + proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id) if data.title is not None: proposal.title = data.title @@ -268,15 +256,7 @@ async def accept_proposal( db: AsyncSession = Depends(get_db), ): """Client acceptance endpoint.""" - result = await db.execute( - select(Proposal).where( - Proposal.id == proposal_id, - Proposal.tenant_id == current_user.tenant_id, - ) - ) - proposal = result.scalar_one_or_none() - if not proposal: - raise HTTPException(status_code=404, detail="عرض السعر غير موجود") + proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id) if proposal.status == QuoteStatus.EXPIRED.value: raise HTTPException(status_code=400, detail="عرض السعر منتهي الصلاحية") @@ -300,16 +280,7 @@ async def generate_pdf_data( db: AsyncSession = Depends(get_db), ): """Generate PDF-ready data for a proposal.""" - result = await db.execute( - select(Proposal).where( - Proposal.id == proposal_id, - Proposal.tenant_id == current_user.tenant_id, - ) - ) - proposal = result.scalar_one_or_none() - if not proposal: - raise HTTPException(status_code=404, detail="عرض السعر غير موجود") - + proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id) generator = ProposalGenerator() ai_req = ProposalInput( deal_title=proposal.title, @@ -323,24 +294,3 @@ async def generate_pdf_data( ) ai_proposal = await generator.generate_proposal(ai_req) return await generator.export_pdf_data(ai_proposal) - - -# ── Helpers ────────────────────────────────────── - -def _proposal_dict(p: Proposal) -> dict: - return { - "id": str(p.id), - "tenant_id": str(p.tenant_id), - "deal_id": str(p.deal_id) if p.deal_id else None, - "lead_id": str(p.lead_id) if p.lead_id else None, - "title": p.title, - "content": p.content, - "total_amount": str(p.total_amount) if p.total_amount else "0", - "currency": p.currency, - "status": p.status, - "valid_until": p.valid_until.isoformat() if p.valid_until else None, - "sent_at": p.sent_at.isoformat() if p.sent_at else None, - "viewed_at": p.viewed_at.isoformat() if p.viewed_at else None, - "created_at": p.created_at.isoformat() if p.created_at else None, - "updated_at": p.updated_at.isoformat() if p.updated_at else None, - } diff --git a/salesflow-saas/backend/app/services/ai/sales_agent.py b/salesflow-saas/backend/app/services/ai/sales_agent.py index 61a5cf8d..9e8fa48b 100644 --- a/salesflow-saas/backend/app/services/ai/sales_agent.py +++ b/salesflow-saas/backend/app/services/ai/sales_agent.py @@ -34,50 +34,23 @@ STATE_TRANSITIONS: dict[str, list[str]] = { } INDUSTRY_QUALIFIERS: dict[str, list[str]] = { - "real_estate": [ - "ما نوع العقار المطلوب (سكني/تجاري)؟", - "ما المنطقة أو الحي المفضل؟", - "ما الميزانية التقريبية؟", - "هل تبحث عن شراء أو إيجار؟", - ], - "healthcare": [ - "ما نوع الخدمة الطبية المطلوبة؟", - "هل لديك تأمين طبي؟", - "هل تفضل موعد صباحي أو مسائي؟", - ], - "services": [ - "ما طبيعة الخدمة المطلوبة؟", - "ما الميزانية التقريبية؟", - "ما الجدول الزمني المتوقع؟", - "هل سبق تجربة مزود خدمة آخر؟", - ], - "contracting": [ - "ما نوع المشروع (بناء/صيانة/تشطيبات)؟", - "ما المساحة التقريبية؟", - "ما الميزانية المخصصة؟", - "هل الموقع في الرياض أو منطقة أخرى؟", - ], - "education": [ - "ما البرنامج أو الدورة المطلوبة؟", - "هل تفضل حضوري أو عن بعد؟", - "ما مستوى الخبرة الحالي؟", - ], - "retail": [ - "ما المنتج أو الفئة المطلوبة؟", - "هل تبحث عن كميات تجارية أو شخصية؟", - "ما المنطقة لغرض التوصيل؟", - ], + "real_estate": ["ما نوع العقار المطلوب (سكني/تجاري)؟", "ما المنطقة أو الحي المفضل؟", + "ما الميزانية التقريبية؟", "هل تبحث عن شراء أو إيجار؟"], + "healthcare": ["ما نوع الخدمة الطبية المطلوبة؟", "هل لديك تأمين طبي؟", + "هل تفضل موعد صباحي أو مسائي؟"], + "services": ["ما طبيعة الخدمة المطلوبة؟", "ما الميزانية التقريبية؟", + "ما الجدول الزمني المتوقع؟", "هل سبق تجربة مزود خدمة آخر؟"], + "contracting": ["ما نوع المشروع (بناء/صيانة/تشطيبات)؟", "ما المساحة التقريبية؟", + "ما الميزانية المخصصة؟", "هل الموقع في الرياض أو منطقة أخرى؟"], + "education": ["ما البرنامج أو الدورة المطلوبة؟", "هل تفضل حضوري أو عن بعد؟", + "ما مستوى الخبرة الحالي؟"], + "retail": ["ما المنتج أو الفئة المطلوبة؟", "هل تبحث عن كميات تجارية أو شخصية؟", + "ما المنطقة لغرض التوصيل؟"], } ESCALATION_TRIGGERS = [ - "أبي أكلم مدير", - "أبي أتكلم مع شخص", - "أبي موظف", - "ما فهمت", - "مشكلة كبيرة", - "شكوى", - "غاضب", - "مستعجل جداً", + "أبي أكلم مدير", "أبي أتكلم مع شخص", "أبي موظف", "ما فهمت", + "مشكلة كبيرة", "شكوى", "غاضب", "مستعجل جداً", ] @@ -224,7 +197,6 @@ class SalesAgent: return resp.content.strip() # ── Internal helpers ───────────────────────────── - def _get_or_create_context( self, phone: str, lead_id: str, name: str, industry: str, ) -> AgentContext: @@ -245,31 +217,13 @@ class SalesAgent: current_q = qualifiers[ctx.questions_asked] if ctx.questions_asked < len(qualifiers) else "" state_instructions = { - ConversationState.GREETING.value: ( - "رحّب بالعميل بأسلوب سعودي ودود. اسأل كيف تقدر تساعده. " - "لا تكن رسمياً أكثر من اللازم." - ), - ConversationState.QUALIFICATION.value: ( - f"اسأل السؤال التالي بأسلوب طبيعي: {current_q}\n" - "حاول استخلاص المعلومات من إجابة العميل." - ), - ConversationState.NEEDS_ANALYSIS.value: ( - "حلل احتياجات العميل وأكّد فهمك. لخّص ما فهمته واسأل إذا في شي ثاني." - ), - ConversationState.SOLUTION_PITCH.value: ( - "اعرض الحل المناسب بناءً على احتياجات العميل. " - "ركّز على الفوائد مع ذكر القيمة بشكل غير مباشر." - ), - ConversationState.OBJECTION_HANDLING.value: ( - "تعامل مع اعتراض العميل بذكاء. أعد التأطير وركّز على القيمة. " - "لا تكن دفاعياً." - ), + "greeting": "رحّب بالعميل بأسلوب سعودي ودود. اسأل كيف تقدر تساعده.", + "qualification": f"اسأل السؤال التالي بأسلوب طبيعي: {current_q}\nحاول استخلاص المعلومات من إجابة العميل.", + "needs_analysis": "حلل احتياجات العميل وأكّد فهمك. لخّص ما فهمته واسأل إذا في شي ثاني.", + "solution_pitch": "اعرض الحل المناسب بناءً على احتياجات العميل. ركّز على الفوائد.", + "objection_handling": "تعامل مع اعتراض العميل بذكاء. أعد التأطير وركّز على القيمة.", } - - instruction = state_instructions.get( - ctx.state, - "أكمل المحادثة بأسلوب مهني وودود.", - ) + instruction = state_instructions.get(ctx.state, "أكمل المحادثة بأسلوب مهني وودود.") system = ( "أنت وكيل مبيعات ذكي لشركة سعودية. تتحدث بالعامية السعودية الراقية.\n" diff --git a/salesflow-saas/backend/app/services/cpq/quote_engine.py b/salesflow-saas/backend/app/services/cpq/quote_engine.py index 11c6439a..c233771c 100644 --- a/salesflow-saas/backend/app/services/cpq/quote_engine.py +++ b/salesflow-saas/backend/app/services/cpq/quote_engine.py @@ -135,16 +135,11 @@ class QuoteEngine: content: dict = dict(proposal.content) line_items: list = list(content.get("line_items", [])) - line_total = item.unit_price * item.quantity line_items.append({ - "id": str(uuid.uuid4())[:8], - "description_ar": item.description_ar, - "description_en": item.description_en, - "quantity": item.quantity, - "unit_price": str(item.unit_price), - "unit": item.unit, - "total": str(line_total), + "id": str(uuid.uuid4())[:8], "description_ar": item.description_ar, + "description_en": item.description_en, "quantity": item.quantity, + "unit_price": str(item.unit_price), "unit": item.unit, "total": str(line_total), }) content["line_items"] = line_items proposal.content = content