"""查询效率测试(迭代第 4 轮)。 **为什么需要**:单测只验证"结果对不对",不验证"代价多大"。 实测发现列表接口的 N+1:50 张销售单产生 **311 条 SQL**、P95 266ms; 加预取后降到 **5 条 SQL**、P95 50ms(5.3 倍)。 本模块用 `assertNumQueries` 把"查询次数"钉死,防止后续改动悄悄退回 N+1。 """ import pytest 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 from apps.inventory.models import Stock, Warehouse from apps.partner.models import Customer from apps.sales import services as sales_services class query_counter: """统计代码块内执行的 SQL 数量(替代未安装的 pytest-django helper)。""" def __init__(self): from django.db import connection from django.test.utils import CaptureQueriesContext self._ctx = CaptureQueriesContext(connection) def __enter__(self): self._ctx.__enter__() return self def __exit__(self, *args): return self._ctx.__exit__(*args) @property def count(self): return len(self._ctx.captured_queries) @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 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("999999")) @pytest.fixture def product(db, tenant): unit = baker.make(Unit, tenant=tenant, code="bottle", name="瓶", is_base=True) return baker.make(Product, tenant=tenant, code="P001", name="可乐", sale_price=Decimal("10"), base_unit=unit) def _seed_bills(tenant, warehouse, customer, product, n=20): from apps.inventory import services as inv inv.inbound(tenant=tenant, warehouse=warehouse, product=product, quantity=Decimal(str(n * 10)), unit_cost=Decimal("5")) for _ in range(n): sales_services.create_sales_bill( tenant=tenant, customer=customer, warehouse=warehouse, lines=[{"product": product, "quantity": 2, "unit_price": 10}], ) # ============================================================ # 销售单列表:查询次数不随单据数增长 # ============================================================ def test_sales_bill_list_query_count_is_constant(db, auth_client, tenant, warehouse, customer, product): """20 张单的列表页:SQL 次数必须是常数级(不随行数增长)。 修复前:每张单 6 条(aggregate 税额 + lines + product + source_unit)= 120+ 条。 修复后:预取 + 内存汇总 = 常数条。 """ _seed_bills(tenant, warehouse, customer, product, n=20) with query_counter() as qc: resp = auth_client.get("/api/v1/sales/bills/?page_size=50") assert resp.status_code == 200 assert resp.json()["count"] == 20 # 20 张单的查询数必须是常数级(修复前 120+) assert qc.count <= 12, f"查询数过多(疑似 N+1):{qc.count} 条 SQL" def test_sales_bill_list_scales_flat(db, auth_client, tenant, warehouse, customer, product): """单据翻倍(20 → 40)时查询次数不增加。""" from django.test.utils import CaptureQueriesContext from django.db import connection _seed_bills(tenant, warehouse, customer, product, n=20) with CaptureQueriesContext(connection) as ctx20: auth_client.get("/api/v1/sales/bills/?page_size=50") n20 = len(ctx20.captured_queries) _seed_bills(tenant, warehouse, customer, product, n=20) # 再来 20 张 with CaptureQueriesContext(connection) as ctx40: resp = auth_client.get("/api/v1/sales/bills/?page_size=50") n40 = len(ctx40.captured_queries) assert resp.json()["count"] == 40 # 40 张单的查询数不应显著多于 20 张(允许 ±2 的波动) assert n40 <= n20 + 2, f"查询数随数据量增长:{n20} → {n40}(N+1 回归)" def test_sales_bill_list_payload_correct(db, auth_client, tenant, warehouse, customer, product): """优化后数据必须仍然正确(预取不能丢字段)。""" _seed_bills(tenant, warehouse, customer, product, n=3) body = auth_client.get("/api/v1/sales/bills/?page_size=50").json() assert body["count"] == 3 row = body["results"][0] # 预取后仍要能拿到客户名、行明细、税额合计 assert row["customer_name"] == "张三" assert row["lines"], "行明细丢失" assert "tax_total" in row assert row["lines"][0]["product_name"] == "可乐" assert row["lines"][0]["unit_name"] == "瓶" # ============================================================ # 其他列表接口 # ============================================================ def test_stock_list_no_n_plus_1(db, auth_client, tenant, warehouse, product): from apps.inventory import services as inv for i in range(15): p = baker.make(Product, tenant=tenant, code=f"SP{i:03d}", name=f"库存品{i}", sale_price=Decimal("5")) inv.inbound(tenant=tenant, warehouse=warehouse, product=p, quantity=Decimal("10"), unit_cost=Decimal("1")) with query_counter() as qc: resp = auth_client.get("/api/v1/inventory/stocks/?page_size=50") assert resp.status_code == 200 assert resp.json()["count"] == 15 assert qc.count <= 10, f"库存列表 N+1:{qc.count} 条 SQL" def test_product_list_no_n_plus_1(db, auth_client, tenant): for i in range(20): baker.make(Product, tenant=tenant, code=f"PP{i:03d}", name=f"商品{i}", sale_price=Decimal("5")) with query_counter() as qc: resp = auth_client.get("/api/v1/catalog/products/?page_size=50") assert resp.status_code == 200 assert resp.json()["count"] == 20 assert qc.count <= 10, f"商品列表 N+1:{qc.count} 条 SQL" def test_receivable_list_no_n_plus_1(db, auth_client, tenant): from apps.finance.models import Receivable c = baker.make(Customer, tenant=tenant, code="CN1", name="客户1") for i in range(15): baker.make(Receivable, tenant=tenant, customer=c, bill_no=f"RC{i:03d}", total_amount=Decimal("100"), status="open") with query_counter() as qc: resp = auth_client.get("/api/v1/finance/receivables/?page_size=50") assert resp.status_code == 200 assert resp.json()["count"] == 15 # 客户名要能取到(说明 select_related 生效) assert resp.json()["results"][0]["customer_name"] == "客户1" assert qc.count <= 10, f"应收列表 N+1:{qc.count} 条 SQL" # ============================================================ # 分页边界:page_size 上限保护 # ============================================================ def test_page_size_capped_at_max(db, auth_client, tenant): """page_size 有上限,防止一次拉全表拖垮服务。""" for i in range(30): baker.make(Product, tenant=tenant, code=f"CAP{i:03d}", name=f"上限品{i}") resp = auth_client.get("/api/v1/catalog/products/?page_size=100000") assert resp.status_code == 200 assert len(resp.json()["results"]) <= 200 # max_page_size