mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-18 23:39:34 +00:00
feat: Add conversation intelligence, message writer, sales agent, APIs, and templates
Continuing Phase 3-6 implementation: - AI: conversation_intelligence.py (Arabic dialogue analysis, buying signals) - AI: message_writer.py (Arabic/English multi-channel message generation) - AI: sales_agent.py (autonomous WhatsApp qualification bot) - API: compliance.py (PDPL consent & data rights endpoints) - API: inbox.py (unified multi-channel inbox) - API: proposals.py (CPQ quote management endpoints) - API: sequences.py (multi-channel sequence management) - Services: territory_manager.py (Saudi region-based lead routing) - Seeds: contracting_template.json (Saudi contracting industry template) - Updated: router.py, consent_manager.py, data_rights.py https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
This commit is contained in:
parent
a329957a3b
commit
141f10db76
245
salesflow-saas/backend/app/api/v1/compliance.py
Normal file
245
salesflow-saas/backend/app/api/v1/compliance.py
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
"""PDPL Compliance API -- consent management, data subject requests, and SDAIA audit reports."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel as Schema
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
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.consent import (
|
||||||
|
PDPLConsent, PDPLConsentAudit, DataRequest,
|
||||||
|
ConsentStatusEnum, DataRequestStatus,
|
||||||
|
)
|
||||||
|
from app.services.pdpl.consent_manager import (
|
||||||
|
ConsentManager, ConsentGrantInput, ConsentRevokeInput,
|
||||||
|
ConsentCheckResult, DataRequestInput, AuditEntry,
|
||||||
|
)
|
||||||
|
from app.services.pdpl.data_rights import DataRightsHandler, ComplianceReport
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/compliance", tags=["PDPL Compliance"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ConsentCreateRequest(Schema):
|
||||||
|
contact_id: UUID
|
||||||
|
purpose: str # marketing, sales, service, analytics
|
||||||
|
channel: str # whatsapp, email, sms, phone
|
||||||
|
consent_text: Optional[str] = None
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
expiry_months: int = 12
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentResponse(Schema):
|
||||||
|
id: UUID
|
||||||
|
contact_id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
purpose: str
|
||||||
|
channel: str
|
||||||
|
status: str
|
||||||
|
granted_at: Optional[datetime] = None
|
||||||
|
revoked_at: Optional[datetime] = None
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentListResponse(Schema):
|
||||||
|
items: list[ConsentResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
|
||||||
|
|
||||||
|
class DataRequestCreateRequest(Schema):
|
||||||
|
contact_id: UUID
|
||||||
|
request_type: str # access, correction, deletion, restriction
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DataRequestResponse(Schema):
|
||||||
|
id: UUID
|
||||||
|
contact_id: UUID
|
||||||
|
request_type: str
|
||||||
|
status: str
|
||||||
|
requested_at: Optional[datetime] = None
|
||||||
|
completed_at: Optional[datetime] = None
|
||||||
|
response_data: Optional[dict] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AuditListResponse(Schema):
|
||||||
|
items: list[AuditEntry]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/consents", response_model=ConsentListResponse)
|
||||||
|
async def list_consents(
|
||||||
|
purpose: Optional[str] = Query(None),
|
||||||
|
channel: Optional[str] = Query(None),
|
||||||
|
consent_status: Optional[str] = Query(None, alias="status"),
|
||||||
|
contact_id: Optional[UUID] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List PDPL consents with filters."""
|
||||||
|
|
||||||
|
query = select(PDPLConsent).where(PDPLConsent.tenant_id == current_user.tenant_id)
|
||||||
|
if purpose:
|
||||||
|
query = query.where(PDPLConsent.purpose == purpose)
|
||||||
|
if channel:
|
||||||
|
query = query.where(PDPLConsent.channel == channel)
|
||||||
|
if consent_status:
|
||||||
|
query = query.where(PDPLConsent.status == consent_status)
|
||||||
|
if contact_id:
|
||||||
|
query = query.where(PDPLConsent.contact_id == contact_id)
|
||||||
|
|
||||||
|
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar() or 0
|
||||||
|
rows = await db.execute(
|
||||||
|
query.order_by(PDPLConsent.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
|
||||||
|
)
|
||||||
|
items = [ConsentResponse.model_validate(c) for c in rows.scalars().all()]
|
||||||
|
return ConsentListResponse(items=items, total=total, page=page, per_page=per_page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/consent", response_model=ConsentResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def record_consent(
|
||||||
|
data: ConsentCreateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Record a new PDPL consent."""
|
||||||
|
|
||||||
|
mgr = ConsentManager(db)
|
||||||
|
consent = await mgr.grant_consent(ConsentGrantInput(
|
||||||
|
contact_id=data.contact_id,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
purpose=data.purpose,
|
||||||
|
channel=data.channel,
|
||||||
|
consent_text=data.consent_text,
|
||||||
|
ip_address=data.ip_address,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
expiry_months=data.expiry_months,
|
||||||
|
))
|
||||||
|
return ConsentResponse.model_validate(consent)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/consent/{consent_id}", status_code=status.HTTP_200_OK)
|
||||||
|
async def revoke_consent(
|
||||||
|
consent_id: UUID,
|
||||||
|
reason: Optional[str] = Query(None),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Revoke a PDPL consent."""
|
||||||
|
|
||||||
|
mgr = ConsentManager(db)
|
||||||
|
try:
|
||||||
|
consent = await mgr.revoke_consent(ConsentRevokeInput(
|
||||||
|
consent_id=consent_id,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
reason=reason,
|
||||||
|
))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
return {"detail": "تم إلغاء الموافقة بنجاح", "consent_id": str(consent.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/data-request", response_model=DataRequestResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def submit_data_request(
|
||||||
|
data: DataRequestCreateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Submit a data subject rights request (access, correction, deletion, restriction)."""
|
||||||
|
|
||||||
|
valid_types = {"access", "correction", "deletion", "restriction"}
|
||||||
|
if data.request_type not in valid_types:
|
||||||
|
raise HTTPException(status_code=400, detail=f"نوع الطلب غير صالح. الأنواع المسموحة: {', '.join(valid_types)}")
|
||||||
|
|
||||||
|
mgr = ConsentManager(db)
|
||||||
|
request = await mgr.process_data_request(DataRequestInput(
|
||||||
|
contact_id=data.contact_id,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
request_type=data.request_type,
|
||||||
|
notes=data.notes,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
))
|
||||||
|
return DataRequestResponse.model_validate(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/data-request/{request_id}", response_model=DataRequestResponse)
|
||||||
|
async def get_data_request(
|
||||||
|
request_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Check data request status."""
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(DataRequest).where(
|
||||||
|
DataRequest.id == request_id,
|
||||||
|
DataRequest.tenant_id == current_user.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
req = result.scalar_one_or_none()
|
||||||
|
if not req:
|
||||||
|
raise HTTPException(status_code=404, detail="طلب البيانات غير موجود")
|
||||||
|
return DataRequestResponse.model_validate(req)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit", response_model=AuditListResponse)
|
||||||
|
async def get_audit_trail(
|
||||||
|
contact_id: Optional[UUID] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(50, ge=1, le=200),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Consent audit trail for compliance review."""
|
||||||
|
|
||||||
|
query = select(PDPLConsentAudit).where(PDPLConsentAudit.tenant_id == current_user.tenant_id)
|
||||||
|
if contact_id:
|
||||||
|
query = query.where(PDPLConsentAudit.contact_id == contact_id)
|
||||||
|
|
||||||
|
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar() or 0
|
||||||
|
mgr = ConsentManager(db)
|
||||||
|
items = await mgr.get_consent_audit(
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
contact_id=contact_id,
|
||||||
|
limit=per_page,
|
||||||
|
offset=(page - 1) * per_page,
|
||||||
|
)
|
||||||
|
return AuditListResponse(items=items, total=total, page=page, per_page=per_page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report", response_model=ComplianceReport)
|
||||||
|
async def generate_compliance_report(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Generate SDAIA-ready PDPL compliance report."""
|
||||||
|
|
||||||
|
handler = DataRightsHandler(db)
|
||||||
|
return await handler.generate_compliance_report(current_user.tenant_id)
|
||||||
258
salesflow-saas/backend/app/api/v1/inbox.py
Normal file
258
salesflow-saas/backend/app/api/v1/inbox.py
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
"""Unified inbox API -- aggregate messages from WhatsApp, Email, SMS."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel as Schema
|
||||||
|
from sqlalchemy import select, func, and_, or_
|
||||||
|
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.message import Message
|
||||||
|
from app.models.lead import Lead
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/inbox", tags=["Inbox"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MessageResponse(Schema):
|
||||||
|
id: UUID
|
||||||
|
lead_id: Optional[UUID] = None
|
||||||
|
channel: str
|
||||||
|
direction: str
|
||||||
|
content: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
sent_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
extra_metadata: Optional[dict] = None
|
||||||
|
lead_name: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class MessageListResponse(Schema):
|
||||||
|
items: list[MessageResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadResponse(Schema):
|
||||||
|
lead_id: UUID
|
||||||
|
lead_name: Optional[str] = None
|
||||||
|
messages: list[MessageResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ReplyInput(Schema):
|
||||||
|
lead_id: UUID
|
||||||
|
channel: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class AssignInput(Schema):
|
||||||
|
lead_id: UUID
|
||||||
|
assigned_to: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class InboxStats(Schema):
|
||||||
|
total_unread: int
|
||||||
|
whatsapp_unread: int
|
||||||
|
email_unread: int
|
||||||
|
sms_unread: int
|
||||||
|
avg_response_minutes: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("", response_model=MessageListResponse)
|
||||||
|
async def list_inbox(
|
||||||
|
channel: Optional[str] = Query(None),
|
||||||
|
status_filter: Optional[str] = Query(None, alias="status"),
|
||||||
|
assigned_to: Optional[UUID] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all messages across channels with filters."""
|
||||||
|
|
||||||
|
query = select(Message).where(Message.tenant_id == current_user.tenant_id)
|
||||||
|
|
||||||
|
if channel:
|
||||||
|
query = query.where(Message.channel == channel)
|
||||||
|
if status_filter:
|
||||||
|
query = query.where(Message.status == status_filter)
|
||||||
|
if assigned_to:
|
||||||
|
query = query.join(Lead, Lead.id == Message.lead_id).where(Lead.assigned_to == assigned_to)
|
||||||
|
if search:
|
||||||
|
query = query.where(Message.content.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar() or 0
|
||||||
|
rows = await db.execute(
|
||||||
|
query.order_by(Message.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
|
||||||
|
)
|
||||||
|
messages = rows.scalars().all()
|
||||||
|
|
||||||
|
# Batch-load lead names
|
||||||
|
lead_ids = {m.lead_id for m in messages if m.lead_id}
|
||||||
|
lead_map: dict[UUID, str] = {}
|
||||||
|
if lead_ids:
|
||||||
|
leads_q = await db.execute(select(Lead.id, Lead.name).where(Lead.id.in_(lead_ids)))
|
||||||
|
lead_map = {row[0]: row[1] for row in leads_q.all()}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for m in messages:
|
||||||
|
resp = MessageResponse.model_validate(m)
|
||||||
|
resp.lead_name = lead_map.get(m.lead_id)
|
||||||
|
items.append(resp)
|
||||||
|
|
||||||
|
return MessageListResponse(items=items, total=total, page=page, per_page=per_page)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=InboxStats)
|
||||||
|
async def inbox_stats(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Unread counts per channel and response-time metrics."""
|
||||||
|
|
||||||
|
tid = current_user.tenant_id
|
||||||
|
base = and_(Message.tenant_id == tid, Message.direction == "inbound", Message.status != "read")
|
||||||
|
|
||||||
|
total = (await db.execute(select(func.count()).where(base))).scalar() or 0
|
||||||
|
wa = (await db.execute(
|
||||||
|
select(func.count()).where(base, Message.channel == "whatsapp")
|
||||||
|
)).scalar() or 0
|
||||||
|
em = (await db.execute(
|
||||||
|
select(func.count()).where(base, Message.channel == "email")
|
||||||
|
)).scalar() or 0
|
||||||
|
sm = (await db.execute(
|
||||||
|
select(func.count()).where(base, Message.channel == "sms")
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
return InboxStats(
|
||||||
|
total_unread=total,
|
||||||
|
whatsapp_unread=wa,
|
||||||
|
email_unread=em,
|
||||||
|
sms_unread=sm,
|
||||||
|
avg_response_minutes=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{message_id}", response_model=ThreadResponse)
|
||||||
|
async def get_thread(
|
||||||
|
message_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get full conversation thread for a message."""
|
||||||
|
|
||||||
|
msg = (await db.execute(
|
||||||
|
select(Message).where(Message.id == message_id, Message.tenant_id == current_user.tenant_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not msg:
|
||||||
|
raise HTTPException(status_code=404, detail="الرسالة غير موجودة")
|
||||||
|
|
||||||
|
if not msg.lead_id:
|
||||||
|
return ThreadResponse(lead_id=message_id, messages=[MessageResponse.model_validate(msg)])
|
||||||
|
|
||||||
|
lead = (await db.execute(select(Lead).where(Lead.id == msg.lead_id))).scalar_one_or_none()
|
||||||
|
thread_q = await db.execute(
|
||||||
|
select(Message)
|
||||||
|
.where(Message.lead_id == msg.lead_id, Message.tenant_id == current_user.tenant_id)
|
||||||
|
.order_by(Message.created_at.asc())
|
||||||
|
)
|
||||||
|
messages = [MessageResponse.model_validate(m) for m in thread_q.scalars().all()]
|
||||||
|
|
||||||
|
return ThreadResponse(
|
||||||
|
lead_id=msg.lead_id,
|
||||||
|
lead_name=lead.name if lead else None,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reply", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def reply_message(
|
||||||
|
data: ReplyInput,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Reply to a conversation -- auto-routes to the correct channel."""
|
||||||
|
|
||||||
|
lead = (await db.execute(
|
||||||
|
select(Lead).where(Lead.id == data.lead_id, Lead.tenant_id == current_user.tenant_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not lead:
|
||||||
|
raise HTTPException(status_code=404, detail="العميل المحتمل غير موجود")
|
||||||
|
|
||||||
|
if data.channel not in ("whatsapp", "email", "sms"):
|
||||||
|
raise HTTPException(status_code=400, detail="قناة غير مدعومة")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
message = Message(
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
lead_id=data.lead_id,
|
||||||
|
channel=data.channel,
|
||||||
|
direction="outbound",
|
||||||
|
content=data.content,
|
||||||
|
status="pending",
|
||||||
|
sent_at=now,
|
||||||
|
extra_metadata={"sent_by": str(current_user.id)},
|
||||||
|
)
|
||||||
|
db.add(message)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(message)
|
||||||
|
|
||||||
|
logger.info("Inbox reply sent: lead=%s channel=%s by=%s", data.lead_id, data.channel, current_user.id)
|
||||||
|
return MessageResponse.model_validate(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/assign", status_code=status.HTTP_200_OK)
|
||||||
|
async def assign_conversation(
|
||||||
|
data: AssignInput,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Assign a conversation (lead) to a team member."""
|
||||||
|
|
||||||
|
lead = (await db.execute(
|
||||||
|
select(Lead).where(Lead.id == data.lead_id, Lead.tenant_id == current_user.tenant_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not lead:
|
||||||
|
raise HTTPException(status_code=404, detail="العميل المحتمل غير موجود")
|
||||||
|
|
||||||
|
lead.assigned_to = data.assigned_to
|
||||||
|
await db.flush()
|
||||||
|
logger.info("Conversation assigned: lead=%s to=%s", data.lead_id, data.assigned_to)
|
||||||
|
return {"detail": "تم تعيين المحادثة بنجاح", "lead_id": str(data.lead_id), "assigned_to": str(data.assigned_to)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{message_id}/read", status_code=status.HTTP_200_OK)
|
||||||
|
async def mark_read(
|
||||||
|
message_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Mark a message as read."""
|
||||||
|
|
||||||
|
msg = (await db.execute(
|
||||||
|
select(Message).where(Message.id == message_id, Message.tenant_id == current_user.tenant_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not msg:
|
||||||
|
raise HTTPException(status_code=404, detail="الرسالة غير موجودة")
|
||||||
|
|
||||||
|
msg.status = "read"
|
||||||
|
await db.flush()
|
||||||
|
return {"detail": "تم تحديد الرسالة كمقروءة", "message_id": str(message_id)}
|
||||||
346
salesflow-saas/backend/app/api/v1/proposals.py
Normal file
346
salesflow-saas/backend/app/api/v1/proposals.py
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
"""
|
||||||
|
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 = ""
|
||||||
|
|
||||||
|
|
||||||
|
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 = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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),
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
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="عرض السعر غير موجود")
|
||||||
|
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."""
|
||||||
|
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="عرض السعر غير موجود")
|
||||||
|
|
||||||
|
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."""
|
||||||
|
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="عرض السعر غير موجود")
|
||||||
|
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."""
|
||||||
|
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="عرض السعر غير موجود")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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,
|
||||||
|
}
|
||||||
@ -4,7 +4,9 @@ from app.api.v1 import (
|
|||||||
companies, contacts, calls, meetings, commissions, payouts, disputes,
|
companies, contacts, calls, meetings, commissions, payouts, disputes,
|
||||||
guarantees, consents, complaints, knowledge, sectors, presentations,
|
guarantees, consents, complaints, knowledge, sectors, presentations,
|
||||||
supervisor, admin, health, analytics, webhooks, prospecting,
|
supervisor, admin, health, analytics, webhooks, prospecting,
|
||||||
|
inbox, sequences,
|
||||||
)
|
)
|
||||||
|
from app.api.v1 import compliance as compliance_router
|
||||||
from app.api.v1 import agents as agents_router
|
from app.api.v1 import agents as agents_router
|
||||||
from app.api.v1 import intelligence as intelligence_router
|
from app.api.v1 import intelligence as intelligence_router
|
||||||
from app.api.v1 import master as master_router
|
from app.api.v1 import master as master_router
|
||||||
@ -56,6 +58,9 @@ api_router.include_router(operations_router.router)
|
|||||||
api_router.include_router(analytics.router, tags=["Analytics & AI"])
|
api_router.include_router(analytics.router, tags=["Analytics & AI"])
|
||||||
api_router.include_router(webhooks.router, tags=["Webhooks"])
|
api_router.include_router(webhooks.router, tags=["Webhooks"])
|
||||||
api_router.include_router(prospecting.router, prefix="/prospecting", tags=["Prospecting"])
|
api_router.include_router(prospecting.router, prefix="/prospecting", tags=["Prospecting"])
|
||||||
|
api_router.include_router(inbox.router)
|
||||||
|
api_router.include_router(sequences.router)
|
||||||
|
api_router.include_router(compliance_router.router)
|
||||||
|
|
||||||
# ── Manus Multi-Agent + Autonomous Intelligence ─────────────
|
# ── Manus Multi-Agent + Autonomous Intelligence ─────────────
|
||||||
api_router.include_router(agents_router.router)
|
api_router.include_router(agents_router.router)
|
||||||
|
|||||||
239
salesflow-saas/backend/app/api/v1/sequences.py
Normal file
239
salesflow-saas/backend/app/api/v1/sequences.py
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
"""Sequences API -- create, manage, and analyze multi-channel outreach sequences."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel as Schema
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
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.sequence import Sequence, SequenceStep, SequenceEnrollment
|
||||||
|
from app.services.sequence_engine import (
|
||||||
|
SequenceEngine, SequenceCreateInput, EnrollInput, SequenceAnalytics,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/sequences", tags=["Sequences"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class StepSchema(Schema):
|
||||||
|
channel: str
|
||||||
|
delay_minutes: int = 0
|
||||||
|
template_content: str
|
||||||
|
template_content_ar: Optional[str] = None
|
||||||
|
variant: Optional[str] = None
|
||||||
|
conditions: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceCreateRequest(Schema):
|
||||||
|
name: str
|
||||||
|
name_ar: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
trigger_event: Optional[str] = None
|
||||||
|
steps: list[StepSchema] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceUpdateRequest(Schema):
|
||||||
|
name: Optional[str] = None
|
||||||
|
name_ar: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
trigger_event: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceResponse(Schema):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
name_ar: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
trigger_event: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
created_at: object
|
||||||
|
step_count: int = 0
|
||||||
|
enrollment_count: int = 0
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceListResponse(Schema):
|
||||||
|
items: list[SequenceResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollRequest(Schema):
|
||||||
|
lead_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollmentResponse(Schema):
|
||||||
|
id: UUID
|
||||||
|
sequence_id: UUID
|
||||||
|
lead_id: UUID
|
||||||
|
current_step: int
|
||||||
|
status: str
|
||||||
|
enrolled_at: object
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("", response_model=SequenceListResponse)
|
||||||
|
async def list_sequences(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List sequences with analytics summary."""
|
||||||
|
|
||||||
|
tid = current_user.tenant_id
|
||||||
|
total = (await db.execute(
|
||||||
|
select(func.count()).where(Sequence.tenant_id == tid)
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
rows = await db.execute(
|
||||||
|
select(Sequence)
|
||||||
|
.where(Sequence.tenant_id == tid)
|
||||||
|
.order_by(Sequence.created_at.desc())
|
||||||
|
.offset((page - 1) * per_page).limit(per_page)
|
||||||
|
)
|
||||||
|
sequences = rows.scalars().all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for seq in sequences:
|
||||||
|
step_count = (await db.execute(
|
||||||
|
select(func.count()).where(SequenceStep.sequence_id == seq.id)
|
||||||
|
)).scalar() or 0
|
||||||
|
enroll_count = (await db.execute(
|
||||||
|
select(func.count()).where(SequenceEnrollment.sequence_id == seq.id)
|
||||||
|
)).scalar() or 0
|
||||||
|
resp = SequenceResponse.model_validate(seq)
|
||||||
|
resp.step_count = step_count
|
||||||
|
resp.enrollment_count = enroll_count
|
||||||
|
items.append(resp)
|
||||||
|
|
||||||
|
return SequenceListResponse(items=items, total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SequenceResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_sequence(
|
||||||
|
data: SequenceCreateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new sequence with steps."""
|
||||||
|
|
||||||
|
engine = SequenceEngine(db)
|
||||||
|
seq = await engine.create_sequence(SequenceCreateInput(
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
|
name=data.name,
|
||||||
|
name_ar=data.name_ar,
|
||||||
|
description=data.description,
|
||||||
|
trigger_event=data.trigger_event,
|
||||||
|
created_by=current_user.id,
|
||||||
|
steps=[s.model_dump() for s in data.steps],
|
||||||
|
))
|
||||||
|
|
||||||
|
resp = SequenceResponse.model_validate(seq)
|
||||||
|
resp.step_count = len(data.steps)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{sequence_id}", response_model=SequenceResponse)
|
||||||
|
async def update_sequence(
|
||||||
|
sequence_id: UUID,
|
||||||
|
data: SequenceUpdateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update sequence metadata."""
|
||||||
|
|
||||||
|
seq = (await db.execute(
|
||||||
|
select(Sequence).where(Sequence.id == sequence_id, Sequence.tenant_id == current_user.tenant_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not seq:
|
||||||
|
raise HTTPException(status_code=404, detail="التسلسل غير موجود")
|
||||||
|
|
||||||
|
for field, val in data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(seq, field, val)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(seq)
|
||||||
|
logger.info("Sequence updated: id=%s", sequence_id)
|
||||||
|
return SequenceResponse.model_validate(seq)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sequence_id}/enroll", response_model=EnrollmentResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def enroll_lead(
|
||||||
|
sequence_id: UUID,
|
||||||
|
data: EnrollRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Enroll a lead into a sequence."""
|
||||||
|
|
||||||
|
seq = (await db.execute(
|
||||||
|
select(Sequence).where(Sequence.id == sequence_id, Sequence.tenant_id == current_user.tenant_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not seq:
|
||||||
|
raise HTTPException(status_code=404, detail="التسلسل غير موجود")
|
||||||
|
if not seq.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="التسلسل غير نشط")
|
||||||
|
|
||||||
|
engine = SequenceEngine(db)
|
||||||
|
try:
|
||||||
|
enrollment = await engine.enroll_lead(EnrollInput(sequence_id=sequence_id, lead_id=data.lead_id))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
return EnrollmentResponse.model_validate(enrollment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sequence_id}/enrollments/{enrollment_id}", status_code=status.HTTP_200_OK)
|
||||||
|
async def stop_enrollment(
|
||||||
|
sequence_id: UUID,
|
||||||
|
enrollment_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Stop an active enrollment."""
|
||||||
|
|
||||||
|
enrollment = (await db.execute(
|
||||||
|
select(SequenceEnrollment).where(
|
||||||
|
SequenceEnrollment.id == enrollment_id,
|
||||||
|
SequenceEnrollment.sequence_id == sequence_id,
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not enrollment:
|
||||||
|
raise HTTPException(status_code=404, detail="التسجيل غير موجود")
|
||||||
|
|
||||||
|
engine = SequenceEngine(db)
|
||||||
|
await engine.stop_enrollment(enrollment_id)
|
||||||
|
return {"detail": "تم إيقاف التسجيل بنجاح", "enrollment_id": str(enrollment_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sequence_id}/analytics", response_model=SequenceAnalytics)
|
||||||
|
async def get_analytics(
|
||||||
|
sequence_id: UUID,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Detailed analytics for a sequence."""
|
||||||
|
|
||||||
|
seq = (await db.execute(
|
||||||
|
select(Sequence).where(Sequence.id == sequence_id, Sequence.tenant_id == current_user.tenant_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not seq:
|
||||||
|
raise HTTPException(status_code=404, detail="التسلسل غير موجود")
|
||||||
|
|
||||||
|
engine = SequenceEngine(db)
|
||||||
|
return await engine.get_sequence_analytics(sequence_id)
|
||||||
@ -0,0 +1,403 @@
|
|||||||
|
"""
|
||||||
|
Arabic Conversation Intelligence — Analyzes WhatsApp/email threads
|
||||||
|
to extract insights, buying/risk signals, and next-best-action recommendations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.services.llm.provider import get_llm
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BuyingSignal:
|
||||||
|
phrase: str
|
||||||
|
confidence: float
|
||||||
|
signal_type: str # "explicit", "implicit"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RiskSignal:
|
||||||
|
phrase: str
|
||||||
|
risk_type: str # "price_objection", "competitor", "hesitation", "delay"
|
||||||
|
severity: str # "low", "medium", "high"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActionItem:
|
||||||
|
description_ar: str
|
||||||
|
description_en: str
|
||||||
|
priority: str # "high", "medium", "low"
|
||||||
|
due_hint: Optional[str] = None # "today", "this_week", "next_week"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConversationInsight:
|
||||||
|
summary_ar: str
|
||||||
|
summary_en: str
|
||||||
|
key_topics: list[str] = field(default_factory=list)
|
||||||
|
buying_signals: list[BuyingSignal] = field(default_factory=list)
|
||||||
|
risk_signals: list[RiskSignal] = field(default_factory=list)
|
||||||
|
objections: list[str] = field(default_factory=list)
|
||||||
|
action_items: list[ActionItem] = field(default_factory=list)
|
||||||
|
next_best_action_ar: str = ""
|
||||||
|
next_best_action_en: str = ""
|
||||||
|
quality_score: float = 0.0 # 0.0 - 10.0
|
||||||
|
message_count: int = 0
|
||||||
|
dominant_language: str = "ar"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pattern constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BUYING_SIGNAL_PATTERNS = [
|
||||||
|
(r"أبي\s*عرض\s*سعر", "explicit", 0.9),
|
||||||
|
(r"كم\s*السعر", "explicit", 0.85),
|
||||||
|
(r"متى\s*تقدرون\s*تبد[وأ]ون", "explicit", 0.9),
|
||||||
|
(r"أبي\s*أشتري", "explicit", 0.95),
|
||||||
|
(r"نبي\s*ن[شس]تري", "explicit", 0.95),
|
||||||
|
(r"عطوني\s*عرض", "explicit", 0.85),
|
||||||
|
(r"ودي\s*آخذ", "explicit", 0.8),
|
||||||
|
(r"أبغى\s*أطلب", "explicit", 0.9),
|
||||||
|
(r"جاهز[ية]?\s*نبدأ", "explicit", 0.95),
|
||||||
|
(r"كيف\s*طريقة\s*الدفع", "implicit", 0.8),
|
||||||
|
(r"فيه\s*ضمان", "implicit", 0.7),
|
||||||
|
(r"عندكم\s*تجربة\s*مجانية", "implicit", 0.6),
|
||||||
|
(r"متى\s*يوصل", "implicit", 0.7),
|
||||||
|
(r"أبي\s*أعرف\s*أكثر", "implicit", 0.5),
|
||||||
|
(r"send\s*(?:me\s*)?(?:a\s*)?quot(?:e|ation)", "explicit", 0.85),
|
||||||
|
(r"how\s*(?:much|soon)", "implicit", 0.7),
|
||||||
|
(r"ready\s*to\s*(?:start|buy|proceed)", "explicit", 0.95),
|
||||||
|
]
|
||||||
|
|
||||||
|
RISK_SIGNAL_PATTERNS = [
|
||||||
|
(r"غالي", "price_objection", "medium", 0.8),
|
||||||
|
(r"فيه\s*أرخص", "competitor", "high", 0.85),
|
||||||
|
(r"بفكر", "hesitation", "medium", 0.7),
|
||||||
|
(r"مو\s*متأكد", "hesitation", "high", 0.8),
|
||||||
|
(r"خلني\s*أستشير", "delay", "medium", 0.6),
|
||||||
|
(r"أرجع\s*لك", "delay", "medium", 0.5),
|
||||||
|
(r"مو\s*الحين", "delay", "medium", 0.7),
|
||||||
|
(r"ما\s*عندي\s*ميزانية", "price_objection", "high", 0.9),
|
||||||
|
(r"المنافس\s*يعطينا\s*أحسن", "competitor", "high", 0.9),
|
||||||
|
(r"نستخدم\s*نظام\s*ثاني", "competitor", "medium", 0.7),
|
||||||
|
(r"ما\s*شفت\s*فايدة", "hesitation", "high", 0.85),
|
||||||
|
(r"too\s*expensive", "price_objection", "medium", 0.8),
|
||||||
|
(r"not\s*sure", "hesitation", "medium", 0.7),
|
||||||
|
(r"(?:need|let)\s*(?:me\s*)?think", "delay", "medium", 0.6),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ConversationIntelligence:
|
||||||
|
"""Analyzes Arabic/English conversation threads for sales intelligence."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._llm = get_llm()
|
||||||
|
|
||||||
|
async def analyze_conversation(
|
||||||
|
self, messages: list[dict], context: Optional[dict] = None
|
||||||
|
) -> ConversationInsight:
|
||||||
|
"""
|
||||||
|
Analyze a conversation thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of {"role": "lead"|"agent", "content": str, "timestamp": str, "channel": str}
|
||||||
|
context: Optional lead/deal context {"lead_name", "company", "industry", "stage"}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ConversationInsight with full analysis.
|
||||||
|
"""
|
||||||
|
context = context or {}
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return ConversationInsight(
|
||||||
|
summary_ar="لا توجد رسائل للتحليل",
|
||||||
|
summary_en="No messages to analyze",
|
||||||
|
message_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regex-based signal extraction (fast)
|
||||||
|
buying_signals = self._extract_buying_signals(messages)
|
||||||
|
risk_signals = self._extract_risk_signals(messages)
|
||||||
|
|
||||||
|
# LLM-based deep analysis
|
||||||
|
try:
|
||||||
|
llm_insight = await self._llm_analyze(messages, context)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"LLM conversation analysis failed: {e}")
|
||||||
|
llm_insight = {}
|
||||||
|
|
||||||
|
# Merge regex and LLM results
|
||||||
|
quality_score = self._calculate_quality_score(messages, buying_signals, risk_signals)
|
||||||
|
|
||||||
|
# Combine LLM action items with defaults
|
||||||
|
action_items = self._parse_action_items(llm_insight.get("action_items", []))
|
||||||
|
if not action_items:
|
||||||
|
action_items = self._generate_default_actions(buying_signals, risk_signals)
|
||||||
|
|
||||||
|
return ConversationInsight(
|
||||||
|
summary_ar=llm_insight.get("summary_ar", self._build_fallback_summary_ar(messages)),
|
||||||
|
summary_en=llm_insight.get("summary_en", self._build_fallback_summary_en(messages)),
|
||||||
|
key_topics=llm_insight.get("key_topics", []),
|
||||||
|
buying_signals=buying_signals,
|
||||||
|
risk_signals=risk_signals,
|
||||||
|
objections=llm_insight.get("objections", []),
|
||||||
|
action_items=action_items,
|
||||||
|
next_best_action_ar=llm_insight.get(
|
||||||
|
"next_best_action_ar",
|
||||||
|
self._default_next_action_ar(buying_signals, risk_signals),
|
||||||
|
),
|
||||||
|
next_best_action_en=llm_insight.get("next_best_action_en", "Follow up with the lead"),
|
||||||
|
quality_score=round(quality_score, 1),
|
||||||
|
message_count=len(messages),
|
||||||
|
dominant_language=self._detect_dominant_language(messages),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Regex Signal Extraction ──────────────────
|
||||||
|
|
||||||
|
def _extract_buying_signals(self, messages: list[dict]) -> list[BuyingSignal]:
|
||||||
|
"""Extract buying signals from conversation using regex patterns."""
|
||||||
|
signals = []
|
||||||
|
lead_texts = " ".join(
|
||||||
|
m.get("content", "") for m in messages if m.get("role") == "lead"
|
||||||
|
)
|
||||||
|
seen_phrases = set()
|
||||||
|
|
||||||
|
for pattern, signal_type, confidence in BUYING_SIGNAL_PATTERNS:
|
||||||
|
for match in re.finditer(pattern, lead_texts, re.IGNORECASE):
|
||||||
|
phrase = match.group(0).strip()
|
||||||
|
if phrase not in seen_phrases:
|
||||||
|
seen_phrases.add(phrase)
|
||||||
|
signals.append(BuyingSignal(
|
||||||
|
phrase=phrase,
|
||||||
|
confidence=confidence,
|
||||||
|
signal_type=signal_type,
|
||||||
|
))
|
||||||
|
return signals
|
||||||
|
|
||||||
|
def _extract_risk_signals(self, messages: list[dict]) -> list[RiskSignal]:
|
||||||
|
"""Extract risk signals from conversation using regex patterns."""
|
||||||
|
signals = []
|
||||||
|
lead_texts = " ".join(
|
||||||
|
m.get("content", "") for m in messages if m.get("role") == "lead"
|
||||||
|
)
|
||||||
|
seen_phrases = set()
|
||||||
|
|
||||||
|
for pattern, risk_type, severity, _confidence in RISK_SIGNAL_PATTERNS:
|
||||||
|
for match in re.finditer(pattern, lead_texts, re.IGNORECASE):
|
||||||
|
phrase = match.group(0).strip()
|
||||||
|
if phrase not in seen_phrases:
|
||||||
|
seen_phrases.add(phrase)
|
||||||
|
signals.append(RiskSignal(
|
||||||
|
phrase=phrase,
|
||||||
|
risk_type=risk_type,
|
||||||
|
severity=severity,
|
||||||
|
))
|
||||||
|
return signals
|
||||||
|
|
||||||
|
# ── LLM Deep Analysis ────────────────────────
|
||||||
|
|
||||||
|
async def _llm_analyze(self, messages: list[dict], context: dict) -> dict:
|
||||||
|
"""Use LLM for deep conversation analysis."""
|
||||||
|
thread_text = self._format_thread(messages)
|
||||||
|
context_str = ""
|
||||||
|
if context:
|
||||||
|
context_str = (
|
||||||
|
f"معلومات العميل: {context.get('lead_name', 'غير معروف')}, "
|
||||||
|
f"الشركة: {context.get('company', 'غير معروف')}, "
|
||||||
|
f"القطاع: {context.get('industry', 'غير محدد')}, "
|
||||||
|
f"المرحلة: {context.get('stage', 'غير محدد')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"أنت محلل محادثات مبيعات خبير في السوق السعودي.\n"
|
||||||
|
"حلل المحادثة التالية واستخرج:\n"
|
||||||
|
"1. ملخص المحادثة بالعربي والإنجليزي\n"
|
||||||
|
"2. المواضيع الرئيسية\n"
|
||||||
|
"3. الاعتراضات التي طرحها العميل\n"
|
||||||
|
"4. المهام والإجراءات المطلوبة\n"
|
||||||
|
"5. أفضل إجراء تالي\n\n"
|
||||||
|
f"{context_str}\n\n"
|
||||||
|
"أجب بصيغة JSON بالضبط:\n"
|
||||||
|
"{\n"
|
||||||
|
' "summary_ar": "ملخص بالعربي",\n'
|
||||||
|
' "summary_en": "English summary",\n'
|
||||||
|
' "key_topics": ["موضوع1", "موضوع2"],\n'
|
||||||
|
' "objections": ["اعتراض1", "اعتراض2"],\n'
|
||||||
|
' "action_items": [\n'
|
||||||
|
' {"description_ar": "وصف", "description_en": "desc", "priority": "high|medium|low", "due_hint": "today|this_week|next_week"}\n'
|
||||||
|
" ],\n"
|
||||||
|
' "next_best_action_ar": "الإجراء التالي بالعربي",\n'
|
||||||
|
' "next_best_action_en": "Next action in English"\n'
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self._llm.complete(
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
user_message=thread_text,
|
||||||
|
json_mode=True,
|
||||||
|
temperature=0.2,
|
||||||
|
max_tokens=1024,
|
||||||
|
)
|
||||||
|
parsed = response.parse_json()
|
||||||
|
return parsed or {}
|
||||||
|
|
||||||
|
# ── Quality Scoring ──────────────────────────
|
||||||
|
|
||||||
|
def _calculate_quality_score(
|
||||||
|
self,
|
||||||
|
messages: list[dict],
|
||||||
|
buying_signals: list[BuyingSignal],
|
||||||
|
risk_signals: list[RiskSignal],
|
||||||
|
) -> float:
|
||||||
|
"""Calculate conversation quality score (0-10)."""
|
||||||
|
score = 5.0 # baseline
|
||||||
|
|
||||||
|
# Message volume factor
|
||||||
|
msg_count = len(messages)
|
||||||
|
if msg_count >= 10:
|
||||||
|
score += 1.0
|
||||||
|
elif msg_count >= 5:
|
||||||
|
score += 0.5
|
||||||
|
|
||||||
|
# Two-way engagement
|
||||||
|
lead_msgs = sum(1 for m in messages if m.get("role") == "lead")
|
||||||
|
agent_msgs = sum(1 for m in messages if m.get("role") == "agent")
|
||||||
|
if lead_msgs > 0 and agent_msgs > 0:
|
||||||
|
ratio = min(lead_msgs, agent_msgs) / max(lead_msgs, agent_msgs)
|
||||||
|
score += ratio * 1.5 # balanced conversation = higher quality
|
||||||
|
|
||||||
|
# Buying signals boost
|
||||||
|
score += min(len(buying_signals) * 0.5, 2.0)
|
||||||
|
|
||||||
|
# Risk signals penalty
|
||||||
|
high_risks = sum(1 for r in risk_signals if r.severity == "high")
|
||||||
|
score -= min(high_risks * 0.5, 2.0)
|
||||||
|
|
||||||
|
# Average message length (longer = more engaged)
|
||||||
|
avg_len = sum(len(m.get("content", "")) for m in messages) / max(msg_count, 1)
|
||||||
|
if avg_len > 100:
|
||||||
|
score += 0.5
|
||||||
|
|
||||||
|
return max(0.0, min(10.0, score))
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_thread(messages: list[dict]) -> str:
|
||||||
|
"""Format messages into a readable thread for LLM."""
|
||||||
|
lines = []
|
||||||
|
for m in messages[-30:]: # limit to last 30 messages
|
||||||
|
role = "العميل" if m.get("role") == "lead" else "المندوب"
|
||||||
|
timestamp = m.get("timestamp", "")
|
||||||
|
channel = m.get("channel", "")
|
||||||
|
prefix = f"[{timestamp}] [{channel}] {role}" if timestamp else f"[{channel}] {role}"
|
||||||
|
lines.append(f"{prefix}: {m.get('content', '')}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _detect_dominant_language(messages: list[dict]) -> str:
|
||||||
|
"""Quick check on whether the conversation is mostly Arabic or English."""
|
||||||
|
arabic_re = re.compile(r"[\u0600-\u06FF]")
|
||||||
|
arabic_chars = 0
|
||||||
|
total_chars = 0
|
||||||
|
for m in messages:
|
||||||
|
content = m.get("content", "")
|
||||||
|
arabic_chars += len(arabic_re.findall(content))
|
||||||
|
total_chars += len(content)
|
||||||
|
if total_chars == 0:
|
||||||
|
return "ar"
|
||||||
|
return "ar" if (arabic_chars / total_chars) > 0.3 else "en"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_action_items(raw_items: list) -> list[ActionItem]:
|
||||||
|
"""Parse LLM action items into ActionItem objects."""
|
||||||
|
items = []
|
||||||
|
for item in raw_items:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
items.append(ActionItem(
|
||||||
|
description_ar=item.get("description_ar", ""),
|
||||||
|
description_en=item.get("description_en", ""),
|
||||||
|
priority=item.get("priority", "medium"),
|
||||||
|
due_hint=item.get("due_hint"),
|
||||||
|
))
|
||||||
|
return items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_default_actions(
|
||||||
|
buying_signals: list[BuyingSignal], risk_signals: list[RiskSignal]
|
||||||
|
) -> list[ActionItem]:
|
||||||
|
"""Generate default actions when LLM is unavailable."""
|
||||||
|
actions = []
|
||||||
|
if buying_signals:
|
||||||
|
explicit = [s for s in buying_signals if s.signal_type == "explicit"]
|
||||||
|
if explicit:
|
||||||
|
actions.append(ActionItem(
|
||||||
|
description_ar="العميل أبدى رغبة شرائية واضحة — أرسل عرض سعر فوراً",
|
||||||
|
description_en="Lead showed explicit buying intent - send proposal immediately",
|
||||||
|
priority="high",
|
||||||
|
due_hint="today",
|
||||||
|
))
|
||||||
|
high_risks = [r for r in risk_signals if r.severity == "high"]
|
||||||
|
if high_risks:
|
||||||
|
risk_types = set(r.risk_type for r in high_risks)
|
||||||
|
if "price_objection" in risk_types:
|
||||||
|
actions.append(ActionItem(
|
||||||
|
description_ar="العميل يشوف السعر غالي — جهّز مقارنة قيمة وعرض خاص",
|
||||||
|
description_en="Price objection detected - prepare value comparison and discount offer",
|
||||||
|
priority="high",
|
||||||
|
due_hint="today",
|
||||||
|
))
|
||||||
|
if "competitor" in risk_types:
|
||||||
|
actions.append(ActionItem(
|
||||||
|
description_ar="العميل يقارن بالمنافسين — جهّز مقارنة تنافسية",
|
||||||
|
description_en="Competitor comparison detected - prepare competitive analysis",
|
||||||
|
priority="high",
|
||||||
|
due_hint="today",
|
||||||
|
))
|
||||||
|
if not actions:
|
||||||
|
actions.append(ActionItem(
|
||||||
|
description_ar="تابع مع العميل برسالة واتساب ودية",
|
||||||
|
description_en="Follow up with a friendly WhatsApp message",
|
||||||
|
priority="medium",
|
||||||
|
due_hint="this_week",
|
||||||
|
))
|
||||||
|
return actions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_next_action_ar(
|
||||||
|
buying_signals: list[BuyingSignal], risk_signals: list[RiskSignal]
|
||||||
|
) -> str:
|
||||||
|
if any(s.signal_type == "explicit" for s in buying_signals):
|
||||||
|
return "العميل جاهز! أرسل عرض سعر مخصص واتصل خلال ساعة."
|
||||||
|
high_risks = [r for r in risk_signals if r.severity == "high"]
|
||||||
|
if high_risks:
|
||||||
|
return "انتبه — فيه إشارات خطر. عالج الاعتراضات قبل المتابعة."
|
||||||
|
if buying_signals:
|
||||||
|
return "فيه اهتمام. أرسل معلومات إضافية وحدد موعد عرض."
|
||||||
|
return "تابع المحادثة واسأل عن احتياجات العميل."
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_fallback_summary_ar(messages: list[dict]) -> str:
|
||||||
|
count = len(messages)
|
||||||
|
lead_count = sum(1 for m in messages if m.get("role") == "lead")
|
||||||
|
return f"محادثة مكونة من {count} رسالة ({lead_count} من العميل). لم يتم تحليل المحتوى بالتفصيل."
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_fallback_summary_en(messages: list[dict]) -> str:
|
||||||
|
count = len(messages)
|
||||||
|
lead_count = sum(1 for m in messages if m.get("role") == "lead")
|
||||||
|
return f"Conversation with {count} messages ({lead_count} from lead). Detailed analysis unavailable."
|
||||||
436
salesflow-saas/backend/app/services/ai/message_writer.py
Normal file
436
salesflow-saas/backend/app/services/ai/message_writer.py
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
"""
|
||||||
|
AI Message Writer — Generates personalized WhatsApp, Email, and SMS messages
|
||||||
|
in Arabic and English with tone control, A/B variants, and Saudi business-hour awareness.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.services.llm.provider import get_llm
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MessageVariant:
|
||||||
|
content: str
|
||||||
|
subject: Optional[str] = None # for email only
|
||||||
|
variant_label: str = "A"
|
||||||
|
estimated_read_time_sec: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MessageDraft:
|
||||||
|
channel: str # "whatsapp", "email", "sms"
|
||||||
|
language: str # "ar", "en"
|
||||||
|
tone: str
|
||||||
|
variants: list[MessageVariant] = field(default_factory=list)
|
||||||
|
best_send_time: Optional[str] = None
|
||||||
|
best_send_day: Optional[str] = None
|
||||||
|
personalization_used: list[str] = field(default_factory=list)
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CHANNEL_LIMITS = {
|
||||||
|
"whatsapp": 4096,
|
||||||
|
"sms": 160,
|
||||||
|
"email": 10000,
|
||||||
|
}
|
||||||
|
|
||||||
|
TONE_INSTRUCTIONS = {
|
||||||
|
"formal": {
|
||||||
|
"ar": (
|
||||||
|
"استخدم لغة رسمية واحترافية. خاطب العميل بـ'حضرتكم'. "
|
||||||
|
"ابدأ بالسلام الرسمي. تجنب العامية."
|
||||||
|
),
|
||||||
|
"en": "Use formal, professional language. Address the recipient respectfully.",
|
||||||
|
},
|
||||||
|
"friendly": {
|
||||||
|
"ar": (
|
||||||
|
"استخدم لغة ودية وعفوية. خاطب العميل بـ'أنت'. "
|
||||||
|
"استخدم تعبيرات سعودية طبيعية مثل 'هلا والله' و'يعطيك العافية'."
|
||||||
|
),
|
||||||
|
"en": "Use a warm, friendly tone. Be conversational and approachable.",
|
||||||
|
},
|
||||||
|
"urgent": {
|
||||||
|
"ar": (
|
||||||
|
"استخدم لغة مباشرة تحث على الإسراع. "
|
||||||
|
"أكد على محدودية العرض أو ضرورة التصرف السريع بدون مبالغة."
|
||||||
|
),
|
||||||
|
"en": "Create urgency without being pushy. Emphasize time-limited opportunity.",
|
||||||
|
},
|
||||||
|
"follow_up": {
|
||||||
|
"ar": (
|
||||||
|
"ذكّر العميل بالمحادثة السابقة بلطف. "
|
||||||
|
"اسأل إذا عنده أسئلة. كن مهذب وغير ملح."
|
||||||
|
),
|
||||||
|
"en": "Gently remind about previous conversation. Ask if they have questions.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
INDUSTRY_CONTEXT = {
|
||||||
|
"real_estate": {
|
||||||
|
"ar": "القطاع العقاري — استخدم مصطلحات مثل: وحدة سكنية، مخطط، صك، موقع استراتيجي، عائد استثماري",
|
||||||
|
"en": "Real estate — use terms like: residential unit, strategic location, ROI, property value",
|
||||||
|
},
|
||||||
|
"healthcare": {
|
||||||
|
"ar": "القطاع الصحي — استخدم مصطلحات مثل: عيادة، مجمع طبي، تأمين، موعد، رعاية صحية",
|
||||||
|
"en": "Healthcare — use terms like: clinic, medical complex, insurance, appointment, care quality",
|
||||||
|
},
|
||||||
|
"retail": {
|
||||||
|
"ar": "قطاع التجزئة — استخدم مصطلحات مثل: نقاط البيع، المخزون، تجربة العميل، موسم التخفيضات",
|
||||||
|
"en": "Retail — use terms like: POS, inventory, customer experience, sales season",
|
||||||
|
},
|
||||||
|
"education": {
|
||||||
|
"ar": "قطاع التعليم — استخدم مصطلحات مثل: تسجيل، رسوم دراسية، منهج، فصل دراسي",
|
||||||
|
"en": "Education — use terms like: enrollment, tuition, curriculum, academic term",
|
||||||
|
},
|
||||||
|
"automotive": {
|
||||||
|
"ar": "قطاع السيارات — استخدم مصطلحات مثل: معرض، وكالة، تمويل، صيانة، موديل",
|
||||||
|
"en": "Automotive — use terms like: showroom, dealership, financing, maintenance, model",
|
||||||
|
},
|
||||||
|
"hospitality": {
|
||||||
|
"ar": "قطاع الضيافة — استخدم مصطلحات مثل: حجز، جناح، باقة، تجربة ضيافة، موسم سياحي",
|
||||||
|
"en": "Hospitality — use terms like: booking, suite, package, guest experience, peak season",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Saudi Arabia timezone: UTC+3
|
||||||
|
SAUDI_TZ_OFFSET = timedelta(hours=3)
|
||||||
|
|
||||||
|
# Optimal send windows (Saudi time, 24h)
|
||||||
|
SEND_WINDOWS = {
|
||||||
|
"whatsapp": {"start": 9, "end": 21, "peak": [10, 14, 19]},
|
||||||
|
"email": {"start": 8, "end": 17, "peak": [9, 11, 14]},
|
||||||
|
"sms": {"start": 9, "end": 20, "peak": [10, 13, 18]},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Saudi work week: Sunday (6) through Thursday (3)
|
||||||
|
SAUDI_WORK_DAYS = {6, 0, 1, 2, 3} # Sunday=6, Mon=0, Tue=1, Wed=2, Thu=3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MessageWriter:
|
||||||
|
"""Generates personalized, culturally-aware sales messages."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._llm = get_llm()
|
||||||
|
|
||||||
|
async def write_message(
|
||||||
|
self,
|
||||||
|
channel: str,
|
||||||
|
tone: str,
|
||||||
|
lead_data: dict,
|
||||||
|
context: Optional[dict] = None,
|
||||||
|
language: str = "ar",
|
||||||
|
) -> MessageDraft:
|
||||||
|
"""
|
||||||
|
Generate a sales message with A/B variants.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: "whatsapp", "email", or "sms"
|
||||||
|
tone: "formal", "friendly", "urgent", "follow_up"
|
||||||
|
lead_data: {"name", "company", "industry", "stage", "city", "last_contact"}
|
||||||
|
context: Optional {"deal_value", "product", "previous_topic", "objection"}
|
||||||
|
language: "ar" or "en"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MessageDraft with two A/B variants and send-time recommendation.
|
||||||
|
"""
|
||||||
|
context = context or {}
|
||||||
|
channel = channel.lower()
|
||||||
|
tone = tone.lower()
|
||||||
|
language = language.lower() if language else "ar"
|
||||||
|
|
||||||
|
if channel not in CHANNEL_LIMITS:
|
||||||
|
channel = "whatsapp"
|
||||||
|
if tone not in TONE_INSTRUCTIONS:
|
||||||
|
tone = "friendly"
|
||||||
|
if language not in ("ar", "en"):
|
||||||
|
language = "ar"
|
||||||
|
|
||||||
|
# Build the prompt
|
||||||
|
system_prompt = self._build_system_prompt(channel, tone, lead_data, context, language)
|
||||||
|
user_prompt = self._build_user_prompt(channel, tone, lead_data, context, language)
|
||||||
|
|
||||||
|
# Generate both variants in one LLM call
|
||||||
|
try:
|
||||||
|
response = await self._llm.complete(
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
user_message=user_prompt,
|
||||||
|
json_mode=True,
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=2048,
|
||||||
|
)
|
||||||
|
parsed = response.parse_json()
|
||||||
|
if parsed:
|
||||||
|
variants = self._parse_variants(parsed, channel)
|
||||||
|
else:
|
||||||
|
raise ValueError("Failed to parse LLM response")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"LLM message generation failed: {e}")
|
||||||
|
variants = self._fallback_variants(channel, tone, lead_data, language)
|
||||||
|
|
||||||
|
# Calculate best send time
|
||||||
|
best_time, best_day = self._calculate_best_send_time(channel)
|
||||||
|
|
||||||
|
# Track which personalization fields were used
|
||||||
|
personalization = [
|
||||||
|
k for k in ("name", "company", "industry", "city", "stage")
|
||||||
|
if lead_data.get(k)
|
||||||
|
]
|
||||||
|
|
||||||
|
return MessageDraft(
|
||||||
|
channel=channel,
|
||||||
|
language=language,
|
||||||
|
tone=tone,
|
||||||
|
variants=variants,
|
||||||
|
best_send_time=best_time,
|
||||||
|
best_send_day=best_day,
|
||||||
|
personalization_used=personalization,
|
||||||
|
metadata={
|
||||||
|
"max_length": CHANNEL_LIMITS[channel],
|
||||||
|
"lead_name": lead_data.get("name", ""),
|
||||||
|
"industry": lead_data.get("industry", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Prompt Construction ──────────────────────
|
||||||
|
|
||||||
|
def _build_system_prompt(
|
||||||
|
self, channel: str, tone: str, lead_data: dict, context: dict, language: str
|
||||||
|
) -> str:
|
||||||
|
lang_key = language if language in ("ar", "en") else "ar"
|
||||||
|
tone_instruction = TONE_INSTRUCTIONS.get(tone, TONE_INSTRUCTIONS["friendly"])[lang_key]
|
||||||
|
|
||||||
|
industry = (lead_data.get("industry") or "").lower().replace(" ", "_")
|
||||||
|
industry_note = ""
|
||||||
|
if industry in INDUSTRY_CONTEXT:
|
||||||
|
industry_note = INDUSTRY_CONTEXT[industry][lang_key]
|
||||||
|
|
||||||
|
char_limit = CHANNEL_LIMITS[channel]
|
||||||
|
|
||||||
|
if language == "ar":
|
||||||
|
prompt = (
|
||||||
|
"أنت كاتب رسائل مبيعات محترف متخصص في السوق السعودي.\n"
|
||||||
|
f"القناة: {channel} (حد أقصى {char_limit} حرف)\n"
|
||||||
|
f"النبرة: {tone_instruction}\n"
|
||||||
|
)
|
||||||
|
if industry_note:
|
||||||
|
prompt += f"القطاع: {industry_note}\n"
|
||||||
|
prompt += (
|
||||||
|
"\nقواعد مهمة:\n"
|
||||||
|
"- لا تستخدم لهجة مصرية أو شامية\n"
|
||||||
|
"- استخدم 'ريال' للعملة\n"
|
||||||
|
"- راعي ثقافة الأعمال السعودية\n"
|
||||||
|
"- اكتب رسالتين مختلفتين (A و B) لاختبار A/B\n"
|
||||||
|
"- الرسالة يجب أن تكون كاملة وجاهزة للإرسال\n"
|
||||||
|
)
|
||||||
|
if channel == "email":
|
||||||
|
prompt += "- أضف عنوان بريد مناسب لكل رسالة\n"
|
||||||
|
else:
|
||||||
|
prompt = (
|
||||||
|
"You are a professional sales message writer for the Saudi market.\n"
|
||||||
|
f"Channel: {channel} (max {char_limit} characters)\n"
|
||||||
|
f"Tone: {tone_instruction}\n"
|
||||||
|
)
|
||||||
|
if industry_note:
|
||||||
|
prompt += f"Industry: {industry_note}\n"
|
||||||
|
prompt += (
|
||||||
|
"\nRules:\n"
|
||||||
|
"- Write two different message variants (A and B) for A/B testing\n"
|
||||||
|
"- Messages must be complete and ready to send\n"
|
||||||
|
"- Be culturally aware of Saudi business norms\n"
|
||||||
|
)
|
||||||
|
if channel == "email":
|
||||||
|
prompt += "- Include an email subject line for each variant\n"
|
||||||
|
|
||||||
|
prompt += (
|
||||||
|
"\nأجب بصيغة JSON فقط:\n"
|
||||||
|
"{\n"
|
||||||
|
' "variant_a": {"content": "...", "subject": "..." },\n'
|
||||||
|
' "variant_b": {"content": "...", "subject": "..." }\n'
|
||||||
|
"}\n"
|
||||||
|
"subject مطلوب فقط للبريد الإلكتروني. للواتساب والرسائل اتركه فارغ."
|
||||||
|
)
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
def _build_user_prompt(
|
||||||
|
self, channel: str, tone: str, lead_data: dict, context: dict, language: str
|
||||||
|
) -> str:
|
||||||
|
name = lead_data.get("name", "العميل" if language == "ar" else "Customer")
|
||||||
|
company = lead_data.get("company", "")
|
||||||
|
industry = lead_data.get("industry", "")
|
||||||
|
stage = lead_data.get("stage", "")
|
||||||
|
city = lead_data.get("city", "")
|
||||||
|
last_contact = lead_data.get("last_contact", "")
|
||||||
|
|
||||||
|
deal_value = context.get("deal_value", "")
|
||||||
|
product = context.get("product", "")
|
||||||
|
previous_topic = context.get("previous_topic", "")
|
||||||
|
objection = context.get("objection", "")
|
||||||
|
|
||||||
|
if language == "ar":
|
||||||
|
parts = [f"اكتب رسالة {channel} للعميل:"]
|
||||||
|
parts.append(f"- الاسم: {name}")
|
||||||
|
if company:
|
||||||
|
parts.append(f"- الشركة: {company}")
|
||||||
|
if industry:
|
||||||
|
parts.append(f"- القطاع: {industry}")
|
||||||
|
if stage:
|
||||||
|
parts.append(f"- مرحلة البيع: {stage}")
|
||||||
|
if city:
|
||||||
|
parts.append(f"- المدينة: {city}")
|
||||||
|
if last_contact:
|
||||||
|
parts.append(f"- آخر تواصل: {last_contact}")
|
||||||
|
if deal_value:
|
||||||
|
parts.append(f"- قيمة الصفقة: {deal_value} ريال")
|
||||||
|
if product:
|
||||||
|
parts.append(f"- المنتج: {product}")
|
||||||
|
if previous_topic:
|
||||||
|
parts.append(f"- الموضوع السابق: {previous_topic}")
|
||||||
|
if objection:
|
||||||
|
parts.append(f"- اعتراض العميل: {objection}")
|
||||||
|
else:
|
||||||
|
parts = [f"Write a {channel} message for:"]
|
||||||
|
parts.append(f"- Name: {name}")
|
||||||
|
if company:
|
||||||
|
parts.append(f"- Company: {company}")
|
||||||
|
if industry:
|
||||||
|
parts.append(f"- Industry: {industry}")
|
||||||
|
if stage:
|
||||||
|
parts.append(f"- Stage: {stage}")
|
||||||
|
if city:
|
||||||
|
parts.append(f"- City: {city}")
|
||||||
|
if last_contact:
|
||||||
|
parts.append(f"- Last contact: {last_contact}")
|
||||||
|
if deal_value:
|
||||||
|
parts.append(f"- Deal value: {deal_value} SAR")
|
||||||
|
if product:
|
||||||
|
parts.append(f"- Product: {product}")
|
||||||
|
if previous_topic:
|
||||||
|
parts.append(f"- Previous topic: {previous_topic}")
|
||||||
|
if objection:
|
||||||
|
parts.append(f"- Objection: {objection}")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
# ── Response Parsing ─────────────────────────
|
||||||
|
|
||||||
|
def _parse_variants(self, parsed: dict, channel: str) -> list[MessageVariant]:
|
||||||
|
"""Parse LLM JSON response into MessageVariant objects."""
|
||||||
|
variants = []
|
||||||
|
char_limit = CHANNEL_LIMITS[channel]
|
||||||
|
|
||||||
|
for key, label in [("variant_a", "A"), ("variant_b", "B")]:
|
||||||
|
variant_data = parsed.get(key, {})
|
||||||
|
if isinstance(variant_data, str):
|
||||||
|
content = variant_data
|
||||||
|
subject = None
|
||||||
|
else:
|
||||||
|
content = variant_data.get("content", "")
|
||||||
|
subject = variant_data.get("subject") if channel == "email" else None
|
||||||
|
|
||||||
|
# Truncate if over limit
|
||||||
|
if len(content) > char_limit:
|
||||||
|
content = content[:char_limit - 3] + "..."
|
||||||
|
|
||||||
|
read_time = max(1, len(content) // 200 * 10) # ~200 chars / 10 sec
|
||||||
|
|
||||||
|
variants.append(MessageVariant(
|
||||||
|
content=content,
|
||||||
|
subject=subject,
|
||||||
|
variant_label=label,
|
||||||
|
estimated_read_time_sec=read_time,
|
||||||
|
))
|
||||||
|
|
||||||
|
return variants
|
||||||
|
|
||||||
|
def _fallback_variants(
|
||||||
|
self, channel: str, tone: str, lead_data: dict, language: str
|
||||||
|
) -> list[MessageVariant]:
|
||||||
|
"""Generate basic fallback messages when LLM is unavailable."""
|
||||||
|
name = lead_data.get("name", "")
|
||||||
|
company = lead_data.get("company", "")
|
||||||
|
|
||||||
|
if language == "ar":
|
||||||
|
greeting = "السلام عليكم" if tone == "formal" else "هلا والله"
|
||||||
|
if tone == "follow_up":
|
||||||
|
text_a = f"{greeting} {name}، كيف حالك؟ حبيت أتابع معك بخصوص محادثتنا السابقة. هل عندك أي أسئلة؟"
|
||||||
|
text_b = f"{greeting} {name}، يعطيك العافية! تواصلنا قبل وحبيت أشوف إذا تحتاج أي شي ثاني."
|
||||||
|
elif tone == "urgent":
|
||||||
|
text_a = f"{greeting} {name}، عندنا عرض خاص ينتهي قريب. تبي أرسل لك التفاصيل؟"
|
||||||
|
text_b = f"{greeting} {name}، فرصة محدودة متاحة الحين. هل تحب نتكلم عنها؟"
|
||||||
|
else:
|
||||||
|
text_a = f"{greeting} {name}، يسعدنا تواصلك! كيف نقدر نساعدك اليوم؟"
|
||||||
|
text_b = f"{greeting} {name}، حياك الله! عندنا حلول ممتازة تناسب احتياجاتك."
|
||||||
|
else:
|
||||||
|
if tone == "follow_up":
|
||||||
|
text_a = f"Hi {name}, following up on our previous conversation. Do you have any questions?"
|
||||||
|
text_b = f"Hello {name}, just checking in. Would you like to continue our discussion?"
|
||||||
|
elif tone == "urgent":
|
||||||
|
text_a = f"Hi {name}, we have a limited-time offer. Shall I share the details?"
|
||||||
|
text_b = f"Hello {name}, a special opportunity is available now. Interested?"
|
||||||
|
else:
|
||||||
|
text_a = f"Hi {name}, great to connect! How can we help you today?"
|
||||||
|
text_b = f"Hello {name}, we have solutions tailored to your needs. Let's chat!"
|
||||||
|
|
||||||
|
return [
|
||||||
|
MessageVariant(content=text_a, variant_label="A", estimated_read_time_sec=10),
|
||||||
|
MessageVariant(content=text_b, variant_label="B", estimated_read_time_sec=10),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Send Time Calculation ────────────────────
|
||||||
|
|
||||||
|
def _calculate_best_send_time(self, channel: str) -> tuple[str, str]:
|
||||||
|
"""Calculate best send time based on Saudi business hours."""
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
now_saudi = now_utc + SAUDI_TZ_OFFSET
|
||||||
|
|
||||||
|
window = SEND_WINDOWS.get(channel, SEND_WINDOWS["whatsapp"])
|
||||||
|
peak_hours = window["peak"]
|
||||||
|
|
||||||
|
# Find the next available peak hour
|
||||||
|
current_hour = now_saudi.hour
|
||||||
|
current_weekday = now_saudi.weekday()
|
||||||
|
|
||||||
|
# Check today first
|
||||||
|
if current_weekday in SAUDI_WORK_DAYS:
|
||||||
|
for peak in peak_hours:
|
||||||
|
if peak > current_hour:
|
||||||
|
best_time = f"{peak:02d}:00 (توقيت السعودية)"
|
||||||
|
day_names_ar = {
|
||||||
|
6: "الأحد", 0: "الاثنين", 1: "الثلاثاء",
|
||||||
|
2: "الأربعاء", 3: "الخميس",
|
||||||
|
}
|
||||||
|
best_day = day_names_ar.get(current_weekday, "اليوم")
|
||||||
|
return best_time, best_day
|
||||||
|
|
||||||
|
# Next work day
|
||||||
|
days_ahead = 1
|
||||||
|
while days_ahead <= 7:
|
||||||
|
next_day = (current_weekday + days_ahead) % 7
|
||||||
|
if next_day in SAUDI_WORK_DAYS:
|
||||||
|
best_time = f"{peak_hours[0]:02d}:00 (توقيت السعودية)"
|
||||||
|
day_names_ar = {
|
||||||
|
6: "الأحد", 0: "الاثنين", 1: "الثلاثاء",
|
||||||
|
2: "الأربعاء", 3: "الخميس", 4: "الجمعة", 5: "السبت",
|
||||||
|
}
|
||||||
|
best_day = day_names_ar.get(next_day, "")
|
||||||
|
return best_time, best_day
|
||||||
|
days_ahead += 1
|
||||||
|
|
||||||
|
return f"{peak_hours[0]:02d}:00 (توقيت السعودية)", "الأحد"
|
||||||
346
salesflow-saas/backend/app/services/ai/sales_agent.py
Normal file
346
salesflow-saas/backend/app/services/ai/sales_agent.py
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
"""
|
||||||
|
Dealix Autonomous AI Sales Agent for WhatsApp
|
||||||
|
وكيل مبيعات ذكي يعمل تلقائياً عبر الواتساب — يؤهل العملاء ويحجز المواعيد
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.services.llm.provider import get_llm
|
||||||
|
|
||||||
|
logger = logging.getLogger("dealix.ai.sales_agent")
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationState(str, Enum):
|
||||||
|
GREETING = "greeting"
|
||||||
|
QUALIFICATION = "qualification"
|
||||||
|
NEEDS_ANALYSIS = "needs_analysis"
|
||||||
|
SOLUTION_PITCH = "solution_pitch"
|
||||||
|
OBJECTION_HANDLING = "objection_handling"
|
||||||
|
CLOSE_OR_ESCALATE = "close_or_escalate"
|
||||||
|
|
||||||
|
|
||||||
|
STATE_TRANSITIONS: dict[str, list[str]] = {
|
||||||
|
"greeting": ["qualification"],
|
||||||
|
"qualification": ["needs_analysis", "close_or_escalate"],
|
||||||
|
"needs_analysis": ["solution_pitch", "close_or_escalate"],
|
||||||
|
"solution_pitch": ["objection_handling", "close_or_escalate"],
|
||||||
|
"objection_handling": ["solution_pitch", "close_or_escalate"],
|
||||||
|
"close_or_escalate": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
INDUSTRY_QUALIFIERS: dict[str, list[str]] = {
|
||||||
|
"real_estate": [
|
||||||
|
"ما نوع العقار المطلوب (سكني/تجاري)؟",
|
||||||
|
"ما المنطقة أو الحي المفضل؟",
|
||||||
|
"ما الميزانية التقريبية؟",
|
||||||
|
"هل تبحث عن شراء أو إيجار؟",
|
||||||
|
],
|
||||||
|
"healthcare": [
|
||||||
|
"ما نوع الخدمة الطبية المطلوبة؟",
|
||||||
|
"هل لديك تأمين طبي؟",
|
||||||
|
"هل تفضل موعد صباحي أو مسائي؟",
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
"ما طبيعة الخدمة المطلوبة؟",
|
||||||
|
"ما الميزانية التقريبية؟",
|
||||||
|
"ما الجدول الزمني المتوقع؟",
|
||||||
|
"هل سبق تجربة مزود خدمة آخر؟",
|
||||||
|
],
|
||||||
|
"contracting": [
|
||||||
|
"ما نوع المشروع (بناء/صيانة/تشطيبات)؟",
|
||||||
|
"ما المساحة التقريبية؟",
|
||||||
|
"ما الميزانية المخصصة؟",
|
||||||
|
"هل الموقع في الرياض أو منطقة أخرى؟",
|
||||||
|
],
|
||||||
|
"education": [
|
||||||
|
"ما البرنامج أو الدورة المطلوبة؟",
|
||||||
|
"هل تفضل حضوري أو عن بعد؟",
|
||||||
|
"ما مستوى الخبرة الحالي؟",
|
||||||
|
],
|
||||||
|
"retail": [
|
||||||
|
"ما المنتج أو الفئة المطلوبة؟",
|
||||||
|
"هل تبحث عن كميات تجارية أو شخصية؟",
|
||||||
|
"ما المنطقة لغرض التوصيل؟",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
ESCALATION_TRIGGERS = [
|
||||||
|
"أبي أكلم مدير",
|
||||||
|
"أبي أتكلم مع شخص",
|
||||||
|
"أبي موظف",
|
||||||
|
"ما فهمت",
|
||||||
|
"مشكلة كبيرة",
|
||||||
|
"شكوى",
|
||||||
|
"غاضب",
|
||||||
|
"مستعجل جداً",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentContext(BaseModel):
|
||||||
|
lead_id: str = ""
|
||||||
|
phone: str = ""
|
||||||
|
name: str = ""
|
||||||
|
industry: str = "services"
|
||||||
|
state: str = ConversationState.GREETING.value
|
||||||
|
history: list[dict] = Field(default_factory=list)
|
||||||
|
qualification_data: dict = Field(default_factory=dict)
|
||||||
|
questions_asked: int = 0
|
||||||
|
escalated: bool = False
|
||||||
|
meeting_proposed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AgentResponse(BaseModel):
|
||||||
|
reply: str
|
||||||
|
new_state: str
|
||||||
|
should_escalate: bool = False
|
||||||
|
meeting_suggested: bool = False
|
||||||
|
qualification_complete: bool = False
|
||||||
|
extracted_data: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SalesAgent:
|
||||||
|
"""Autonomous WhatsApp sales agent with Arabic dialogue and state machine."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.llm = get_llm()
|
||||||
|
self._contexts: dict[str, AgentContext] = {}
|
||||||
|
|
||||||
|
async def handle_message(
|
||||||
|
self,
|
||||||
|
phone: str,
|
||||||
|
message: str,
|
||||||
|
lead_id: str = "",
|
||||||
|
name: str = "",
|
||||||
|
industry: str = "services",
|
||||||
|
) -> AgentResponse:
|
||||||
|
"""Main entry: process an inbound WhatsApp message and produce a reply."""
|
||||||
|
ctx = self._get_or_create_context(phone, lead_id, name, industry)
|
||||||
|
ctx.history.append({"role": "user", "content": message, "ts": _now_iso()})
|
||||||
|
|
||||||
|
if await self.should_escalate(message, ctx):
|
||||||
|
ctx.escalated = True
|
||||||
|
ctx.state = ConversationState.CLOSE_OR_ESCALATE.value
|
||||||
|
reply = (
|
||||||
|
f"حياك الله {ctx.name or ''}، أقدّر تواصلك. "
|
||||||
|
"بحوّلك الحين لأحد مستشارينا المتخصصين يخدمك بشكل أفضل. لحظات من فضلك."
|
||||||
|
)
|
||||||
|
ctx.history.append({"role": "assistant", "content": reply, "ts": _now_iso()})
|
||||||
|
return AgentResponse(
|
||||||
|
reply=reply,
|
||||||
|
new_state=ctx.state,
|
||||||
|
should_escalate=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
reply, extracted = await self._generate_state_reply(ctx, message)
|
||||||
|
ctx.qualification_data.update(extracted)
|
||||||
|
ctx.history.append({"role": "assistant", "content": reply, "ts": _now_iso()})
|
||||||
|
|
||||||
|
next_state = await self._determine_next_state(ctx, message)
|
||||||
|
ctx.state = next_state
|
||||||
|
|
||||||
|
qualification_complete = ctx.questions_asked >= len(
|
||||||
|
INDUSTRY_QUALIFIERS.get(ctx.industry, INDUSTRY_QUALIFIERS["services"])
|
||||||
|
)
|
||||||
|
|
||||||
|
meeting_suggested = False
|
||||||
|
if qualification_complete and not ctx.meeting_proposed:
|
||||||
|
meeting_reply = await self.suggest_meeting(ctx)
|
||||||
|
reply += f"\n\n{meeting_reply}"
|
||||||
|
ctx.meeting_proposed = True
|
||||||
|
meeting_suggested = True
|
||||||
|
|
||||||
|
return AgentResponse(
|
||||||
|
reply=reply,
|
||||||
|
new_state=ctx.state,
|
||||||
|
should_escalate=False,
|
||||||
|
meeting_suggested=meeting_suggested,
|
||||||
|
qualification_complete=qualification_complete,
|
||||||
|
extracted_data=extracted,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def qualify_lead(self, ctx: AgentContext) -> dict:
|
||||||
|
"""Return current qualification status and gathered data."""
|
||||||
|
qualifiers = INDUSTRY_QUALIFIERS.get(ctx.industry, INDUSTRY_QUALIFIERS["services"])
|
||||||
|
return {
|
||||||
|
"lead_id": ctx.lead_id,
|
||||||
|
"industry": ctx.industry,
|
||||||
|
"state": ctx.state,
|
||||||
|
"questions_total": len(qualifiers),
|
||||||
|
"questions_asked": ctx.questions_asked,
|
||||||
|
"qualification_data": ctx.qualification_data,
|
||||||
|
"is_qualified": ctx.questions_asked >= len(qualifiers),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def suggest_meeting(self, ctx: AgentContext) -> str:
|
||||||
|
"""Generate a meeting suggestion message."""
|
||||||
|
prompt = (
|
||||||
|
"أنت مساعد مبيعات في السوق السعودي. اقترح موعد اجتماع للعميل "
|
||||||
|
"بأسلوب ودود ومهني بالسعودية. اذكر 2-3 أوقات متاحة هذا الأسبوع. "
|
||||||
|
"اجعل الرد مختصراً (3 أسطر كحد أقصى)."
|
||||||
|
)
|
||||||
|
resp = await self.llm.complete(
|
||||||
|
system_prompt=prompt,
|
||||||
|
user_message=f"اسم العميل: {ctx.name}\nالقطاع: {ctx.industry}",
|
||||||
|
temperature=0.6,
|
||||||
|
max_tokens=150,
|
||||||
|
)
|
||||||
|
return resp.content.strip()
|
||||||
|
|
||||||
|
async def should_escalate(self, message: str, ctx: AgentContext) -> bool:
|
||||||
|
"""Determine if message requires human handoff."""
|
||||||
|
msg_lower = message.strip()
|
||||||
|
for trigger in ESCALATION_TRIGGERS:
|
||||||
|
if trigger in msg_lower:
|
||||||
|
logger.info("Escalation triggered for %s: matched '%s'", ctx.phone, trigger)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if len(ctx.history) > 10 and ctx.state == ConversationState.OBJECTION_HANDLING.value:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def generate_follow_up(self, phone: str, days_dormant: int = 3) -> Optional[str]:
|
||||||
|
"""Generate a follow-up message for a dormant lead."""
|
||||||
|
ctx = self._contexts.get(phone)
|
||||||
|
if not ctx:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"أنت مساعد مبيعات سعودي. اكتب رسالة متابعة ودية لعميل لم يرد منذ "
|
||||||
|
f"{days_dormant} أيام. اجعلها مختصرة (سطرين) ومهتمة بدون ضغط. "
|
||||||
|
"اسم العميل: " + (ctx.name or "العميل")
|
||||||
|
)
|
||||||
|
resp = await self.llm.complete(
|
||||||
|
system_prompt=prompt,
|
||||||
|
user_message=f"القطاع: {ctx.industry}. آخر حالة: {ctx.state}",
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=100,
|
||||||
|
)
|
||||||
|
return resp.content.strip()
|
||||||
|
|
||||||
|
# ── Internal helpers ─────────────────────────────
|
||||||
|
|
||||||
|
def _get_or_create_context(
|
||||||
|
self, phone: str, lead_id: str, name: str, industry: str,
|
||||||
|
) -> AgentContext:
|
||||||
|
if phone not in self._contexts:
|
||||||
|
self._contexts[phone] = AgentContext(
|
||||||
|
lead_id=lead_id, phone=phone, name=name, industry=industry,
|
||||||
|
)
|
||||||
|
ctx = self._contexts[phone]
|
||||||
|
if name and not ctx.name:
|
||||||
|
ctx.name = name
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
async def _generate_state_reply(
|
||||||
|
self, ctx: AgentContext, message: str,
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
"""Generate a reply appropriate to the current conversation state."""
|
||||||
|
qualifiers = INDUSTRY_QUALIFIERS.get(ctx.industry, INDUSTRY_QUALIFIERS["services"])
|
||||||
|
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: (
|
||||||
|
"تعامل مع اعتراض العميل بذكاء. أعد التأطير وركّز على القيمة. "
|
||||||
|
"لا تكن دفاعياً."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
instruction = state_instructions.get(
|
||||||
|
ctx.state,
|
||||||
|
"أكمل المحادثة بأسلوب مهني وودود.",
|
||||||
|
)
|
||||||
|
|
||||||
|
system = (
|
||||||
|
"أنت وكيل مبيعات ذكي لشركة سعودية. تتحدث بالعامية السعودية الراقية.\n"
|
||||||
|
"قواعدك:\n"
|
||||||
|
"1. ردود مختصرة (3-4 أسطر كحد أقصى)\n"
|
||||||
|
"2. لا تستخدم رموز تعبيرية مبالغ فيها\n"
|
||||||
|
"3. كن ودوداً ومحترفاً\n"
|
||||||
|
"4. استخلص أي معلومات ذات قيمة من رد العميل\n"
|
||||||
|
f"5. التعليمات الحالية: {instruction}\n"
|
||||||
|
"أجب بـ JSON: {\"reply\": \"...\", \"extracted\": {\"key\": \"value\"}}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_history = ctx.history[-6:]
|
||||||
|
history_text = "\n".join(
|
||||||
|
f"{'العميل' if h['role'] == 'user' else 'الوكيل'}: {h['content']}"
|
||||||
|
for h in recent_history
|
||||||
|
)
|
||||||
|
|
||||||
|
user_msg = f"المحادثة السابقة:\n{history_text}\n\nرسالة العميل الجديدة: {message}"
|
||||||
|
|
||||||
|
resp = await self.llm.complete(
|
||||||
|
system_prompt=system,
|
||||||
|
user_message=user_msg,
|
||||||
|
temperature=0.6,
|
||||||
|
max_tokens=250,
|
||||||
|
json_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = resp.parse_json()
|
||||||
|
if parsed:
|
||||||
|
reply = parsed.get("reply", message)
|
||||||
|
extracted = parsed.get("extracted", {})
|
||||||
|
else:
|
||||||
|
reply = resp.content.strip()
|
||||||
|
extracted = {}
|
||||||
|
|
||||||
|
if ctx.state == ConversationState.QUALIFICATION.value:
|
||||||
|
ctx.questions_asked += 1
|
||||||
|
|
||||||
|
return reply, extracted
|
||||||
|
|
||||||
|
async def _determine_next_state(self, ctx: AgentContext, message: str) -> str:
|
||||||
|
"""Decide the next conversation state."""
|
||||||
|
allowed = STATE_TRANSITIONS.get(ctx.state, [])
|
||||||
|
if not allowed:
|
||||||
|
return ctx.state
|
||||||
|
|
||||||
|
qualifiers = INDUSTRY_QUALIFIERS.get(ctx.industry, INDUSTRY_QUALIFIERS["services"])
|
||||||
|
|
||||||
|
if ctx.state == ConversationState.GREETING.value:
|
||||||
|
return ConversationState.QUALIFICATION.value
|
||||||
|
|
||||||
|
if ctx.state == ConversationState.QUALIFICATION.value:
|
||||||
|
if ctx.questions_asked >= len(qualifiers):
|
||||||
|
return ConversationState.NEEDS_ANALYSIS.value
|
||||||
|
return ConversationState.QUALIFICATION.value
|
||||||
|
|
||||||
|
if ctx.state == ConversationState.NEEDS_ANALYSIS.value:
|
||||||
|
return ConversationState.SOLUTION_PITCH.value
|
||||||
|
|
||||||
|
if ctx.state == ConversationState.SOLUTION_PITCH.value:
|
||||||
|
negative_signals = ["غالي", "ما أبي", "لا", "مو مقتنع", "كثير", "فكر"]
|
||||||
|
if any(s in message for s in negative_signals):
|
||||||
|
return ConversationState.OBJECTION_HANDLING.value
|
||||||
|
return ConversationState.CLOSE_OR_ESCALATE.value
|
||||||
|
|
||||||
|
if ctx.state == ConversationState.OBJECTION_HANDLING.value:
|
||||||
|
return ConversationState.SOLUTION_PITCH.value
|
||||||
|
|
||||||
|
return ctx.state
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
@ -1,8 +1,6 @@
|
|||||||
"""PDPL consent engine -- tracks, validates, and audits consent per Saudi data protection law.
|
"""PDPL consent engine -- tracks, validates, and audits consent.
|
||||||
|
|
||||||
Penalty for violations: up to 5,000,000 SAR per incident.
|
Penalty for violations: up to 5,000,000 SAR per incident.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -14,18 +12,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.models.consent import (
|
from app.models.consent import (
|
||||||
PDPLConsent, PDPLConsentAudit, DataRequest,
|
PDPLConsent, PDPLConsentAudit, DataRequest,
|
||||||
ConsentStatusEnum, ConsentPurpose, ConsentChannel,
|
ConsentStatusEnum, DataRequestStatus,
|
||||||
DataRequestType, DataRequestStatus,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_EXPIRY_MONTHS = 12
|
DEFAULT_EXPIRY_MONTHS = 12
|
||||||
CROSS_BORDER_ALLOWED_COUNTRIES = {"SA", "AE", "BH", "KW", "OM", "QA"}
|
CROSS_BORDER_ALLOWED = {"SA", "AE", "BH", "KW", "OM", "QA"}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pydantic schemas
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ConsentGrantInput(Schema):
|
class ConsentGrantInput(Schema):
|
||||||
contact_id: UUID
|
contact_id: UUID
|
||||||
@ -72,29 +65,18 @@ class AuditEntry(Schema):
|
|||||||
purpose: str
|
purpose: str
|
||||||
details: dict
|
details: dict
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ConsentManager
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ConsentManager:
|
class ConsentManager:
|
||||||
"""Core PDPL consent engine for Dealix CRM."""
|
"""Core PDPL consent engine for Dealix CRM."""
|
||||||
|
|
||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
# -- grant ---------------------------------------------------------------
|
|
||||||
|
|
||||||
async def grant_consent(self, data: ConsentGrantInput) -> PDPLConsent:
|
async def grant_consent(self, data: ConsentGrantInput) -> PDPLConsent:
|
||||||
"""Record a new consent grant. Existing active consent for same
|
"""Grant consent. Revokes existing active consent for same triplet (re-consent)."""
|
||||||
contact+purpose+channel is revoked first (re-consent flow)."""
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# Revoke any existing active consent for same triplet (re-consent)
|
|
||||||
existing = await self._find_active(data.contact_id, data.purpose, data.channel)
|
existing = await self._find_active(data.contact_id, data.purpose, data.channel)
|
||||||
if existing:
|
if existing:
|
||||||
existing.status = ConsentStatusEnum.REVOKED.value
|
existing.status = ConsentStatusEnum.REVOKED.value
|
||||||
@ -104,198 +86,127 @@ class ConsentManager:
|
|||||||
action="revoked_for_renewal", actor_id=data.actor_id,
|
action="revoked_for_renewal", actor_id=data.actor_id,
|
||||||
channel=data.channel, purpose=data.purpose,
|
channel=data.channel, purpose=data.purpose,
|
||||||
details={"reason": "re-consent on purpose change"},
|
details={"reason": "re-consent on purpose change"},
|
||||||
ip_address=data.ip_address,
|
ip_address=data.ip_address, tenant_id=data.tenant_id,
|
||||||
tenant_id=data.tenant_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
consent = PDPLConsent(
|
consent = PDPLConsent(
|
||||||
contact_id=data.contact_id,
|
contact_id=data.contact_id, tenant_id=data.tenant_id,
|
||||||
tenant_id=data.tenant_id,
|
purpose=data.purpose, channel=data.channel,
|
||||||
purpose=data.purpose,
|
status=ConsentStatusEnum.GRANTED.value, granted_at=now,
|
||||||
channel=data.channel,
|
|
||||||
status=ConsentStatusEnum.GRANTED.value,
|
|
||||||
granted_at=now,
|
|
||||||
expires_at=now + timedelta(days=30 * data.expiry_months),
|
expires_at=now + timedelta(days=30 * data.expiry_months),
|
||||||
ip_address=data.ip_address,
|
ip_address=data.ip_address, consent_text=data.consent_text,
|
||||||
consent_text=data.consent_text,
|
|
||||||
granted_by=data.actor_id,
|
granted_by=data.actor_id,
|
||||||
)
|
)
|
||||||
self.db.add(consent)
|
self.db.add(consent)
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
await self.db.refresh(consent)
|
await self.db.refresh(consent)
|
||||||
|
|
||||||
await self._audit(
|
await self._audit(
|
||||||
consent_id=consent.id, contact_id=data.contact_id,
|
consent_id=consent.id, contact_id=data.contact_id,
|
||||||
action="granted", actor_id=data.actor_id,
|
action="granted", actor_id=data.actor_id,
|
||||||
channel=data.channel, purpose=data.purpose,
|
channel=data.channel, purpose=data.purpose,
|
||||||
details={"expiry_months": data.expiry_months, "consent_text": data.consent_text or ""},
|
details={"expiry_months": data.expiry_months},
|
||||||
ip_address=data.ip_address,
|
ip_address=data.ip_address, tenant_id=data.tenant_id,
|
||||||
tenant_id=data.tenant_id,
|
|
||||||
)
|
)
|
||||||
logger.info("PDPL consent granted: contact=%s purpose=%s channel=%s", data.contact_id, data.purpose, data.channel)
|
logger.info("PDPL consent granted: contact=%s purpose=%s", data.contact_id, data.purpose)
|
||||||
return consent
|
return consent
|
||||||
|
|
||||||
# -- revoke --------------------------------------------------------------
|
|
||||||
|
|
||||||
async def revoke_consent(self, data: ConsentRevokeInput) -> PDPLConsent:
|
async def revoke_consent(self, data: ConsentRevokeInput) -> PDPLConsent:
|
||||||
"""Revoke an existing consent immediately."""
|
"""Revoke an existing consent immediately."""
|
||||||
|
result = await self.db.execute(select(PDPLConsent).where(PDPLConsent.id == data.consent_id))
|
||||||
result = await self.db.execute(
|
|
||||||
select(PDPLConsent).where(PDPLConsent.id == data.consent_id)
|
|
||||||
)
|
|
||||||
consent = result.scalar_one_or_none()
|
consent = result.scalar_one_or_none()
|
||||||
if not consent:
|
if not consent:
|
||||||
raise ValueError("سجل الموافقة غير موجود") # Consent record not found
|
raise ValueError("سجل الموافقة غير موجود")
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
consent.status = ConsentStatusEnum.REVOKED.value
|
consent.status = ConsentStatusEnum.REVOKED.value
|
||||||
consent.revoked_at = now
|
consent.revoked_at = now
|
||||||
|
|
||||||
await self._audit(
|
await self._audit(
|
||||||
consent_id=consent.id, contact_id=consent.contact_id,
|
consent_id=consent.id, contact_id=consent.contact_id,
|
||||||
action="revoked", actor_id=data.actor_id,
|
action="revoked", actor_id=data.actor_id,
|
||||||
channel=consent.channel, purpose=consent.purpose,
|
channel=consent.channel, purpose=consent.purpose,
|
||||||
details={"reason": data.reason or "user_request"},
|
details={"reason": data.reason or "user_request"},
|
||||||
ip_address=data.ip_address,
|
ip_address=data.ip_address, tenant_id=consent.tenant_id,
|
||||||
tenant_id=consent.tenant_id,
|
|
||||||
)
|
)
|
||||||
logger.info("PDPL consent revoked: id=%s contact=%s", consent.id, consent.contact_id)
|
logger.info("PDPL consent revoked: id=%s", consent.id)
|
||||||
return consent
|
return consent
|
||||||
|
|
||||||
# -- check ---------------------------------------------------------------
|
async def check_consent(self, contact_id: UUID, purpose: str, channel: str) -> ConsentCheckResult:
|
||||||
|
"""Validate consent before outbound message. 5M SAR penalty per violation."""
|
||||||
async def check_consent(
|
|
||||||
self,
|
|
||||||
contact_id: UUID,
|
|
||||||
purpose: str,
|
|
||||||
channel: str,
|
|
||||||
) -> ConsentCheckResult:
|
|
||||||
"""Validate consent before any outbound message. Must be called
|
|
||||||
before every send -- violations carry up to 5M SAR penalty."""
|
|
||||||
|
|
||||||
consent = await self._find_active(contact_id, purpose, channel)
|
consent = await self._find_active(contact_id, purpose, channel)
|
||||||
if not consent:
|
if not consent:
|
||||||
return ConsentCheckResult(
|
return ConsentCheckResult(
|
||||||
allowed=False,
|
allowed=False, message=f"No active consent for {purpose}/{channel}",
|
||||||
message=f"No active consent for {purpose}/{channel}",
|
|
||||||
message_ar="لا توجد موافقة فعالة لهذا الغرض والقناة",
|
message_ar="لا توجد موافقة فعالة لهذا الغرض والقناة",
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if consent.expires_at and consent.expires_at <= now:
|
if consent.expires_at and consent.expires_at <= now:
|
||||||
consent.status = ConsentStatusEnum.EXPIRED.value
|
consent.status = ConsentStatusEnum.EXPIRED.value
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return ConsentCheckResult(
|
return ConsentCheckResult(
|
||||||
allowed=False,
|
allowed=False, consent_id=consent.id, status=ConsentStatusEnum.EXPIRED.value,
|
||||||
consent_id=consent.id,
|
expires_at=consent.expires_at, message="Consent expired",
|
||||||
status=ConsentStatusEnum.EXPIRED.value,
|
|
||||||
expires_at=consent.expires_at,
|
|
||||||
message="Consent expired -- re-consent required",
|
|
||||||
message_ar="انتهت صلاحية الموافقة -- يلزم تجديد الموافقة",
|
message_ar="انتهت صلاحية الموافقة -- يلزم تجديد الموافقة",
|
||||||
)
|
)
|
||||||
|
|
||||||
return ConsentCheckResult(
|
return ConsentCheckResult(
|
||||||
allowed=True,
|
allowed=True, consent_id=consent.id, status=consent.status,
|
||||||
consent_id=consent.id,
|
expires_at=consent.expires_at, message="Consent valid",
|
||||||
status=consent.status,
|
|
||||||
expires_at=consent.expires_at,
|
|
||||||
message="Consent valid",
|
|
||||||
message_ar="الموافقة صالحة",
|
message_ar="الموافقة صالحة",
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- data request --------------------------------------------------------
|
|
||||||
|
|
||||||
async def process_data_request(self, data: DataRequestInput) -> DataRequest:
|
async def process_data_request(self, data: DataRequestInput) -> DataRequest:
|
||||||
"""Submit a PDPL data subject rights request."""
|
"""Submit a PDPL data subject rights request."""
|
||||||
|
|
||||||
request = DataRequest(
|
request = DataRequest(
|
||||||
contact_id=data.contact_id,
|
contact_id=data.contact_id, tenant_id=data.tenant_id,
|
||||||
tenant_id=data.tenant_id,
|
request_type=data.request_type, status=DataRequestStatus.PENDING.value,
|
||||||
request_type=data.request_type,
|
requested_at=datetime.now(timezone.utc), notes=data.notes,
|
||||||
status=DataRequestStatus.PENDING.value,
|
|
||||||
requested_at=datetime.now(timezone.utc),
|
|
||||||
notes=data.notes,
|
|
||||||
handled_by=data.actor_id,
|
handled_by=data.actor_id,
|
||||||
)
|
)
|
||||||
self.db.add(request)
|
self.db.add(request)
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
await self.db.refresh(request)
|
await self.db.refresh(request)
|
||||||
logger.info("PDPL data request created: type=%s contact=%s", data.request_type, data.contact_id)
|
logger.info("PDPL data request: type=%s contact=%s", data.request_type, data.contact_id)
|
||||||
return request
|
return request
|
||||||
|
|
||||||
# -- audit ---------------------------------------------------------------
|
|
||||||
|
|
||||||
async def get_consent_audit(
|
async def get_consent_audit(
|
||||||
self,
|
self, tenant_id: UUID, contact_id: Optional[UUID] = None,
|
||||||
tenant_id: UUID,
|
limit: int = 100, offset: int = 0,
|
||||||
contact_id: Optional[UUID] = None,
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> list[AuditEntry]:
|
) -> list[AuditEntry]:
|
||||||
"""Return consent audit trail filtered by tenant and optionally contact."""
|
"""Return consent audit trail."""
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(PDPLConsentAudit)
|
select(PDPLConsentAudit).where(PDPLConsentAudit.tenant_id == tenant_id)
|
||||||
.where(PDPLConsentAudit.tenant_id == tenant_id)
|
|
||||||
.order_by(PDPLConsentAudit.created_at.desc())
|
.order_by(PDPLConsentAudit.created_at.desc())
|
||||||
)
|
)
|
||||||
if contact_id:
|
if contact_id:
|
||||||
query = query.where(PDPLConsentAudit.contact_id == contact_id)
|
query = query.where(PDPLConsentAudit.contact_id == contact_id)
|
||||||
query = query.offset(offset).limit(limit)
|
result = await self.db.execute(query.offset(offset).limit(limit))
|
||||||
result = await self.db.execute(query)
|
|
||||||
return [AuditEntry.model_validate(row) for row in result.scalars().all()]
|
return [AuditEntry.model_validate(row) for row in result.scalars().all()]
|
||||||
|
|
||||||
# -- cross-border --------------------------------------------------------
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_cross_border_transfer(destination_country: str) -> ConsentCheckResult:
|
def check_cross_border_transfer(destination_country: str) -> ConsentCheckResult:
|
||||||
"""Check if data transfer to destination country is PDPL-compliant.
|
"""Check if transfer to destination is PDPL-compliant."""
|
||||||
SDAIA requires adequate protection level or explicit consent."""
|
|
||||||
|
|
||||||
code = destination_country.upper().strip()
|
code = destination_country.upper().strip()
|
||||||
if code in CROSS_BORDER_ALLOWED_COUNTRIES:
|
if code in CROSS_BORDER_ALLOWED:
|
||||||
return ConsentCheckResult(
|
return ConsentCheckResult(
|
||||||
allowed=True,
|
allowed=True, message=f"Transfer to {code} permitted under GCC adequacy",
|
||||||
message=f"Transfer to {code} permitted under GCC adequacy",
|
|
||||||
message_ar=f"النقل إلى {code} مسموح بموجب كفاية دول الخليج",
|
message_ar=f"النقل إلى {code} مسموح بموجب كفاية دول الخليج",
|
||||||
)
|
)
|
||||||
return ConsentCheckResult(
|
return ConsentCheckResult(
|
||||||
allowed=False,
|
allowed=False, message=f"Transfer to {code} requires explicit consent and SDAIA approval",
|
||||||
message=f"Transfer to {code} requires explicit consent and SDAIA approval",
|
|
||||||
message_ar=f"النقل إلى {code} يتطلب موافقة صريحة وموافقة الهيئة",
|
message_ar=f"النقل إلى {code} يتطلب موافقة صريحة وموافقة الهيئة",
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- private helpers -----------------------------------------------------
|
async def _find_active(self, contact_id: UUID, purpose: str, channel: str) -> Optional[PDPLConsent]:
|
||||||
|
|
||||||
async def _find_active(
|
|
||||||
self, contact_id: UUID, purpose: str, channel: str
|
|
||||||
) -> Optional[PDPLConsent]:
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(PDPLConsent).where(
|
select(PDPLConsent).where(and_(
|
||||||
and_(
|
PDPLConsent.contact_id == contact_id, PDPLConsent.purpose == purpose,
|
||||||
PDPLConsent.contact_id == contact_id,
|
PDPLConsent.channel == channel, PDPLConsent.status == ConsentStatusEnum.GRANTED.value,
|
||||||
PDPLConsent.purpose == purpose,
|
)).order_by(PDPLConsent.granted_at.desc()).limit(1)
|
||||||
PDPLConsent.channel == channel,
|
|
||||||
PDPLConsent.status == ConsentStatusEnum.GRANTED.value,
|
|
||||||
)
|
|
||||||
).order_by(PDPLConsent.granted_at.desc()).limit(1)
|
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def _audit(
|
async def _audit(self, *, consent_id, contact_id, action, actor_id,
|
||||||
self, *, consent_id, contact_id, action, actor_id,
|
channel, purpose, details, ip_address, tenant_id) -> None:
|
||||||
channel, purpose, details, ip_address, tenant_id,
|
self.db.add(PDPLConsentAudit(
|
||||||
) -> None:
|
consent_id=consent_id, contact_id=contact_id, tenant_id=tenant_id,
|
||||||
entry = PDPLConsentAudit(
|
action=action, actor_id=actor_id, channel=channel, purpose=purpose,
|
||||||
consent_id=consent_id,
|
details=details or {}, ip_address=ip_address,
|
||||||
contact_id=contact_id,
|
))
|
||||||
tenant_id=tenant_id,
|
|
||||||
action=action,
|
|
||||||
actor_id=actor_id,
|
|
||||||
channel=channel,
|
|
||||||
purpose=purpose,
|
|
||||||
details=details or {},
|
|
||||||
ip_address=ip_address,
|
|
||||||
)
|
|
||||||
self.db.add(entry)
|
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
|
|||||||
@ -1,34 +1,23 @@
|
|||||||
"""PDPL data subject rights handler.
|
"""PDPL data subject rights handler.
|
||||||
|
Right to access, correction, deletion, restriction + SDAIA compliance reports.
|
||||||
Implements: right to access, correction, deletion, restriction of processing.
|
|
||||||
Generates compliance reports for SDAIA audits.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel as Schema
|
from pydantic import BaseModel as Schema
|
||||||
from sqlalchemy import select, func, and_
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.consent import (
|
from app.models.consent import PDPLConsent, DataRequest, DataRequestStatus, DataRequestType
|
||||||
PDPLConsent, PDPLConsentAudit, DataRequest,
|
|
||||||
DataRequestStatus, DataRequestType,
|
|
||||||
)
|
|
||||||
from app.models.lead import Lead
|
from app.models.lead import Lead
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
HARD_DELETE_DELAY_DAYS = 30
|
HARD_DELETE_DELAY_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pydantic schemas
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class DataExport(Schema):
|
class DataExport(Schema):
|
||||||
contact_id: UUID
|
contact_id: UUID
|
||||||
personal_data: dict
|
personal_data: dict
|
||||||
@ -40,7 +29,7 @@ class DataExport(Schema):
|
|||||||
class CorrectionInput(Schema):
|
class CorrectionInput(Schema):
|
||||||
contact_id: UUID
|
contact_id: UUID
|
||||||
tenant_id: UUID
|
tenant_id: UUID
|
||||||
corrections: dict[str, Any] # field_name -> new_value
|
corrections: dict[str, Any]
|
||||||
actor_id: Optional[UUID] = None
|
actor_id: Optional[UUID] = None
|
||||||
reason: Optional[str] = None
|
reason: Optional[str] = None
|
||||||
|
|
||||||
@ -82,29 +71,17 @@ class ComplianceReport(Schema):
|
|||||||
violations_detected: int
|
violations_detected: int
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# DataRightsHandler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class DataRightsHandler:
|
class DataRightsHandler:
|
||||||
"""Handles PDPL data subject rights for Dealix contacts."""
|
"""Handles PDPL data subject rights for Dealix contacts."""
|
||||||
|
|
||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
# -- export (right to access) -------------------------------------------
|
|
||||||
|
|
||||||
async def export_data(self, contact_id: UUID, tenant_id: UUID) -> DataExport:
|
async def export_data(self, contact_id: UUID, tenant_id: UUID) -> DataExport:
|
||||||
"""Export all personal data held for a contact as structured JSON."""
|
"""Export all personal data held for a contact (right to access)."""
|
||||||
|
|
||||||
lead = await self._get_lead(contact_id, tenant_id)
|
lead = await self._get_lead(contact_id, tenant_id)
|
||||||
|
|
||||||
# Consent records
|
|
||||||
consents_q = await self.db.execute(
|
consents_q = await self.db.execute(
|
||||||
select(PDPLConsent).where(
|
select(PDPLConsent).where(PDPLConsent.contact_id == contact_id, PDPLConsent.tenant_id == tenant_id)
|
||||||
PDPLConsent.contact_id == contact_id,
|
|
||||||
PDPLConsent.tenant_id == tenant_id,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
consents = [
|
consents = [
|
||||||
{"purpose": c.purpose, "channel": c.channel, "status": c.status,
|
{"purpose": c.purpose, "channel": c.channel, "status": c.status,
|
||||||
@ -112,241 +89,138 @@ class DataRightsHandler:
|
|||||||
"expires_at": c.expires_at.isoformat() if c.expires_at else None}
|
"expires_at": c.expires_at.isoformat() if c.expires_at else None}
|
||||||
for c in consents_q.scalars().all()
|
for c in consents_q.scalars().all()
|
||||||
]
|
]
|
||||||
|
msgs_q = await self.db.execute(select(Message).where(Message.lead_id == contact_id).limit(500))
|
||||||
# Messages
|
|
||||||
msgs_q = await self.db.execute(
|
|
||||||
select(Message).where(Message.lead_id == contact_id).limit(500)
|
|
||||||
)
|
|
||||||
messages = [
|
messages = [
|
||||||
{"channel": m.channel, "direction": m.direction,
|
{"channel": m.channel, "direction": m.direction, "content": m.content,
|
||||||
"content": m.content, "sent_at": m.sent_at.isoformat() if m.sent_at else None}
|
"sent_at": m.sent_at.isoformat() if m.sent_at else None}
|
||||||
for m in msgs_q.scalars().all()
|
for m in msgs_q.scalars().all()
|
||||||
]
|
]
|
||||||
|
logger.info("PDPL data export: contact=%s", contact_id)
|
||||||
personal = {
|
|
||||||
"name": lead.name,
|
|
||||||
"phone": lead.phone,
|
|
||||||
"email": lead.email,
|
|
||||||
"source": lead.source,
|
|
||||||
"status": lead.status,
|
|
||||||
"score": lead.score,
|
|
||||||
"notes": lead.notes,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("PDPL data export completed: contact=%s", contact_id)
|
|
||||||
return DataExport(
|
return DataExport(
|
||||||
contact_id=contact_id,
|
contact_id=contact_id,
|
||||||
personal_data=personal,
|
personal_data={"name": lead.name, "phone": lead.phone, "email": lead.email,
|
||||||
consents=consents,
|
"source": lead.source, "status": lead.status, "score": lead.score, "notes": lead.notes},
|
||||||
messages=messages,
|
consents=consents, messages=messages, exported_at=datetime.now(timezone.utc),
|
||||||
exported_at=datetime.now(timezone.utc),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- correction ----------------------------------------------------------
|
|
||||||
|
|
||||||
async def correct_data(self, data: CorrectionInput) -> CorrectionResult:
|
async def correct_data(self, data: CorrectionInput) -> CorrectionResult:
|
||||||
"""Update personal data fields with full audit trail."""
|
"""Update personal data fields with audit trail."""
|
||||||
|
|
||||||
lead = await self._get_lead(data.contact_id, data.tenant_id)
|
lead = await self._get_lead(data.contact_id, data.tenant_id)
|
||||||
allowed_fields = {"name", "phone", "email", "notes"}
|
allowed_fields = {"name", "phone", "email", "notes"}
|
||||||
previous: dict[str, Any] = {}
|
previous: dict[str, Any] = {}
|
||||||
updated_fields: list[str] = []
|
updated: list[str] = []
|
||||||
|
|
||||||
for field, new_val in data.corrections.items():
|
for field, new_val in data.corrections.items():
|
||||||
if field not in allowed_fields:
|
if field not in allowed_fields:
|
||||||
logger.warning("PDPL correction rejected for field=%s", field)
|
logger.warning("PDPL correction rejected for field=%s", field)
|
||||||
continue
|
continue
|
||||||
previous[field] = getattr(lead, field, None)
|
previous[field] = getattr(lead, field, None)
|
||||||
setattr(lead, field, new_val)
|
setattr(lead, field, new_val)
|
||||||
updated_fields.append(field)
|
updated.append(field)
|
||||||
|
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
|
self.db.add(DataRequest(
|
||||||
# Audit via data request record
|
contact_id=data.contact_id, tenant_id=data.tenant_id,
|
||||||
req = DataRequest(
|
request_type=DataRequestType.CORRECTION.value, status=DataRequestStatus.COMPLETED.value,
|
||||||
contact_id=data.contact_id,
|
requested_at=datetime.now(timezone.utc), completed_at=datetime.now(timezone.utc),
|
||||||
tenant_id=data.tenant_id,
|
response_data={"corrections": data.corrections, "previous": previous}, handled_by=data.actor_id,
|
||||||
request_type=DataRequestType.CORRECTION.value,
|
))
|
||||||
status=DataRequestStatus.COMPLETED.value,
|
|
||||||
requested_at=datetime.now(timezone.utc),
|
|
||||||
completed_at=datetime.now(timezone.utc),
|
|
||||||
response_data={"corrections": data.corrections, "previous": previous, "reason": data.reason},
|
|
||||||
handled_by=data.actor_id,
|
|
||||||
)
|
|
||||||
self.db.add(req)
|
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
|
logger.info("PDPL correction: contact=%s fields=%s", data.contact_id, updated)
|
||||||
|
return CorrectionResult(contact_id=data.contact_id, fields_updated=updated,
|
||||||
|
previous_values=previous, updated_at=datetime.now(timezone.utc))
|
||||||
|
|
||||||
logger.info("PDPL data correction: contact=%s fields=%s", data.contact_id, updated_fields)
|
async def delete_data(self, contact_id: UUID, tenant_id: UUID,
|
||||||
return CorrectionResult(
|
actor_id: Optional[UUID] = None) -> DeletionResult:
|
||||||
contact_id=data.contact_id,
|
"""Soft-delete contact; schedule hard-delete after 30 days."""
|
||||||
fields_updated=updated_fields,
|
|
||||||
previous_values=previous,
|
|
||||||
updated_at=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- deletion (right to erasure) ----------------------------------------
|
|
||||||
|
|
||||||
async def delete_data(self, contact_id: UUID, tenant_id: UUID, actor_id: Optional[UUID] = None) -> DeletionResult:
|
|
||||||
"""Soft-delete contact now; schedule hard-delete after 30 days."""
|
|
||||||
|
|
||||||
lead = await self._get_lead(contact_id, tenant_id)
|
lead = await self._get_lead(contact_id, tenant_id)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
hard_delete_at = now + timedelta(days=HARD_DELETE_DELAY_DAYS)
|
hard_at = now + timedelta(days=HARD_DELETE_DELAY_DAYS)
|
||||||
|
|
||||||
# Soft-delete: mark status and clear PII
|
|
||||||
lead.status = "deleted"
|
lead.status = "deleted"
|
||||||
lead.notes = f"[PDPL deletion requested {now.isoformat()}] " + (lead.notes or "")
|
lead.notes = f"[PDPL deletion {now.isoformat()}] " + (lead.notes or "")
|
||||||
lead.extra_metadata = {
|
lead.extra_metadata = {**(lead.extra_metadata or {}),
|
||||||
**(lead.extra_metadata or {}),
|
"_pdpl_soft_deleted": True, "_pdpl_hard_delete_at": hard_at.isoformat()}
|
||||||
"_pdpl_soft_deleted": True,
|
# Revoke active consents
|
||||||
"_pdpl_hard_delete_at": hard_delete_at.isoformat(),
|
cq = await self.db.execute(
|
||||||
}
|
select(PDPLConsent).where(PDPLConsent.contact_id == contact_id,
|
||||||
|
PDPLConsent.tenant_id == tenant_id, PDPLConsent.status == "granted")
|
||||||
# Revoke all active consents
|
|
||||||
consents_q = await self.db.execute(
|
|
||||||
select(PDPLConsent).where(
|
|
||||||
PDPLConsent.contact_id == contact_id,
|
|
||||||
PDPLConsent.tenant_id == tenant_id,
|
|
||||||
PDPLConsent.status == "granted",
|
|
||||||
)
|
)
|
||||||
)
|
for c in cq.scalars().all():
|
||||||
for consent in consents_q.scalars().all():
|
c.status = "revoked"
|
||||||
consent.status = "revoked"
|
c.revoked_at = now
|
||||||
consent.revoked_at = now
|
self.db.add(DataRequest(
|
||||||
|
contact_id=contact_id, tenant_id=tenant_id,
|
||||||
# Record the request
|
request_type=DataRequestType.DELETION.value, status=DataRequestStatus.PROCESSING.value,
|
||||||
req = DataRequest(
|
requested_at=now, response_data={"hard_delete_at": hard_at.isoformat()}, handled_by=actor_id,
|
||||||
contact_id=contact_id,
|
))
|
||||||
tenant_id=tenant_id,
|
|
||||||
request_type=DataRequestType.DELETION.value,
|
|
||||||
status=DataRequestStatus.PROCESSING.value,
|
|
||||||
requested_at=now,
|
|
||||||
response_data={"hard_delete_at": hard_delete_at.isoformat()},
|
|
||||||
handled_by=actor_id,
|
|
||||||
)
|
|
||||||
self.db.add(req)
|
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
|
logger.info("PDPL deletion: contact=%s hard_delete=%s", contact_id, hard_at)
|
||||||
logger.info("PDPL deletion scheduled: contact=%s hard_delete=%s", contact_id, hard_delete_at)
|
|
||||||
return DeletionResult(
|
return DeletionResult(
|
||||||
contact_id=contact_id,
|
contact_id=contact_id, status="soft_deleted", soft_deleted_at=now,
|
||||||
status="soft_deleted",
|
hard_delete_scheduled=hard_at,
|
||||||
soft_deleted_at=now,
|
message=f"Hard delete scheduled for {hard_at.date()}",
|
||||||
hard_delete_scheduled=hard_delete_at,
|
message_ar=f"الحذف النهائي مجدول بتاريخ {hard_at.date()}",
|
||||||
message=f"Contact soft-deleted. Hard delete scheduled for {hard_delete_at.date()}",
|
|
||||||
message_ar=f"تم حذف جهة الاتصال مبدئيًا. الحذف النهائي مجدول بتاريخ {hard_delete_at.date()}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- restriction ---------------------------------------------------------
|
async def restrict_processing(self, contact_id: UUID, tenant_id: UUID,
|
||||||
|
actor_id: Optional[UUID] = None) -> RestrictionResult:
|
||||||
async def restrict_processing(
|
"""Flag contact as restricted -- no outbound processing allowed."""
|
||||||
self, contact_id: UUID, tenant_id: UUID, actor_id: Optional[UUID] = None
|
|
||||||
) -> RestrictionResult:
|
|
||||||
"""Flag a contact as restricted -- no outbound processing allowed."""
|
|
||||||
|
|
||||||
lead = await self._get_lead(contact_id, tenant_id)
|
lead = await self._get_lead(contact_id, tenant_id)
|
||||||
lead.extra_metadata = {
|
lead.extra_metadata = {**(lead.extra_metadata or {}),
|
||||||
**(lead.extra_metadata or {}),
|
|
||||||
"_pdpl_restricted": True,
|
"_pdpl_restricted": True,
|
||||||
"_pdpl_restricted_at": datetime.now(timezone.utc).isoformat(),
|
"_pdpl_restricted_at": datetime.now(timezone.utc).isoformat()}
|
||||||
}
|
self.db.add(DataRequest(
|
||||||
|
contact_id=contact_id, tenant_id=tenant_id,
|
||||||
req = DataRequest(
|
request_type=DataRequestType.RESTRICTION.value, status=DataRequestStatus.COMPLETED.value,
|
||||||
contact_id=contact_id,
|
requested_at=datetime.now(timezone.utc), completed_at=datetime.now(timezone.utc),
|
||||||
tenant_id=tenant_id,
|
response_data={"restricted": True}, handled_by=actor_id,
|
||||||
request_type=DataRequestType.RESTRICTION.value,
|
))
|
||||||
status=DataRequestStatus.COMPLETED.value,
|
|
||||||
requested_at=datetime.now(timezone.utc),
|
|
||||||
completed_at=datetime.now(timezone.utc),
|
|
||||||
response_data={"restricted": True},
|
|
||||||
handled_by=actor_id,
|
|
||||||
)
|
|
||||||
self.db.add(req)
|
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
|
logger.info("PDPL restriction: contact=%s", contact_id)
|
||||||
logger.info("PDPL processing restricted: contact=%s", contact_id)
|
|
||||||
return RestrictionResult(
|
return RestrictionResult(
|
||||||
contact_id=contact_id,
|
contact_id=contact_id, restricted=True,
|
||||||
restricted=True,
|
message="Contact processing restricted per PDPL",
|
||||||
message="Contact processing restricted per PDPL request",
|
message_ar="تم تقييد معالجة بيانات جهة الاتصال وفقًا لنظام حماية البيانات",
|
||||||
message_ar="تم تقييد معالجة بيانات جهة الاتصال وفقًا لطلب نظام حماية البيانات",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- compliance report ---------------------------------------------------
|
|
||||||
|
|
||||||
async def generate_compliance_report(self, tenant_id: UUID) -> ComplianceReport:
|
async def generate_compliance_report(self, tenant_id: UUID) -> ComplianceReport:
|
||||||
"""Generate SDAIA-ready compliance report for a tenant."""
|
"""Generate SDAIA-ready compliance report."""
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# Consent counts
|
|
||||||
total = (await self.db.execute(
|
total = (await self.db.execute(
|
||||||
select(func.count()).where(PDPLConsent.tenant_id == tenant_id)
|
select(func.count()).where(PDPLConsent.tenant_id == tenant_id))).scalar() or 0
|
||||||
)).scalar() or 0
|
|
||||||
active = (await self.db.execute(
|
active = (await self.db.execute(
|
||||||
select(func.count()).where(PDPLConsent.tenant_id == tenant_id, PDPLConsent.status == "granted")
|
select(func.count()).where(PDPLConsent.tenant_id == tenant_id, PDPLConsent.status == "granted"))).scalar() or 0
|
||||||
)).scalar() or 0
|
|
||||||
revoked = (await self.db.execute(
|
revoked = (await self.db.execute(
|
||||||
select(func.count()).where(PDPLConsent.tenant_id == tenant_id, PDPLConsent.status == "revoked")
|
select(func.count()).where(PDPLConsent.tenant_id == tenant_id, PDPLConsent.status == "revoked"))).scalar() or 0
|
||||||
)).scalar() or 0
|
|
||||||
expired = (await self.db.execute(
|
expired = (await self.db.execute(
|
||||||
select(func.count()).where(PDPLConsent.tenant_id == tenant_id, PDPLConsent.status == "expired")
|
select(func.count()).where(PDPLConsent.tenant_id == tenant_id, PDPLConsent.status == "expired"))).scalar() or 0
|
||||||
)).scalar() or 0
|
|
||||||
|
|
||||||
# Data requests
|
|
||||||
pending = (await self.db.execute(
|
pending = (await self.db.execute(
|
||||||
select(func.count()).where(DataRequest.tenant_id == tenant_id, DataRequest.status == "pending")
|
select(func.count()).where(DataRequest.tenant_id == tenant_id, DataRequest.status == "pending"))).scalar() or 0
|
||||||
)).scalar() or 0
|
|
||||||
completed = (await self.db.execute(
|
completed = (await self.db.execute(
|
||||||
select(func.count()).where(DataRequest.tenant_id == tenant_id, DataRequest.status == "completed")
|
select(func.count()).where(DataRequest.tenant_id == tenant_id, DataRequest.status == "completed"))).scalar() or 0
|
||||||
)).scalar() or 0
|
|
||||||
|
|
||||||
# Breakdown by type
|
|
||||||
type_rows = (await self.db.execute(
|
type_rows = (await self.db.execute(
|
||||||
select(DataRequest.request_type, func.count())
|
select(DataRequest.request_type, func.count()).where(DataRequest.tenant_id == tenant_id)
|
||||||
.where(DataRequest.tenant_id == tenant_id)
|
.group_by(DataRequest.request_type))).all()
|
||||||
.group_by(DataRequest.request_type)
|
by_type = {r[0]: r[1] for r in type_rows}
|
||||||
)).all()
|
# Average resolution time
|
||||||
by_type = {row[0]: row[1] for row in type_rows}
|
|
||||||
|
|
||||||
# Avg resolution time
|
|
||||||
avg_hours: Optional[float] = None
|
avg_hours: Optional[float] = None
|
||||||
completed_reqs = (await self.db.execute(
|
done = (await self.db.execute(
|
||||||
select(DataRequest).where(
|
select(DataRequest).where(DataRequest.tenant_id == tenant_id, DataRequest.status == "completed",
|
||||||
DataRequest.tenant_id == tenant_id,
|
DataRequest.completed_at.isnot(None)).limit(500))).scalars().all()
|
||||||
DataRequest.status == "completed",
|
if done:
|
||||||
DataRequest.completed_at.isnot(None),
|
deltas = [(r.completed_at - r.requested_at).total_seconds() / 3600
|
||||||
).limit(500)
|
for r in done if r.completed_at and r.requested_at]
|
||||||
)).scalars().all()
|
|
||||||
if completed_reqs:
|
|
||||||
deltas = [
|
|
||||||
(r.completed_at - r.requested_at).total_seconds() / 3600
|
|
||||||
for r in completed_reqs if r.completed_at and r.requested_at
|
|
||||||
]
|
|
||||||
avg_hours = round(sum(deltas) / len(deltas), 2) if deltas else None
|
avg_hours = round(sum(deltas) / len(deltas), 2) if deltas else None
|
||||||
|
logger.info("PDPL report generated: tenant=%s", tenant_id)
|
||||||
logger.info("PDPL compliance report generated: tenant=%s", tenant_id)
|
|
||||||
return ComplianceReport(
|
return ComplianceReport(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id, generated_at=now, total_consents=total,
|
||||||
generated_at=now,
|
active_consents=active, revoked_consents=revoked, expired_consents=expired,
|
||||||
total_consents=total,
|
pending_requests=pending, completed_requests=completed,
|
||||||
active_consents=active,
|
requests_by_type=by_type, avg_resolution_hours=avg_hours, violations_detected=0,
|
||||||
revoked_consents=revoked,
|
|
||||||
expired_consents=expired,
|
|
||||||
pending_requests=pending,
|
|
||||||
completed_requests=completed,
|
|
||||||
requests_by_type=by_type,
|
|
||||||
avg_resolution_hours=avg_hours,
|
|
||||||
violations_detected=0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- private helpers -----------------------------------------------------
|
|
||||||
|
|
||||||
async def _get_lead(self, contact_id: UUID, tenant_id: UUID) -> Lead:
|
async def _get_lead(self, contact_id: UUID, tenant_id: UUID) -> Lead:
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(Lead).where(Lead.id == contact_id, Lead.tenant_id == tenant_id)
|
select(Lead).where(Lead.id == contact_id, Lead.tenant_id == tenant_id))
|
||||||
)
|
|
||||||
lead = result.scalar_one_or_none()
|
lead = result.scalar_one_or_none()
|
||||||
if not lead:
|
if not lead:
|
||||||
raise ValueError("جهة الاتصال غير موجودة") # Contact not found
|
raise ValueError("جهة الاتصال غير موجودة")
|
||||||
return lead
|
return lead
|
||||||
|
|||||||
281
salesflow-saas/backend/app/services/territory_manager.py
Normal file
281
salesflow-saas/backend/app/services/territory_manager.py
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
"""
|
||||||
|
Dealix Saudi Territory Manager
|
||||||
|
إدارة المناطق وتوزيع العملاء على مندوبي المبيعات تلقائياً
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select, func, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.lead import Lead
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.deal import Deal
|
||||||
|
|
||||||
|
logger = logging.getLogger("dealix.territory")
|
||||||
|
|
||||||
|
SAUDI_REGIONS: dict[str, dict] = {
|
||||||
|
"riyadh": {
|
||||||
|
"name_ar": "الرياض",
|
||||||
|
"name_en": "Riyadh",
|
||||||
|
"cities_ar": ["الرياض", "الخرج", "الدرعية", "المجمعة"],
|
||||||
|
},
|
||||||
|
"jeddah": {
|
||||||
|
"name_ar": "جدة",
|
||||||
|
"name_en": "Jeddah",
|
||||||
|
"cities_ar": ["جدة", "رابغ", "الليث"],
|
||||||
|
},
|
||||||
|
"eastern": {
|
||||||
|
"name_ar": "المنطقة الشرقية",
|
||||||
|
"name_en": "Eastern Province",
|
||||||
|
"cities_ar": ["الدمام", "الخبر", "الظهران", "الجبيل", "الأحساء", "القطيف"],
|
||||||
|
},
|
||||||
|
"makkah": {
|
||||||
|
"name_ar": "مكة المكرمة",
|
||||||
|
"name_en": "Makkah",
|
||||||
|
"cities_ar": ["مكة المكرمة", "الطائف"],
|
||||||
|
},
|
||||||
|
"madinah": {
|
||||||
|
"name_ar": "المدينة المنورة",
|
||||||
|
"name_en": "Madinah",
|
||||||
|
"cities_ar": ["المدينة المنورة", "ينبع"],
|
||||||
|
},
|
||||||
|
"asir": {
|
||||||
|
"name_ar": "عسير",
|
||||||
|
"name_en": "Asir",
|
||||||
|
"cities_ar": ["أبها", "خميس مشيط", "النماص"],
|
||||||
|
},
|
||||||
|
"qassim": {
|
||||||
|
"name_ar": "القصيم",
|
||||||
|
"name_en": "Qassim",
|
||||||
|
"cities_ar": ["بريدة", "عنيزة", "الرس"],
|
||||||
|
},
|
||||||
|
"tabuk": {
|
||||||
|
"name_ar": "تبوك",
|
||||||
|
"name_en": "Tabuk",
|
||||||
|
"cities_ar": ["تبوك", "ضبا", "الوجه"],
|
||||||
|
},
|
||||||
|
"hail": {
|
||||||
|
"name_ar": "حائل",
|
||||||
|
"name_en": "Hail",
|
||||||
|
"cities_ar": ["حائل", "بقعاء"],
|
||||||
|
},
|
||||||
|
"jazan": {
|
||||||
|
"name_ar": "جازان",
|
||||||
|
"name_en": "Jazan",
|
||||||
|
"cities_ar": ["جازان", "صبيا", "أبو عريش"],
|
||||||
|
},
|
||||||
|
"najran": {
|
||||||
|
"name_ar": "نجران",
|
||||||
|
"name_en": "Najran",
|
||||||
|
"cities_ar": ["نجران", "شرورة"],
|
||||||
|
},
|
||||||
|
"baha": {
|
||||||
|
"name_ar": "الباحة",
|
||||||
|
"name_en": "Al Baha",
|
||||||
|
"cities_ar": ["الباحة", "بلجرشي"],
|
||||||
|
},
|
||||||
|
"jouf": {
|
||||||
|
"name_ar": "الجوف",
|
||||||
|
"name_en": "Al Jouf",
|
||||||
|
"cities_ar": ["سكاكا", "دومة الجندل"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TerritoryAssignment(BaseModel):
|
||||||
|
territory_key: str
|
||||||
|
rep_ids: list[str] = Field(default_factory=list)
|
||||||
|
round_robin_index: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TerritoryStats(BaseModel):
|
||||||
|
territory_key: str
|
||||||
|
name_ar: str
|
||||||
|
name_en: str
|
||||||
|
total_leads: int = 0
|
||||||
|
total_deals: int = 0
|
||||||
|
total_value: float = 0.0
|
||||||
|
win_rate: float = 0.0
|
||||||
|
reps_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TerritoryManager:
|
||||||
|
"""Territory-based lead routing and performance analytics for Saudi regions."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self._assignments: dict[str, TerritoryAssignment] = {}
|
||||||
|
|
||||||
|
async def assign_territory(
|
||||||
|
self, territory_key: str, rep_ids: list[str],
|
||||||
|
) -> dict:
|
||||||
|
"""Assign sales reps to a territory."""
|
||||||
|
if territory_key not in SAUDI_REGIONS:
|
||||||
|
raise ValueError(f"منطقة غير معروفة: {territory_key}")
|
||||||
|
|
||||||
|
self._assignments[territory_key] = TerritoryAssignment(
|
||||||
|
territory_key=territory_key,
|
||||||
|
rep_ids=rep_ids,
|
||||||
|
round_robin_index=0,
|
||||||
|
)
|
||||||
|
region = SAUDI_REGIONS[territory_key]
|
||||||
|
logger.info(
|
||||||
|
"Territory '%s' assigned to %d reps", territory_key, len(rep_ids),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"territory": territory_key,
|
||||||
|
"name_ar": region["name_ar"],
|
||||||
|
"name_en": region["name_en"],
|
||||||
|
"reps_assigned": len(rep_ids),
|
||||||
|
"rep_ids": rep_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def auto_route_lead(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
lead_id: str,
|
||||||
|
region_key: Optional[str] = None,
|
||||||
|
city_hint: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Auto-assign a lead to the next rep in the matching territory via round-robin."""
|
||||||
|
territory_key = region_key
|
||||||
|
if not territory_key and city_hint:
|
||||||
|
territory_key = self._detect_territory(city_hint)
|
||||||
|
if not territory_key:
|
||||||
|
territory_key = "riyadh"
|
||||||
|
|
||||||
|
assignment = self._assignments.get(territory_key)
|
||||||
|
if not assignment or not assignment.rep_ids:
|
||||||
|
logger.warning(
|
||||||
|
"No reps assigned to territory '%s', falling back to riyadh",
|
||||||
|
territory_key,
|
||||||
|
)
|
||||||
|
assignment = self._assignments.get("riyadh")
|
||||||
|
territory_key = "riyadh"
|
||||||
|
|
||||||
|
if not assignment or not assignment.rep_ids:
|
||||||
|
return {
|
||||||
|
"lead_id": lead_id,
|
||||||
|
"assigned_to": None,
|
||||||
|
"territory": territory_key,
|
||||||
|
"error_ar": "لا يوجد مندوبين معينين لهذه المنطقة",
|
||||||
|
}
|
||||||
|
|
||||||
|
rep_id = assignment.rep_ids[assignment.round_robin_index % len(assignment.rep_ids)]
|
||||||
|
assignment.round_robin_index += 1
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Lead).where(
|
||||||
|
Lead.id == uuid.UUID(lead_id),
|
||||||
|
Lead.tenant_id == uuid.UUID(tenant_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lead = result.scalar_one_or_none()
|
||||||
|
if lead:
|
||||||
|
lead.assigned_to = uuid.UUID(rep_id)
|
||||||
|
metadata = dict(lead.extra_metadata or {})
|
||||||
|
metadata["territory"] = territory_key
|
||||||
|
metadata["auto_routed_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
lead.extra_metadata = metadata
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
region = SAUDI_REGIONS.get(territory_key, {})
|
||||||
|
logger.info("Lead %s routed to rep %s in %s", lead_id, rep_id, territory_key)
|
||||||
|
return {
|
||||||
|
"lead_id": lead_id,
|
||||||
|
"assigned_to": rep_id,
|
||||||
|
"territory": territory_key,
|
||||||
|
"territory_name_ar": region.get("name_ar", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_territory_stats(
|
||||||
|
self, tenant_id: str, territory_key: Optional[str] = None,
|
||||||
|
) -> list[TerritoryStats]:
|
||||||
|
"""Get performance analytics per territory."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
keys = [territory_key] if territory_key else list(SAUDI_REGIONS.keys())
|
||||||
|
stats_list: list[TerritoryStats] = []
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
region = SAUDI_REGIONS.get(key)
|
||||||
|
if not region:
|
||||||
|
continue
|
||||||
|
|
||||||
|
assignment = self._assignments.get(key)
|
||||||
|
rep_ids = assignment.rep_ids if assignment else []
|
||||||
|
|
||||||
|
if not rep_ids:
|
||||||
|
stats_list.append(TerritoryStats(
|
||||||
|
territory_key=key,
|
||||||
|
name_ar=region["name_ar"],
|
||||||
|
name_en=region["name_en"],
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
rep_uuids = [uuid.UUID(r) for r in rep_ids]
|
||||||
|
tid = uuid.UUID(tenant_id)
|
||||||
|
|
||||||
|
lead_count_q = select(func.count()).where(
|
||||||
|
Lead.tenant_id == tid,
|
||||||
|
Lead.assigned_to.in_(rep_uuids),
|
||||||
|
)
|
||||||
|
total_leads = (await self.db.execute(lead_count_q)).scalar() or 0
|
||||||
|
|
||||||
|
deals_q = select(func.count(), func.coalesce(func.sum(Deal.value), 0)).where(
|
||||||
|
Deal.tenant_id == tid,
|
||||||
|
Deal.assigned_to.in_(rep_uuids),
|
||||||
|
)
|
||||||
|
row = (await self.db.execute(deals_q)).one_or_none()
|
||||||
|
total_deals = row[0] if row else 0
|
||||||
|
total_value = float(row[1]) if row else 0.0
|
||||||
|
|
||||||
|
won_q = select(func.count()).where(
|
||||||
|
Deal.tenant_id == tid,
|
||||||
|
Deal.assigned_to.in_(rep_uuids),
|
||||||
|
Deal.stage == "closed_won",
|
||||||
|
)
|
||||||
|
won_count = (await self.db.execute(won_q)).scalar() or 0
|
||||||
|
win_rate = round((won_count / total_deals) * 100, 1) if total_deals > 0 else 0.0
|
||||||
|
|
||||||
|
stats_list.append(TerritoryStats(
|
||||||
|
territory_key=key,
|
||||||
|
name_ar=region["name_ar"],
|
||||||
|
name_en=region["name_en"],
|
||||||
|
total_leads=total_leads,
|
||||||
|
total_deals=total_deals,
|
||||||
|
total_value=total_value,
|
||||||
|
win_rate=win_rate,
|
||||||
|
reps_count=len(rep_ids),
|
||||||
|
))
|
||||||
|
|
||||||
|
return stats_list
|
||||||
|
|
||||||
|
def list_regions(self) -> list[dict]:
|
||||||
|
"""Return all Saudi regions with metadata."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"name_ar": info["name_ar"],
|
||||||
|
"name_en": info["name_en"],
|
||||||
|
"cities_ar": info["cities_ar"],
|
||||||
|
"reps_assigned": len(self._assignments.get(key, TerritoryAssignment(territory_key=key)).rep_ids),
|
||||||
|
}
|
||||||
|
for key, info in SAUDI_REGIONS.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
def _detect_territory(self, city_hint: str) -> Optional[str]:
|
||||||
|
"""Detect territory from a city name hint (Arabic or English)."""
|
||||||
|
hint_lower = city_hint.strip().lower()
|
||||||
|
for key, info in SAUDI_REGIONS.items():
|
||||||
|
if hint_lower in info["name_en"].lower() or hint_lower == key:
|
||||||
|
return key
|
||||||
|
for city in info["cities_ar"]:
|
||||||
|
if city in city_hint:
|
||||||
|
return key
|
||||||
|
return None
|
||||||
118
salesflow-saas/seeds/contracting_template.json
Normal file
118
salesflow-saas/seeds/contracting_template.json
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"industry": "contracting",
|
||||||
|
"name": "Contracting & Services",
|
||||||
|
"name_ar": "مقاولات وخدمات",
|
||||||
|
"pipeline_stages": [
|
||||||
|
{"key": "inquiry", "name_en": "Inquiry", "name_ar": "استفسار", "order": 1, "probability": 10},
|
||||||
|
{"key": "site_visit", "name_en": "Site Visit", "name_ar": "زيارة موقع", "order": 2, "probability": 25},
|
||||||
|
{"key": "quotation", "name_en": "Quotation", "name_ar": "عرض سعر", "order": 3, "probability": 40},
|
||||||
|
{"key": "negotiation", "name_en": "Negotiation", "name_ar": "تفاوض", "order": 4, "probability": 60},
|
||||||
|
{"key": "contract", "name_en": "Contract", "name_ar": "عقد", "order": 5, "probability": 80},
|
||||||
|
{"key": "execution", "name_en": "Execution", "name_ar": "تنفيذ", "order": 6, "probability": 90},
|
||||||
|
{"key": "completion", "name_en": "Completion", "name_ar": "إنجاز", "order": 7, "probability": 100}
|
||||||
|
],
|
||||||
|
"message_templates": [
|
||||||
|
{
|
||||||
|
"name": "welcome",
|
||||||
|
"name_ar": "رسالة ترحيب",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "lead_created",
|
||||||
|
"content_ar": "أهلاً {name}! شكراً لتواصلك مع {company}. متخصصين في أعمال المقاولات والصيانة. وش نوع المشروع اللي تحتاجه؟",
|
||||||
|
"content_en": "Hello {name}! Thank you for contacting {company}. We specialize in contracting and maintenance. What type of project do you need?",
|
||||||
|
"delay_minutes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "site_visit_confirmation",
|
||||||
|
"name_ar": "تأكيد زيارة الموقع",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "stage_change_site_visit",
|
||||||
|
"content_ar": "مرحباً {name}، تم تحديد موعد زيارة الموقع يوم {date} الساعة {time}. فريقنا الفني بيكون موجود للمعاينة وأخذ المقاسات. الموقع: {location}",
|
||||||
|
"content_en": "Hi {name}, site visit confirmed for {date} at {time}. Our technical team will be there for inspection. Location: {location}",
|
||||||
|
"delay_minutes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "quotation_ready",
|
||||||
|
"name_ar": "عرض السعر جاهز",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "stage_change_quotation",
|
||||||
|
"content_ar": "مرحباً {name}، عرض السعر جاهز لمشروعك. الإجمالي: {total_amount} ريال شامل الضريبة. يشمل المواد والعمالة والضمان. نرسل لك التفاصيل الكاملة؟",
|
||||||
|
"content_en": "Hi {name}, your quotation is ready. Total: {total_amount} SAR including VAT. Includes materials, labor, and warranty. Shall we send the full details?",
|
||||||
|
"delay_minutes": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_response_followup",
|
||||||
|
"name_ar": "متابعة عدم الرد",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "no_response",
|
||||||
|
"content_ar": "مرحباً {name}، تواصلنا معك بخصوص مشروع {project_type}. إذا عندك أي استفسار أو تبي تعدّل على العرض، لا تتردد تراسلنا. نحن هنا لخدمتك.",
|
||||||
|
"content_en": "Hi {name}, we reached out regarding your {project_type} project. If you have questions or want to modify the quote, don't hesitate to reach out.",
|
||||||
|
"delay_minutes": 4320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "execution_update",
|
||||||
|
"name_ar": "تحديث التنفيذ",
|
||||||
|
"channel": "whatsapp",
|
||||||
|
"trigger": "stage_change_execution",
|
||||||
|
"content_ar": "مرحباً {name}، نبشرك إن العمل بدأ في مشروعك. المدة المتوقعة: {duration}. بنرسل لك تحديثات دورية عن سير العمل. لأي استفسار تواصل معنا.",
|
||||||
|
"content_en": "Hi {name}, work has started on your project. Expected duration: {duration}. We'll send periodic progress updates.",
|
||||||
|
"delay_minutes": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"proposal_templates": [
|
||||||
|
{
|
||||||
|
"name": "project_quotation",
|
||||||
|
"name_ar": "عرض سعر مشروع",
|
||||||
|
"sections": [
|
||||||
|
{"title_ar": "نطاق العمل", "title_en": "Scope of Work"},
|
||||||
|
{"title_ar": "المواد والمواصفات", "title_en": "Materials & Specifications"},
|
||||||
|
{"title_ar": "الجدول الزمني", "title_en": "Timeline"},
|
||||||
|
{"title_ar": "التكلفة التفصيلية", "title_en": "Detailed Cost Breakdown"},
|
||||||
|
{"title_ar": "شروط الدفع", "title_en": "Payment Terms"},
|
||||||
|
{"title_ar": "الضمان", "title_en": "Warranty"},
|
||||||
|
{"title_ar": "الشروط والأحكام", "title_en": "Terms & Conditions"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maintenance_contract",
|
||||||
|
"name_ar": "عقد صيانة",
|
||||||
|
"sections": [
|
||||||
|
{"title_ar": "نطاق خدمات الصيانة", "title_en": "Maintenance Scope"},
|
||||||
|
{"title_ar": "جدول الزيارات الدورية", "title_en": "Periodic Visit Schedule"},
|
||||||
|
{"title_ar": "قطع الغيار المشمولة", "title_en": "Included Spare Parts"},
|
||||||
|
{"title_ar": "الرسوم السنوية", "title_en": "Annual Fees"},
|
||||||
|
{"title_ar": "مدة العقد والتجديد", "title_en": "Contract Duration & Renewal"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workflow_templates": [
|
||||||
|
{
|
||||||
|
"name": "new_project_flow",
|
||||||
|
"name_ar": "تدفق مشروع جديد",
|
||||||
|
"trigger": "lead_created",
|
||||||
|
"actions": [
|
||||||
|
{"type": "send_message", "template": "welcome", "delay_minutes": 0},
|
||||||
|
{"type": "create_task", "subject": "اتصل بالعميل وحدد نوع المشروع", "delay_minutes": 15},
|
||||||
|
{"type": "send_message", "template": "no_response_followup", "delay_minutes": 4320, "condition": "no_response"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "site_visit_flow",
|
||||||
|
"name_ar": "تدفق زيارة الموقع",
|
||||||
|
"trigger": "stage_change_site_visit",
|
||||||
|
"actions": [
|
||||||
|
{"type": "send_message", "template": "site_visit_confirmation", "delay_minutes": 0},
|
||||||
|
{"type": "create_task", "subject": "تجهيز فريق المعاينة الفنية", "delay_minutes": 60},
|
||||||
|
{"type": "create_task", "subject": "إعداد عرض السعر بعد المعاينة", "delay_minutes": 1440}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "execution_monitoring_flow",
|
||||||
|
"name_ar": "تدفق متابعة التنفيذ",
|
||||||
|
"trigger": "stage_change_execution",
|
||||||
|
"actions": [
|
||||||
|
{"type": "send_message", "template": "execution_update", "delay_minutes": 0},
|
||||||
|
{"type": "create_task", "subject": "تحديث العميل بتقرير أسبوعي", "delay_minutes": 10080}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user