"""AI 应用路由:应收风险 + 配额。 - GET /api/v1/ai/risk/ranking/?top_n=&min_outstanding= 风险客户排行 - GET /api/v1/ai/risk/customer// 单客户风险详情 - 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)