Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/api/v1/crm.py
2026-07-31 13:33:00 +00:00

3475 lines
133 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# noqa: D100
"""
API endpoints برای CRM: فرایندها، مراحل، سرنخ، فرصت فروش، فعالیت
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Path, Body, Query, Request
from sqlalchemy.orm import Session, joinedload, selectinload
from sqlalchemy import and_, or_, text
from adapters.db.session import get_db
from adapters.db.models.crm import (
CrmProcessDefinition,
CrmProcessStage,
Lead,
Deal,
CrmActivity,
CrmChangeHistory,
CrmSequence,
CrmSequenceStep,
CrmSequenceEnrollment,
)
from adapters.api.v1.schema_models.crm import (
CrmProcessDefinitionCreate,
CrmProcessDefinitionUpdate,
CrmProcessDefinitionResponse,
CrmProcessStageCreate,
CrmProcessStageUpdate,
CrmProcessStageResponse,
LeadCreate,
LeadUpdate,
LeadResponse,
LeadConvertRequest,
DealCreate,
DealUpdate,
DealResponse,
CrmActivityCreate,
CrmActivityUpdate,
CrmActivityResponse,
CrmNoteTypeCreate,
CrmNoteTypeUpdate,
CrmNoteCreate,
CrmNoteUpdate,
CrmNoteCommentCreate,
CrmTagCreate,
CrmTagUpdate,
CrmSetTagsRequest,
CrmCloseReasonCreate,
CrmCloseReasonUpdate,
CrmCustomFieldCreate,
CrmCustomFieldUpdate,
CrmDealLinesReplaceRequest,
CrmSequenceCreate,
CrmSequenceUpdate,
CrmSequenceEnrollRequest,
CrmDealConvertToInvoiceRequest,
CrmAutomationSettingsUpdate,
)
from app.services import crm_tag_service
from app.services import crm_close_reason_service
from app.services import crm_custom_field_service
from app.services import crm_deal_line_service
from app.services import crm_customer_360_service
from app.services import crm_automation_service
from adapters.db.models.person import Person, PersonType
from adapters.api.v1.schema_models.person import PersonCreateRequest
from app.services.person_service import create_person
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access, require_business_permission_dep
from app.core.responses import success_response, format_datetime_fields, ApiError
from app.services import crm_calendar_note_service as crm_cal_notes
router = APIRouter(prefix="/crm", tags=["CRM"])
@router.get(
"/businesses/{business_id}/summary",
summary="خلاصه CRM",
description="تعداد سرنخ‌ها، فرصت‌های فروش، مبلغ کل و نرخ تبدیل",
)
@require_business_access("business_id")
async def get_crm_summary(
request: Request,
business_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
row = db.execute(
text(
"""
SELECT
(SELECT COUNT(*) FROM crm_leads WHERE business_id = :bid) AS total_leads,
(SELECT COUNT(*) FROM crm_leads WHERE business_id = :bid AND person_id IS NOT NULL) AS converted_leads,
(SELECT COUNT(*) FROM crm_deals WHERE business_id = :bid) AS total_deals,
(SELECT COALESCE(SUM(amount), 0) FROM crm_deals WHERE business_id = :bid) AS total_deals_amount,
(SELECT COUNT(*) FROM crm_deals WHERE business_id = :bid AND closed_at IS NOT NULL) AS closed_deals
"""
),
{"bid": business_id},
).mappings().first()
total_leads = int(row["total_leads"] or 0) if row else 0
converted_leads = int(row["converted_leads"] or 0) if row else 0
total_deals = int(row["total_deals"] or 0) if row else 0
deals_amount = float(row["total_deals_amount"] or 0) if row else 0.0
closed_deals = int(row["closed_deals"] or 0) if row else 0
conversion_rate = (converted_leads / total_leads * 100) if total_leads else 0
data = {
"total_leads": total_leads,
"converted_leads": converted_leads,
"conversion_rate": round(conversion_rate, 1),
"total_deals": total_deals,
"closed_deals": closed_deals,
"total_deals_amount": float(deals_amount),
}
return success_response(data=data, request=request)
@router.get(
"/businesses/{business_id}/follow-ups-today",
summary="پیگیری‌های امروز",
description="سرنخ‌ها و فرصت‌های فروش با یادآور پیگیری در امروز یا روزهای آینده (تا ۷ روز)",
)
@require_business_access("business_id")
async def get_follow_ups_today(
request: Request,
business_id: int = Path(..., gt=0),
days_ahead: int = Query(7, ge=0, le=30, description="تعداد روزهای آینده"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
# بازهٔ «امروز» بر اساس تقویم محلی ایران (نه نیمه‌شب UTC)، سپس تبدیل به UTC نَیْو برای مقایسه با DB
tz = ZoneInfo("Asia/Tehran")
utc = timezone.utc
now_local = datetime.now(tz)
start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
end_local = start_local + timedelta(days=days_ahead)
start = start_local.astimezone(utc).replace(tzinfo=None)
end = end_local.astimezone(utc).replace(tzinfo=None)
current_user_id = ctx.get_user_id()
restrict_assignee = bool(
current_user_id and not (ctx.is_superadmin() or ctx.is_business_owner(business_id))
)
q_leads = (
db.query(Lead)
.options(
selectinload(Lead.stage),
selectinload(Lead.assigned_to),
selectinload(Lead.person),
selectinload(Lead.created_by),
)
.filter(
Lead.business_id == business_id,
Lead.next_follow_up_at.isnot(None),
Lead.next_follow_up_at >= start,
Lead.next_follow_up_at <= end,
Lead.person_id.is_(None),
)
)
if restrict_assignee:
q_leads = q_leads.filter(or_(Lead.assigned_to_user_id.is_(None), Lead.assigned_to_user_id == current_user_id))
q_leads = q_leads.order_by(Lead.next_follow_up_at).limit(50)
q_deals = (
db.query(Deal)
.options(
selectinload(Deal.stage),
selectinload(Deal.assigned_to),
selectinload(Deal.person),
selectinload(Deal.created_by),
)
.filter(
Deal.business_id == business_id,
Deal.closed_at.is_(None),
Deal.next_follow_up_at.isnot(None),
Deal.next_follow_up_at >= start,
Deal.next_follow_up_at <= end,
)
)
if restrict_assignee:
q_deals = q_deals.filter(or_(Deal.assigned_to_user_id.is_(None), Deal.assigned_to_user_id == current_user_id))
q_deals = q_deals.order_by(Deal.next_follow_up_at).limit(50)
leads = q_leads.all()
deals = q_deals.all()
data = {
"leads": [_lead_to_dict(l, request) for l in leads],
"deals": [_deal_to_dict(d, request) for d in deals],
}
return success_response(data=data, request=request)
@router.get(
"/businesses/{business_id}/reports/pipeline",
summary="گزارش پایپلاین فروش",
description="تعداد و مبلغ فرصت‌ها به تفکیک مرحله. با from_date و to_date فقط فرصت‌های ایجادشده در این بازه شمرده می‌شوند.",
)
@require_business_access("business_id")
async def get_pipeline_report(
request: Request,
business_id: int = Path(..., gt=0),
process_definition_id: Optional[int] = Query(None, description="شناسه فرایند پایپلاین"),
from_date: Optional[str] = Query(None, description="از تاریخ (YYYY-MM-DD) - فیلتر بر اساس created_at"),
to_date: Optional[str] = Query(None, description="تا تاریخ (YYYY-MM-DD)"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from sqlalchemy import func
from datetime import datetime as dt
deal_filter = and_(Deal.stage_id == CrmProcessStage.id, Deal.business_id == business_id)
if from_date:
try:
fd = dt.strptime(from_date, "%Y-%m-%d").date()
deal_filter = and_(deal_filter, func.date(Deal.created_at) >= fd)
except ValueError:
pass
if to_date:
try:
td = dt.strptime(to_date, "%Y-%m-%d").date()
deal_filter = and_(deal_filter, func.date(Deal.created_at) <= td)
except ValueError:
pass
q = db.query(
CrmProcessStage.id,
CrmProcessStage.name,
CrmProcessStage.order_index,
func.count(Deal.id).label("deal_count"),
func.coalesce(func.sum(Deal.amount), 0).label("total_amount"),
).outerjoin(Deal, deal_filter)
q = q.join(CrmProcessDefinition, CrmProcessDefinition.id == CrmProcessStage.process_definition_id)
q = q.filter(
CrmProcessDefinition.business_id == business_id,
CrmProcessDefinition.process_type == "sales_pipeline",
)
if process_definition_id:
q = q.filter(CrmProcessDefinition.id == process_definition_id)
q = q.group_by(CrmProcessStage.id, CrmProcessStage.name, CrmProcessStage.order_index)
q = q.order_by(CrmProcessStage.order_index)
rows = q.all()
data = [
{
"stage_id": r.id,
"stage_name": r.name,
"order_index": r.order_index,
"deal_count": r.deal_count,
"total_amount": float(r.total_amount or 0),
}
for r in rows
]
return success_response(data=data, request=request)
@router.get(
"/businesses/{business_id}/reports/lead-funnel",
summary="گزارش قیف سرنخ",
description="تعداد سرنخ به تفکیک مرحله. با from_date و to_date فقط سرنخ‌های ایجادشده در این بازه شمرده می‌شوند.",
)
@require_business_access("business_id")
async def get_lead_funnel_report(
request: Request,
business_id: int = Path(..., gt=0),
process_definition_id: Optional[int] = Query(None),
from_date: Optional[str] = Query(None, description="از تاریخ (YYYY-MM-DD)"),
to_date: Optional[str] = Query(None, description="تا تاریخ (YYYY-MM-DD)"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from sqlalchemy import func
from datetime import datetime as dt
lead_filter = and_(Lead.stage_id == CrmProcessStage.id, Lead.business_id == business_id)
if from_date:
try:
fd = dt.strptime(from_date, "%Y-%m-%d").date()
lead_filter = and_(lead_filter, func.date(Lead.created_at) >= fd)
except ValueError:
pass
if to_date:
try:
td = dt.strptime(to_date, "%Y-%m-%d").date()
lead_filter = and_(lead_filter, func.date(Lead.created_at) <= td)
except ValueError:
pass
q = db.query(
CrmProcessStage.id,
CrmProcessStage.name,
CrmProcessStage.order_index,
func.count(Lead.id).label("lead_count"),
).outerjoin(Lead, lead_filter)
q = q.join(CrmProcessDefinition, CrmProcessDefinition.id == CrmProcessStage.process_definition_id)
q = q.filter(
CrmProcessDefinition.business_id == business_id,
CrmProcessDefinition.process_type == "lead_funnel",
)
if process_definition_id:
q = q.filter(CrmProcessDefinition.id == process_definition_id)
q = q.group_by(CrmProcessStage.id, CrmProcessStage.name, CrmProcessStage.order_index)
q = q.order_by(CrmProcessStage.order_index)
rows = q.all()
data = [
{"stage_id": r.id, "stage_name": r.name, "order_index": r.order_index, "lead_count": r.lead_count}
for r in rows
]
return success_response(data=data, request=request)
@router.get(
"/businesses/{business_id}/reports/lead-sources",
summary="گزارش منبع سرنخ",
description="تعداد سرنخ به تفکیک منبع",
)
@require_business_access("business_id")
async def get_lead_sources_report(
request: Request,
business_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from sqlalchemy import func
q = (
db.query(Lead.source_code, func.count(Lead.id).label("count"))
.filter(Lead.business_id == business_id, Lead.source_code.isnot(None), Lead.source_code != "")
.group_by(Lead.source_code)
)
rows = q.all()
data = [{"source_code": r.source_code, "count": r.count} for r in rows]
return success_response(data=data, request=request)
@router.get(
"/businesses/{business_id}/reports/weighted-forecast",
summary="پیش‌بینی درآمد (مبلغ موزون)",
description="مجموع مبلغ فرصت‌های باز ضرب در احتمال موفقیت (پیش‌بینی درآمد مورد انتظار)",
)
@require_business_access("business_id")
async def get_weighted_forecast_report(
request: Request,
business_id: int = Path(..., gt=0),
process_definition_id: Optional[int] = Query(None, description="فیلتر پایپلاین"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from sqlalchemy import func
from sqlalchemy.sql import case
q = (
db.query(
func.coalesce(
func.sum(Deal.amount * func.coalesce(Deal.probability_percent, 50) / 100),
0,
).label("weighted_total"),
func.count(Deal.id).label("deal_count"),
func.coalesce(func.sum(Deal.amount), 0).label("total_amount"),
)
.filter(Deal.business_id == business_id, Deal.closed_at.is_(None))
)
if process_definition_id:
q = q.filter(Deal.process_definition_id == process_definition_id)
row = q.first()
data = {
"weighted_total": float(row.weighted_total or 0),
"total_amount": float(row.total_amount or 0),
"deal_count": row.deal_count or 0,
}
return success_response(data=data, request=request)
@router.get(
"/businesses/{business_id}/reports/employee-performance",
summary="گزارش عملکرد کارمندان",
description="مقایسه عملکرد کارمندان بر اساس سرنخ، فرصت فروش و فعالیت",
)
@require_business_access("business_id")
async def get_employee_performance_report(
request: Request,
business_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
user_ids: set[int] = set()
for (uid,) in (
db.query(Lead.assigned_to_user_id)
.filter(Lead.business_id == business_id, Lead.assigned_to_user_id.isnot(None))
.distinct()
.all()
):
if uid:
user_ids.add(int(uid))
for (uid,) in (
db.query(Deal.assigned_to_user_id)
.filter(Deal.business_id == business_id, Deal.assigned_to_user_id.isnot(None))
.distinct()
.all()
):
if uid:
user_ids.add(int(uid))
for (uid,) in db.query(CrmActivity.created_by_user_id).filter(CrmActivity.business_id == business_id).distinct().all():
user_ids.add(int(uid))
restricted_to_self = False
current_user_id = ctx.get_user_id()
if current_user_id and not (
ctx.is_superadmin()
or ctx.is_business_owner(business_id)
or ctx.has_business_permission("crm", "reports_team")
):
restricted_to_self = True
user_ids = {uid for uid in user_ids if uid == current_user_id}
if not user_ids:
user_ids = {int(current_user_id)}
if not user_ids:
return success_response(data=[], restricted_to_self=restricted_to_self, request=request)
ids_sql = ",".join(str(int(x)) for x in sorted(user_ids))
sql = text(
f"""
WITH u AS (SELECT unnest(ARRAY[{ids_sql}]::int[]) AS uid)
SELECT u.uid AS user_id,
usr.first_name AS first_name,
usr.last_name AS last_name,
(SELECT COUNT(*)::int FROM crm_leads l
WHERE l.business_id = :bid AND l.assigned_to_user_id = u.uid) AS leads_count,
(SELECT COUNT(*)::int FROM crm_leads l
WHERE l.business_id = :bid AND l.assigned_to_user_id = u.uid AND l.person_id IS NOT NULL) AS converted_leads,
(SELECT COUNT(*)::int FROM crm_deals d
WHERE d.business_id = :bid AND d.assigned_to_user_id = u.uid) AS deals_count,
(SELECT COUNT(*)::int FROM crm_deals d
WHERE d.business_id = :bid AND d.assigned_to_user_id = u.uid AND d.closed_at IS NOT NULL) AS closed_deals,
(SELECT COALESCE(SUM(d.amount), 0)::float FROM crm_deals d
WHERE d.business_id = :bid AND d.assigned_to_user_id = u.uid) AS total_amount,
(SELECT COUNT(*)::int FROM crm_activities a
WHERE a.business_id = :bid AND a.created_by_user_id = u.uid) AS activities_count
FROM u
JOIN users usr ON usr.id = u.uid
ORDER BY total_amount DESC NULLS LAST, deals_count DESC
"""
)
rows = db.execute(sql, {"bid": business_id}).mappings().all()
result = []
for r in rows:
fn = (r.get("first_name") or "").strip()
ln = (r.get("last_name") or "").strip()
nm = f"{fn} {ln}".strip() or str(r["user_id"])
lc = int(r["leads_count"] or 0)
cc = int(r["converted_leads"] or 0)
conv_rate = (cc / lc * 100) if lc else 0
result.append(
{
"user_id": int(r["user_id"]),
"user_name": nm,
"leads_count": lc,
"converted_leads": cc,
"conversion_rate": round(conv_rate, 1),
"deals_count": int(r["deals_count"] or 0),
"closed_deals": int(r["closed_deals"] or 0),
"total_amount": float(r["total_amount"] or 0),
"activities_count": int(r["activities_count"] or 0),
}
)
return success_response(data=result, restricted_to_self=restricted_to_self, request=request)
@router.get(
"/businesses/{business_id}/reports/sales-trend",
summary="روند فروش در زمان",
description="تعداد و مبلغ فرصت‌های بسته‌شده در بازه زمانی",
)
@require_business_access("business_id")
async def get_sales_trend_report(
request: Request,
business_id: int = Path(..., gt=0),
period: str = Query("month", description="day | week | month"),
months: int = Query(6, ge=1, le=24, description="تعداد ماه گذشته"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from sqlalchemy import func
from datetime import datetime, timedelta
end_dt = datetime.utcnow()
start_dt = end_dt - timedelta(days=months * 31)
try:
q = (
db.query(func.date(Deal.closed_at).label("period"), func.count(Deal.id).label("count"), func.coalesce(func.sum(Deal.amount), 0).label("amount"))
.filter(
Deal.business_id == business_id,
Deal.closed_at.isnot(None),
Deal.closed_at >= start_dt,
Deal.closed_at <= end_dt,
)
.group_by(func.date(Deal.closed_at))
)
rows = q.all()
raw_data = [{"period": str(r.period), "count": r.count, "amount": float(r.amount or 0)} for r in rows]
if period == "month":
by_month: Dict[str, Any] = {}
for r in raw_data:
p = r["period"]
month_key = p[:7] if len(p) >= 7 else p
if month_key not in by_month:
by_month[month_key] = {"period": month_key, "count": 0, "amount": 0}
by_month[month_key]["count"] += r["count"]
by_month[month_key]["amount"] += r["amount"]
data = sorted(by_month.values(), key=lambda x: x["period"])
else:
data = sorted(raw_data, key=lambda x: x["period"])
except Exception:
q = (
db.query(func.date(Deal.closed_at).label("period"), func.count(Deal.id).label("count"), func.coalesce(func.sum(Deal.amount), 0).label("amount"))
.filter(Deal.business_id == business_id, Deal.closed_at.isnot(None))
.group_by(func.date(Deal.closed_at))
)
rows = q.all()
data = [{"period": str(r.period), "count": r.count, "amount": float(r.amount or 0)} for r in rows]
data.sort(key=lambda x: x["period"])
return success_response(data=data, request=request)
def _stage_to_dict(stage: CrmProcessStage, request: Request = None) -> Dict[str, Any]:
d = {
"id": stage.id,
"process_definition_id": stage.process_definition_id,
"stage_code": stage.stage_code,
"name": stage.name,
"order_index": stage.order_index,
"color": stage.color,
"is_win": stage.is_win,
"is_lost": stage.is_lost,
"allow_transition_to": stage.allow_transition_to,
"created_at": stage.created_at.isoformat(),
"updated_at": stage.updated_at.isoformat(),
}
if request:
d = format_datetime_fields(d, request)
return d
def _process_to_dict(p: CrmProcessDefinition, request: Request = None, include_stages: bool = True) -> Dict[str, Any]:
d = {
"id": p.id,
"business_id": p.business_id,
"process_type": p.process_type,
"code": p.code,
"name": p.name,
"description": p.description,
"is_default": p.is_default,
"is_active": p.is_active,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
"created_by_user_id": p.created_by_user_id,
}
if include_stages and p.stages:
d["stages"] = [_stage_to_dict(s, request) for s in p.stages]
if request:
d = format_datetime_fields(d, request)
return d
def _log_crm_change(
db: Session,
business_id: int,
entity_type: str,
entity_id: int,
field_name: str,
old_value: Any,
new_value: Any,
user_id: int,
) -> None:
"""ثبت یک رکورد در تاریخچه تغییرات CRM."""
if old_value == new_value:
return
old_s = str(old_value) if old_value is not None else ""
new_s = str(new_value) if new_value is not None else ""
if old_s == new_s:
return
rec = CrmChangeHistory(
business_id=business_id,
entity_type=entity_type,
entity_id=entity_id,
field_name=field_name,
old_value=old_s[:4000] if len(old_s) > 4000 else old_s,
new_value=new_s[:4000] if len(new_s) > 4000 else new_s,
changed_by_user_id=user_id,
)
db.add(rec)
# --- فرایندها (Process Definitions) ---
@router.get(
"/businesses/{business_id}/process-definitions",
summary="لیست فرایندهای CRM",
description="دریافت لیست فرایندهای تعریف‌شده برای کسب‌وکار (فانل سرنخ، pipeline فروش، انواع فعالیت)",
)
@require_business_access("business_id")
async def list_process_definitions(
request: Request,
business_id: int = Path(..., gt=0),
process_type: Optional[str] = Query(None, description="فیلتر نوع: lead_funnel | sales_pipeline | activity_type | lead_source"),
is_active: Optional[bool] = Query(None),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
q = (
db.query(CrmProcessDefinition)
.options(selectinload(CrmProcessDefinition.stages))
.filter(CrmProcessDefinition.business_id == business_id)
)
if process_type:
q = q.filter(CrmProcessDefinition.process_type == process_type)
if is_active is not None:
q = q.filter(CrmProcessDefinition.is_active == is_active)
items = q.order_by(CrmProcessDefinition.process_type, CrmProcessDefinition.code).all()
data = [_process_to_dict(p, request) for p in items]
return success_response(data=data, request=request)
@router.post(
"/businesses/{business_id}/process-definitions",
summary="ایجاد فرایند CRM",
description="تعریف فرایند جدید (فانل سرنخ یا pipeline فروش) با مراحل",
)
@require_business_access("business_id")
async def create_process_definition(
request: Request,
business_id: int = Path(..., gt=0),
body: CrmProcessDefinitionCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
# یکتا بودن code در همان business و process_type
existing = (
db.query(CrmProcessDefinition)
.filter(
and_(
CrmProcessDefinition.business_id == business_id,
CrmProcessDefinition.process_type == body.process_type,
CrmProcessDefinition.code == body.code,
)
)
.first()
)
if existing:
raise ApiError("CRM_PROCESS_CODE_EXISTS", f"فرایند با کد {body.code} از قبل وجود دارد.", http_status=400)
proc = CrmProcessDefinition(
business_id=business_id,
process_type=body.process_type,
code=body.code,
name=body.name,
description=body.description,
is_default=body.is_default,
is_active=body.is_active,
created_by_user_id=ctx.get_user_id(),
)
db.add(proc)
db.flush()
for i, s in enumerate(body.stages or []):
stage = CrmProcessStage(
process_definition_id=proc.id,
stage_code=s.stage_code,
name=s.name,
order_index=s.order_index,
color=s.color,
is_win=s.is_win,
is_lost=s.is_lost,
allow_transition_to=s.allow_transition_to,
)
db.add(stage)
db.commit()
db.refresh(proc)
return success_response(data=_process_to_dict(proc, request), request=request, message="CRM_PROCESS_CREATED")
@router.get(
"/businesses/{business_id}/process-definitions/{definition_id}",
summary="جزئیات فرایند",
)
@require_business_access("business_id")
async def get_process_definition(
request: Request,
business_id: int = Path(..., gt=0),
definition_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
proc = (
db.query(CrmProcessDefinition)
.filter(
and_(
CrmProcessDefinition.id == definition_id,
CrmProcessDefinition.business_id == business_id,
)
)
.first()
)
if not proc:
raise ApiError("NOT_FOUND", "فرایند یافت نشد.", http_status=404)
return success_response(data=_process_to_dict(proc, request), request=request)
@router.put(
"/businesses/{business_id}/process-definitions/{definition_id}",
summary="ویرایش فرایند",
)
@require_business_access("business_id")
async def update_process_definition(
request: Request,
business_id: int = Path(..., gt=0),
definition_id: int = Path(..., gt=0),
body: CrmProcessDefinitionUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
proc = (
db.query(CrmProcessDefinition)
.filter(
and_(
CrmProcessDefinition.id == definition_id,
CrmProcessDefinition.business_id == business_id,
)
)
.first()
)
if not proc:
raise ApiError("NOT_FOUND", "فرایند یافت نشد.", http_status=404)
if body.name is not None:
proc.name = body.name
if body.description is not None:
proc.description = body.description
if body.is_default is not None:
proc.is_default = body.is_default
if body.is_active is not None:
proc.is_active = body.is_active
db.commit()
db.refresh(proc)
return success_response(data=_process_to_dict(proc, request), request=request, message="CRM_PROCESS_UPDATED")
@router.delete(
"/businesses/{business_id}/process-definitions/{definition_id}",
summary="حذف فرایند",
)
@require_business_access("business_id")
async def delete_process_definition(
request: Request,
business_id: int = Path(..., gt=0),
definition_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
proc = (
db.query(CrmProcessDefinition)
.filter(
and_(
CrmProcessDefinition.id == definition_id,
CrmProcessDefinition.business_id == business_id,
)
)
.first()
)
if not proc:
raise ApiError("NOT_FOUND", "فرایند یافت نشد.", http_status=404)
# بررسی استفاده در Lead/Deal
if db.query(Lead).filter(Lead.process_definition_id == definition_id).first():
raise ApiError("CRM_PROCESS_IN_USE", "این فرایند در سرنخ‌ها استفاده شده و قابل حذف نیست.", http_status=400)
if db.query(Deal).filter(Deal.process_definition_id == definition_id).first():
raise ApiError("CRM_PROCESS_IN_USE", "این فرایند در فرصت‌های فروش استفاده شده و قابل حذف نیست.", http_status=400)
db.delete(proc)
db.commit()
return success_response(message="CRM_PROCESS_DELETED", request=request)
# --- مراحل (Stages) ---
@router.get(
"/businesses/{business_id}/process-definitions/{definition_id}/stages",
summary="لیست مراحل یک فرایند",
)
@require_business_access("business_id")
async def list_stages(
request: Request,
business_id: int = Path(..., gt=0),
definition_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
proc = (
db.query(CrmProcessDefinition)
.filter(
and_(
CrmProcessDefinition.id == definition_id,
CrmProcessDefinition.business_id == business_id,
)
)
.first()
)
if not proc:
raise ApiError("NOT_FOUND", "فرایند یافت نشد.", http_status=404)
stages = db.query(CrmProcessStage).filter(CrmProcessStage.process_definition_id == definition_id).order_by(CrmProcessStage.order_index).all()
data = [_stage_to_dict(s, request) for s in stages]
return success_response(data=data, request=request)
@router.post(
"/businesses/{business_id}/process-definitions/{definition_id}/stages",
summary="افزودن مرحله به فرایند",
)
@require_business_access("business_id")
async def create_stage(
request: Request,
business_id: int = Path(..., gt=0),
definition_id: int = Path(..., gt=0),
body: CrmProcessStageCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
proc = (
db.query(CrmProcessDefinition)
.filter(
and_(
CrmProcessDefinition.id == definition_id,
CrmProcessDefinition.business_id == business_id,
)
)
.first()
)
if not proc:
raise ApiError("NOT_FOUND", "فرایند یافت نشد.", http_status=404)
existing = (
db.query(CrmProcessStage)
.filter(
and_(
CrmProcessStage.process_definition_id == definition_id,
CrmProcessStage.stage_code == body.stage_code,
)
)
.first()
)
if existing:
raise ApiError("CRM_STAGE_CODE_EXISTS", f"مرحله با کد {body.stage_code} از قبل وجود دارد.", http_status=400)
stage = CrmProcessStage(
process_definition_id=definition_id,
stage_code=body.stage_code,
name=body.name,
order_index=body.order_index,
color=body.color,
is_win=body.is_win,
is_lost=body.is_lost,
allow_transition_to=body.allow_transition_to,
)
db.add(stage)
db.commit()
db.refresh(stage)
return success_response(data=_stage_to_dict(stage, request), request=request, message="CRM_STAGE_CREATED")
@router.put(
"/businesses/{business_id}/process-definitions/{definition_id}/stages/{stage_id}",
summary="ویرایش مرحله",
)
@require_business_access("business_id")
async def update_stage(
request: Request,
business_id: int = Path(..., gt=0),
definition_id: int = Path(..., gt=0),
stage_id: int = Path(..., gt=0),
body: CrmProcessStageUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
proc = (
db.query(CrmProcessDefinition)
.filter(
and_(
CrmProcessDefinition.id == definition_id,
CrmProcessDefinition.business_id == business_id,
)
)
.first()
)
if not proc:
raise ApiError("NOT_FOUND", "فرایند یافت نشد.", http_status=404)
stage = (
db.query(CrmProcessStage)
.filter(
and_(
CrmProcessStage.id == stage_id,
CrmProcessStage.process_definition_id == definition_id,
)
)
.first()
)
if not stage:
raise ApiError("NOT_FOUND", "مرحله یافت نشد.", http_status=404)
if body.stage_code is not None:
stage.stage_code = body.stage_code
if body.name is not None:
stage.name = body.name
if body.order_index is not None:
stage.order_index = body.order_index
if body.color is not None:
stage.color = body.color
if body.is_win is not None:
stage.is_win = body.is_win
if body.is_lost is not None:
stage.is_lost = body.is_lost
if body.allow_transition_to is not None:
stage.allow_transition_to = body.allow_transition_to
db.commit()
db.refresh(stage)
return success_response(data=_stage_to_dict(stage, request), request=request, message="CRM_STAGE_UPDATED")
@router.delete(
"/businesses/{business_id}/process-definitions/{definition_id}/stages/{stage_id}",
summary="حذف مرحله",
)
@require_business_access("business_id")
async def delete_stage(
request: Request,
business_id: int = Path(..., gt=0),
definition_id: int = Path(..., gt=0),
stage_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
proc = (
db.query(CrmProcessDefinition)
.filter(
and_(
CrmProcessDefinition.id == definition_id,
CrmProcessDefinition.business_id == business_id,
)
)
.first()
)
if not proc:
raise ApiError("NOT_FOUND", "فرایند یافت نشد.", http_status=404)
stage = (
db.query(CrmProcessStage)
.filter(
and_(
CrmProcessStage.id == stage_id,
CrmProcessStage.process_definition_id == definition_id,
)
)
.first()
)
if not stage:
raise ApiError("NOT_FOUND", "مرحله یافت نشد.", http_status=404)
if db.query(Lead).filter(Lead.stage_id == stage_id).first():
raise ApiError("CRM_STAGE_IN_USE", "این مرحله در سرنخ‌ها استفاده شده است.", http_status=400)
if db.query(Deal).filter(Deal.stage_id == stage_id).first():
raise ApiError("CRM_STAGE_IN_USE", "این مرحله در فرصت‌های فروش استفاده شده است.", http_status=400)
db.delete(stage)
db.commit()
return success_response(message="CRM_STAGE_DELETED", request=request)
# --- سرنخ (Leads) ---
def _lead_to_dict(lead: Lead, request: Request = None, tags: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
d = {
"id": lead.id,
"business_id": lead.business_id,
"code": lead.code,
"process_definition_id": lead.process_definition_id,
"stage_id": lead.stage_id,
"stage_name": lead.stage.name if lead.stage else None,
"source_code": lead.source_code,
"name": lead.name,
"company_name": lead.company_name,
"mobile": lead.mobile,
"email": lead.email,
"description": lead.description,
"assigned_to_user_id": lead.assigned_to_user_id,
"assigned_to_name": (
f"{lead.assigned_to.first_name or ''} {lead.assigned_to.last_name or ''}".strip()
if lead.assigned_to else None
),
"next_follow_up_at": lead.next_follow_up_at.isoformat() if lead.next_follow_up_at else None,
"person_id": lead.person_id,
"person_name": lead.person.alias_name if lead.person else None,
"converted_at": lead.converted_at.isoformat() if lead.converted_at else None,
"score": lead.score,
"sla_due_at": lead.sla_due_at.isoformat() if lead.sla_due_at else None,
"first_touched_at": lead.first_touched_at.isoformat() if lead.first_touched_at else None,
"last_activity_at": lead.last_activity_at.isoformat() if lead.last_activity_at else None,
"tags": tags,
"created_at": lead.created_at.isoformat(),
"updated_at": lead.updated_at.isoformat(),
"created_by_user_id": lead.created_by_user_id,
"created_by_name": (
f"{lead.created_by.first_name or ''} {lead.created_by.last_name or ''}".strip()
if lead.created_by else None
),
}
if request:
d = format_datetime_fields(d, request)
return d
@router.get(
"/businesses/{business_id}/leads",
summary="لیست سرنخ‌ها",
)
@require_business_access("business_id")
async def list_leads(
request: Request,
business_id: int = Path(..., gt=0),
process_definition_id: Optional[int] = Query(None),
stage_id: Optional[int] = Query(None),
assigned_to_user_id: Optional[int] = Query(None),
search: Optional[str] = Query(None, description="جستجو در نام، شرکت، موبایل، ایمیل"),
from_date: Optional[str] = Query(None, description="از تاریخ ایجاد (YYYY-MM-DD)"),
to_date: Optional[str] = Query(None, description="تا تاریخ ایجاد (YYYY-MM-DD)"),
open_only: Optional[bool] = Query(None, description="فقط سرنخ تبدیل‌نشده"),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from datetime import datetime as dt
from sqlalchemy import func as sa_func
q = (
db.query(Lead)
.options(
selectinload(Lead.stage),
selectinload(Lead.assigned_to),
selectinload(Lead.person),
selectinload(Lead.created_by),
)
.filter(Lead.business_id == business_id)
)
if process_definition_id:
q = q.filter(Lead.process_definition_id == process_definition_id)
if stage_id:
q = q.filter(Lead.stage_id == stage_id)
if assigned_to_user_id is not None:
q = q.filter(Lead.assigned_to_user_id == assigned_to_user_id)
if open_only is True:
q = q.filter(Lead.person_id.is_(None))
if from_date:
try:
fd = dt.strptime(from_date, "%Y-%m-%d").date()
q = q.filter(sa_func.date(Lead.created_at) >= fd)
except ValueError:
pass
if to_date:
try:
td = dt.strptime(to_date, "%Y-%m-%d").date()
q = q.filter(sa_func.date(Lead.created_at) <= td)
except ValueError:
pass
if search and search.strip():
term = f"%{search.strip()}%"
q = q.filter(
or_(
Lead.name.ilike(term),
Lead.company_name.ilike(term),
Lead.mobile.ilike(term),
Lead.email.ilike(term),
)
)
total = q.count()
items = q.order_by(Lead.created_at.desc()).offset((page - 1) * limit).limit(limit).all()
data = [_lead_to_dict(lead, request) for lead in items]
return success_response(
data={"items": data, "total": total, "page": page, "limit": limit},
request=request,
)
@router.post(
"/businesses/{business_id}/leads",
summary="ایجاد سرنخ",
)
@require_business_access("business_id")
async def create_lead(
request: Request,
business_id: int = Path(..., gt=0),
body: LeadCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
proc = (
db.query(CrmProcessDefinition)
.filter(
and_(
CrmProcessDefinition.id == body.process_definition_id,
CrmProcessDefinition.business_id == business_id,
)
)
.first()
)
if not proc:
raise ApiError("NOT_FOUND", "فرایند یافت نشد.", http_status=404)
stage = (
db.query(CrmProcessStage)
.filter(
and_(
CrmProcessStage.id == body.stage_id,
CrmProcessStage.process_definition_id == body.process_definition_id,
)
)
.first()
)
if not stage:
raise ApiError("NOT_FOUND", "مرحله یافت نشد.", http_status=404)
mobile_norm = (str(body.mobile).strip() if body.mobile else "") or None
email_norm = (str(body.email).strip().lower() if body.email else "") or None
if mobile_norm or email_norm:
dup_parts = []
if mobile_norm:
dup_parts.append(Lead.mobile == mobile_norm)
if email_norm:
dup_parts.append(Lead.email == email_norm)
dup = (
db.query(Lead)
.filter(Lead.business_id == business_id, Lead.person_id.is_(None), or_(*dup_parts))
.first()
)
if dup:
raise ApiError(
"CRM_LEAD_DUPLICATE",
"سرنخ فعال با همین موبایل یا ایمیل از قبل وجود دارد.",
http_status=409,
)
from datetime import date
from app.services.document_numbering_service import generate_document_code
code_val: str
if body.code and str(body.code).strip():
code_val = str(body.code).strip()
existing = db.query(Lead).filter(Lead.business_id == business_id, Lead.code == code_val).first()
if existing:
raise ApiError("CRM_LEAD_CODE_EXISTS", f"سرنخ با کد {code_val} از قبل وجود دارد.", http_status=400)
else:
code_val = generate_document_code(db, business_id, "crm_lead", date.today())
# ادغام فیلدهای سفارشی در extra_info تحت کلید _custom
extra_info_val = crm_custom_field_service.validate_and_merge_custom_values(
db, business_id, "lead", body.extra_info, getattr(body, "custom_fields", None)
)
lead = Lead(
business_id=business_id,
process_definition_id=body.process_definition_id,
stage_id=body.stage_id,
code=code_val,
source_code=body.source_code,
name=body.name,
company_name=body.company_name,
mobile=body.mobile,
email=body.email,
description=body.description,
assigned_to_user_id=body.assigned_to_user_id,
next_follow_up_at=body.next_follow_up_at,
extra_info=extra_info_val,
created_by_user_id=ctx.get_user_id(),
)
# بارگذاری مرحله برای امتیازدهی
lead.stage = stage
# اتوماسیون: تخصیص خودکار، SLA، امتیازدهی
try:
crm_automation_service.apply_auto_assign(db, business_id, lead)
crm_automation_service.set_lead_sla(db, business_id, lead)
crm_automation_service.apply_lead_score(db, business_id, lead)
except Exception:
pass
db.add(lead)
db.flush()
# اعمال برچسب‌ها در صورت ارسال
if getattr(body, "tag_ids", None) is not None:
crm_tag_service.set_lead_tags(db, business_id, lead.id, body.tag_ids)
db.commit()
db.refresh(lead)
_lead_tags = crm_tag_service.get_lead_tags(db, business_id, lead.id)
try:
from app.services.workflow.workflow_trigger_service import trigger_lead_created
trigger_lead_created(
db, business_id,
lead_id=lead.id,
process_definition_id=lead.process_definition_id,
stage_id=lead.stage_id,
name=lead.name,
user_id=ctx.get_user_id(),
)
except Exception:
pass
return success_response(data=_lead_to_dict(lead, request, tags=_lead_tags), request=request, message="CRM_LEAD_CREATED")
@router.get(
"/businesses/{business_id}/leads/{lead_id}",
summary="جزئیات سرنخ",
)
@require_business_access("business_id")
async def get_lead(
request: Request,
business_id: int = Path(..., gt=0),
lead_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
lead = (
db.query(Lead)
.options(
selectinload(Lead.stage),
selectinload(Lead.assigned_to),
selectinload(Lead.person),
selectinload(Lead.created_by),
)
.filter(and_(Lead.id == lead_id, Lead.business_id == business_id))
.first()
)
if not lead:
raise ApiError("NOT_FOUND", "سرنخ یافت نشد.", http_status=404)
tags = crm_tag_service.get_lead_tags(db, business_id, lead_id)
return success_response(data=_lead_to_dict(lead, request, tags=tags), request=request)
@router.get(
"/businesses/{business_id}/leads/{lead_id}/history",
summary="تاریخچه تغییرات سرنخ",
)
@require_business_access("business_id")
async def get_lead_history(
request: Request,
business_id: int = Path(..., gt=0),
lead_id: int = Path(..., gt=0),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
lead = db.query(Lead).filter(and_(Lead.id == lead_id, Lead.business_id == business_id)).first()
if not lead:
raise ApiError("NOT_FOUND", "سرنخ یافت نشد.", http_status=404)
rows = (
db.query(CrmChangeHistory)
.options(joinedload(CrmChangeHistory.changed_by))
.filter(
CrmChangeHistory.business_id == business_id,
CrmChangeHistory.entity_type == "lead",
CrmChangeHistory.entity_id == lead_id,
)
.order_by(CrmChangeHistory.changed_at.desc())
.limit(limit)
.all()
)
data = [
{
"id": r.id,
"field_name": r.field_name,
"old_value": r.old_value,
"new_value": r.new_value,
"changed_at": r.changed_at.isoformat(),
"changed_by_user_id": r.changed_by_user_id,
"changed_by_name": (
f"{r.changed_by.first_name or ''} {r.changed_by.last_name or ''}".strip()
if r.changed_by else str(r.changed_by_user_id)
),
}
for r in rows
]
return success_response(data=data, request=request)
@router.put(
"/businesses/{business_id}/leads/{lead_id}",
summary="ویرایش سرنخ",
)
@require_business_access("business_id")
async def update_lead(
request: Request,
business_id: int = Path(..., gt=0),
lead_id: int = Path(..., gt=0),
body: LeadUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
lead = (
db.query(Lead)
.options(
selectinload(Lead.stage),
selectinload(Lead.assigned_to),
selectinload(Lead.person),
selectinload(Lead.created_by),
)
.filter(and_(Lead.id == lead_id, Lead.business_id == business_id))
.first()
)
if not lead:
raise ApiError("NOT_FOUND", "سرنخ یافت نشد.", http_status=404)
old_assigned_to_user_id = lead.assigned_to_user_id
if body.stage_id is not None:
stage = db.query(CrmProcessStage).filter(
and_(
CrmProcessStage.id == body.stage_id,
CrmProcessStage.process_definition_id == lead.process_definition_id,
)
).first()
if not stage:
raise ApiError("NOT_FOUND", "مرحله یافت نشد.", http_status=404)
old_stage_name = (lead.stage.name if lead.stage else None) or str(lead.stage_id)
old_stage_id = lead.stage_id
lead.stage_id = body.stage_id
_log_crm_change(db, business_id, "lead", lead_id, "stage_id", old_stage_name, stage.name, ctx.get_user_id())
try:
from app.services.workflow.workflow_trigger_service import trigger_lead_stage_changed
trigger_lead_stage_changed(db, business_id, lead_id=lead_id, old_stage_id=old_stage_id, new_stage_id=body.stage_id, user_id=ctx.get_user_id())
except Exception:
pass
if body.source_code is not None:
lead.source_code = body.source_code
if body.code is not None and str(body.code).strip():
code_val = str(body.code).strip()
existing = db.query(Lead).filter(Lead.business_id == business_id, Lead.code == code_val, Lead.id != lead_id).first()
if existing:
raise ApiError("CRM_LEAD_CODE_EXISTS", f"سرنخ با کد {code_val} از قبل وجود دارد.", http_status=400)
lead.code = code_val
if body.name is not None:
lead.name = body.name
if body.company_name is not None:
lead.company_name = body.company_name
if body.mobile is not None:
lead.mobile = body.mobile
if body.email is not None:
lead.email = body.email
if body.description is not None:
lead.description = body.description
if body.assigned_to_user_id is not None:
new_assign = body.assigned_to_user_id
if new_assign != old_assigned_to_user_id:
lead.assigned_to_user_id = new_assign
try:
from app.services.workflow.workflow_trigger_service import trigger_lead_assigned
trigger_lead_assigned(
db,
business_id,
lead_id=lead_id,
old_assigned_to_user_id=old_assigned_to_user_id,
new_assigned_to_user_id=new_assign,
user_id=ctx.get_user_id(),
)
except Exception:
pass
if body.next_follow_up_at is not None:
lead.next_follow_up_at = body.next_follow_up_at
if body.extra_info is not None:
lead.extra_info = body.extra_info
if getattr(body, "custom_fields", None) is not None:
lead.extra_info = crm_custom_field_service.validate_and_merge_custom_values(
db, business_id, "lead", lead.extra_info, body.custom_fields
)
if getattr(body, "tag_ids", None) is not None:
crm_tag_service.set_lead_tags(db, business_id, lead_id, body.tag_ids)
db.commit()
db.refresh(lead)
tags = crm_tag_service.get_lead_tags(db, business_id, lead_id)
return success_response(data=_lead_to_dict(lead, request, tags=tags), request=request, message="CRM_LEAD_UPDATED")
@router.post(
"/businesses/{business_id}/leads/{lead_id}/convert",
summary="تبدیل سرنخ به مشتری",
description="ایجاد شخص (مشتری) از سرنخ و اتصال آن به سرنخ. با ارسال create_deal می‌توان همزمان یک فرصت فروش ایجاد کرد.",
)
@require_business_access("business_id")
async def convert_lead_to_customer(
request: Request,
business_id: int = Path(..., gt=0),
lead_id: int = Path(..., gt=0),
body: LeadConvertRequest = Body(default=None),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
from datetime import datetime, date
lead = db.query(Lead).filter(and_(Lead.id == lead_id, Lead.business_id == business_id)).first()
if not lead:
raise ApiError("NOT_FOUND", "سرنخ یافت نشد.", http_status=404)
if lead.person_id is not None:
raise ApiError("CRM_LEAD_ALREADY_CONVERTED", "این سرنخ قبلاً به مشتری تبدیل شده است.", http_status=400)
alias = (lead.name or lead.company_name or "مشتری بدون نام").strip()
if not alias:
alias = "مشتری بدون نام"
person_data = PersonCreateRequest(
alias_name=alias[:255],
first_name=lead.name,
company_name=lead.company_name,
mobile=lead.mobile,
email=lead.email,
person_types=[PersonType.CUSTOMER],
)
result = create_person(db, business_id, person_data)
person = result.get("data")
if not person or "id" not in person:
raise ApiError("CRM_CONVERT_FAILED", "ایجاد مشتری از سرنخ ناموفق بود.", http_status=500)
person_id = person["id"]
lead.person_id = person_id
lead.converted_at = datetime.utcnow()
deal_data = None
if body and body.create_deal:
opt = body.create_deal
proc = (
db.query(CrmProcessDefinition)
.filter(
and_(
CrmProcessDefinition.id == opt.process_definition_id,
CrmProcessDefinition.business_id == business_id,
CrmProcessDefinition.process_type == "sales_pipeline",
)
)
.first()
)
if not proc:
raise ApiError("NOT_FOUND", "فرایند پایپلاین فروش یافت نشد.", http_status=404)
stage = (
db.query(CrmProcessStage)
.filter(
and_(
CrmProcessStage.id == opt.stage_id,
CrmProcessStage.process_definition_id == opt.process_definition_id,
)
)
.first()
)
if not stage:
raise ApiError("NOT_FOUND", "مرحله پایپلاین یافت نشد.", http_status=404)
from app.services.document_numbering_service import generate_document_code
code_val = generate_document_code(db, business_id, "crm_deal", date.today())
new_deal = Deal(
business_id=business_id,
person_id=person_id,
code=code_val,
process_definition_id=opt.process_definition_id,
stage_id=opt.stage_id,
title=opt.title,
amount=opt.amount,
currency_id=opt.currency_id,
probability_percent=opt.probability_percent,
expected_close_date=opt.expected_close_date,
assigned_to_user_id=opt.assigned_to_user_id or lead.assigned_to_user_id,
description=opt.description,
created_by_user_id=ctx.get_user_id(),
)
db.add(new_deal)
db.flush()
db.refresh(new_deal)
deal_data = _deal_to_dict(new_deal, request)
db.commit()
db.refresh(lead)
try:
from app.services.workflow.workflow_trigger_service import trigger_lead_converted
trigger_lead_converted(db, business_id, lead_id=lead_id, person_id=person_id, user_id=ctx.get_user_id())
except Exception:
pass
out = {"lead": _lead_to_dict(lead, request), "person": result["data"]}
if deal_data:
out["deal"] = deal_data
return success_response(
data=out,
request=request,
message="CRM_LEAD_CONVERTED",
)
@router.delete(
"/businesses/{business_id}/leads/{lead_id}",
summary="حذف سرنخ",
)
@require_business_access("business_id")
async def delete_lead(
request: Request,
business_id: int = Path(..., gt=0),
lead_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
lead = db.query(Lead).filter(and_(Lead.id == lead_id, Lead.business_id == business_id)).first()
if not lead:
raise ApiError("NOT_FOUND", "سرنخ یافت نشد.", http_status=404)
db.delete(lead)
db.commit()
return success_response(message="CRM_LEAD_DELETED", request=request)
# --- فرصت فروش (Deals) ---
def _deal_to_dict(deal: Deal, request: Request = None, tags: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
d = {
"id": deal.id,
"business_id": deal.business_id,
"code": deal.code,
"person_id": deal.person_id,
"person_name": deal.person.alias_name if deal.person else None,
"process_definition_id": deal.process_definition_id,
"stage_id": deal.stage_id,
"stage_name": deal.stage.name if deal.stage else None,
"title": deal.title,
"amount": float(deal.amount),
"currency_id": deal.currency_id,
"probability_percent": deal.probability_percent,
"expected_close_date": deal.expected_close_date.isoformat() if deal.expected_close_date else None,
"next_follow_up_at": deal.next_follow_up_at.isoformat() if deal.next_follow_up_at else None,
"closed_at": deal.closed_at.isoformat() if deal.closed_at else None,
"document_id": deal.document_id,
"assigned_to_user_id": deal.assigned_to_user_id,
"assigned_to_name": (
f"{deal.assigned_to.first_name or ''} {deal.assigned_to.last_name or ''}".strip()
if deal.assigned_to else None
),
"won_reason_code": deal.won_reason_code,
"lost_reason_code": deal.lost_reason_code,
"competitor_name": deal.competitor_name,
"stage_entered_at": deal.stage_entered_at.isoformat() if deal.stage_entered_at else None,
"tags": tags,
"created_at": deal.created_at.isoformat(),
"updated_at": deal.updated_at.isoformat(),
"created_by_user_id": deal.created_by_user_id,
"created_by_name": (
f"{deal.created_by.first_name or ''} {deal.created_by.last_name or ''}".strip()
if deal.created_by else None
),
}
if request:
d = format_datetime_fields(d, request)
return d
@router.get(
"/businesses/{business_id}/deals",
summary="لیست فرصت‌های فروش",
)
@require_business_access("business_id")
async def list_deals(
request: Request,
business_id: int = Path(..., gt=0),
process_definition_id: Optional[int] = Query(None),
stage_id: Optional[int] = Query(None),
person_id: Optional[int] = Query(None),
assigned_to_user_id: Optional[int] = Query(None),
search: Optional[str] = Query(None, description="جستجو در عنوان، نام مشتری"),
from_date: Optional[str] = Query(None, description="از تاریخ ایجاد (YYYY-MM-DD)"),
to_date: Optional[str] = Query(None, description="تا تاریخ ایجاد (YYYY-MM-DD)"),
open_only: Optional[bool] = Query(None, description="فقط فرصت‌های باز (بسته نشده)"),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from datetime import datetime as dt
from sqlalchemy import func as sa_func
q = (
db.query(Deal)
.options(
selectinload(Deal.person),
selectinload(Deal.stage),
selectinload(Deal.assigned_to),
selectinload(Deal.created_by),
)
.filter(Deal.business_id == business_id)
)
if process_definition_id:
q = q.filter(Deal.process_definition_id == process_definition_id)
if stage_id:
q = q.filter(Deal.stage_id == stage_id)
if person_id:
q = q.filter(Deal.person_id == person_id)
if assigned_to_user_id is not None:
q = q.filter(Deal.assigned_to_user_id == assigned_to_user_id)
if open_only is True:
q = q.filter(Deal.closed_at.is_(None))
if from_date:
try:
fd = dt.strptime(from_date, "%Y-%m-%d").date()
q = q.filter(sa_func.date(Deal.created_at) >= fd)
except ValueError:
pass
if to_date:
try:
td = dt.strptime(to_date, "%Y-%m-%d").date()
q = q.filter(sa_func.date(Deal.created_at) <= td)
except ValueError:
pass
if search and search.strip():
term = f"%{search.strip()}%"
q = q.join(Deal.person).filter(
or_(
Deal.title.ilike(term),
Person.alias_name.ilike(term),
)
)
total = q.count()
items = q.order_by(Deal.updated_at.desc()).offset((page - 1) * limit).limit(limit).all()
data = [_deal_to_dict(dl, request) for dl in items]
return success_response(
data={"items": data, "total": total, "page": page, "limit": limit},
request=request,
)
@router.post(
"/businesses/{business_id}/deals",
summary="ایجاد فرصت فروش",
)
@require_business_access("business_id")
async def create_deal(
request: Request,
business_id: int = Path(..., gt=0),
body: DealCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
from adapters.db.models.person import Person
person = db.query(Person).filter(and_(Person.id == body.person_id, Person.business_id == business_id)).first()
if not person:
raise ApiError("NOT_FOUND", "شخص (مشتری) یافت نشد.", http_status=404)
proc = (
db.query(CrmProcessDefinition)
.filter(
and_(
CrmProcessDefinition.id == body.process_definition_id,
CrmProcessDefinition.business_id == business_id,
)
)
.first()
)
if not proc:
raise ApiError("NOT_FOUND", "فرایند یافت نشد.", http_status=404)
stage = (
db.query(CrmProcessStage)
.filter(
and_(
CrmProcessStage.id == body.stage_id,
CrmProcessStage.process_definition_id == body.process_definition_id,
)
)
.first()
)
if not stage:
raise ApiError("NOT_FOUND", "مرحله یافت نشد.", http_status=404)
from datetime import date
from app.services.document_numbering_service import generate_document_code
code_val: str
if body.code and str(body.code).strip():
code_val = str(body.code).strip()
existing = db.query(Deal).filter(Deal.business_id == business_id, Deal.code == code_val).first()
if existing:
raise ApiError("CRM_DEAL_CODE_EXISTS", f"فرصت فروش با کد {code_val} از قبل وجود دارد.", http_status=400)
else:
code_val = generate_document_code(db, business_id, "crm_deal", date.today())
from datetime import datetime as _dt_now
extra_info_val = crm_custom_field_service.validate_and_merge_custom_values(
db, business_id, "deal", body.extra_info, getattr(body, "custom_fields", None)
)
deal = Deal(
business_id=business_id,
person_id=body.person_id,
code=code_val,
process_definition_id=body.process_definition_id,
stage_id=body.stage_id,
title=body.title,
amount=body.amount,
currency_id=body.currency_id,
probability_percent=body.probability_percent,
expected_close_date=body.expected_close_date,
next_follow_up_at=body.next_follow_up_at,
assigned_to_user_id=body.assigned_to_user_id,
description=body.description,
extra_info=extra_info_val,
stage_entered_at=_dt_now.utcnow(),
created_by_user_id=ctx.get_user_id(),
)
db.add(deal)
db.flush()
if getattr(body, "tag_ids", None) is not None:
crm_tag_service.set_deal_tags(db, business_id, deal.id, body.tag_ids)
db.commit()
db.refresh(deal)
_deal_tags = crm_tag_service.get_deal_tags(db, business_id, deal.id)
try:
from app.services.workflow.workflow_trigger_service import trigger_deal_created
trigger_deal_created(
db, business_id,
deal_id=deal.id,
process_definition_id=deal.process_definition_id,
stage_id=deal.stage_id,
person_id=deal.person_id,
title=deal.title,
amount=float(deal.amount),
user_id=ctx.get_user_id(),
)
except Exception:
pass
return success_response(data=_deal_to_dict(deal, request, tags=_deal_tags), request=request, message="CRM_DEAL_CREATED")
@router.get(
"/businesses/{business_id}/deals/{deal_id}",
summary="جزئیات فرصت فروش",
)
@require_business_access("business_id")
async def get_deal(
request: Request,
business_id: int = Path(..., gt=0),
deal_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
deal = (
db.query(Deal)
.options(
selectinload(Deal.stage),
selectinload(Deal.assigned_to),
selectinload(Deal.person),
selectinload(Deal.created_by),
)
.filter(and_(Deal.id == deal_id, Deal.business_id == business_id))
.first()
)
if not deal:
raise ApiError("NOT_FOUND", "فرصت فروش یافت نشد.", http_status=404)
tags = crm_tag_service.get_deal_tags(db, business_id, deal_id)
return success_response(data=_deal_to_dict(deal, request, tags=tags), request=request)
@router.get(
"/businesses/{business_id}/deals/{deal_id}/history",
summary="تاریخچه تغییرات فرصت فروش",
)
@require_business_access("business_id")
async def get_deal_history(
request: Request,
business_id: int = Path(..., gt=0),
deal_id: int = Path(..., gt=0),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
deal = db.query(Deal).filter(and_(Deal.id == deal_id, Deal.business_id == business_id)).first()
if not deal:
raise ApiError("NOT_FOUND", "فرصت فروش یافت نشد.", http_status=404)
rows = (
db.query(CrmChangeHistory)
.options(joinedload(CrmChangeHistory.changed_by))
.filter(
CrmChangeHistory.business_id == business_id,
CrmChangeHistory.entity_type == "deal",
CrmChangeHistory.entity_id == deal_id,
)
.order_by(CrmChangeHistory.changed_at.desc())
.limit(limit)
.all()
)
data = [
{
"id": r.id,
"field_name": r.field_name,
"old_value": r.old_value,
"new_value": r.new_value,
"changed_at": r.changed_at.isoformat(),
"changed_by_user_id": r.changed_by_user_id,
"changed_by_name": (
f"{r.changed_by.first_name or ''} {r.changed_by.last_name or ''}".strip()
if r.changed_by else str(r.changed_by_user_id)
),
}
for r in rows
]
return success_response(data=data, request=request)
@router.put(
"/businesses/{business_id}/deals/{deal_id}",
summary="ویرایش فرصت فروش (شامل تغییر مرحله)",
)
@require_business_access("business_id")
async def update_deal(
request: Request,
business_id: int = Path(..., gt=0),
deal_id: int = Path(..., gt=0),
body: DealUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
deal = (
db.query(Deal)
.options(
selectinload(Deal.stage),
selectinload(Deal.assigned_to),
selectinload(Deal.person),
selectinload(Deal.created_by),
)
.filter(and_(Deal.id == deal_id, Deal.business_id == business_id))
.first()
)
if not deal:
raise ApiError("NOT_FOUND", "فرصت فروش یافت نشد.", http_status=404)
from datetime import datetime as _dt_now
old_assigned_to_user_id = deal.assigned_to_user_id
if body.stage_id is not None:
stage = db.query(CrmProcessStage).filter(
and_(
CrmProcessStage.id == body.stage_id,
CrmProcessStage.process_definition_id == deal.process_definition_id,
)
).first()
if not stage:
raise ApiError("NOT_FOUND", "مرحله یافت نشد.", http_status=404)
# هنگام رفتن به مرحله برد/باخت، دلیل مربوطه الزامی و باید معتبر باشد
if body.stage_id != deal.stage_id:
if stage.is_win:
won_code = body.won_reason_code or deal.won_reason_code
if not won_code:
raise ApiError("CRM_DEAL_WON_REASON_REQUIRED", "برای بردن معامله، دلیل برد الزامی است.", http_status=400)
if not crm_close_reason_service.is_valid_reason_code(db, business_id, "won", won_code):
raise ApiError("CRM_DEAL_WON_REASON_INVALID", "کد دلیل برد نامعتبر است.", http_status=400)
deal.won_reason_code = won_code
if stage.is_lost:
lost_code = body.lost_reason_code or deal.lost_reason_code
if not lost_code:
raise ApiError("CRM_DEAL_LOST_REASON_REQUIRED", "برای باختن معامله، دلیل باخت الزامی است.", http_status=400)
if not crm_close_reason_service.is_valid_reason_code(db, business_id, "lost", lost_code):
raise ApiError("CRM_DEAL_LOST_REASON_INVALID", "کد دلیل باخت نامعتبر است.", http_status=400)
deal.lost_reason_code = lost_code
old_stage_name = (deal.stage.name if deal.stage else None) or str(deal.stage_id)
old_stage_id = deal.stage_id
if body.stage_id != deal.stage_id:
deal.stage_entered_at = _dt_now.utcnow()
deal.stage_id = body.stage_id
_log_crm_change(db, business_id, "deal", deal_id, "stage_id", old_stage_name, stage.name, ctx.get_user_id())
try:
from app.services.workflow.workflow_trigger_service import trigger_deal_stage_changed
trigger_deal_stage_changed(db, business_id, deal_id=deal_id, old_stage_id=old_stage_id, new_stage_id=body.stage_id, user_id=ctx.get_user_id())
except Exception:
pass
if body.code is not None and str(body.code).strip():
code_val = str(body.code).strip()
existing = db.query(Deal).filter(Deal.business_id == business_id, Deal.code == code_val, Deal.id != deal_id).first()
if existing:
raise ApiError("CRM_DEAL_CODE_EXISTS", f"فرصت فروش با کد {code_val} از قبل وجود دارد.", http_status=400)
deal.code = code_val
if body.title is not None:
deal.title = body.title
if body.amount is not None:
_log_crm_change(db, business_id, "deal", deal_id, "amount", str(deal.amount), str(body.amount), ctx.get_user_id())
deal.amount = body.amount
if body.currency_id is not None:
deal.currency_id = body.currency_id
if body.probability_percent is not None:
deal.probability_percent = body.probability_percent
if body.expected_close_date is not None:
deal.expected_close_date = body.expected_close_date
if body.next_follow_up_at is not None:
deal.next_follow_up_at = body.next_follow_up_at
if body.document_id is not None:
deal.document_id = body.document_id
if body.assigned_to_user_id is not None:
new_assign = body.assigned_to_user_id
if new_assign != old_assigned_to_user_id:
deal.assigned_to_user_id = new_assign
try:
from app.services.workflow.workflow_trigger_service import trigger_deal_assigned
trigger_deal_assigned(
db,
business_id,
deal_id=deal_id,
old_assigned_to_user_id=old_assigned_to_user_id,
new_assigned_to_user_id=new_assign,
user_id=ctx.get_user_id(),
)
except Exception:
pass
if body.description is not None:
deal.description = body.description
if body.extra_info is not None:
deal.extra_info = body.extra_info
if getattr(body, "won_reason_code", None) is not None:
deal.won_reason_code = body.won_reason_code or None
if getattr(body, "lost_reason_code", None) is not None:
deal.lost_reason_code = body.lost_reason_code or None
if getattr(body, "competitor_name", None) is not None:
deal.competitor_name = body.competitor_name or None
if getattr(body, "custom_fields", None) is not None:
deal.extra_info = crm_custom_field_service.validate_and_merge_custom_values(
db, business_id, "deal", deal.extra_info, body.custom_fields
)
if getattr(body, "tag_ids", None) is not None:
crm_tag_service.set_deal_tags(db, business_id, deal_id, body.tag_ids)
if body.closed_at is not None:
deal.closed_at = body.closed_at
try:
from app.services.workflow.workflow_trigger_service import trigger_deal_closed
st = deal.stage
is_win = bool(st and st.is_win)
is_lost = bool(st and st.is_lost)
trigger_deal_closed(
db,
business_id,
deal_id=deal_id,
amount=float(deal.amount),
is_win=is_win,
document_id=deal.document_id,
user_id=ctx.get_user_id(),
is_lost=is_lost,
)
except Exception:
pass
db.commit()
db.refresh(deal)
tags = crm_tag_service.get_deal_tags(db, business_id, deal_id)
return success_response(data=_deal_to_dict(deal, request, tags=tags), request=request, message="CRM_DEAL_UPDATED")
@router.delete(
"/businesses/{business_id}/deals/{deal_id}",
summary="حذف فرصت فروش",
)
@require_business_access("business_id")
async def delete_deal(
request: Request,
business_id: int = Path(..., gt=0),
deal_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
deal = db.query(Deal).filter(and_(Deal.id == deal_id, Deal.business_id == business_id)).first()
if not deal:
raise ApiError("NOT_FOUND", "فرصت فروش یافت نشد.", http_status=404)
db.delete(deal)
db.commit()
return success_response(message="CRM_DEAL_DELETED", request=request)
# --- فعالیت (Activities) ---
def _activity_to_dict(a: CrmActivity, request: Request = None) -> Dict[str, Any]:
d = {
"id": a.id,
"business_id": a.business_id,
"person_id": a.person_id,
"person_name": a.person.alias_name if getattr(a, "person", None) else None,
"lead_id": a.lead_id,
"lead_name": a.lead.name if getattr(a, "lead", None) else None,
"code": a.code,
"activity_type": a.activity_type,
"subject": a.subject,
"description": a.description,
"activity_date": a.activity_date.isoformat(),
"deal_id": a.deal_id,
"is_task": bool(a.is_task),
"due_at": a.due_at.isoformat() if a.due_at else None,
"status": a.status,
"completed_at": a.completed_at.isoformat() if a.completed_at else None,
"assigned_to_user_id": a.assigned_to_user_id,
"assigned_to_name": (
f"{a.assigned_to.first_name or ''} {a.assigned_to.last_name or ''}".strip()
if getattr(a, "assigned_to", None) else None
),
"outcome": a.outcome,
"priority": a.priority,
"created_by_user_id": a.created_by_user_id,
"created_by_name": (
f"{a.created_by.first_name or ''} {a.created_by.last_name or ''}".strip()
if a.created_by else None
),
"created_at": a.created_at.isoformat(),
"updated_at": a.updated_at.isoformat(),
}
if request:
d = format_datetime_fields(d, request)
return d
@router.get(
"/businesses/{business_id}/activities",
summary="لیست فعالیت‌ها (با فیلتر شخص)",
)
@require_business_access("business_id")
async def list_activities(
request: Request,
business_id: int = Path(..., gt=0),
person_id: Optional[int] = Query(None),
lead_id: Optional[int] = Query(None, description="فیلتر بر اساس سرنخ"),
deal_id: Optional[int] = Query(None),
activity_type: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
q = (
db.query(CrmActivity)
.options(
selectinload(CrmActivity.created_by),
selectinload(CrmActivity.lead),
selectinload(CrmActivity.person),
selectinload(CrmActivity.assigned_to),
)
.filter(CrmActivity.business_id == business_id)
)
if person_id:
q = q.filter(CrmActivity.person_id == person_id)
if lead_id is not None:
q = q.filter(CrmActivity.lead_id == lead_id)
if deal_id:
q = q.filter(CrmActivity.deal_id == deal_id)
if activity_type:
q = q.filter(CrmActivity.activity_type == activity_type)
total = q.count()
items = q.order_by(CrmActivity.activity_date.desc()).offset((page - 1) * limit).limit(limit).all()
data = [_activity_to_dict(a, request) for a in items]
return success_response(
data={"items": data, "total": total, "page": page, "limit": limit},
request=request,
)
@router.post(
"/businesses/{business_id}/activities",
summary="ثبت فعالیت",
)
@require_business_access("business_id")
async def create_activity(
request: Request,
business_id: int = Path(..., gt=0),
body: CrmActivityCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
from adapters.db.models.person import Person
person_id_val: Optional[int] = body.person_id
lead_id_val: Optional[int] = body.lead_id
if not person_id_val and not lead_id_val:
raise ApiError("CRM_ACTIVITY_PERSON_OR_LEAD", "یکی از person_id یا lead_id الزامی است.", http_status=400)
if lead_id_val is not None:
lead = db.query(Lead).filter(and_(Lead.id == lead_id_val, Lead.business_id == business_id)).first()
if not lead:
raise ApiError("NOT_FOUND", "سرنخ یافت نشد.", http_status=404)
if lead.person_id and not person_id_val:
person_id_val = int(lead.person_id)
if person_id_val is not None:
person = db.query(Person).filter(and_(Person.id == person_id_val, Person.business_id == business_id)).first()
if not person:
raise ApiError("NOT_FOUND", "شخص یافت نشد.", http_status=404)
from datetime import date
from app.services.document_numbering_service import generate_document_code
code_val: str
if body.code and str(body.code).strip():
code_val = str(body.code).strip()
existing = db.query(CrmActivity).filter(CrmActivity.business_id == business_id, CrmActivity.code == code_val).first()
if existing:
raise ApiError("CRM_ACTIVITY_CODE_EXISTS", f"فعالیت با کد {code_val} از قبل وجود دارد.", http_status=400)
else:
code_val = generate_document_code(db, business_id, "crm_activity", body.activity_date.date())
from datetime import datetime as _dt_now
extra_info_val = crm_custom_field_service.validate_and_merge_custom_values(
db, business_id, "activity", body.extra_info, getattr(body, "custom_fields", None)
)
status_val = body.status or "open"
completed_at_val = _dt_now.utcnow() if status_val == "done" else None
activity = CrmActivity(
business_id=business_id,
person_id=person_id_val,
lead_id=lead_id_val,
code=code_val,
activity_type=body.activity_type,
subject=body.subject,
description=body.description,
activity_date=body.activity_date,
deal_id=body.deal_id,
is_task=bool(body.is_task),
due_at=body.due_at,
status=status_val,
completed_at=completed_at_val,
assigned_to_user_id=body.assigned_to_user_id,
outcome=body.outcome,
priority=body.priority or "normal",
created_by_user_id=ctx.get_user_id(),
extra_info=extra_info_val,
)
db.add(activity)
# به‌روزرسانی نشانه‌های تماس سرنخ (اولین برخورد / آخرین فعالیت) — فقط برای لاگ فعالیت نه وظیفهٔ آینده
if lead_id_val is not None and not body.is_task:
_lead_obj = db.query(Lead).filter(Lead.id == lead_id_val, Lead.business_id == business_id).first()
if _lead_obj is not None:
now_touch = _dt_now.utcnow()
if _lead_obj.first_touched_at is None:
_lead_obj.first_touched_at = now_touch
_lead_obj.last_activity_at = now_touch
db.commit()
db.refresh(activity)
activity = (
db.query(CrmActivity)
.options(
selectinload(CrmActivity.lead),
selectinload(CrmActivity.created_by),
selectinload(CrmActivity.person),
selectinload(CrmActivity.assigned_to),
)
.filter(CrmActivity.id == activity.id)
.first()
)
try:
from app.services.workflow.workflow_trigger_service import trigger_crm_activity_created
trigger_crm_activity_created(db, business_id, activity.id, user_id=ctx.get_user_id())
except Exception:
pass
return success_response(data=_activity_to_dict(activity, request), request=request, message="CRM_ACTIVITY_CREATED")
@router.get(
"/businesses/{business_id}/activities/{activity_id}",
summary="جزئیات فعالیت",
)
@require_business_access("business_id")
async def get_activity(
request: Request,
business_id: int = Path(..., gt=0),
activity_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
activity = (
db.query(CrmActivity)
.options(
selectinload(CrmActivity.lead),
selectinload(CrmActivity.person),
selectinload(CrmActivity.created_by),
selectinload(CrmActivity.assigned_to),
)
.filter(and_(CrmActivity.id == activity_id, CrmActivity.business_id == business_id))
.first()
)
if not activity:
raise ApiError("NOT_FOUND", "فعالیت یافت نشد.", http_status=404)
return success_response(data=_activity_to_dict(activity, request), request=request)
@router.put(
"/businesses/{business_id}/activities/{activity_id}",
summary="ویرایش فعالیت",
)
@require_business_access("business_id")
async def update_activity(
request: Request,
business_id: int = Path(..., gt=0),
activity_id: int = Path(..., gt=0),
body: CrmActivityUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
activity = db.query(CrmActivity).filter(
and_(CrmActivity.id == activity_id, CrmActivity.business_id == business_id)
).first()
if not activity:
raise ApiError("NOT_FOUND", "فعالیت یافت نشد.", http_status=404)
if body.code is not None and str(body.code).strip():
code_val = str(body.code).strip()
existing = db.query(CrmActivity).filter(
CrmActivity.business_id == business_id,
CrmActivity.code == code_val,
CrmActivity.id != activity_id,
).first()
if existing:
raise ApiError("CRM_ACTIVITY_CODE_EXISTS", f"فعالیت با کد {code_val} از قبل وجود دارد.", http_status=400)
activity.code = code_val
if body.activity_type is not None:
activity.activity_type = body.activity_type
if body.subject is not None:
activity.subject = body.subject
if body.description is not None:
activity.description = body.description
if body.activity_date is not None:
activity.activity_date = body.activity_date
if body.deal_id is not None:
activity.deal_id = body.deal_id
if body.extra_info is not None:
activity.extra_info = body.extra_info
if body.is_task is not None:
activity.is_task = body.is_task
if body.due_at is not None:
activity.due_at = body.due_at
if body.assigned_to_user_id is not None:
activity.assigned_to_user_id = body.assigned_to_user_id
if body.outcome is not None:
activity.outcome = body.outcome
if body.priority is not None:
activity.priority = body.priority
if body.status is not None:
from datetime import datetime as _dt_now
if body.status not in ("open", "done", "cancelled"):
raise ApiError("CRM_ACTIVITY_INVALID_STATUS", "وضعیت نامعتبر است.", http_status=400)
activity.status = body.status
activity.completed_at = _dt_now.utcnow() if body.status == "done" else None
if getattr(body, "custom_fields", None) is not None:
activity.extra_info = crm_custom_field_service.validate_and_merge_custom_values(
db, business_id, "activity", activity.extra_info, body.custom_fields
)
db.commit()
activity = (
db.query(CrmActivity)
.options(
selectinload(CrmActivity.lead),
selectinload(CrmActivity.created_by),
selectinload(CrmActivity.person),
selectinload(CrmActivity.assigned_to),
)
.filter(CrmActivity.id == activity.id)
.first()
)
return success_response(data=_activity_to_dict(activity, request), request=request, message="CRM_ACTIVITY_UPDATED")
@router.delete(
"/businesses/{business_id}/activities/{activity_id}",
summary="حذف فعالیت",
)
@require_business_access("business_id")
async def delete_activity(
request: Request,
business_id: int = Path(..., gt=0),
activity_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
activity = db.query(CrmActivity).filter(
and_(CrmActivity.id == activity_id, CrmActivity.business_id == business_id)
).first()
if not activity:
raise ApiError("NOT_FOUND", "فعالیت یافت نشد.", http_status=404)
db.delete(activity)
db.commit()
return success_response(message="CRM_ACTIVITY_DELETED", request=request)
# --- یادداشت و تقویم CRM ---
def _crm_note_translator(request: Request):
return getattr(request.state, "translator", None)
@router.get(
"/businesses/{business_id}/note-types",
summary="انواع یادداشت CRM",
)
@require_business_access("business_id")
async def list_crm_note_types(
request: Request,
business_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
lang = getattr(ctx, "language", None) or "fa"
items = crm_cal_notes.list_note_types(db, business_id, lang)
db.commit()
return success_response(data={"items": format_datetime_fields(items, request)}, request=request)
@router.post(
"/businesses/{business_id}/note-types",
summary="ایجاد نوع یادداشت سفارشی",
)
@require_business_access("business_id")
async def create_crm_note_type(
request: Request,
business_id: int = Path(..., gt=0),
body: CrmNoteTypeCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
lang = getattr(ctx, "language", None) or "fa"
tr = _crm_note_translator(request)
row = crm_cal_notes.create_note_type(
db,
business_id,
code=body.code,
title_i18n=dict(body.title_i18n),
scheduling_mode=body.scheduling_mode,
allow_comments=body.allow_comments,
sort_order=body.sort_order,
lang=lang,
translator=tr,
)
db.commit()
return success_response(data=format_datetime_fields(row, request), request=request, message="CRM_NOTE_TYPE_CREATED")
@router.patch(
"/businesses/{business_id}/note-types/{type_id}",
summary="ویرایش نوع یادداشت",
)
@require_business_access("business_id")
async def update_crm_note_type(
request: Request,
business_id: int = Path(..., gt=0),
type_id: int = Path(..., gt=0),
body: CrmNoteTypeUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
lang = getattr(ctx, "language", None) or "fa"
tr = _crm_note_translator(request)
row = crm_cal_notes.update_note_type(
db,
business_id,
type_id,
title_i18n=body.title_i18n,
scheduling_mode=body.scheduling_mode,
allow_comments=body.allow_comments,
is_active=body.is_active,
sort_order=body.sort_order,
lang=lang,
translator=tr,
)
db.commit()
return success_response(data=format_datetime_fields(row, request), request=request, message="CRM_NOTE_TYPE_UPDATED")
@router.delete(
"/businesses/{business_id}/note-types/{type_id}",
summary="حذف نوع یادداشت غیرسیستمی",
)
@require_business_access("business_id")
async def delete_crm_note_type(
request: Request,
business_id: int = Path(..., gt=0),
type_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
tr = _crm_note_translator(request)
crm_cal_notes.delete_note_type(db, business_id, type_id, tr)
db.commit()
return success_response(message="CRM_NOTE_TYPE_DELETED", request=request)
@router.get(
"/businesses/{business_id}/notes",
summary="لیست یادداشت‌های تقویم در بازه تاریخ",
)
@require_business_access("business_id")
async def list_crm_notes(
request: Request,
business_id: int = Path(..., gt=0),
from_date: str = Query(..., description="YYYY-MM-DD میلادی"),
to_date: str = Query(..., description="YYYY-MM-DD میلادی"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from datetime import datetime as dt
try:
fd = dt.strptime(from_date, "%Y-%m-%d").date()
td = dt.strptime(to_date, "%Y-%m-%d").date()
except ValueError:
raise ApiError("CRM_NOTE_DATE_RANGE_INVALID", "Invalid date range", translator=_crm_note_translator(request))
lang = getattr(ctx, "language", None) or "fa"
items = crm_cal_notes.list_notes(db, ctx, business_id, fd, td, lang)
db.commit()
return success_response(data={"items": format_datetime_fields(items, request)}, request=request)
@router.get(
"/businesses/{business_id}/notes/{note_id}",
summary="جزئیات یادداشت",
)
@require_business_access("business_id")
async def get_crm_note(
request: Request,
business_id: int = Path(..., gt=0),
note_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
lang = getattr(ctx, "language", None) or "fa"
data = crm_cal_notes.get_note(db, ctx, business_id, note_id, lang)
db.commit()
if not data:
raise ApiError("CRM_NOTE_NOT_FOUND", "Not found", http_status=404, translator=_crm_note_translator(request))
return success_response(data=format_datetime_fields(data, request), request=request)
@router.post(
"/businesses/{business_id}/notes",
summary="ایجاد یادداشت",
)
@require_business_access("business_id")
async def create_crm_note(
request: Request,
business_id: int = Path(..., gt=0),
body: CrmNoteCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
tr = _crm_note_translator(request)
note = crm_cal_notes.create_note(
db,
ctx,
business_id,
note_type_id=body.note_type_id,
visibility=body.visibility,
title=body.title,
body=body.body,
occurs_on=body.occurs_on,
starts_at=body.starts_at,
ends_at=body.ends_at,
lead_id=body.lead_id,
shared_user_ids=body.shared_user_ids,
translator=tr,
)
db.commit()
db.refresh(note)
lang = getattr(ctx, "language", None) or "fa"
data = crm_cal_notes.get_note(db, ctx, business_id, note.id, lang)
return success_response(data=format_datetime_fields(data, request), request=request, message="CRM_NOTE_CREATED")
@router.patch(
"/businesses/{business_id}/notes/{note_id}",
summary="ویرایش یادداشت",
)
@require_business_access("business_id")
async def update_crm_note(
request: Request,
business_id: int = Path(..., gt=0),
note_id: int = Path(..., gt=0),
body: CrmNoteUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
tr = _crm_note_translator(request)
dump = body.model_dump(exclude_unset=True)
if dump.get("clear_lead"):
lead_field_set = True
lead_val = None
elif "lead_id" in dump:
lead_field_set = True
lead_val = dump.get("lead_id")
else:
lead_field_set = False
lead_val = None
note = crm_cal_notes.update_note(
db,
ctx,
business_id,
note_id,
note_type_id=body.note_type_id,
visibility=body.visibility,
title=body.title,
body=body.body,
occurs_on=body.occurs_on,
starts_at=body.starts_at,
ends_at=body.ends_at,
lead_id=lead_val,
lead_field_set=lead_field_set,
status=body.status,
shared_user_ids=body.shared_user_ids,
translator=tr,
)
db.commit()
lang = getattr(ctx, "language", None) or "fa"
data = crm_cal_notes.get_note(db, ctx, business_id, note.id, lang)
return success_response(data=format_datetime_fields(data, request), request=request, message="CRM_NOTE_UPDATED")
@router.delete(
"/businesses/{business_id}/notes/{note_id}",
summary="حذف نرم یادداشت",
)
@require_business_access("business_id")
async def delete_crm_note(
request: Request,
business_id: int = Path(..., gt=0),
note_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
tr = _crm_note_translator(request)
crm_cal_notes.soft_delete_note(db, ctx, business_id, note_id, tr)
db.commit()
return success_response(message="CRM_NOTE_DELETED", request=request)
@router.get(
"/businesses/{business_id}/notes/{note_id}/comments",
summary="کامنت‌های یادداشت",
)
@require_business_access("business_id")
async def list_crm_note_comments(
request: Request,
business_id: int = Path(..., gt=0),
note_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
lang = getattr(ctx, "language", None) or "fa"
items = crm_cal_notes.list_comments(db, ctx, business_id, note_id, lang)
db.commit()
return success_response(data={"items": format_datetime_fields(items, request)}, request=request)
@router.post(
"/businesses/{business_id}/notes/{note_id}/comments",
summary="افزودن کامنت",
)
@require_business_access("business_id")
async def create_crm_note_comment(
request: Request,
business_id: int = Path(..., gt=0),
note_id: int = Path(..., gt=0),
body: CrmNoteCommentCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
tr = _crm_note_translator(request)
row = crm_cal_notes.add_comment(db, ctx, business_id, note_id, body.body, tr)
db.commit()
return success_response(data=format_datetime_fields(row, request), request=request, message="CRM_NOTE_COMMENT_CREATED")
@router.delete(
"/businesses/{business_id}/notes/{note_id}/comments/{comment_id}",
summary="حذف کامنت",
)
@require_business_access("business_id")
async def delete_crm_note_comment(
request: Request,
business_id: int = Path(..., gt=0),
note_id: int = Path(..., gt=0),
comment_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
tr = _crm_note_translator(request)
crm_cal_notes.delete_comment(db, ctx, business_id, note_id, comment_id, tr)
db.commit()
return success_response(message="CRM_NOTE_COMMENT_DELETED", request=request)
@router.get(
"/businesses/{business_id}/notes/{note_id}/audit",
summary="تاریخچه audit یادداشت",
)
@require_business_access("business_id")
async def list_crm_note_audit(
request: Request,
business_id: int = Path(..., gt=0),
note_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
tr = _crm_note_translator(request)
items = crm_cal_notes.list_audit(db, ctx, business_id, note_id)
db.commit()
return success_response(data={"items": format_datetime_fields(items, request)}, request=request)
# ============================================================================
# برچسب‌ها (Tags)
# ============================================================================
@router.get("/businesses/{business_id}/tags", summary="لیست برچسب‌های CRM")
@require_business_access("business_id")
async def list_crm_tags(
request: Request,
business_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
items = crm_tag_service.list_tags(db, business_id)
db.commit()
return success_response(data={"items": items}, request=request)
@router.post("/businesses/{business_id}/tags", summary="ایجاد برچسب")
@require_business_access("business_id")
async def create_crm_tag(
request: Request,
business_id: int = Path(..., gt=0),
body: CrmTagCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
tag = crm_tag_service.create_tag(db, business_id, name=body.name, color=body.color, sort_order=body.sort_order)
db.commit()
return success_response(data=crm_tag_service.tag_to_dict(tag), request=request, message="CRM_TAG_CREATED")
@router.patch("/businesses/{business_id}/tags/{tag_id}", summary="ویرایش برچسب")
@require_business_access("business_id")
async def update_crm_tag(
request: Request,
business_id: int = Path(..., gt=0),
tag_id: int = Path(..., gt=0),
body: CrmTagUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
tag = crm_tag_service.update_tag(
db, business_id, tag_id,
name=body.name, color=body.color, is_active=body.is_active, sort_order=body.sort_order,
)
db.commit()
return success_response(data=crm_tag_service.tag_to_dict(tag), request=request, message="CRM_TAG_UPDATED")
@router.delete("/businesses/{business_id}/tags/{tag_id}", summary="حذف برچسب")
@require_business_access("business_id")
async def delete_crm_tag(
request: Request,
business_id: int = Path(..., gt=0),
tag_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
crm_tag_service.delete_tag(db, business_id, tag_id)
db.commit()
return success_response(message="CRM_TAG_DELETED", request=request)
@router.put("/businesses/{business_id}/leads/{lead_id}/tags", summary="تنظیم برچسب‌های سرنخ")
@require_business_access("business_id")
async def set_lead_tags_endpoint(
request: Request,
business_id: int = Path(..., gt=0),
lead_id: int = Path(..., gt=0),
body: CrmSetTagsRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
tags = crm_tag_service.set_lead_tags(db, business_id, lead_id, body.tag_ids)
db.commit()
return success_response(data={"tags": tags}, request=request, message="CRM_LEAD_TAGS_SET")
@router.put("/businesses/{business_id}/deals/{deal_id}/tags", summary="تنظیم برچسب‌های فرصت فروش")
@require_business_access("business_id")
async def set_deal_tags_endpoint(
request: Request,
business_id: int = Path(..., gt=0),
deal_id: int = Path(..., gt=0),
body: CrmSetTagsRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
tags = crm_tag_service.set_deal_tags(db, business_id, deal_id, body.tag_ids)
db.commit()
return success_response(data={"tags": tags}, request=request, message="CRM_DEAL_TAGS_SET")
# ============================================================================
# دلایل بستن معامله (Close Reasons)
# ============================================================================
@router.get("/businesses/{business_id}/close-reasons", summary="لیست دلایل برد/باخت")
@require_business_access("business_id")
async def list_close_reasons(
request: Request,
business_id: int = Path(..., gt=0),
reason_type: Optional[str] = Query(None, description="won | lost"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
items = crm_close_reason_service.list_reasons(db, business_id, reason_type=reason_type)
db.commit()
return success_response(data={"items": items}, request=request)
@router.post("/businesses/{business_id}/close-reasons", summary="ایجاد دلیل برد/باخت")
@require_business_access("business_id")
async def create_close_reason(
request: Request,
business_id: int = Path(..., gt=0),
body: CrmCloseReasonCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
r = crm_close_reason_service.create_reason(
db, business_id,
reason_type=body.reason_type, code=body.code, name=body.name, sort_order=body.sort_order,
)
db.commit()
return success_response(data=crm_close_reason_service.reason_to_dict(r), request=request, message="CRM_CLOSE_REASON_CREATED")
@router.patch("/businesses/{business_id}/close-reasons/{reason_id}", summary="ویرایش دلیل")
@require_business_access("business_id")
async def update_close_reason(
request: Request,
business_id: int = Path(..., gt=0),
reason_id: int = Path(..., gt=0),
body: CrmCloseReasonUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
r = crm_close_reason_service.update_reason(
db, business_id, reason_id,
name=body.name, is_active=body.is_active, sort_order=body.sort_order,
)
db.commit()
return success_response(data=crm_close_reason_service.reason_to_dict(r), request=request, message="CRM_CLOSE_REASON_UPDATED")
@router.delete("/businesses/{business_id}/close-reasons/{reason_id}", summary="حذف دلیل")
@require_business_access("business_id")
async def delete_close_reason(
request: Request,
business_id: int = Path(..., gt=0),
reason_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
crm_close_reason_service.delete_reason(db, business_id, reason_id)
db.commit()
return success_response(message="CRM_CLOSE_REASON_DELETED", request=request)
# ============================================================================
# فیلدهای سفارشی (Custom Fields)
# ============================================================================
@router.get("/businesses/{business_id}/custom-fields", summary="لیست فیلدهای سفارشی")
@require_business_access("business_id")
async def list_custom_fields(
request: Request,
business_id: int = Path(..., gt=0),
entity_type: Optional[str] = Query(None, description="lead | deal | activity"),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
items = crm_custom_field_service.list_definitions(db, business_id, entity_type=entity_type)
return success_response(data={"items": items}, request=request)
@router.post("/businesses/{business_id}/custom-fields", summary="ایجاد فیلد سفارشی")
@require_business_access("business_id")
async def create_custom_field(
request: Request,
business_id: int = Path(..., gt=0),
body: CrmCustomFieldCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
d = crm_custom_field_service.create_definition(
db, business_id,
entity_type=body.entity_type, field_key=body.field_key, label=body.label,
field_type=body.field_type, options=body.options, is_required=body.is_required,
sort_order=body.sort_order,
)
db.commit()
return success_response(data=crm_custom_field_service.definition_to_dict(d), request=request, message="CRM_CUSTOM_FIELD_CREATED")
@router.patch("/businesses/{business_id}/custom-fields/{field_id}", summary="ویرایش فیلد سفارشی")
@require_business_access("business_id")
async def update_custom_field(
request: Request,
business_id: int = Path(..., gt=0),
field_id: int = Path(..., gt=0),
body: CrmCustomFieldUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
d = crm_custom_field_service.update_definition(
db, business_id, field_id,
label=body.label, options=body.options, is_required=body.is_required,
sort_order=body.sort_order, is_active=body.is_active,
)
db.commit()
return success_response(data=crm_custom_field_service.definition_to_dict(d), request=request, message="CRM_CUSTOM_FIELD_UPDATED")
@router.delete("/businesses/{business_id}/custom-fields/{field_id}", summary="حذف فیلد سفارشی")
@require_business_access("business_id")
async def delete_custom_field(
request: Request,
business_id: int = Path(..., gt=0),
field_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
crm_custom_field_service.delete_definition(db, business_id, field_id)
db.commit()
return success_response(message="CRM_CUSTOM_FIELD_DELETED", request=request)
# ============================================================================
# خطوط فرصت فروش (Deal Lines)
# ============================================================================
@router.get("/businesses/{business_id}/deals/{deal_id}/lines", summary="خطوط فرصت فروش")
@require_business_access("business_id")
async def list_deal_lines(
request: Request,
business_id: int = Path(..., gt=0),
deal_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
lines = crm_deal_line_service.list_lines(db, business_id, deal_id)
return success_response(data={"items": lines}, request=request)
@router.put("/businesses/{business_id}/deals/{deal_id}/lines", summary="جایگزینی خطوط فرصت فروش")
@require_business_access("business_id")
async def replace_deal_lines(
request: Request,
business_id: int = Path(..., gt=0),
deal_id: int = Path(..., gt=0),
body: CrmDealLinesReplaceRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
lines_input = [l.model_dump() for l in body.lines]
result = crm_deal_line_service.replace_lines(db, business_id, deal_id, lines_input)
db.commit()
return success_response(data=result, request=request, message="CRM_DEAL_LINES_SAVED")
# ============================================================================
# دید ۳۶۰ مشتری
# ============================================================================
@router.get("/businesses/{business_id}/persons/{person_id}/customer-360", summary="دید ۳۶۰ درجه مشتری")
@require_business_access("business_id")
async def get_customer_360(
request: Request,
business_id: int = Path(..., gt=0),
person_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
data = crm_customer_360_service.get_customer_360(db, business_id, person_id)
return success_response(data=format_datetime_fields(data, request), request=request)
# ============================================================================
# صف کاری من
# ============================================================================
@router.get("/businesses/{business_id}/my-work-queue", summary="صف کاری من")
@require_business_access("business_id")
async def my_work_queue(
request: Request,
business_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from datetime import datetime, timedelta
user_id = ctx.get_user_id()
now = datetime.utcnow()
horizon = now + timedelta(hours=24)
# وظایف باز تخصیص‌یافته به من
tasks = (
db.query(CrmActivity)
.options(selectinload(CrmActivity.assigned_to), selectinload(CrmActivity.created_by))
.filter(
CrmActivity.business_id == business_id,
CrmActivity.is_task.is_(True),
CrmActivity.status == "open",
CrmActivity.assigned_to_user_id == user_id,
)
.order_by(CrmActivity.due_at.asc().nullslast())
.limit(100)
.all()
)
# پیگیری‌های نزدیک سرنخ‌های من
lead_follow_ups = (
db.query(Lead)
.options(selectinload(Lead.stage), selectinload(Lead.assigned_to))
.filter(
Lead.business_id == business_id,
Lead.assigned_to_user_id == user_id,
Lead.person_id.is_(None),
Lead.next_follow_up_at.isnot(None),
Lead.next_follow_up_at <= horizon,
)
.order_by(Lead.next_follow_up_at.asc())
.limit(100)
.all()
)
# پیگیری‌های نزدیک فرصت‌های من
deal_follow_ups = (
db.query(Deal)
.options(selectinload(Deal.stage), selectinload(Deal.assigned_to), selectinload(Deal.person))
.filter(
Deal.business_id == business_id,
Deal.assigned_to_user_id == user_id,
Deal.closed_at.is_(None),
Deal.next_follow_up_at.isnot(None),
Deal.next_follow_up_at <= horizon,
)
.order_by(Deal.next_follow_up_at.asc())
.limit(100)
.all()
)
data = {
"tasks": [_activity_to_dict(t, request) for t in tasks],
"overdue_tasks_count": sum(1 for t in tasks if t.due_at and t.due_at < now),
"lead_follow_ups": [_lead_to_dict(l, request) for l in lead_follow_ups],
"deal_follow_ups": [_deal_to_dict(d, request) for d in deal_follow_ups],
}
return success_response(data=data, request=request)
# ============================================================================
# گزارش دلایل برد/باخت
# ============================================================================
@router.get("/businesses/{business_id}/reports/lost-reasons", summary="گزارش دلایل باخت")
@require_business_access("business_id")
async def report_lost_reasons(
request: Request,
business_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from sqlalchemy import func
rows = (
db.query(
Deal.lost_reason_code,
func.count(Deal.id).label("count"),
func.coalesce(func.sum(Deal.amount), 0).label("total_amount"),
)
.filter(Deal.business_id == business_id, Deal.lost_reason_code.isnot(None), Deal.lost_reason_code != "")
.group_by(Deal.lost_reason_code)
.all()
)
reason_names = {
r["code"]: r["name"]
for r in crm_close_reason_service.list_reasons(db, business_id, reason_type="lost")
}
db.commit()
data = [
{
"reason_code": r.lost_reason_code,
"reason_name": reason_names.get(r.lost_reason_code, r.lost_reason_code),
"count": int(r.count or 0),
"total_amount": float(r.total_amount or 0),
}
for r in rows
]
return success_response(data=data, request=request)
@router.get("/businesses/{business_id}/reports/won-reasons", summary="گزارش دلایل برد")
@require_business_access("business_id")
async def report_won_reasons(
request: Request,
business_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from sqlalchemy import func
rows = (
db.query(
Deal.won_reason_code,
func.count(Deal.id).label("count"),
func.coalesce(func.sum(Deal.amount), 0).label("total_amount"),
)
.filter(Deal.business_id == business_id, Deal.won_reason_code.isnot(None), Deal.won_reason_code != "")
.group_by(Deal.won_reason_code)
.all()
)
reason_names = {
r["code"]: r["name"]
for r in crm_close_reason_service.list_reasons(db, business_id, reason_type="won")
}
db.commit()
data = [
{
"reason_code": r.won_reason_code,
"reason_name": reason_names.get(r.won_reason_code, r.won_reason_code),
"count": int(r.count or 0),
"total_amount": float(r.total_amount or 0),
}
for r in rows
]
return success_response(data=data, request=request)
# ============================================================================
# تبدیل فرصت فروش به فاکتور
# ============================================================================
@router.post("/businesses/{business_id}/deals/{deal_id}/convert-to-invoice", summary="تبدیل فرصت فروش به فاکتور/پیش‌فاکتور")
@require_business_access("business_id")
async def convert_deal_to_invoice(
request: Request,
business_id: int = Path(..., gt=0),
deal_id: int = Path(..., gt=0),
body: CrmDealConvertToInvoiceRequest = Body(default=CrmDealConvertToInvoiceRequest()),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
from datetime import date, datetime
from decimal import Decimal
deal = (
db.query(Deal)
.options(selectinload(Deal.lines), selectinload(Deal.stage))
.filter(and_(Deal.id == deal_id, Deal.business_id == business_id))
.first()
)
if not deal:
raise ApiError("NOT_FOUND", "فرصت فروش یافت نشد.", http_status=404)
if deal.document_id:
raise ApiError("CRM_DEAL_ALREADY_INVOICED", "برای این فرصت فروش قبلاً فاکتور صادر شده است.", http_status=400)
# تعیین ارز: ارز معامله یا ارز پیش‌فرض کسب‌وکار
from adapters.db.models.business import Business as _Biz
biz = db.query(_Biz).filter(_Biz.id == business_id).first()
currency_id = deal.currency_id or (biz.default_currency_id if biz else None)
if not currency_id:
raise ApiError("CRM_DEAL_NO_CURRENCY", "ارز فرصت فروش مشخص نیست و ارز پیش‌فرض کسب‌وکار نیز تنظیم نشده است.", http_status=400)
# ساخت خطوط فاکتور از خطوط دارای کالا
invoice_lines: List[Dict[str, Any]] = []
for ln in sorted(deal.lines, key=lambda x: (x.sort_order or 0, x.id)):
if not ln.product_id:
# خطوط بدون کالا در فاکتور حسابداری قابل ثبت نیستند و نادیده گرفته می‌شوند
continue
qty = Decimal(str(ln.quantity or 0))
unit_price = Decimal(str(ln.unit_price or 0))
gross = qty * unit_price
discount_percent = Decimal(str(ln.discount_percent or 0))
line_discount = (gross * discount_percent / Decimal(100)) if discount_percent else Decimal(0)
line_total = Decimal(str(ln.line_total or (gross - line_discount)))
extra: Dict[str, Any] = {
"unit_price": float(unit_price),
"line_discount": float(line_discount),
"line_total": float(line_total),
}
if body.warehouse_id:
extra["warehouse_id"] = int(body.warehouse_id)
invoice_lines.append(
{
"product_id": int(ln.product_id),
"quantity": float(qty),
"description": ln.description,
"extra_info": extra,
}
)
if not invoice_lines:
raise ApiError(
"CRM_DEAL_NO_INVOICEABLE_LINES",
"برای صدور فاکتور، حداقل یک خط دارای کالا لازم است. لطفاً ابتدا خطوط فرصت فروش را با کالا تکمیل کنید.",
http_status=400,
)
invoice_data: Dict[str, Any] = {
"invoice_type": "invoice_sales",
"currency_id": int(currency_id),
"document_date": date.today().isoformat(),
"is_proforma": bool(body.is_proforma),
"lines": invoice_lines,
"extra_info": {"person_id": int(deal.person_id)},
"description": f"صادر شده از فرصت فروش {deal.code or deal.id}",
}
try:
from app.services.invoice_service import create_invoice
result = create_invoice(db, business_id, ctx.get_user_id(), invoice_data)
except ApiError:
raise
except Exception as e:
raise ApiError("CRM_DEAL_INVOICE_FAILED", f"ایجاد فاکتور ناموفق بود: {e}", http_status=500)
document_id = result.get("id") if isinstance(result, dict) else None
if document_id:
deal.document_id = int(document_id)
# بستن معامله به‌عنوان برد در صورت درخواست
if body.close_as_won:
won_code = body.won_reason_code or deal.won_reason_code
if won_code and crm_close_reason_service.is_valid_reason_code(db, business_id, "won", won_code):
deal.won_reason_code = won_code
deal.closed_at = datetime.utcnow()
# انتقال به مرحله برد در صورت وجود
win_stage = (
db.query(CrmProcessStage)
.filter(
CrmProcessStage.process_definition_id == deal.process_definition_id,
CrmProcessStage.is_win.is_(True),
)
.order_by(CrmProcessStage.order_index.desc())
.first()
)
if win_stage:
deal.stage_id = win_stage.id
deal.stage_entered_at = datetime.utcnow()
db.commit()
db.refresh(deal)
tags = crm_tag_service.get_deal_tags(db, business_id, deal_id)
return success_response(
data={"deal": _deal_to_dict(deal, request, tags=tags), "invoice": result},
request=request,
message="CRM_DEAL_CONVERTED_TO_INVOICE",
)
# ============================================================================
# توالی‌های خودکار (Sequences)
# ============================================================================
def _sequence_to_dict(seq: CrmSequence, include_steps: bool = True) -> Dict[str, Any]:
d = {
"id": seq.id,
"business_id": seq.business_id,
"name": seq.name,
"description": seq.description,
"is_active": bool(seq.is_active),
"created_at": seq.created_at.isoformat() if seq.created_at else None,
"updated_at": seq.updated_at.isoformat() if seq.updated_at else None,
"created_by_user_id": seq.created_by_user_id,
}
if include_steps:
d["steps"] = [
{
"id": s.id,
"step_order": s.step_order,
"delay_hours": s.delay_hours,
"action_type": s.action_type,
"action_config": s.action_config,
}
for s in sorted(seq.steps, key=lambda x: x.step_order)
]
return d
@router.get("/businesses/{business_id}/sequences", summary="لیست توالی‌های خودکار")
@require_business_access("business_id")
async def list_sequences(
request: Request,
business_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
rows = (
db.query(CrmSequence)
.options(selectinload(CrmSequence.steps))
.filter(CrmSequence.business_id == business_id)
.order_by(CrmSequence.created_at.desc())
.all()
)
return success_response(data={"items": [_sequence_to_dict(s) for s in rows]}, request=request)
@router.post("/businesses/{business_id}/sequences", summary="ایجاد توالی خودکار")
@require_business_access("business_id")
async def create_sequence(
request: Request,
business_id: int = Path(..., gt=0),
body: CrmSequenceCreate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
seq = CrmSequence(
business_id=business_id,
name=body.name,
description=body.description,
is_active=body.is_active,
created_by_user_id=ctx.get_user_id(),
)
db.add(seq)
db.flush()
for s in body.steps or []:
db.add(
CrmSequenceStep(
sequence_id=seq.id,
step_order=s.step_order,
delay_hours=s.delay_hours,
action_type=s.action_type,
action_config=s.action_config,
)
)
db.commit()
db.refresh(seq)
return success_response(data=_sequence_to_dict(seq), request=request, message="CRM_SEQUENCE_CREATED")
@router.get("/businesses/{business_id}/sequences/{sequence_id}", summary="جزئیات توالی")
@require_business_access("business_id")
async def get_sequence(
request: Request,
business_id: int = Path(..., gt=0),
sequence_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
seq = (
db.query(CrmSequence)
.options(selectinload(CrmSequence.steps))
.filter(and_(CrmSequence.id == sequence_id, CrmSequence.business_id == business_id))
.first()
)
if not seq:
raise ApiError("NOT_FOUND", "توالی یافت نشد.", http_status=404)
return success_response(data=_sequence_to_dict(seq), request=request)
@router.patch("/businesses/{business_id}/sequences/{sequence_id}", summary="ویرایش توالی")
@require_business_access("business_id")
async def update_sequence(
request: Request,
business_id: int = Path(..., gt=0),
sequence_id: int = Path(..., gt=0),
body: CrmSequenceUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
seq = (
db.query(CrmSequence)
.options(selectinload(CrmSequence.steps))
.filter(and_(CrmSequence.id == sequence_id, CrmSequence.business_id == business_id))
.first()
)
if not seq:
raise ApiError("NOT_FOUND", "توالی یافت نشد.", http_status=404)
if body.name is not None:
seq.name = body.name
if body.description is not None:
seq.description = body.description
if body.is_active is not None:
seq.is_active = body.is_active
if body.steps is not None:
db.query(CrmSequenceStep).filter(CrmSequenceStep.sequence_id == seq.id).delete(synchronize_session=False)
for s in body.steps:
db.add(
CrmSequenceStep(
sequence_id=seq.id,
step_order=s.step_order,
delay_hours=s.delay_hours,
action_type=s.action_type,
action_config=s.action_config,
)
)
db.commit()
db.refresh(seq)
return success_response(data=_sequence_to_dict(seq), request=request, message="CRM_SEQUENCE_UPDATED")
@router.delete("/businesses/{business_id}/sequences/{sequence_id}", summary="حذف توالی")
@require_business_access("business_id")
async def delete_sequence(
request: Request,
business_id: int = Path(..., gt=0),
sequence_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
seq = (
db.query(CrmSequence)
.filter(and_(CrmSequence.id == sequence_id, CrmSequence.business_id == business_id))
.first()
)
if not seq:
raise ApiError("NOT_FOUND", "توالی یافت نشد.", http_status=404)
db.delete(seq)
db.commit()
return success_response(message="CRM_SEQUENCE_DELETED", request=request)
@router.post("/businesses/{business_id}/sequences/{sequence_id}/enroll", summary="ثبت‌نام در توالی")
@require_business_access("business_id")
async def enroll_sequence(
request: Request,
business_id: int = Path(..., gt=0),
sequence_id: int = Path(..., gt=0),
body: CrmSequenceEnrollRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
enrollment = crm_automation_service.enroll_in_sequence(
db, business_id, sequence_id, body.entity_type, body.entity_id
)
db.commit()
db.refresh(enrollment)
data = {
"id": enrollment.id,
"sequence_id": enrollment.sequence_id,
"entity_type": enrollment.entity_type,
"entity_id": enrollment.entity_id,
"status": enrollment.status,
"current_step_order": enrollment.current_step_order,
"next_run_at": enrollment.next_run_at.isoformat() if enrollment.next_run_at else None,
}
return success_response(data=format_datetime_fields(data, request), request=request, message="CRM_SEQUENCE_ENROLLED")
# ============================================================================
# تنظیمات اتوماسیون CRM
# ============================================================================
def _automation_settings_to_dict(s) -> Dict[str, Any]:
return {
"business_id": s.business_id,
"lead_sla_hours": s.lead_sla_hours,
"auto_assign_enabled": bool(s.auto_assign_enabled),
"auto_assign_user_ids": s.auto_assign_user_ids or [],
"auto_assign_cursor": s.auto_assign_cursor,
"follow_up_notify_enabled": bool(s.follow_up_notify_enabled),
"stale_deal_days": s.stale_deal_days,
"score_rules": s.score_rules,
}
@router.get("/businesses/{business_id}/automation-settings", summary="تنظیمات اتوماسیون CRM")
@require_business_access("business_id")
async def get_automation_settings(
request: Request,
business_id: int = Path(..., gt=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "view")),
) -> Dict[str, Any]:
from app.services.crm_chat_service import get_or_create_crm_settings
s = get_or_create_crm_settings(db, business_id)
return success_response(data=_automation_settings_to_dict(s), request=request)
@router.patch("/businesses/{business_id}/automation-settings", summary="ویرایش تنظیمات اتوماسیون CRM")
@require_business_access("business_id")
async def update_automation_settings(
request: Request,
business_id: int = Path(..., gt=0),
body: CrmAutomationSettingsUpdate = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_permission_dep("crm", "write")),
) -> Dict[str, Any]:
from datetime import datetime
from app.services.crm_chat_service import get_or_create_crm_settings
s = get_or_create_crm_settings(db, business_id)
if body.lead_sla_hours is not None:
s.lead_sla_hours = body.lead_sla_hours
if body.auto_assign_enabled is not None:
s.auto_assign_enabled = body.auto_assign_enabled
if body.auto_assign_user_ids is not None:
s.auto_assign_user_ids = body.auto_assign_user_ids
if body.follow_up_notify_enabled is not None:
s.follow_up_notify_enabled = body.follow_up_notify_enabled
if body.stale_deal_days is not None:
s.stale_deal_days = body.stale_deal_days
if body.score_rules is not None:
s.score_rules = body.score_rules
s.updated_at = datetime.utcnow()
db.commit()
db.refresh(s)
return success_response(data=_automation_settings_to_dict(s), request=request, message="CRM_AUTOMATION_SETTINGS_SAVED")
from adapters.api.v1.crm_chat import router as _crm_web_chat_router # noqa: E402
router.include_router(_crm_web_chat_router)