baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
+240
View File
@@ -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)