Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/customer_club_analytics_service.py
2026-04-25 02:31:13 +03:30

459 lines
15 KiB
Python
Executable file

from __future__ import annotations
import logging
from calendar import monthrange
from collections import defaultdict
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional, Sequence, Tuple
from sqlalchemy import String, and_, cast, desc, func, or_
from sqlalchemy.orm import Session
from adapters.db.models.customer_club import CustomerClubBalance, CustomerClubRfmSnapshot, CustomerClubSettings
from adapters.db.models.document import Document
from adapters.db.models.person import Person
from app.core.responses import ApiError
from app.services.customer_club_service import (
_basis_net_and_total_from_document,
_person_is_customer,
rfm_normalized_from_snapshot,
)
from app.services.invoice_service import INVOICE_SALES, INVOICE_SALES_RETURN
logger = logging.getLogger(__name__)
def subtract_months(d: date, months: int) -> date:
y, mth = d.year, d.month
mth -= months
while mth <= 0:
mth += 12
y -= 1
last_day = monthrange(y, mth)[1]
return date(y, mth, min(d.day, last_day))
def _monetary_amount(db: Session, doc: Document, monetary_basis: str) -> Decimal:
net_b, total_b = _basis_net_and_total_from_document(db, doc)
return total_b if monetary_basis == "total_with_tax" else net_b
def _quintile_scores(values: Sequence[float], *, higher_is_better: bool) -> List[int]:
n = len(values)
if n == 0:
return []
if n == 1:
return [5]
order = sorted(range(n), key=lambda i: values[i], reverse=higher_is_better)
scores = [1] * n
for q in range(5):
start = q * n // 5
end = (q + 1) * n // 5 if q < 4 else n
score_val = 5 - q
for j in range(start, end):
scores[order[j]] = score_val
return scores
def _normalize_weights(wr: Optional[Decimal], wf: Optional[Decimal], wm: Optional[Decimal]) -> Tuple[Decimal, Decimal, Decimal]:
if wr is not None and wf is not None and wm is not None:
s = wr + wf + wm
if s > 0:
return wr / s, wf / s, wm / s
return Decimal("0.34"), Decimal("0.33"), Decimal("0.33")
def _min_max_norm(xs: Sequence[float], *, invert: bool) -> List[float]:
if not xs:
return []
mn = min(xs)
mx = max(xs)
span = float(mx - mn) if mx != mn else 1.0
out: List[float] = []
for x in xs:
v = float((Decimal(str(x)) - Decimal(str(mn))) / Decimal(str(span)))
if invert:
v = 1.0 - v
out.append(max(0.0, min(1.0, v)))
return out
def default_segment_label(r: int, f: int, m: int) -> str:
if r >= 5 and f >= 5 and m >= 5:
return "قهرمانان"
if r >= 4 and f >= 4 and m >= 4:
return "وفاداران ارزشمند"
if r >= 5 and f <= 2 and m >= 4:
return "تازه‌واردهای پرارزش"
if r >= 5 and f <= 2:
return "تازه‌وارد"
if r <= 2 and f >= 4:
return "در معرض ریزش"
if r <= 2 and f <= 2 and m <= 2:
return "غیرفعال / از دست‌رفته"
if r <= 2:
return "خرید قدیمی"
if f >= 4 and m <= 2:
return "پرتکرار کم‌ارزش"
if m >= 5 and f <= 2:
return "خریدهای احجام بالا (گاه‌به‌گاه)"
return "سگمنت ترکیبی"
def _resolve_segment_label(
r: int,
f: int,
m: int,
custom_map: Optional[Dict[str, Any]],
) -> str:
key = f"{r}-{f}-{m}"
if isinstance(custom_map, dict) and custom_map.get(key):
return str(custom_map[key])[:160]
return default_segment_label(r, f, m)
def _compute_clv(
settings: CustomerClubSettings,
monetary: Decimal,
frequency: int,
window_months: int,
) -> Optional[Decimal]:
if not getattr(settings, "clv_analytics_enabled", False):
return None
formula = getattr(settings, "clv_formula", None) or "historical_total"
if formula == "historical_total":
return monetary.quantize(Decimal("0.0001"))
if formula == "avg_order_projection":
life = getattr(settings, "clv_avg_lifespan_years", None) or Decimal("3")
if frequency <= 0 or window_months <= 0:
return Decimal("0")
years = Decimal(str(window_months)) / Decimal("12")
if years <= 0:
return Decimal("0")
aov = monetary / Decimal(str(frequency))
purchases_per_year = Decimal(str(frequency)) / years
out = aov * purchases_per_year * life
return out.quantize(Decimal("0.0001"))
return monetary.quantize(Decimal("0.0001"))
def recalculate_rfm_snapshots(db: Session, business_id: int) -> Dict[str, Any]:
settings = db.query(CustomerClubSettings).filter(CustomerClubSettings.business_id == business_id).first()
if not settings:
raise ApiError(
"CUSTOMER_CLUB_SETTINGS_MISSING",
"Customer club settings not found.",
http_status=404,
)
if not getattr(settings, "rfm_analytics_enabled", False) and not getattr(settings, "clv_analytics_enabled", False):
raise ApiError(
"CUSTOMER_CLUB_ANALYTICS_DISABLED",
"Enable RFM or CLV analytics in customer club settings first.",
http_status=400,
)
window_months = max(1, int(getattr(settings, "rfm_analysis_window_months", 12) or 12))
end_d = date.today()
start_d = subtract_months(end_d, window_months)
monetary_basis = getattr(settings, "rfm_monetary_basis", None) or "net"
scoring_method = getattr(settings, "rfm_scoring_method", None) or "quintiles"
custom_labels = getattr(settings, "rfm_segment_labels_json", None)
wr, wf, wm = _normalize_weights(
getattr(settings, "rfm_weight_recency", None),
getattr(settings, "rfm_weight_frequency", None),
getattr(settings, "rfm_weight_monetary", None),
)
docs = (
db.query(Document)
.filter(
and_(
Document.business_id == business_id,
Document.is_proforma.is_(False),
Document.document_type.in_((INVOICE_SALES, INVOICE_SALES_RETURN)),
Document.document_date >= start_d,
Document.document_date <= end_d,
)
)
.all()
)
sales_dates: Dict[int, List[date]] = defaultdict(list)
sales_count: Dict[int, int] = defaultdict(int)
monetary_sum: Dict[int, Decimal] = defaultdict(lambda: Decimal("0"))
for doc in docs:
ex = doc.extra_info or {}
if not isinstance(ex, dict):
continue
pid = ex.get("person_id")
try:
person_id = int(pid) if pid is not None else None
except (TypeError, ValueError):
continue
if not person_id:
continue
person = db.query(Person).filter(and_(Person.id == person_id, Person.business_id == business_id)).first()
if not person:
continue
if settings.require_customer_person_type and not _person_is_customer(person):
continue
amt = _monetary_amount(db, doc, monetary_basis)
if doc.document_type == INVOICE_SALES:
sales_dates[person_id].append(doc.document_date)
sales_count[person_id] += 1
monetary_sum[person_id] += amt
else:
monetary_sum[person_id] -= amt
eligible_pids = [pid for pid in sales_count.keys() if sales_count[pid] > 0]
if not eligible_pids:
db.query(CustomerClubRfmSnapshot).filter(CustomerClubRfmSnapshot.business_id == business_id).delete(
synchronize_session=False
)
db.commit()
return {
"persons_computed": 0,
"window_start": start_d.isoformat(),
"window_end": end_d.isoformat(),
"computed_at": datetime.utcnow().isoformat(),
}
recency_days: Dict[int, int] = {}
for pid in eligible_pids:
last_dt = max(sales_dates[pid])
recency_days[pid] = max(0, (end_d - last_dt).days)
index_order = eligible_pids
n = len(index_order)
R_raw = [float(recency_days[pid]) for pid in index_order]
F_raw = [float(sales_count[pid]) for pid in index_order]
M_raw = [float(monetary_sum[pid]) for pid in index_order]
r_scores = _quintile_scores(R_raw, higher_is_better=False)
f_scores = _quintile_scores(F_raw, higher_is_better=True)
m_scores = _quintile_scores(M_raw, higher_is_better=True)
composite: Optional[List[float]] = None
if scoring_method == "weighted":
norm_r = _min_max_norm(R_raw, invert=True)
norm_f = _min_max_norm(F_raw, invert=False)
norm_m = _min_max_norm(M_raw, invert=False)
composite = [
float(wr * Decimal(str(norm_r[i])) + wf * Decimal(str(norm_f[i])) + wm * Decimal(str(norm_m[i])))
for i in range(n)
]
computed_at = datetime.utcnow()
db.query(CustomerClubRfmSnapshot).filter(CustomerClubRfmSnapshot.business_id == business_id).delete(
synchronize_session=False
)
for i, pid in enumerate(index_order):
r = int(r_scores[i])
f = int(f_scores[i])
m = int(m_scores[i])
cell = f"{r}-{f}-{m}"
label = _resolve_segment_label(r, f, m, custom_labels if isinstance(custom_labels, dict) else None)
money = monetary_sum[pid].quantize(Decimal("0.0001"))
comp_dec: Decimal | None = None
if composite is not None:
comp_dec = Decimal(str(composite[i])).quantize(Decimal("0.00000001"))
clv = _compute_clv(settings, monetary_sum[pid], int(sales_count[pid]), window_months)
row = CustomerClubRfmSnapshot(
business_id=business_id,
person_id=pid,
recency_days=int(recency_days[pid]),
frequency_count=int(sales_count[pid]),
monetary_total=money,
r_score=r if getattr(settings, "rfm_analytics_enabled", False) else None,
f_score=f if getattr(settings, "rfm_analytics_enabled", False) else None,
m_score=m if getattr(settings, "rfm_analytics_enabled", False) else None,
rfm_cell=cell if getattr(settings, "rfm_analytics_enabled", False) else None,
segment_label=label if getattr(settings, "rfm_analytics_enabled", False) else None,
composite_score=comp_dec if getattr(settings, "rfm_analytics_enabled", False) else None,
clv_estimate=clv,
computed_at=computed_at,
)
db.add(row)
db.commit()
return {
"persons_computed": n,
"window_start": start_d.isoformat(),
"window_end": end_d.isoformat(),
"computed_at": computed_at.isoformat(),
}
def get_rfm_summary(db: Session, business_id: int) -> Dict[str, Any]:
settings = db.query(CustomerClubSettings).filter(CustomerClubSettings.business_id == business_id).first()
window_months = int(getattr(settings, "rfm_analysis_window_months", 12) or 12) if settings else 12
end_d = date.today()
start_d = subtract_months(end_d, window_months)
subq = (
db.query(func.max(CustomerClubRfmSnapshot.computed_at))
.filter(CustomerClubRfmSnapshot.business_id == business_id)
.scalar()
)
total = db.query(func.count(CustomerClubRfmSnapshot.id)).filter(
CustomerClubRfmSnapshot.business_id == business_id
).scalar() or 0
segments_rows = (
db.query(CustomerClubRfmSnapshot.segment_label, func.count(CustomerClubRfmSnapshot.id))
.filter(CustomerClubRfmSnapshot.business_id == business_id)
.group_by(CustomerClubRfmSnapshot.segment_label)
.all()
)
segments = [{"label": str(lab or ""), "count": int(cnt)} for lab, cnt in segments_rows]
segments.sort(key=lambda x: (-x["count"], x["label"]))
integration_mode = (
getattr(settings, "loyalty_rfm_integration_mode", None) or "decoupled" if settings else "decoupled"
)
return {
"total_persons": int(total),
"computed_at": subq.isoformat() if subq else None,
"window": {
"start": start_d.isoformat(),
"end": end_d.isoformat(),
"months": window_months,
},
"segments": segments,
"rfm_enabled": bool(getattr(settings, "rfm_analytics_enabled", False)) if settings else False,
"clv_enabled": bool(getattr(settings, "clv_analytics_enabled", False)) if settings else False,
"loyalty_rfm_integration_mode": integration_mode,
}
def list_rfm_persons(
db: Session,
business_id: int,
*,
skip: int = 0,
limit: int = 50,
segment_label: Optional[str] = None,
search: Optional[str] = None,
sort: str = "monetary_total",
sort_dir: str = "desc",
) -> Tuple[List[Dict[str, Any]], int]:
q = (
db.query(CustomerClubRfmSnapshot, Person)
.join(Person, Person.id == CustomerClubRfmSnapshot.person_id)
.filter(CustomerClubRfmSnapshot.business_id == business_id)
)
if segment_label and segment_label.strip():
q = q.filter(CustomerClubRfmSnapshot.segment_label == segment_label.strip())
if search and search.strip():
term = f"%{search.strip()}%"
q = q.filter(
or_(
Person.alias_name.ilike(term),
Person.company_name.ilike(term),
cast(Person.code, String).ilike(term),
)
)
total = q.count()
sort_col = CustomerClubRfmSnapshot.monetary_total
if sort == "recency_days":
sort_col = CustomerClubRfmSnapshot.recency_days
elif sort == "frequency_count":
sort_col = CustomerClubRfmSnapshot.frequency_count
elif sort == "clv_estimate":
sort_col = CustomerClubRfmSnapshot.clv_estimate
elif sort == "segment_label":
sort_col = CustomerClubRfmSnapshot.segment_label
elif sort == "composite_score":
sort_col = CustomerClubRfmSnapshot.composite_score
if sort_dir.lower() == "asc":
q = q.order_by(sort_col.asc().nulls_last(), CustomerClubRfmSnapshot.person_id.asc())
else:
q = q.order_by(sort_col.desc().nulls_last(), CustomerClubRfmSnapshot.person_id.asc())
rows = q.offset(skip).limit(limit).all()
balances = {}
pids = [int(s.person_id) for s, _p in rows if s.person_id]
if pids:
brs = (
db.query(CustomerClubBalance)
.filter(
and_(CustomerClubBalance.business_id == business_id, CustomerClubBalance.person_id.in_(pids))
)
.all()
)
for b in brs:
balances[int(b.person_id)] = float(b.balance_points)
items: List[Dict[str, Any]] = []
for snap, person in rows:
pid = int(snap.person_id)
rfm_n = rfm_normalized_from_snapshot(snap)
items.append(
{
"person_id": pid,
"person_name": person.alias_name,
"person_code": person.code,
"company_name": person.company_name,
"recency_days": snap.recency_days,
"frequency_count": snap.frequency_count,
"monetary_total": float(snap.monetary_total),
"r_score": snap.r_score,
"f_score": snap.f_score,
"m_score": snap.m_score,
"rfm_cell": snap.rfm_cell,
"segment_label": snap.segment_label,
"composite_score": float(snap.composite_score) if snap.composite_score is not None else None,
"rfm_normalized_score": float(rfm_n) if rfm_n is not None else None,
"clv_estimate": float(snap.clv_estimate) if snap.clv_estimate is not None else None,
"loyalty_balance_points": balances.get(pid),
"computed_at": snap.computed_at.isoformat() if snap.computed_at else None,
}
)
return items, int(total)
def list_rfm_person_ids(
db: Session,
business_id: int,
*,
segment_label: Optional[str] = None,
search: Optional[str] = None,
limit: int = 5000,
) -> Dict[str, Any]:
limit = min(max(1, int(limit)), 10000)
q = db.query(CustomerClubRfmSnapshot.person_id).filter(CustomerClubRfmSnapshot.business_id == business_id)
if segment_label and segment_label.strip():
q = q.filter(CustomerClubRfmSnapshot.segment_label == segment_label.strip())
if search and search.strip():
term = f"%{search.strip()}%"
q = q.join(Person, Person.id == CustomerClubRfmSnapshot.person_id).filter(
or_(
Person.alias_name.ilike(term),
Person.company_name.ilike(term),
cast(Person.code, String).ilike(term),
)
)
q = q.distinct()
total = q.count()
rows = q.order_by(CustomerClubRfmSnapshot.person_id.asc()).limit(limit).all()
ids = [int(r[0]) for r in rows]
return {
"person_ids": ids,
"total": int(total),
"limit": limit,
"truncated": int(total) > len(ids),
}