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
+371
View File
@@ -0,0 +1,371 @@
"""批次 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