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