241 lines
8.2 KiB
Python
241 lines
8.2 KiB
Python
"""AI 应用路由:应收风险 + 配额。
|
||
|
||
- GET /api/v1/ai/risk/ranking/?top_n=&min_outstanding= 风险客户排行
|
||
- GET /api/v1/ai/risk/customer/<id>/ 单客户风险详情
|
||
- POST /api/v1/ai/risk/scan/ 立即扫描并按规则发预警
|
||
"""
|
||
|
||
from asgiref.sync import sync_to_async
|
||
from adrf.decorators import api_view
|
||
from rest_framework import status
|
||
from rest_framework.response import Response
|
||
from rest_framework.exceptions import ValidationError, NotFound
|
||
|
||
from apps.core.viewset import resolve_tenant
|
||
from . import risk as ai_risk
|
||
from . import llm
|
||
|
||
|
||
async def _tenant(request):
|
||
code = request.META.get("HTTP_X_TENANT_ID", "")
|
||
tenant = await sync_to_async(resolve_tenant)(code)
|
||
if tenant is None:
|
||
raise ValidationError({"tenant": "无法识别租户"})
|
||
return tenant
|
||
|
||
|
||
@api_view(["GET"])
|
||
async def risk_ranking(request):
|
||
"""应收风险客户排行(纯统计,不依赖 LLM)。"""
|
||
tenant = await _tenant(request)
|
||
try:
|
||
top_n = int(request.query_params.get("top_n", 5))
|
||
except (TypeError, ValueError):
|
||
top_n = 5
|
||
min_outstanding = request.query_params.get("min_outstanding", "0")
|
||
|
||
from decimal import Decimal
|
||
try:
|
||
min_val = Decimal(str(min_outstanding))
|
||
except Exception:
|
||
min_val = Decimal("0")
|
||
|
||
items = await sync_to_async(ai_risk.risk_ranking)(
|
||
tenant, top_n=top_n, min_outstanding=min_val
|
||
)
|
||
return Response({
|
||
"llm_enhanced": llm.available(),
|
||
"count": len(items),
|
||
"results": items,
|
||
})
|
||
|
||
|
||
@api_view(["GET"])
|
||
async def customer_risk(request, customer_id):
|
||
"""单客户风险评分明细(三因子 + 加权)。"""
|
||
tenant = await _tenant(request)
|
||
from apps.partner.models import Customer
|
||
|
||
customer = await sync_to_async(
|
||
Customer.objects.filter(tenant=tenant, pk=customer_id).first
|
||
)()
|
||
if customer is None:
|
||
raise NotFound("customer not found")
|
||
|
||
data = await sync_to_async(ai_risk.score_customer)(tenant, customer)
|
||
return Response(data)
|
||
|
||
|
||
@api_view(["GET"])
|
||
async def collection_advice(request, customer_id):
|
||
"""催收建议:有 LLM KEY 时生成人话建议,否则返回统计理由。"""
|
||
tenant = await _tenant(request)
|
||
from apps.partner.models import Customer
|
||
|
||
customer = await sync_to_async(
|
||
Customer.objects.filter(tenant=tenant, pk=customer_id).first
|
||
)()
|
||
if customer is None:
|
||
raise NotFound("customer not found")
|
||
|
||
data = await sync_to_async(ai_risk.score_customer)(tenant, customer)
|
||
factors = data["factors"]
|
||
stat_reason = (
|
||
f"风险分 {data['score']:.0f}({data['level']}),未结 ¥{data['outstanding']:.2f}。"
|
||
f"{factors['cycle_drift']['reason']};{factors['outstanding_trend']['reason']};"
|
||
f"{factors['order_drop']['reason']}。"
|
||
)
|
||
|
||
advice = None
|
||
if llm.available():
|
||
prompt = (
|
||
f"客户「{data['customer_name']}」当前应收风险情况:{stat_reason}"
|
||
f"请给业务员一句催收行动建议。"
|
||
)
|
||
advice = await sync_to_async(llm.collect_advice)(prompt)
|
||
|
||
return Response({
|
||
"customer_id": data["customer_id"],
|
||
"customer_name": data["customer_name"],
|
||
"score": data["score"],
|
||
"level": data["level"],
|
||
"llm_enhanced": bool(advice),
|
||
"advice": advice or stat_reason,
|
||
"stat_reason": stat_reason,
|
||
})
|
||
|
||
|
||
@api_view(["POST"])
|
||
async def run_risk_scan(request):
|
||
"""立即执行风险扫描(按 risk_score 规则发预警通知)。"""
|
||
tenant = await _tenant(request)
|
||
from apps.notify.services import check_risk_score_alerts
|
||
|
||
n = await sync_to_async(check_risk_score_alerts)(tenant)
|
||
return Response({
|
||
"ok": True,
|
||
"created_count": n,
|
||
"message": f"扫描完成,新增 {n} 条风险预警",
|
||
}, status=status.HTTP_200_OK)
|
||
|
||
|
||
# ============================================================
|
||
# B2 · AI 开单
|
||
# ============================================================
|
||
|
||
@api_view(["POST"])
|
||
async def parse_order(request):
|
||
"""AI 录单:POST {text} → 抽取商品行 + 匹配档案(不落库,前端确认后建单)。
|
||
|
||
无 LLM KEY 时返回 400 + code=llm_unavailable(明确报错,不是 500)。
|
||
配额超限返回 403 + code=quota_exceeded(引导升级)。
|
||
"""
|
||
tenant = await _tenant(request)
|
||
from . import orders as ai_orders
|
||
from . import usage as ai_usage
|
||
|
||
payload = request.data or {}
|
||
text = (payload.get("text") or "").strip()
|
||
if not text:
|
||
raise ValidationError({"detail": "text 必填"})
|
||
|
||
# 配额超限由全局处理器映射为 403 + 升级引导
|
||
await sync_to_async(ai_usage.check_quota)(tenant, ai_usage.KIND_PARSE_ORDER)
|
||
|
||
try:
|
||
items = await sync_to_async(ai_orders.extract_items)(
|
||
text, allow_rule_fallback=bool(payload.get("allow_rule_fallback"))
|
||
)
|
||
except ai_orders.LlmUnavailable as exc:
|
||
return Response({
|
||
"code": "llm_unavailable",
|
||
"detail": str(exc),
|
||
}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
if not items:
|
||
return Response({"code": "empty", "detail": "未能从文本中识别出商品行",
|
||
"matched": [], "unmatched": []},
|
||
status=status.HTTP_200_OK)
|
||
|
||
def _resolve_unit(tenant_, product, item):
|
||
"""把抽取到的单位名映射到商品单位 + 取价(复用 A1 取价口径)。"""
|
||
from apps.catalog.models import UnitConversion
|
||
|
||
out = {"unit_id": None, "unit_name": "", "price": str(product.sale_price)}
|
||
want = (item.get("unit") or "").strip()
|
||
base = product.base_unit
|
||
if base is not None and want and (want == base.name or want == base.code):
|
||
out.update({"unit_id": base.id, "unit_name": base.name})
|
||
return out
|
||
conv = None
|
||
if want:
|
||
conv = (
|
||
UnitConversion.objects.filter(product=product)
|
||
.select_related("unit")
|
||
.filter(unit__name=want).first()
|
||
or UnitConversion.objects.filter(product=product)
|
||
.select_related("unit")
|
||
.filter(unit__code=want).first()
|
||
)
|
||
if conv is not None:
|
||
out.update({
|
||
"unit_id": conv.unit_id,
|
||
"unit_name": conv.unit.name,
|
||
"price": str(product.sale_price * conv.rate),
|
||
})
|
||
return out
|
||
|
||
result = await sync_to_async(ai_orders.match_products)(
|
||
tenant, items, unit_resolver=_resolve_unit
|
||
)
|
||
await sync_to_async(ai_usage.record_usage)(
|
||
tenant, ai_usage.KIND_PARSE_ORDER,
|
||
detail={"items": len(items), "matched": len(result["matched"])},
|
||
)
|
||
result["llm_enhanced"] = True
|
||
result["usage"] = await sync_to_async(ai_usage.monthly_count)(
|
||
tenant, ai_usage.KIND_PARSE_ORDER
|
||
)
|
||
return Response(result)
|
||
|
||
|
||
@api_view(["GET"])
|
||
async def ai_usage_summary(request):
|
||
"""AI 用量总览(前端显示"免费版 10 次/月"进度)。"""
|
||
tenant = await _tenant(request)
|
||
from . import usage as ai_usage
|
||
|
||
data = await sync_to_async(ai_usage.usage_summary)(tenant)
|
||
data["llm_available"] = llm.available()
|
||
return Response(data)
|
||
|
||
|
||
# ============================================================
|
||
# B3 · AI 经营问答
|
||
# ============================================================
|
||
|
||
@api_view(["POST"])
|
||
async def ask(request):
|
||
"""老板参谋:POST {question} → 白名单取数(只读)→ 组织语言作答。
|
||
|
||
无 LLM 时仍返回结构化统计答案(llm_enhanced=False);前端据 llm_available
|
||
决定是否展示入口。
|
||
"""
|
||
tenant = await _tenant(request)
|
||
from . import ask as ai_ask
|
||
from . import usage as ai_usage
|
||
|
||
payload = request.data or {}
|
||
question = (payload.get("question") or "").strip()
|
||
if not question:
|
||
raise ValidationError({"detail": "question 必填"})
|
||
|
||
# 配额超限由全局处理器映射
|
||
await sync_to_async(ai_usage.check_quota)(tenant, ai_usage.KIND_ASK)
|
||
|
||
data = await sync_to_async(ai_ask.answer)(tenant, question)
|
||
await sync_to_async(ai_usage.record_usage)(
|
||
tenant, ai_usage.KIND_ASK, detail={"intents": data["intents"]}
|
||
)
|
||
return Response(data)
|