"""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]