mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-17 23:09:35 +00:00
297 lines
9.9 KiB
Python
297 lines
9.9 KiB
Python
"""
|
|
Dealix Proposals & Quotes API
|
|
إدارة عروض الأسعار والعروض التجارية
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select, func, and_
|
|
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.proposal import Proposal
|
|
from app.services.cpq.quote_engine import (
|
|
QuoteEngine, QuoteCreate, LineItemInput, DiscountInput, QuoteStatus,
|
|
)
|
|
from app.services.cpq.proposal_generator import (
|
|
ProposalGenerator, ProposalInput,
|
|
)
|
|
|
|
router = APIRouter(prefix="/proposals", tags=["Proposals & Quotes"])
|
|
|
|
|
|
# ── Request / Response Models ────────────────────
|
|
|
|
class ProposalCreateRequest(BaseModel):
|
|
deal_id: Optional[str] = None
|
|
lead_id: Optional[str] = None
|
|
title: str
|
|
currency: str = "SAR"
|
|
industry: str = "services"
|
|
validity_days: int = 30
|
|
vat_registration_number: Optional[str] = None
|
|
client_name: str = ""
|
|
client_company: str = ""
|
|
notes_ar: str = ""
|
|
|
|
|
|
class ProposalUpdateRequest(BaseModel):
|
|
title: Optional[str] = None
|
|
notes_ar: Optional[str] = None
|
|
validity_days: Optional[int] = None
|
|
vat_registration_number: Optional[str] = None
|
|
|
|
|
|
class SendRequest(BaseModel):
|
|
channel: str = Field(pattern=r"^(whatsapp|email)$", default="whatsapp")
|
|
recipient: str
|
|
|
|
|
|
class AcceptRequest(BaseModel):
|
|
client_signature: str = ""
|
|
notes: 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 ────────────────────────────────────
|
|
|
|
@router.get("")
|
|
async def list_proposals(
|
|
status: Optional[str] = Query(None),
|
|
deal_id: Optional[UUID] = Query(None),
|
|
date_from: Optional[str] = Query(None),
|
|
date_to: Optional[str] = Query(None),
|
|
page: int = Query(1, ge=1),
|
|
per_page: int = Query(25, ge=1, le=100),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List proposals with filters."""
|
|
query = select(Proposal).where(Proposal.tenant_id == current_user.tenant_id)
|
|
|
|
if status:
|
|
query = query.where(Proposal.status == status)
|
|
if deal_id:
|
|
query = query.where(Proposal.deal_id == deal_id)
|
|
if date_from:
|
|
query = query.where(Proposal.created_at >= datetime.fromisoformat(date_from))
|
|
if date_to:
|
|
query = query.where(Proposal.created_at <= datetime.fromisoformat(date_to))
|
|
|
|
count_q = select(func.count()).select_from(query.subquery())
|
|
total = (await db.execute(count_q)).scalar() or 0
|
|
|
|
query = query.order_by(Proposal.created_at.desc())
|
|
query = query.offset((page - 1) * per_page).limit(per_page)
|
|
result = await db.execute(query)
|
|
items = [_proposal_dict(p) for p in result.scalars().all()]
|
|
|
|
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
async def create_proposal(
|
|
data: ProposalCreateRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create a new proposal/quote."""
|
|
engine = QuoteEngine(db)
|
|
quote_data = QuoteCreate(tenant_id=str(current_user.tenant_id), **data.model_dump())
|
|
return await engine.create_quote(quote_data)
|
|
|
|
|
|
@router.get("/analytics")
|
|
async def proposal_analytics(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Win rate, average deal size, time-to-close analytics."""
|
|
tid = current_user.tenant_id
|
|
|
|
total_q = select(func.count()).where(Proposal.tenant_id == tid)
|
|
total = (await db.execute(total_q)).scalar() or 0
|
|
|
|
accepted_q = select(func.count()).where(
|
|
Proposal.tenant_id == tid, Proposal.status == QuoteStatus.ACCEPTED.value,
|
|
)
|
|
accepted = (await db.execute(accepted_q)).scalar() or 0
|
|
|
|
rejected_q = select(func.count()).where(
|
|
Proposal.tenant_id == tid, Proposal.status == QuoteStatus.REJECTED.value,
|
|
)
|
|
rejected = (await db.execute(rejected_q)).scalar() or 0
|
|
|
|
avg_value_q = select(func.avg(Proposal.total_amount)).where(
|
|
Proposal.tenant_id == tid, Proposal.status == QuoteStatus.ACCEPTED.value,
|
|
)
|
|
avg_value = (await db.execute(avg_value_q)).scalar()
|
|
|
|
decided = accepted + rejected
|
|
win_rate = round((accepted / decided) * 100, 1) if decided > 0 else 0.0
|
|
|
|
return {
|
|
"total_proposals": total,
|
|
"accepted": accepted,
|
|
"rejected": rejected,
|
|
"win_rate_percent": win_rate,
|
|
"average_deal_value": float(avg_value) if avg_value else 0.0,
|
|
"currency": "SAR",
|
|
}
|
|
|
|
|
|
@router.get("/{proposal_id}")
|
|
async def get_proposal(
|
|
proposal_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get full proposal details."""
|
|
proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id)
|
|
return _proposal_dict(proposal)
|
|
|
|
|
|
@router.put("/{proposal_id}")
|
|
async def update_proposal(
|
|
proposal_id: UUID,
|
|
data: ProposalUpdateRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update proposal metadata."""
|
|
proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id)
|
|
|
|
if data.title is not None:
|
|
proposal.title = data.title
|
|
if data.notes_ar is not None:
|
|
content = dict(proposal.content)
|
|
content["notes_ar"] = data.notes_ar
|
|
proposal.content = content
|
|
if data.vat_registration_number is not None:
|
|
content = dict(proposal.content)
|
|
content["vat_registration_number"] = data.vat_registration_number
|
|
proposal.content = content
|
|
|
|
await db.flush()
|
|
await db.refresh(proposal)
|
|
return _proposal_dict(proposal)
|
|
|
|
|
|
@router.post("/{proposal_id}/items")
|
|
async def add_line_item(
|
|
proposal_id: UUID,
|
|
item: LineItemInput,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Add a line item to the quote."""
|
|
engine = QuoteEngine(db)
|
|
return await engine.add_line_item(str(current_user.tenant_id), str(proposal_id), item)
|
|
|
|
|
|
@router.post("/{proposal_id}/discount")
|
|
async def apply_discount(
|
|
proposal_id: UUID,
|
|
discount: DiscountInput,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Apply a discount to the quote."""
|
|
engine = QuoteEngine(db)
|
|
return await engine.apply_discount(str(current_user.tenant_id), str(proposal_id), discount)
|
|
|
|
|
|
@router.post("/{proposal_id}/send")
|
|
async def send_proposal(
|
|
proposal_id: UUID,
|
|
data: SendRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Send proposal via WhatsApp or Email."""
|
|
engine = QuoteEngine(db)
|
|
return await engine.send_quote(
|
|
str(current_user.tenant_id), str(proposal_id), data.channel, data.recipient,
|
|
)
|
|
|
|
|
|
@router.post("/{proposal_id}/accept")
|
|
async def accept_proposal(
|
|
proposal_id: UUID,
|
|
data: AcceptRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Client acceptance endpoint."""
|
|
proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id)
|
|
if proposal.status == QuoteStatus.EXPIRED.value:
|
|
raise HTTPException(status_code=400, detail="عرض السعر منتهي الصلاحية")
|
|
|
|
proposal.status = QuoteStatus.ACCEPTED.value
|
|
content = dict(proposal.content)
|
|
content["acceptance"] = {
|
|
"signature": data.client_signature,
|
|
"notes": data.notes,
|
|
"accepted_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
proposal.content = content
|
|
await db.flush()
|
|
await db.refresh(proposal)
|
|
return _proposal_dict(proposal)
|
|
|
|
|
|
@router.get("/{proposal_id}/pdf")
|
|
async def generate_pdf_data(
|
|
proposal_id: UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Generate PDF-ready data for a proposal."""
|
|
proposal = await _fetch_proposal(db, proposal_id, current_user.tenant_id)
|
|
generator = ProposalGenerator()
|
|
ai_req = ProposalInput(
|
|
deal_title=proposal.title,
|
|
client_name=proposal.content.get("client_name", ""),
|
|
client_company=proposal.content.get("client_company", ""),
|
|
industry=proposal.content.get("industry", "services"),
|
|
deal_value=float(proposal.total_amount) if proposal.total_amount else 0.0,
|
|
currency=proposal.currency or "SAR",
|
|
requirements=proposal.content.get("notes_ar", ""),
|
|
language="both",
|
|
)
|
|
ai_proposal = await generator.generate_proposal(ai_req)
|
|
return await generator.export_pdf_data(ai_proposal)
|