system-prompts-and-models-o.../salesflow-saas/backend/app/api/v1/proposals.py
Sami Assiri 1ceeea9004 feat(tier1): finalize production activation and revenue execution pack
Complete Tier-1 closure follow-through by wiring docs governance gates, RC release readiness checks, source-of-truth enforcement, executive weekly contract surface, and go-live severity notes.
Add full go-live revenue execution documentation set (production activation, real production playbook, trust expansion, first 3 clients, live deployment, and automated revenue engine) and register all canonical paths.

Made-with: Cursor
2026-04-17 14:13:57 +03:00

316 lines
11 KiB
Python

"""
Dealix Proposals & Quotes API
إدارة عروض الأسعار والعروض التجارية
"""
from datetime import datetime, timezone
from typing import Literal, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
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
# Tier-1 / WS7: external proposal with company contacts → PDPL + OWASP mapping required
external_company_contacts: bool = False
pdpl_processing_class: Optional[Literal["public", "internal", "personal", "sensitive"]] = None
ecc_control_owner: Optional[str] = None
owasp_surface_ref: Optional[str] = None
@model_validator(mode="after")
def saudi_sensitive_path(self) -> "SendRequest":
if self.external_company_contacts:
if not self.pdpl_processing_class:
raise ValueError(
"pdpl_processing_class required when external_company_contacts is true"
)
if not self.owasp_surface_ref:
raise ValueError(
"owasp_surface_ref required when external_company_contacts is true "
"(map to OWASP LLM / control matrix)"
)
return self
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)