372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""批次 B2/B3 测试:AI 开单(抽取→匹配→配额)+ AI 经营问答(白名单取数→降级)。"""
|
||
|
||
import pytest
|
||
from datetime import date, timedelta
|
||
from decimal import Decimal
|
||
from model_bakery import baker
|
||
from rest_framework.test import APIClient
|
||
from rest_framework_simplejwt.tokens import RefreshToken
|
||
|
||
from apps.catalog.models import Product, Unit, UnitConversion
|
||
from apps.inventory.models import Warehouse
|
||
from apps.partner.models import Customer
|
||
from apps.finance.models import Receivable
|
||
from apps.ai import orders as ai_orders
|
||
from apps.ai import ask as ai_ask
|
||
from apps.ai import usage as ai_usage
|
||
from apps.ai.models import AiUsage
|
||
|
||
|
||
@pytest.fixture
|
||
def auth_client(db, user, tenant):
|
||
c = APIClient()
|
||
refresh = RefreshToken.for_user(user)
|
||
c.credentials(
|
||
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
|
||
HTTP_X_TENANT_ID=tenant.code,
|
||
)
|
||
return c
|
||
|
||
|
||
@pytest.fixture
|
||
def base_unit(db, tenant):
|
||
return baker.make(Unit, tenant=tenant, code="bottle", name="瓶", is_base=True)
|
||
|
||
|
||
@pytest.fixture
|
||
def box_unit(db, tenant):
|
||
return baker.make(Unit, tenant=tenant, code="box", name="箱", is_base=False)
|
||
|
||
|
||
@pytest.fixture
|
||
def cola(db, tenant, base_unit, box_unit):
|
||
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
||
barcode="6901234567890", sale_price=Decimal("3"),
|
||
base_unit=base_unit)
|
||
UnitConversion.objects.create(tenant=tenant, product=p, unit=box_unit,
|
||
rate=Decimal("24"))
|
||
return p
|
||
|
||
|
||
@pytest.fixture
|
||
def milk(db, tenant, base_unit):
|
||
return baker.make(Product, tenant=tenant, code="P002", name="鲜牛奶",
|
||
sale_price=Decimal("15"), base_unit=base_unit)
|
||
|
||
|
||
@pytest.fixture
|
||
def warehouse(db, tenant):
|
||
return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓")
|
||
|
||
|
||
@pytest.fixture
|
||
def customer(db, tenant):
|
||
return baker.make(Customer, tenant=tenant, code="C001", name="张三便利店",
|
||
credit_limit=Decimal("10000"))
|
||
|
||
|
||
@pytest.fixture
|
||
def ai_quota(db, tenant):
|
||
"""把租户挂到一个自定义套餐上以控制 AI 配额(billing 装好后配额来源是套餐)。"""
|
||
from apps.billing.models import Plan, Subscription, seed_plans
|
||
|
||
seed_plans()
|
||
|
||
def _set(parse_order: int = 10, ask: int = 20):
|
||
plan, _created = Plan.objects.update_or_create(
|
||
code="test-quota", defaults={
|
||
"name": "测试套餐", "price_monthly": Decimal("0"),
|
||
"limits": {
|
||
"users": 99, "products": 99999, "bills_monthly": 0,
|
||
"ai_parse_order": parse_order, "ai_ask": ask,
|
||
"batch_managed": True, "finance_ledger": True,
|
||
"print_templates": 0, "storefront": False,
|
||
},
|
||
"sort_order": 99, "is_active": True,
|
||
},
|
||
)
|
||
sub = Subscription.objects.filter(tenant=tenant).first()
|
||
if sub is None:
|
||
Sub = Subscription
|
||
sub = Sub.objects.create(
|
||
tenant=tenant, plan=plan, status="active",
|
||
period_start=date.today(), period_end=date.today() + timedelta(days=30),
|
||
)
|
||
else:
|
||
sub.plan = plan
|
||
sub.status = "active"
|
||
sub.save(update_fields=["plan", "status", "updated_at"])
|
||
return plan
|
||
|
||
return _set
|
||
|
||
|
||
# ============================================================
|
||
# 抽取(无 LLM → 明确报错 / 规则兜底)
|
||
# ============================================================
|
||
|
||
|
||
def test_extract_raises_without_llm(db, settings):
|
||
"""无 KEY 时默认抛 LlmUnavailable(不做静默降级)。"""
|
||
settings.AI_PROVIDER = ""
|
||
settings.AI_API_KEY = ""
|
||
with pytest.raises(ai_orders.LlmUnavailable):
|
||
ai_orders.extract_items("可乐 2 箱")
|
||
|
||
|
||
def test_extract_rule_fallback_when_allowed(db, settings):
|
||
"""显式允许时走规则兜底:抓 "商品名 + 数量 + 单位"。"""
|
||
settings.AI_PROVIDER = ""
|
||
settings.AI_API_KEY = ""
|
||
items = ai_orders.extract_items("可乐 2箱\n鲜牛奶 3瓶", allow_rule_fallback=True)
|
||
assert len(items) == 2
|
||
assert items[0]["name"] == "可乐"
|
||
assert items[0]["qty"] == 2.0
|
||
assert items[0]["unit"] == "箱"
|
||
assert items[1]["name"] == "鲜牛奶"
|
||
|
||
|
||
def test_extract_rule_fallback_chinese_numbers(db, settings):
|
||
settings.AI_PROVIDER = ""
|
||
settings.AI_API_KEY = ""
|
||
items = ai_orders.extract_items("可乐 两箱,鲜牛奶 十瓶", allow_rule_fallback=True)
|
||
assert items[0]["qty"] == 2.0
|
||
assert items[1]["qty"] == 10.0
|
||
|
||
|
||
# ============================================================
|
||
# 商品匹配
|
||
# ============================================================
|
||
|
||
|
||
def test_match_by_barcode(db, tenant, cola):
|
||
out = ai_orders.match_products(tenant, [
|
||
{"name": "随便写的名字", "barcode": "6901234567890", "qty": 1, "unit": ""},
|
||
])
|
||
assert len(out["matched"]) == 1
|
||
assert out["matched"][0]["match_by"] == "barcode"
|
||
assert out["matched"][0]["product_code"] == "P001"
|
||
|
||
|
||
def test_match_by_code_exact(db, tenant, cola, milk):
|
||
out = ai_orders.match_products(tenant, [
|
||
{"name": "P002", "barcode": "", "qty": 2, "unit": ""},
|
||
])
|
||
assert out["matched"][0]["product_code"] == "P002"
|
||
assert out["matched"][0]["match_by"] == "code"
|
||
|
||
|
||
def test_match_by_name_exact_and_contains(db, tenant, cola, milk):
|
||
out = ai_orders.match_products(tenant, [
|
||
{"name": "可乐", "barcode": "", "qty": 1, "unit": ""},
|
||
{"name": "牛奶", "barcode": "", "qty": 1, "unit": ""}, # 包含匹配 鲜牛奶
|
||
])
|
||
assert len(out["matched"]) == 2
|
||
assert out["matched"][0]["match_by"] == "name_exact"
|
||
assert out["matched"][1]["match_by"] == "name_contains"
|
||
assert out["matched"][1]["product_name"] == "鲜牛奶"
|
||
|
||
|
||
def test_match_unmatched_reported(db, tenant, cola):
|
||
out = ai_orders.match_products(tenant, [
|
||
{"name": "完全不存在的东西XYZ", "barcode": "", "qty": 1, "unit": ""},
|
||
])
|
||
assert out["matched"] == []
|
||
assert len(out["unmatched"]) == 1
|
||
|
||
|
||
def test_match_resolves_unit_and_price(db, tenant, cola, box_unit):
|
||
def resolver(tenant_, product, item):
|
||
if item.get("unit") == "箱":
|
||
conv = UnitConversion.objects.get(product=product, unit=box_unit)
|
||
return {"unit_id": conv.unit_id, "unit_name": conv.unit.name,
|
||
"price": str(product.sale_price * conv.rate)}
|
||
return {"unit_id": None, "unit_name": "", "price": str(product.sale_price)}
|
||
|
||
out = ai_orders.match_products(
|
||
tenant,
|
||
[{"name": "可乐", "barcode": "", "qty": 2, "unit": "箱"}],
|
||
unit_resolver=resolver,
|
||
)
|
||
row = out["matched"][0]
|
||
assert row["unit_name"] == "箱"
|
||
assert Decimal(row["price"]) == Decimal("72") # 3 × 24
|
||
|
||
|
||
# ============================================================
|
||
# 配额
|
||
# ============================================================
|
||
|
||
|
||
def test_quota_check_and_exceed(db, tenant, ai_quota):
|
||
ai_quota(parse_order=2)
|
||
assert ai_usage.check_quota(tenant, ai_usage.KIND_PARSE_ORDER)["quota"] == 2
|
||
ai_usage.record_usage(tenant, ai_usage.KIND_PARSE_ORDER)
|
||
ai_usage.record_usage(tenant, ai_usage.KIND_PARSE_ORDER)
|
||
with pytest.raises(ai_usage.QuotaExceeded):
|
||
ai_usage.check_quota(tenant, ai_usage.KIND_PARSE_ORDER)
|
||
|
||
|
||
def test_quota_zero_means_unlimited(db, tenant, ai_quota):
|
||
ai_quota(parse_order=0)
|
||
for _ in range(5):
|
||
ai_usage.record_usage(tenant, ai_usage.KIND_PARSE_ORDER)
|
||
out = ai_usage.check_quota(tenant, ai_usage.KIND_PARSE_ORDER)
|
||
assert out["allowed"] is True
|
||
assert out["remaining"] is None
|
||
|
||
|
||
def test_usage_summary_counts_per_kind(db, tenant):
|
||
ai_usage.record_usage(tenant, ai_usage.KIND_PARSE_ORDER)
|
||
ai_usage.record_usage(tenant, ai_usage.KIND_ASK)
|
||
ai_usage.record_usage(tenant, ai_usage.KIND_ASK)
|
||
summary = ai_usage.usage_summary(tenant)
|
||
by_kind = {i["kind"]: i["used"] for i in summary["items"]}
|
||
assert by_kind[ai_usage.KIND_PARSE_ORDER] == 1
|
||
assert by_kind[ai_usage.KIND_ASK] == 2
|
||
|
||
|
||
# ============================================================
|
||
# API:AI 开单
|
||
# ============================================================
|
||
|
||
|
||
def test_parse_order_api_llm_unavailable_400(db, auth_client, settings, cola):
|
||
settings.AI_PROVIDER = ""
|
||
settings.AI_API_KEY = ""
|
||
resp = auth_client.post("/api/v1/ai/parse-order/", {"text": "可乐 2 箱"}, format="json")
|
||
assert resp.status_code == 400, resp.content
|
||
assert resp.json()["code"] == "llm_unavailable"
|
||
|
||
|
||
def test_parse_order_api_rule_fallback(db, auth_client, settings, cola, milk):
|
||
"""显式开兜底 → 走规则抽取 + 匹配,全链路跑通(不依赖 LLM)。"""
|
||
settings.AI_PROVIDER = ""
|
||
settings.AI_API_KEY = ""
|
||
resp = auth_client.post("/api/v1/ai/parse-order/", {
|
||
"text": "可乐 2箱\n鲜牛奶 3瓶",
|
||
"allow_rule_fallback": True,
|
||
}, format="json")
|
||
assert resp.status_code == 200, resp.content
|
||
body = resp.json()
|
||
assert len(body["matched"]) == 2
|
||
assert body["matched"][0]["product_code"] == "P001"
|
||
assert body["matched"][1]["product_code"] == "P002"
|
||
# 用量已登记
|
||
assert AiUsage.objects.filter(kind=ai_usage.KIND_PARSE_ORDER).count() == 1
|
||
|
||
|
||
def test_parse_order_api_quota_403(db, auth_client, tenant, settings, ai_quota, cola):
|
||
settings.AI_PROVIDER = ""
|
||
settings.AI_API_KEY = ""
|
||
ai_quota(parse_order=1)
|
||
ai_usage.record_usage(tenant, ai_usage.KIND_PARSE_ORDER)
|
||
|
||
resp = auth_client.post("/api/v1/ai/parse-order/", {
|
||
"text": "可乐 1箱", "allow_rule_fallback": True,
|
||
}, format="json")
|
||
assert resp.status_code == 403, resp.content
|
||
assert resp.json()["code"] == "quota_exceeded"
|
||
|
||
|
||
def test_parse_order_empty_text_400(db, auth_client):
|
||
resp = auth_client.post("/api/v1/ai/parse-order/", {"text": " "}, format="json")
|
||
assert resp.status_code == 400
|
||
|
||
|
||
def test_usage_api(db, auth_client):
|
||
resp = auth_client.get("/api/v1/ai/usage/")
|
||
assert resp.status_code == 200, resp.content
|
||
body = resp.json()
|
||
assert body["llm_available"] is False
|
||
assert len(body["items"]) == 3
|
||
|
||
|
||
# ============================================================
|
||
# B3 经营问答
|
||
# ============================================================
|
||
|
||
|
||
def test_detect_intents():
|
||
assert ai_ask.detect_intents("这个月销售额多少") == ["sales"]
|
||
assert set(ai_ask.detect_intents("应收和库存怎么样")) == {"receivable", "inventory"}
|
||
assert ai_ask.detect_intents("今天天气如何") == ["overview"]
|
||
|
||
|
||
def test_gather_tools_readonly_and_scoped(db, tenant, other_tenant, cola, warehouse):
|
||
"""工具取数只读且带租户隔离:别的租户数据不进结果。"""
|
||
out = ai_ask.gather(tenant, "应收账款情况")
|
||
assert "receivable" in out["data"]
|
||
assert "应收未结合计" in out["data"]["receivable"]
|
||
# 另一租户有应收,但不得出现在本租户结果里
|
||
other_customer = baker.make(Customer, tenant=other_tenant, code="OC1", name="别家客户")
|
||
baker.make(Receivable, tenant=other_tenant, customer=other_customer,
|
||
bill_no="RC-OTHER", bill_date=date.today(),
|
||
total_amount=Decimal("9999"), status="open")
|
||
out2 = ai_ask.gather(tenant, "应收账款情况")
|
||
assert Decimal(str(out2["data"]["receivable"]["应收未结合计"])) == Decimal("0")
|
||
|
||
|
||
def test_answer_falls_back_without_llm(db, tenant, settings):
|
||
settings.AI_PROVIDER = ""
|
||
settings.AI_API_KEY = ""
|
||
out = ai_ask.answer(tenant, "本月销售额和应收怎么样")
|
||
assert out["llm_enhanced"] is False
|
||
assert out["answer"]
|
||
assert "本月销售额" in out["answer"] or "应收未结" in out["answer"]
|
||
|
||
|
||
def test_answer_with_data(db, tenant, customer, warehouse, cola):
|
||
"""有单据数据时,统计答案里能读到金额。"""
|
||
from apps.inventory import services as inv
|
||
from apps.sales import services as sales_services
|
||
|
||
inv.inbound(tenant=tenant, warehouse=warehouse, product=cola,
|
||
quantity=Decimal("100"), unit_cost=Decimal("2"))
|
||
bill = sales_services.create_sales_bill(
|
||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||
lines=[{"product": cola, "quantity": 10, "unit_price": 3}],
|
||
)
|
||
sales_services.confirm_sales_bill(bill)
|
||
|
||
out = ai_ask.answer(tenant, "本月销售额多少")
|
||
assert "30.00" in out["answer"]
|
||
|
||
|
||
def test_ask_api_without_llm_still_answers(db, auth_client, tenant, settings):
|
||
settings.AI_PROVIDER = ""
|
||
settings.AI_API_KEY = ""
|
||
resp = auth_client.post("/api/v1/ai/ask/", {"question": "本月销售和应收情况"},
|
||
format="json")
|
||
assert resp.status_code == 200, resp.content
|
||
body = resp.json()
|
||
assert body["llm_enhanced"] is False
|
||
assert body["answer"]
|
||
assert "sales" in body["intents"] or "receivable" in body["intents"]
|
||
|
||
|
||
def test_ask_api_requires_question(db, auth_client):
|
||
resp = auth_client.post("/api/v1/ai/ask/", {}, format="json")
|
||
assert resp.status_code == 400
|
||
|
||
|
||
def test_ask_records_usage(db, auth_client, tenant, settings):
|
||
settings.AI_PROVIDER = ""
|
||
settings.AI_API_KEY = ""
|
||
auth_client.post("/api/v1/ai/ask/", {"question": "库存多少"}, format="json")
|
||
assert AiUsage.objects.filter(kind=ai_usage.KIND_ASK).count() == 1
|
||
|
||
|
||
def test_ask_quota_403(db, auth_client, tenant, settings, ai_quota):
|
||
ai_quota(ask=1)
|
||
ai_usage.record_usage(tenant, ai_usage.KIND_ASK)
|
||
resp = auth_client.post("/api/v1/ai/ask/", {"question": "销售额"}, format="json")
|
||
assert resp.status_code == 403
|
||
assert resp.json()["code"] == "quota_exceeded"
|
||
|
||
|
||
def test_tenant_isolation_usage(db, tenant, other_tenant):
|
||
ai_usage.record_usage(tenant, ai_usage.KIND_ASK)
|
||
assert ai_usage.monthly_count(tenant, ai_usage.KIND_ASK) == 1
|
||
assert ai_usage.monthly_count(other_tenant, ai_usage.KIND_ASK) == 0
|