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 = ""
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,
}

View File

@ -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"

View File

@ -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