Files
dealerhub/backend/tests/test_demo.py
T

307 lines
12 KiB
Python

"""批次 C2 · 演示账套与免注册体验测试。
覆盖:seed_demo 幂等重建、数据构成(批次/多单位/账龄六桶/风险客户)、
免注册 token 签发、演示租户只读保护(含手工 curl 场景)、只读不误伤正常租户。
"""
import pytest
from datetime import date
from decimal import Decimal
from django.core.management import call_command
from rest_framework.test import APIClient
@pytest.fixture
def demo_db(db):
"""跑一次 seed_demo(测试库内)。"""
call_command("seed_demo")
return True
@pytest.fixture
def demo_client(demo_db):
return APIClient()
# ---------- seed_demo 数据构成 ----------
def test_seed_demo_creates_tenant_and_user(demo_db):
from django.contrib.auth import get_user_model
from apps.core.models import Tenant
tenant = Tenant.objects.get(code="demo")
assert tenant.is_active is True
user = get_user_model().objects.get(username="demo")
assert user.is_staff is False
assert user.is_superuser is False
assert user.check_password("demo12345")
def test_seed_demo_idempotent(demo_db):
"""重复执行不产生重复单据。"""
from apps.purchase.models import PurchaseBill
from apps.sales.models import SalesBill
from apps.finance.models import Receivable
before = (SalesBill.objects.filter(tenant__code="demo").count(),
PurchaseBill.objects.filter(tenant__code="demo").count(),
Receivable.objects.filter(tenant__code="demo").count())
call_command("seed_demo")
after = (SalesBill.objects.filter(tenant__code="demo").count(),
PurchaseBill.objects.filter(tenant__code="demo").count(),
Receivable.objects.filter(tenant__code="demo").count())
assert before == after, f"重复执行产生了新数据:{before} → {after}"
def test_seed_demo_products_feature_matrix(demo_db):
"""商品覆盖:多单位、批次效期、称重(kg 基本单位)、最低售价。"""
from apps.catalog.models import Product, UnitConversion
tenant_products = Product.objects.filter(tenant__code="demo")
# 扩充后 22 个 SKU(覆盖 4 品类 / 批次 / 称重 / 多单位 / 3 档税率)
assert tenant_products.count() >= 20
cola = tenant_products.get(code="P1001")
assert cola.min_sale_price == Decimal("1.6000")
assert UnitConversion.objects.filter(product=cola).count() >= 2 # 箱 + 件
milk = tenant_products.get(code="P2001")
assert milk.is_batch_managed is True
assert milk.shelf_life_days == 21
# 称重商品用 kg 作基本单位
bulk = tenant_products.get(code="P4003")
assert bulk.base_unit.code == "kg"
# 零食类用袋作基本单位(多单位体系里 bag 也是基本单位)
snack = tenant_products.get(code="P3001")
assert snack.base_unit.code == "bag"
def test_seed_demo_batches_have_expiry(demo_db):
from apps.inventory.models import StockBatch
batches = StockBatch.objects.filter(tenant__code="demo", on_hand__gt=0)
assert batches.count() >= 2
assert all(b.expiry_date is not None for b in batches)
# 至少一个近效期(≤30 天)用于演示预警
today = date.today()
assert any((b.expiry_date - today).days <= 30 for b in batches)
def test_seed_demo_aging_covers_buckets(demo_db):
"""账龄六桶都要有分布(否则演示图只有一根柱)。"""
from apps.core.models import Tenant
from apps.finance.services import receivable_aging
tenant = Tenant.objects.get(code="demo")
aging = receivable_aging(tenant)
non_zero = {
b["bucket"] for b in aging["buckets"] if Decimal(b["amount"]) > 0
}
assert non_zero == {"0-30", "31-60", "61-90", "91-180", "181-365", "365+"}, aging
assert Decimal(aging["total"]) > 0
def test_seed_demo_has_risk_customer(demo_db):
"""风险客户(C1005 长期欠款未开单)应得到中高分数。"""
from apps.core.models import Tenant
from apps.ai import risk as ai_risk
tenant = Tenant.objects.get(code="demo")
ranking = ai_risk.risk_ranking(tenant, top_n=5)
assert ranking, "演示账套应有风险客户"
top = ranking[0]
assert top["outstanding"] > 0
assert top["score"] > 0
def test_seed_demo_reset_clears_and_rebuilds(demo_db):
"""reset 后重建业务数据和商城数据,且剧本单 + 历史单都在。"""
from apps.catalog.models import Product
from apps.core.models import Tenant
from apps.partner.models import Customer
from apps.sales.models import SalesBill
from apps.storefront.models import (
CustomerProductAuth, StorefrontAccount, StorefrontOrder,
StorefrontOrderLine,
)
tenant = Tenant.objects.get(code="demo")
customer = Customer.objects.get(tenant=tenant, code="C1001")
# Use a product outside the seeded first-four whitelist so the stale row
# does not collide with the idempotent seed data's unique constraint.
product = Product.objects.get(tenant=tenant, code="P1005")
stale_account = StorefrontAccount.objects.create(
tenant=tenant, customer=customer, phone="13999999999",
display_name="待清理商城账号",
)
stale_auth = CustomerProductAuth.objects.create(
tenant=tenant, customer=customer, product=product, is_active=True,
)
stale_order = StorefrontOrder.objects.create(
tenant=tenant, customer=customer, account=stale_account,
order_no="STale-DEMO-RESET", total_amount=Decimal("2.0000"),
)
StorefrontOrderLine.objects.create(
tenant=tenant, order=stale_order, product=product,
quantity=Decimal("1"), unit_price=Decimal("2"), amount=Decimal("2"),
)
call_command("seed_demo", "--reset")
n = SalesBill.objects.filter(tenant=tenant).count()
# 固定种子下应有 10 张剧本单 + 数十张历史单
assert n >= 60, f"重建后单据过少:{n}"
assert not StorefrontAccount.objects.filter(pk=stale_account.pk).exists()
assert not CustomerProductAuth.objects.filter(pk=stale_auth.pk).exists()
assert not StorefrontOrder.objects.filter(pk=stale_order.pk).exists()
assert StorefrontAccount.objects.filter(tenant=tenant).count() == customer_count(tenant)
def customer_count(tenant):
"""Expected one storefront account per active demo customer."""
from apps.partner.models import Customer
return Customer.objects.filter(tenant=tenant, is_active=True).count()
# ---------- 免注册进入 ----------
def test_enter_demo_returns_token(demo_client):
resp = demo_client.post("/api/v1/demo/enter/", {}, format="json")
assert resp.status_code == 200, resp.content
body = resp.json()
assert body["tenant"] == "demo"
assert body["read_only"] is True
assert body["access"]
def test_enter_demo_token_works_for_reads(demo_client):
token = demo_client.post("/api/v1/demo/enter/", {}, format="json").json()["access"]
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_X_TENANT_ID="demo")
resp = c.get("/api/v1/sales/bills/")
assert resp.status_code == 200, resp.content
assert resp.json()["count"] >= 6
def test_enter_demo_503_when_not_seeded(db):
c = APIClient()
resp = c.post("/api/v1/demo/enter/", {}, format="json")
assert resp.status_code == 503
assert resp.json()["code"] == "demo_not_ready"
# ---------- 只读保护 ----------
def test_demo_read_only_blocks_writes(demo_client):
"""演示租户任何写操作被 403(即使拿到有效 token)。"""
token = demo_client.post("/api/v1/demo/enter/", {}, format="json").json()["access"]
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_X_TENANT_ID="demo")
resp = c.post("/api/v1/catalog/products/",
{"code": "HACK01", "name": "不该被创建"}, format="json")
assert resp.status_code == 403, resp.content
assert resp.json()["code"] == "demo_read_only"
resp2 = c.post("/api/v1/sales/bills/create-bill/", {
"customer": 1, "warehouse": 1,
"lines": [{"product": 1, "quantity": 1, "unit_price": 1}],
}, format="json")
assert resp2.status_code == 403
assert resp2.json()["code"] == "demo_read_only"
def test_demo_read_only_allows_reads(demo_client):
"""读接口 + AI 问答(POST 但只读语义)在演示租户下放行。"""
token = demo_client.post("/api/v1/demo/enter/", {}, format="json").json()["access"]
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f"Bearer {token}", HTTP_X_TENANT_ID="demo")
resp = c.get("/api/v1/report/dashboard/summary/")
assert resp.status_code == 200, resp.content
def test_demo_middleware_does_not_affect_other_tenants(db, tenant, django_user_model):
"""正常租户写操作不受演示只读拦截。"""
from rest_framework_simplejwt.tokens import RefreshToken
from apps.core.models import TenantMembership
user = django_user_model.objects.create_user(username="normal", password="pass12345")
# This case is about the demo read-only middleware, not authorization.
# Give the user the membership the permission layer requires so the write
# actually reaches the middleware check.
TenantMembership.objects.create(user=user, tenant=tenant, role="member")
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f"Bearer {RefreshToken.for_user(user).access_token}",
HTTP_X_TENANT_ID=tenant.code)
resp = c.post("/api/v1/catalog/products/",
{"code": "NORMAL01", "name": "正常商品"}, format="json")
assert resp.status_code == 201, resp.content
def test_demo_enter_is_public(db):
"""进入演示不需要 JWT。"""
c = APIClient()
# 未初始化时应是 503(而非 401)→ 证明鉴权放行
resp = c.post("/api/v1/demo/enter/", {}, format="json")
assert resp.status_code in (200, 503)
assert resp.status_code != 401
def test_seed_demo_rich_dataset(demo_db):
"""扩充后的数据量足以支撑报表与 AI 演示。"""
from apps.core.models import Tenant
from apps.inventory.models import StockBatch, StockMovement
from apps.partner.models import Customer
from apps.catalog.models import Product
from apps.sales.models import SalesBill
t = Tenant.objects.get(code="demo")
assert Product.objects.filter(tenant=t).count() >= 20
assert Customer.objects.filter(tenant=t).count() >= 12
assert SalesBill.objects.filter(tenant=t).count() >= 60
assert StockMovement.objects.filter(tenant=t).count() >= 100
assert StockBatch.objects.filter(tenant=t, on_hand__gt=0).count() >= 5
def test_seed_demo_spans_multiple_months(demo_db):
"""历史单据必须跨多个月份(否则报表趋势是一条直线)。"""
from django.db.models import Count
from django.db.models.functions import TruncMonth
from apps.core.models import Tenant
from apps.sales.models import SalesBill
t = Tenant.objects.get(code="demo")
months = (
SalesBill.objects.filter(tenant=t, state="confirmed")
.annotate(m=TruncMonth("bill_date")).values("m")
.annotate(n=Count("id")).count()
)
assert months >= 4, f"只有 {months} 个月份的数据,趋势图无意义"
def test_seed_demo_risk_differentiation(demo_db):
"""AI 风险分必须有区分度:至少一个高风险 + 多数低风险。"""
from apps.ai import risk as ai_risk
from apps.core.models import Tenant
from apps.partner.models import Customer
t = Tenant.objects.get(code="demo")
# 给风险户补上"近期慢回款"画像(seed 已生成,这里直接评估)
scores = []
for c in Customer.objects.filter(tenant=t):
d = ai_risk.score_customer(t, c)
if d["outstanding"] > 0:
scores.append(d["score"])
assert scores, "没有有待收余额的客户"
assert max(scores) >= 60, f"最高风险分只有 {max(scores):.1f},无高风险样本"
assert max(scores) - min(scores) >= 30, "风险分缺乏区分度"