Files
dealerhub/backend/apps/ai/views.py
T

241 lines
8.2 KiB
Python
Raw 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 应用路由:应收风险 + 配额。
- 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)