baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
"""AI 应收风险预警(纯统计引擎,不依赖 LLM)。
|
||||
|
||||
对标结论:竞品 AI 全在开单/问答,没人做应收风险——这是差异化卖点。
|
||||
本模块只做统计评分(可离线、可测试、无 KEY 也能跑),LLM 仅作为话术增强(见 `advice.py`)。
|
||||
|
||||
三因子加权 → 0-100 风险分:
|
||||
1. 回款周期漂移(权重 40):客户近期实际回款天数 vs 历史均值,变慢得高分
|
||||
2. 欠款趋势(权重 35):未结余额环比上升幅度
|
||||
3. 开单频次骤降(权重 25):欠款在账但近期不再开单(可能已流失/绕单)
|
||||
|
||||
评分分档:high ≥70 / medium 40-69 / low <40
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from django.db.models import Sum
|
||||
|
||||
# 因子权重(和为 1.0)
|
||||
WEIGHT_CYCLE_DRIFT = Decimal("0.40")
|
||||
WEIGHT_OUTSTANDING_TREND = Decimal("0.35")
|
||||
WEIGHT_ORDER_DROP = Decimal("0.25")
|
||||
|
||||
# 分档阈值
|
||||
THRESHOLD_HIGH = Decimal("70")
|
||||
THRESHOLD_MEDIUM = Decimal("40")
|
||||
|
||||
RECENT_WINDOW_DAYS = 90
|
||||
BASELINE_WINDOW_DAYS = 365
|
||||
|
||||
|
||||
def _d(value) -> Decimal:
|
||||
return Decimal(str(value or 0))
|
||||
|
||||
|
||||
def _payback_days_by_receivable(tenant, customer, *, since: Optional[date] = None) -> list:
|
||||
"""返回该客户每张已核销应收的"从开单到收齐"天数列表。
|
||||
|
||||
用 Allocation(收款核销)里最晚一笔核销日期 - 应收单开单日期近似回款周期;
|
||||
未结清的应收不参与(它们没有回款周期,但会进"欠款趋势"因子)。
|
||||
"""
|
||||
from apps.finance.models import Allocation
|
||||
|
||||
qs = Allocation.objects.filter(
|
||||
tenant=tenant,
|
||||
kind="receipt",
|
||||
receivable__customer=customer,
|
||||
receivable__isnull=False,
|
||||
is_deleted=False,
|
||||
).select_related("receipt", "receivable")
|
||||
|
||||
if since is not None:
|
||||
qs = qs.filter(receivable__bill_date__gte=since)
|
||||
|
||||
latest = {}
|
||||
for a in qs:
|
||||
rid = a.receivable_id
|
||||
rd = a.receipt.bill_date if a.receipt and a.receipt.bill_date else None
|
||||
if rd is None:
|
||||
continue
|
||||
if rid not in latest or rd > latest[rid][1]:
|
||||
latest[rid] = (a.receivable.bill_date, rd)
|
||||
|
||||
days = []
|
||||
for bill_date, receipt_date in latest.values():
|
||||
delta = (receipt_date - bill_date).days
|
||||
if delta >= 0:
|
||||
days.append(delta)
|
||||
return days
|
||||
|
||||
|
||||
def cycle_drift_score(tenant, customer, *, today: Optional[date] = None) -> dict:
|
||||
"""回款周期漂移因子:近期平均回款天数 vs 历史基线。"""
|
||||
today = today or date.today()
|
||||
recent_start = today - timedelta(days=RECENT_WINDOW_DAYS)
|
||||
baseline_start = today - timedelta(days=BASELINE_WINDOW_DAYS)
|
||||
|
||||
recent = _payback_days_by_receivable(tenant, customer, since=recent_start)
|
||||
baseline_all = _payback_days_by_receivable(tenant, customer, since=baseline_start)
|
||||
# 基线排除近期,避免自己比自己
|
||||
baseline = [d for d in baseline_all if d not in recent] or baseline_all
|
||||
|
||||
if not recent or not baseline:
|
||||
return {"score": Decimal("0"), "recent_days": None, "baseline_days": None,
|
||||
"reason": "样本不足,回款周期因子不计分"}
|
||||
|
||||
recent_avg = Decimal(sum(recent)) / Decimal(len(recent))
|
||||
base_avg = Decimal(sum(baseline)) / Decimal(len(baseline))
|
||||
if base_avg <= 0:
|
||||
return {"score": Decimal("0"), "recent_days": recent_avg,
|
||||
"baseline_days": base_avg, "reason": "基线为 0,不计分"}
|
||||
|
||||
ratio = recent_avg / base_avg
|
||||
# ratio ≤1 → 0 分;1.0→1.5 线性到 100 分;≥1.5 → 100
|
||||
if ratio <= 1:
|
||||
score = Decimal("0")
|
||||
else:
|
||||
score = min(Decimal("100"), (ratio - 1) / Decimal("0.5") * Decimal("100"))
|
||||
return {
|
||||
"score": score.quantize(Decimal("0.01")),
|
||||
"recent_days": float(recent_avg),
|
||||
"baseline_days": float(base_avg),
|
||||
"reason": (f"近 {RECENT_WINDOW_DAYS} 天平均回款 {recent_avg:.0f} 天,"
|
||||
f"历史均值 {base_avg:.0f} 天,"
|
||||
f"{'变慢' if ratio > 1 else '未变慢'} {abs(ratio - 1) * 100:.0f}%"),
|
||||
}
|
||||
|
||||
|
||||
def outstanding_trend_score(tenant, customer, *, today: Optional[date] = None) -> dict:
|
||||
"""欠款趋势因子:本月未结 vs 上月同口径,上升得高分。"""
|
||||
from apps.finance.models import Receivable
|
||||
|
||||
today = today or date.today()
|
||||
this_month_start = today.replace(day=1)
|
||||
last_month_end = this_month_start - timedelta(days=1)
|
||||
last_month_start = last_month_end.replace(day=1)
|
||||
|
||||
def _outstanding(as_of: date) -> Decimal:
|
||||
agg = Receivable.objects.filter(
|
||||
tenant=tenant, customer=customer,
|
||||
status__in=["open", "partial"], is_deleted=False,
|
||||
bill_date__lte=as_of,
|
||||
).aggregate(amount=Sum("total_amount"), paid=Sum("paid_amount"))
|
||||
return _d(agg["amount"]) - _d(agg["paid"])
|
||||
|
||||
now_val = _outstanding(today)
|
||||
prev_val = _outstanding(last_month_end)
|
||||
|
||||
if prev_val <= 0:
|
||||
# 上期无欠款、本期有 → 新增欠款,按金额档给分(不直接满分)
|
||||
if now_val > 0:
|
||||
return {"score": Decimal("50"), "current": float(now_val),
|
||||
"previous": 0.0, "reason": f"上月无欠款,本月新增未结 ¥{now_val:.2f}"}
|
||||
return {"score": Decimal("0"), "current": 0.0, "previous": 0.0,
|
||||
"reason": "两期均无欠款"}
|
||||
|
||||
growth = (now_val - prev_val) / prev_val
|
||||
if growth <= 0:
|
||||
score = Decimal("0")
|
||||
else:
|
||||
# 增长 0→50% 线性到 100 分
|
||||
score = min(Decimal("100"), growth / Decimal("0.5") * Decimal("100"))
|
||||
return {
|
||||
"score": score.quantize(Decimal("0.01")),
|
||||
"current": float(now_val),
|
||||
"previous": float(prev_val),
|
||||
"reason": (f"未结余额 ¥{prev_val:.2f} → ¥{now_val:.2f},"
|
||||
f"{'上升' if growth > 0 else '下降'} {abs(growth) * 100:.0f}%"),
|
||||
}
|
||||
|
||||
|
||||
def order_drop_score(tenant, customer, *, today: Optional[date] = None) -> dict:
|
||||
"""开单频次骤降因子:欠款在账但连续 N 天无新单。"""
|
||||
from apps.sales.models import SalesBill
|
||||
|
||||
today = today or date.today()
|
||||
last_bill = SalesBill.objects.filter(
|
||||
tenant=tenant, customer=customer, is_deleted=False,
|
||||
).exclude(state="cancelled").order_by("-bill_date").first()
|
||||
|
||||
from apps.partner.services import credit_usage
|
||||
usage = credit_usage(tenant=tenant, customer=customer)
|
||||
outstanding = _d(usage["outstanding"])
|
||||
|
||||
if outstanding <= 0:
|
||||
return {"score": Decimal("0"), "days_since_last_bill": None,
|
||||
"reason": "无欠款,频次因子不适用"}
|
||||
|
||||
if last_bill is None:
|
||||
return {"score": Decimal("80"), "days_since_last_bill": None,
|
||||
"reason": "有欠款但从未开单(历史导入或异常)"}
|
||||
|
||||
gap = (today - last_bill.bill_date).days
|
||||
if gap <= 30:
|
||||
score = Decimal("0")
|
||||
elif gap >= 90:
|
||||
score = Decimal("100")
|
||||
else:
|
||||
score = (Decimal(gap - 30) / Decimal("60") * Decimal("100"))
|
||||
return {
|
||||
"score": score.quantize(Decimal("0.01")),
|
||||
"days_since_last_bill": gap,
|
||||
"last_bill_no": last_bill.bill_no,
|
||||
"reason": (f"已 {gap} 天未开新单,但仍有 ¥{outstanding:.2f} 欠款在账"
|
||||
if gap > 30 else f"最近 {gap} 天内有开单,频次正常"),
|
||||
}
|
||||
|
||||
|
||||
def score_customer(tenant, customer, *, today: Optional[date] = None) -> dict:
|
||||
"""单客户风险评分:三因子加权 → 0-100 + 分档 + 理由。"""
|
||||
cycle = cycle_drift_score(tenant, customer, today=today)
|
||||
trend = outstanding_trend_score(tenant, customer, today=today)
|
||||
drop = order_drop_score(tenant, customer, today=today)
|
||||
|
||||
total = (
|
||||
_d(cycle["score"]) * WEIGHT_CYCLE_DRIFT
|
||||
+ _d(trend["score"]) * WEIGHT_OUTSTANDING_TREND
|
||||
+ _d(drop["score"]) * WEIGHT_ORDER_DROP
|
||||
).quantize(Decimal("0.01"))
|
||||
|
||||
if total >= THRESHOLD_HIGH:
|
||||
level = "high"
|
||||
elif total >= THRESHOLD_MEDIUM:
|
||||
level = "medium"
|
||||
else:
|
||||
level = "low"
|
||||
|
||||
from apps.partner.services import credit_usage
|
||||
usage = credit_usage(tenant=tenant, customer=customer)
|
||||
|
||||
return {
|
||||
"customer_id": customer.id,
|
||||
"customer_code": customer.code,
|
||||
"customer_name": customer.name,
|
||||
"score": float(total),
|
||||
"level": level,
|
||||
"outstanding": float(_d(usage["outstanding"])),
|
||||
"limit": float(_d(usage["limit"])),
|
||||
"factors": {
|
||||
"cycle_drift": {**cycle, "score": float(cycle["score"]),
|
||||
"weight": float(WEIGHT_CYCLE_DRIFT)},
|
||||
"outstanding_trend": {**trend, "score": float(trend["score"]),
|
||||
"weight": float(WEIGHT_OUTSTANDING_TREND)},
|
||||
"order_drop": {**drop, "score": float(drop["score"]),
|
||||
"weight": float(WEIGHT_ORDER_DROP)},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def risk_ranking(tenant, *, top_n: int = 5, min_outstanding: Decimal = Decimal("0"),
|
||||
today: Optional[date] = None) -> list:
|
||||
"""全租户客户风险排行(默认只看有欠款的客户),按分数降序取前 N。"""
|
||||
from apps.finance.models import Receivable
|
||||
from apps.partner.models import Customer
|
||||
from django.db.models import Q
|
||||
|
||||
customer_ids = list(
|
||||
Receivable.objects.filter(
|
||||
tenant=tenant, status__in=["open", "partial"], is_deleted=False,
|
||||
).values_list("customer_id", flat=True).distinct()
|
||||
)
|
||||
if not customer_ids:
|
||||
return []
|
||||
|
||||
qs = Customer.objects.filter(tenant=tenant, id__in=customer_ids, is_deleted=False)
|
||||
if min_outstanding > 0:
|
||||
qs = qs.filter(Q(credit_limit__gt=0) | Q(id__in=customer_ids))
|
||||
|
||||
results = [score_customer(tenant, c, today=today) for c in qs]
|
||||
results = [r for r in results if _d(r["outstanding"]) > min_outstanding]
|
||||
results.sort(key=lambda r: r["score"], reverse=True)
|
||||
return results[:top_n]
|
||||
Reference in New Issue
Block a user