system-prompts-and-models-o.../salesflow-saas/backend/app/api/v1/proposals.py

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)