baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
"""批次 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
|
||||
Reference in New Issue
Block a user