Files

354 lines
14 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.
"""批次 B1 · AI 应收风险预警测试。
覆盖:三因子评分逻辑(回款变慢 / 欠款上升 / 频次骤降各有正分)、分档、
风险排行、预警去重、API 全链路、无 LLM KEY 时的降级路径、租户隔离。
"""
import pytest
from datetime import date, timedelta
from decimal import Decimal
from model_bakery import baker
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.catalog.models import Product
from apps.inventory.models import Warehouse
from apps.partner.models import Customer
from apps.finance.models import Receivable, Receipt, Allocation
from apps.notify.models import Notification, AlertRule
from apps.sales.models import SalesBill
from apps.ai import risk as ai_risk
@pytest.fixture
def auth_client(db, user, tenant):
c = APIClient()
refresh = RefreshToken.for_user(user)
c.credentials(
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
HTTP_X_TENANT_ID=tenant.code,
)
return c
@pytest.fixture
def customer(db, tenant):
return baker.make(Customer, tenant=tenant, code="C001", name="张三便利店",
credit_limit=Decimal("5000"))
@pytest.fixture
def warehouse(db, tenant):
return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓")
@pytest.fixture
def product(db, tenant):
return baker.make(Product, tenant=tenant, code="P001", name="可乐",
sale_price=Decimal("10"))
def _receivable(tenant, customer, amount, *, bill_date, paid=0, no="RC001"):
return baker.make(
Receivable, tenant=tenant, customer=customer, bill_no=no,
bill_date=bill_date, total_amount=Decimal(str(amount)),
paid_amount=Decimal(str(paid)),
status="paid" if paid and Decimal(str(paid)) >= Decimal(str(amount)) else
("partial" if paid else "open"),
)
def _settle(tenant, customer, recv, *, receipt_date, method="现金"):
"""造一笔收款 + 核销,用于回款周期计算。"""
receipt = baker.make(
Receipt, tenant=tenant, customer=customer, bill_no=f"SK{recv.bill_no}",
bill_date=receipt_date, amount=recv.total_amount, status="posted", method=method,
)
baker.make(
Allocation, tenant=tenant, kind="receipt",
receivable=recv, receipt=receipt, amount=recv.total_amount,
)
return receipt
# ---------- 因子:回款周期漂移 ----------
def test_cycle_drift_zero_when_no_samples(tenant, customer):
"""无核销记录 → 不计分(不误报)。"""
out = ai_risk.cycle_drift_score(tenant, customer)
assert Decimal(str(out["score"])) == 0
assert "样本不足" in out["reason"]
def test_cycle_drift_positive_when_slower(tenant, customer):
"""历史 10 天回款,近期 40 天回款 → 明显变慢,得分高。"""
today = date.today()
# 基线:1 年前 3 笔,10 天回款
for i in range(3):
d = today - timedelta(days=300 + i * 10)
r = _receivable(tenant, customer, 100, bill_date=d, no=f"RC-B{i}")
_settle(tenant, customer, r, receipt_date=d + timedelta(days=10))
# 近期:40 天回款(仍在 90 天窗口内)
r2 = _receivable(tenant, customer, 200, bill_date=today - timedelta(days=50), no="RC-N1")
_settle(tenant, customer, r2, receipt_date=today - timedelta(days=10))
out = ai_risk.cycle_drift_score(tenant, customer, today=today)
assert Decimal(str(out["score"])) > 50, out
assert out["recent_days"] > out["baseline_days"]
def test_cycle_drift_zero_when_faster(tenant, customer):
"""近期回款更快 → 不得分。"""
today = date.today()
for i in range(3):
d = today - timedelta(days=300 + i * 10)
r = _receivable(tenant, customer, 100, bill_date=d, no=f"RC-S{i}")
_settle(tenant, customer, r, receipt_date=d + timedelta(days=60))
r2 = _receivable(tenant, customer, 200, bill_date=today - timedelta(days=30), no="RC-F1")
_settle(tenant, customer, r2, receipt_date=today - timedelta(days=25))
out = ai_risk.cycle_drift_score(tenant, customer, today=today)
assert Decimal(str(out["score"])) == 0, out
# ---------- 因子:欠款趋势 ----------
def test_outstanding_trend_positive_on_growth(tenant, customer):
"""上月欠 1000,本月欠 1600(+60%)→ 高分。"""
today = date.today()
this_month_start = today.replace(day=1)
last_month_end = this_month_start - timedelta(days=1)
_receivable(tenant, customer, 1000, bill_date=last_month_end - timedelta(days=5),
no="RC-OLD")
_receivable(tenant, customer, 600, bill_date=today, no="RC-NEW")
# 注意:本月口径含上月未结,故 as_of 今天 = 1600,上月 = 1000
out = ai_risk.outstanding_trend_score(tenant, customer, today=today)
assert Decimal(str(out["score"])) > 50, out
assert out["current"] > out["previous"]
def test_outstanding_trend_zero_on_decline(tenant, customer):
today = date.today()
this_month_start = today.replace(day=1)
last_month_end = this_month_start - timedelta(days=1)
_receivable(tenant, customer, 2000, bill_date=last_month_end - timedelta(days=3),
paid=1500, no="RC-OLD2")
out = ai_risk.outstanding_trend_score(tenant, customer, today=today)
assert Decimal(str(out["score"])) == 0, out
def test_outstanding_trend_new_debt_when_no_history(tenant, customer):
"""上月无欠款、本月有 → 50 分(新增欠款,但不直接满分)。"""
today = date.today()
_receivable(tenant, customer, 800, bill_date=today, no="RC-FRESH")
out = ai_risk.outstanding_trend_score(tenant, customer, today=today)
assert Decimal(str(out["score"])) == 50, out
assert "新增" in out["reason"]
# ---------- 因子:开单频次骤降 ----------
def test_order_drop_zero_without_outstanding(tenant, customer, warehouse, product):
out = ai_risk.order_drop_score(tenant, customer)
assert Decimal(str(out["score"])) == 0
assert "无欠款" in out["reason"]
def test_order_drop_high_when_long_gap(tenant, customer, warehouse, product):
"""有欠款 + 90 天未开单 → 100 分。"""
today = date.today()
_receivable(tenant, customer, 500, bill_date=today - timedelta(days=100), no="RC-GAP")
from apps.sales import services as sales_services
bill = sales_services.create_sales_bill(
tenant=tenant, customer=customer, warehouse=warehouse,
lines=[{"product": product, "quantity": 1, "unit_price": 10}],
bill_date=today - timedelta(days=95),
)
out = ai_risk.order_drop_score(tenant, customer, today=today)
assert Decimal(str(out["score"])) == 100, out
assert out["days_since_last_bill"] >= 90
def test_order_drop_zero_within_30_days(tenant, customer, warehouse, product):
today = date.today()
_receivable(tenant, customer, 500, bill_date=today, no="RC-RECENT")
from apps.sales import services as sales_services
sales_services.create_sales_bill(
tenant=tenant, customer=customer, warehouse=warehouse,
lines=[{"product": product, "quantity": 1, "unit_price": 10}],
bill_date=today - timedelta(days=5),
)
out = ai_risk.order_drop_score(tenant, customer, today=today)
assert Decimal(str(out["score"])) == 0, out
# ---------- 综合评分 ----------
def test_score_customer_high_risk_combo(tenant, customer, warehouse, product):
"""回款变慢 + 欠款上升 + 长期未开单 → high 档。"""
today = date.today()
this_month_start = today.replace(day=1)
last_month_end = this_month_start - timedelta(days=1)
# 基线快回款
for i in range(3):
d = today - timedelta(days=320 + i * 10)
r = _receivable(tenant, customer, 100, bill_date=d, no=f"RC-H{i}")
_settle(tenant, customer, r, receipt_date=d + timedelta(days=5))
# 近期待收:慢回款 + 未结欠款
r_recent = _receivable(tenant, customer, 300, bill_date=today - timedelta(days=45),
no="RC-HN")
_settle(tenant, customer, r_recent, receipt_date=today - timedelta(days=3))
_receivable(tenant, customer, 900, bill_date=last_month_end - timedelta(days=2),
no="RC-HOLD1")
_receivable(tenant, customer, 700, bill_date=today, no="RC-HOLD2")
data = ai_risk.score_customer(tenant, customer, today=today)
assert data["level"] == "high", data
assert data["score"] >= 70
assert set(data["factors"]) == {"cycle_drift", "outstanding_trend", "order_drop"}
def test_score_customer_low_when_healthy(tenant, customer, warehouse, product):
"""按时回款 + 无欠款 → low 档,分数低。"""
today = date.today()
r = _receivable(tenant, customer, 300, bill_date=today - timedelta(days=20),
paid=300, no="RC-OK")
_settle(tenant, customer, r, receipt_date=today - timedelta(days=15))
data = ai_risk.score_customer(tenant, customer, today=today)
assert data["level"] == "low", data
assert data["score"] < 40
def test_risk_ranking_orders_by_score(tenant, warehouse, product):
"""排行按分数降序,且只含有效应收客户。"""
today = date.today()
c_high = baker.make(Customer, tenant=tenant, code="C-H", name="高风险客户",
credit_limit=Decimal("1000"))
c_low = baker.make(Customer, tenant=tenant, code="C-L", name="低风险客户",
credit_limit=Decimal("1000"))
# 高风险:大额欠款 + 长期无单
_receivable(tenant, c_high, 2000, bill_date=today - timedelta(days=120), no="RC-HH")
# 低风险:今天刚开单 + 小额欠款
_receivable(tenant, c_low, 50, bill_date=today, no="RC-LL")
from apps.sales import services as sales_services
for c in (c_low,):
sales_services.create_sales_bill(
tenant=tenant, customer=c, warehouse=warehouse,
lines=[{"product": product, "quantity": 1, "unit_price": 10}],
bill_date=today - timedelta(days=2),
)
ranking = ai_risk.risk_ranking(tenant, top_n=5, today=today)
assert len(ranking) == 2
assert ranking[0]["customer_code"] == "C-H"
assert ranking[0]["score"] >= ranking[1]["score"]
def test_risk_ranking_empty_without_receivables(tenant, customer):
assert ai_risk.risk_ranking(tenant, top_n=5) == []
# ---------- API ----------
def test_risk_ranking_api(db, auth_client, tenant, customer):
today = date.today()
_receivable(tenant, customer, 1200, bill_date=today - timedelta(days=100), no="RC-API")
resp = auth_client.get("/api/v1/ai/risk/ranking/?top_n=5")
assert resp.status_code == 200, resp.content
body = resp.json()
assert body["llm_enhanced"] is False # 测试环境无 KEY → 降级
assert body["count"] >= 1
assert body["results"][0]["customer_code"] == "C001"
def test_customer_risk_api(db, auth_client, tenant, customer):
resp = auth_client.get(f"/api/v1/ai/risk/customer/{customer.id}/")
assert resp.status_code == 200, resp.content
body = resp.json()
assert body["customer_code"] == "C001"
assert "factors" in body
def test_customer_risk_api_404_other_tenant(db, auth_client, tenant, other_tenant):
other = baker.make(Customer, tenant=other_tenant, code="X1", name="别家客户")
resp = auth_client.get(f"/api/v1/ai/risk/customer/{other.id}/")
assert resp.status_code == 404
def test_collection_advice_falls_back_to_statistics(db, auth_client, tenant, customer):
"""无 KEY → advice 回落到统计理由,且 llm_enhanced=False(不 500)。"""
today = date.today()
_receivable(tenant, customer, 2000, bill_date=today - timedelta(days=120), no="RC-ADV")
resp = auth_client.get(f"/api/v1/ai/risk/advice/{customer.id}/")
assert resp.status_code == 200, resp.content
body = resp.json()
assert body["llm_enhanced"] is False
assert body["advice"] == body["stat_reason"]
assert "风险分" in body["advice"]
# ---------- 预警规则 + 扫描 ----------
def test_risk_scan_creates_notification(db, auth_client, tenant, customer):
today = date.today()
_receivable(tenant, customer, 3000, bill_date=today - timedelta(days=120), no="RC-SCAN")
AlertRule.objects.create(
tenant=tenant, code="ai_risk_score", name="AI 风险预警",
rule_type="risk_score", threshold=Decimal("10"), is_enabled=True,
)
resp = auth_client.post("/api/v1/ai/risk/scan/", {}, format="json")
assert resp.status_code == 200, resp.content
assert resp.json()["created_count"] >= 1
n = Notification.objects.filter(tenant=tenant, extra_data__rule="risk_score")
assert n.count() >= 1
assert "应收风险" in n.first().title
def test_risk_scan_dedupes_same_day(db, auth_client, tenant, customer):
"""同日二次扫描不重复发同客户预警。"""
today = date.today()
_receivable(tenant, customer, 3000, bill_date=today - timedelta(days=120), no="RC-DD")
AlertRule.objects.create(
tenant=tenant, code="ai_risk_score", name="AI 风险预警",
rule_type="risk_score", threshold=Decimal("10"), is_enabled=True,
)
auth_client.post("/api/v1/ai/risk/scan/", {}, format="json")
resp2 = auth_client.post("/api/v1/ai/risk/scan/", {}, format="json")
assert resp2.json()["created_count"] == 0
def test_risk_scan_skipped_without_enabled_rule(db, auth_client, tenant, customer):
"""规则未启用 → 不扫描(尊重配置)。"""
today = date.today()
_receivable(tenant, customer, 3000, bill_date=today - timedelta(days=120), no="RC-NR")
resp = auth_client.post("/api/v1/ai/risk/scan/", {}, format="json")
assert resp.status_code == 200
assert resp.json()["created_count"] == 0
def test_init_default_rules_includes_risk(db, tenant):
from apps.notify.services import init_default_alert_rules
init_default_alert_rules(tenant)
assert AlertRule.objects.filter(tenant=tenant, rule_type="risk_score").exists()
def test_run_all_alert_checks_includes_risk_count(db, tenant, customer):
from apps.notify.services import run_all_alert_checks
result = run_all_alert_checks(tenant)
assert "risk_score_alerts_count" in result
assert "total_alerts" in result
def test_llm_unavailable_by_default(db, settings):
"""默认无 KEY → available() False(保证离线可跑)。"""
from apps.ai import llm
settings.AI_PROVIDER = ""
settings.AI_API_KEY = ""
assert llm.available() is False
assert llm.chat([{"role": "user", "content": "hi"}]) is None
assert llm.collect_advice("hi") is None