Files

256 lines
9.7 KiB
Python
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.
"""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]