fix: Finalize proposals API, sales agent, and quote engine

https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
This commit is contained in:
Claude 2026-04-11 07:46:37 +00:00
parent 680b82b1e4
commit 2996827f5b
No known key found for this signature in database
3 changed files with 54 additions and 155 deletions

View File

@ -58,16 +58,32 @@ class AcceptRequest(BaseModel):
notes: str = "" notes: str = ""
class AIProposalRequest(BaseModel): # ── Helpers ──────────────────────────────────────
deal_title: str
client_name: str async def _fetch_proposal(db: AsyncSession, proposal_id: UUID, tenant_id) -> Proposal:
client_company: str = "" result = await db.execute(
industry: str = "services" select(Proposal).where(Proposal.id == proposal_id, Proposal.tenant_id == tenant_id)
deal_value: float = 0.0 )
currency: str = "SAR" p = result.scalar_one_or_none()
requirements: str = "" if not p:
language: str = "ar" raise HTTPException(status_code=404, detail="عرض السعر غير موجود")
extra_context: str = "" 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 ──────────────────────────────────── # ── Endpoints ────────────────────────────────────
@ -114,19 +130,7 @@ async def create_proposal(
): ):
"""Create a new proposal/quote.""" """Create a new proposal/quote."""
engine = QuoteEngine(db) engine = QuoteEngine(db)
quote_data = QuoteCreate( quote_data = QuoteCreate(tenant_id=str(current_user.tenant_id), **data.model_dump())
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,
)
return await engine.create_quote(quote_data) return await engine.create_quote(quote_data)
@ -176,15 +180,7 @@ async def get_proposal(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Get full proposal details.""" """Get full proposal details."""
result = await db.execute( proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id)
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="عرض السعر غير موجود")
return _proposal_dict(proposal) return _proposal_dict(proposal)
@ -196,15 +192,7 @@ async def update_proposal(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Update proposal metadata.""" """Update proposal metadata."""
result = await db.execute( proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id)
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="عرض السعر غير موجود")
if data.title is not None: if data.title is not None:
proposal.title = data.title proposal.title = data.title
@ -268,15 +256,7 @@ async def accept_proposal(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Client acceptance endpoint.""" """Client acceptance endpoint."""
result = await db.execute( proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id)
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="عرض السعر غير موجود")
if proposal.status == QuoteStatus.EXPIRED.value: if proposal.status == QuoteStatus.EXPIRED.value:
raise HTTPException(status_code=400, detail="عرض السعر منتهي الصلاحية") raise HTTPException(status_code=400, detail="عرض السعر منتهي الصلاحية")
@ -300,16 +280,7 @@ async def generate_pdf_data(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Generate PDF-ready data for a proposal.""" """Generate PDF-ready data for a proposal."""
result = await db.execute( proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id)
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="عرض السعر غير موجود")
generator = ProposalGenerator() generator = ProposalGenerator()
ai_req = ProposalInput( ai_req = ProposalInput(
deal_title=proposal.title, deal_title=proposal.title,
@ -323,24 +294,3 @@ async def generate_pdf_data(
) )
ai_proposal = await generator.generate_proposal(ai_req) ai_proposal = await generator.generate_proposal(ai_req)
return await generator.export_pdf_data(ai_proposal) 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,
}

View File

@ -34,50 +34,23 @@ STATE_TRANSITIONS: dict[str, list[str]] = {
} }
INDUSTRY_QUALIFIERS: dict[str, list[str]] = { INDUSTRY_QUALIFIERS: dict[str, list[str]] = {
"real_estate": [ "real_estate": ["ما نوع العقار المطلوب (سكني/تجاري)؟", "ما المنطقة أو الحي المفضل؟",
"ما نوع العقار المطلوب (سكني/تجاري)؟", "ما الميزانية التقريبية؟", "هل تبحث عن شراء أو إيجار؟"],
"ما المنطقة أو الحي المفضل؟", "healthcare": ["ما نوع الخدمة الطبية المطلوبة؟", "هل لديك تأمين طبي؟",
"ما الميزانية التقريبية؟", "هل تفضل موعد صباحي أو مسائي؟"],
"هل تبحث عن شراء أو إيجار؟", "services": ["ما طبيعة الخدمة المطلوبة؟", "ما الميزانية التقريبية؟",
], "ما الجدول الزمني المتوقع؟", "هل سبق تجربة مزود خدمة آخر؟"],
"healthcare": [ "contracting": ["ما نوع المشروع (بناء/صيانة/تشطيبات)؟", "ما المساحة التقريبية؟",
"ما نوع الخدمة الطبية المطلوبة؟", "ما الميزانية المخصصة؟", "هل الموقع في الرياض أو منطقة أخرى؟"],
"هل لديك تأمين طبي؟", "education": ["ما البرنامج أو الدورة المطلوبة؟", "هل تفضل حضوري أو عن بعد؟",
"هل تفضل موعد صباحي أو مسائي؟", "ما مستوى الخبرة الحالي؟"],
], "retail": ["ما المنتج أو الفئة المطلوبة؟", "هل تبحث عن كميات تجارية أو شخصية؟",
"services": [ "ما المنطقة لغرض التوصيل؟"],
"ما طبيعة الخدمة المطلوبة؟",
"ما الميزانية التقريبية؟",
"ما الجدول الزمني المتوقع؟",
"هل سبق تجربة مزود خدمة آخر؟",
],
"contracting": [
"ما نوع المشروع (بناء/صيانة/تشطيبات)؟",
"ما المساحة التقريبية؟",
"ما الميزانية المخصصة؟",
"هل الموقع في الرياض أو منطقة أخرى؟",
],
"education": [
"ما البرنامج أو الدورة المطلوبة؟",
"هل تفضل حضوري أو عن بعد؟",
"ما مستوى الخبرة الحالي؟",
],
"retail": [
"ما المنتج أو الفئة المطلوبة؟",
"هل تبحث عن كميات تجارية أو شخصية؟",
"ما المنطقة لغرض التوصيل؟",
],
} }
ESCALATION_TRIGGERS = [ ESCALATION_TRIGGERS = [
"أبي أكلم مدير", "أبي أكلم مدير", "أبي أتكلم مع شخص", "أبي موظف", "ما فهمت",
"أبي أتكلم مع شخص", "مشكلة كبيرة", "شكوى", "غاضب", "مستعجل جداً",
"أبي موظف",
"ما فهمت",
"مشكلة كبيرة",
"شكوى",
"غاضب",
"مستعجل جداً",
] ]
@ -224,7 +197,6 @@ class SalesAgent:
return resp.content.strip() return resp.content.strip()
# ── Internal helpers ───────────────────────────── # ── Internal helpers ─────────────────────────────
def _get_or_create_context( def _get_or_create_context(
self, phone: str, lead_id: str, name: str, industry: str, self, phone: str, lead_id: str, name: str, industry: str,
) -> AgentContext: ) -> AgentContext:
@ -245,31 +217,13 @@ class SalesAgent:
current_q = qualifiers[ctx.questions_asked] if ctx.questions_asked < len(qualifiers) else "" current_q = qualifiers[ctx.questions_asked] if ctx.questions_asked < len(qualifiers) else ""
state_instructions = { state_instructions = {
ConversationState.GREETING.value: ( "greeting": "رحّب بالعميل بأسلوب سعودي ودود. اسأل كيف تقدر تساعده.",
"رحّب بالعميل بأسلوب سعودي ودود. اسأل كيف تقدر تساعده. " "qualification": f"اسأل السؤال التالي بأسلوب طبيعي: {current_q}\nحاول استخلاص المعلومات من إجابة العميل.",
"لا تكن رسمياً أكثر من اللازم." "needs_analysis": "حلل احتياجات العميل وأكّد فهمك. لخّص ما فهمته واسأل إذا في شي ثاني.",
), "solution_pitch": "اعرض الحل المناسب بناءً على احتياجات العميل. ركّز على الفوائد.",
ConversationState.QUALIFICATION.value: ( "objection_handling": "تعامل مع اعتراض العميل بذكاء. أعد التأطير وركّز على القيمة.",
f"اسأل السؤال التالي بأسلوب طبيعي: {current_q}\n"
"حاول استخلاص المعلومات من إجابة العميل."
),
ConversationState.NEEDS_ANALYSIS.value: (
"حلل احتياجات العميل وأكّد فهمك. لخّص ما فهمته واسأل إذا في شي ثاني."
),
ConversationState.SOLUTION_PITCH.value: (
"اعرض الحل المناسب بناءً على احتياجات العميل. "
"ركّز على الفوائد مع ذكر القيمة بشكل غير مباشر."
),
ConversationState.OBJECTION_HANDLING.value: (
"تعامل مع اعتراض العميل بذكاء. أعد التأطير وركّز على القيمة. "
"لا تكن دفاعياً."
),
} }
instruction = state_instructions.get(ctx.state, "أكمل المحادثة بأسلوب مهني وودود.")
instruction = state_instructions.get(
ctx.state,
"أكمل المحادثة بأسلوب مهني وودود.",
)
system = ( system = (
"أنت وكيل مبيعات ذكي لشركة سعودية. تتحدث بالعامية السعودية الراقية.\n" "أنت وكيل مبيعات ذكي لشركة سعودية. تتحدث بالعامية السعودية الراقية.\n"

View File

@ -135,16 +135,11 @@ class QuoteEngine:
content: dict = dict(proposal.content) content: dict = dict(proposal.content)
line_items: list = list(content.get("line_items", [])) line_items: list = list(content.get("line_items", []))
line_total = item.unit_price * item.quantity line_total = item.unit_price * item.quantity
line_items.append({ line_items.append({
"id": str(uuid.uuid4())[:8], "id": str(uuid.uuid4())[:8], "description_ar": item.description_ar,
"description_ar": item.description_ar, "description_en": item.description_en, "quantity": item.quantity,
"description_en": item.description_en, "unit_price": str(item.unit_price), "unit": item.unit, "total": str(line_total),
"quantity": item.quantity,
"unit_price": str(item.unit_price),
"unit": item.unit,
"total": str(line_total),
}) })
content["line_items"] = line_items content["line_items"] = line_items
proposal.content = content proposal.content = content