Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/crm_customer_360_service.py
2026-07-31 13:45:02 +00:00

293 lines
9.9 KiB
Python

# noqa: D100
"""سرویس دید ۳۶۰ درجه مشتری: تجمیع اطلاعات شخص، معاملات، فعالیت‌ها، وظایف، اسناد و برچسب‌ها."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session, selectinload
from adapters.db.models.person import Person, PersonSocialContact
from adapters.db.models.crm import (
Deal,
CrmActivity,
Lead,
CrmNote,
CrmTag,
CrmDealTagLink,
)
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from app.core.responses import ApiError
def _iso(dt: Optional[datetime]) -> Optional[str]:
return dt.isoformat() if dt else None
def _full_name(user) -> Optional[str]:
if not user:
return None
return f"{user.first_name or ''} {user.last_name or ''}".strip() or None
def get_customer_360(db: Session, business_id: int, person_id: int) -> Dict[str, Any]:
"""تجمیع دید کامل مشتری همراه با تایم‌لاین رویدادهای مرتب‌شده نزولی."""
person = (
db.query(Person)
.options(selectinload(Person.social_contacts))
.filter(and_(Person.id == person_id, Person.business_id == business_id))
.first()
)
if not person:
raise ApiError("NOT_FOUND", "شخص یافت نشد.", http_status=404)
# اطلاعات پایه شخص
core = {
"id": person.id,
"alias_name": person.alias_name,
"first_name": person.first_name,
"last_name": person.last_name,
"company_name": person.company_name,
"mobile": person.mobile,
"phone": person.phone,
"email": person.email,
"city": person.city,
"province": person.province,
"address": person.address,
"created_at": _iso(person.created_at),
}
social_contacts = [
{
"id": sc.id,
"platform_key": sc.platform_key,
"custom_label": sc.custom_label,
"value": sc.value,
}
for sc in sorted(person.social_contacts, key=lambda x: x.sort_order or 0)
]
# معاملات باز/بسته
deals = (
db.query(Deal)
.options(selectinload(Deal.stage), selectinload(Deal.assigned_to))
.filter(Deal.business_id == business_id, Deal.person_id == person_id)
.order_by(Deal.created_at.desc())
.all()
)
open_deals: List[Dict[str, Any]] = []
closed_deals: List[Dict[str, Any]] = []
for d in deals:
item = {
"id": d.id,
"code": d.code,
"title": d.title,
"amount": float(d.amount or 0),
"stage_id": d.stage_id,
"stage_name": d.stage.name if d.stage else None,
"closed_at": _iso(d.closed_at),
"won_reason_code": d.won_reason_code,
"lost_reason_code": d.lost_reason_code,
"assigned_to_name": _full_name(d.assigned_to),
"created_at": _iso(d.created_at),
}
(closed_deals if d.closed_at else open_deals).append(item)
# فعالیت‌های اخیر
activities = (
db.query(CrmActivity)
.options(selectinload(CrmActivity.created_by), selectinload(CrmActivity.assigned_to))
.filter(CrmActivity.business_id == business_id, CrmActivity.person_id == person_id)
.order_by(CrmActivity.activity_date.desc())
.limit(30)
.all()
)
recent_activities = [
{
"id": a.id,
"activity_type": a.activity_type,
"subject": a.subject,
"description": a.description,
"activity_date": _iso(a.activity_date),
"is_task": bool(a.is_task),
"status": a.status,
"due_at": _iso(a.due_at),
"outcome": a.outcome,
"created_by_name": _full_name(a.created_by),
}
for a in activities
]
# وظایف باز
tasks_open_rows = (
db.query(CrmActivity)
.options(selectinload(CrmActivity.assigned_to))
.filter(
CrmActivity.business_id == business_id,
CrmActivity.person_id == person_id,
CrmActivity.is_task.is_(True),
CrmActivity.status == "open",
)
.order_by(CrmActivity.due_at.asc().nullslast())
.all()
)
tasks_open = [
{
"id": t.id,
"subject": t.subject,
"due_at": _iso(t.due_at),
"priority": t.priority,
"assigned_to_name": _full_name(t.assigned_to),
}
for t in tasks_open_rows
]
# سرنخ‌هایی که به این شخص تبدیل شده‌اند
leads = (
db.query(Lead)
.filter(Lead.business_id == business_id, Lead.person_id == person_id)
.order_by(Lead.created_at.desc())
.all()
)
converted_leads = [
{
"id": l.id,
"code": l.code,
"name": l.name,
"source_code": l.source_code,
"converted_at": _iso(l.converted_at),
"created_at": _iso(l.created_at),
}
for l in leads
]
lead_ids = [l.id for l in leads]
# یادداشت‌های متصل از طریق سرنخ
notes: List[Dict[str, Any]] = []
if lead_ids:
note_rows = (
db.query(CrmNote)
.filter(
CrmNote.business_id == business_id,
CrmNote.lead_id.in_(lead_ids),
CrmNote.deleted_at.is_(None),
)
.order_by(CrmNote.occurs_on.desc())
.limit(30)
.all()
)
notes = [
{
"id": n.id,
"title": n.title,
"body": n.body,
"occurs_on": n.occurs_on.isoformat() if n.occurs_on else None,
"lead_id": n.lead_id,
}
for n in note_rows
]
# اسناد اخیر (فاکتورها) از طریق خطوط سند
# توجه: Document.extra_info از نوع JSON است؛ DISTINCT روی کل ردیف در PostgreSQL خطا می‌دهد.
doc_id_rows = (
db.query(Document.id, Document.document_date)
.join(DocumentLine, DocumentLine.document_id == Document.id)
.filter(Document.business_id == business_id, DocumentLine.person_id == person_id)
.group_by(Document.id, Document.document_date)
.order_by(Document.document_date.desc(), Document.id.desc())
.limit(20)
.all()
)
doc_ids = [row[0] for row in doc_id_rows]
documents: List[Dict[str, Any]] = []
if doc_ids:
doc_by_id = {
d.id: d
for d in db.query(Document).filter(Document.id.in_(doc_ids)).all()
}
for did in doc_ids:
d = doc_by_id.get(did)
if not d:
continue
documents.append(
{
"id": d.id,
"code": d.code,
"document_type": d.document_type,
"document_date": d.document_date.isoformat() if d.document_date else None,
"is_proforma": bool(d.is_proforma),
"description": d.description,
}
)
# برچسب‌ها از معاملات متصل
deal_ids = [d.id for d in deals]
tags: List[Dict[str, Any]] = []
if deal_ids:
tag_id_rows = (
db.query(CrmTag.id)
.join(CrmDealTagLink, CrmDealTagLink.tag_id == CrmTag.id)
.filter(CrmDealTagLink.deal_id.in_(deal_ids), CrmTag.business_id == business_id)
.group_by(CrmTag.id)
.all()
)
tag_ids = [r[0] for r in tag_id_rows]
if tag_ids:
tag_rows = db.query(CrmTag).filter(CrmTag.id.in_(tag_ids)).order_by(CrmTag.sort_order.asc(), CrmTag.id.asc()).all()
tags = [{"id": t.id, "name": t.name, "color": t.color} for t in tag_rows]
# تایم‌لاین رویدادهای ترکیبی مرتب‌شده نزولی بر اساس تاریخ
timeline: List[Dict[str, Any]] = []
for a in recent_activities:
timeline.append(
{
"type": "task" if a["is_task"] else "activity",
"date": a["activity_date"],
"title": a["subject"] or a["activity_type"],
"ref_id": a["id"],
}
)
for d in open_deals + closed_deals:
timeline.append(
{"type": "deal", "date": d["created_at"], "title": d["title"], "ref_id": d["id"]}
)
for l in converted_leads:
timeline.append(
{"type": "lead", "date": l["created_at"], "title": l["name"], "ref_id": l["id"]}
)
for n in notes:
timeline.append(
{"type": "note", "date": n["occurs_on"], "title": n["title"] or "یادداشت", "ref_id": n["id"]}
)
for doc in documents:
timeline.append(
{"type": "document", "date": doc["document_date"], "title": doc["code"], "ref_id": doc["id"]}
)
timeline.sort(key=lambda x: (x.get("date") or ""), reverse=True)
# خلاصه آماری
total_won = sum(d["amount"] for d in closed_deals)
summary = {
"open_deals_count": len(open_deals),
"closed_deals_count": len(closed_deals),
"open_deals_amount": sum(d["amount"] for d in open_deals),
"closed_deals_amount": total_won,
"open_tasks_count": len(tasks_open),
"activities_count": len(recent_activities),
}
return {
"person": core,
"social_contacts": social_contacts,
"summary": summary,
"open_deals": open_deals,
"closed_deals": closed_deals,
"recent_activities": recent_activities,
"tasks_open": tasks_open,
"converted_leads": converted_leads,
"notes": notes,
"documents": documents,
"tags": tags,
"timeline": timeline,
}