"""批次 D1 · B2B 订货商城测试。 覆盖:商品白名单可见性、商城价复用取价引擎、多单位下单、额度拦截、 SalesOrder 草稿流转、驳回、会话 token 安全、内部授权管理、billing 功能开关。 """ import pytest from datetime import date 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, CustomerProductPrice from apps.storefront.models import ( CustomerProductAuth, StorefrontAccount, StorefrontOrder, issue_session_token, verify_session_token, ) from apps.storefront import services as sf_services @pytest.fixture def client(db): """覆盖 pytest-django 默认的 Django 测试客户端。 默认 client 不认 `format="json"`,会把 JSON 结构按表单编码(导致 lines 变成 字符串列表)。商城用例都用 DRF 的 APIClient。 """ return APIClient() @pytest.fixture def pro_plan(db, tenant): """默认给测试租户专业版:商城是专业版功能(billing 功能开关)。""" from apps.billing.models import Plan, Subscription, seed_plans seed_plans() pro = Plan.objects.get(code="pro") Subscription.objects.filter(tenant=tenant).delete() Subscription.objects.create( tenant=tenant, plan=pro, status="active", period_start=date.today(), period_end=date.today(), ) return pro @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 customer(db, tenant): return baker.make(Customer, tenant=tenant, code="C001", name="张三便利店", phone="13900000001", credit_limit=Decimal("10000")) @pytest.fixture def warehouse(db, tenant): return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓", is_default=True, is_active=True) @pytest.fixture def products(db, tenant, base_unit, box_unit): out = {} for i, (code, name, price) in enumerate([ ("P001", "可乐", "3.00"), ("P002", "雪碧", "3.00"), ("P003", "鲜牛奶", "15.00"), ], start=1): p = baker.make(Product, tenant=tenant, code=code, name=name, sale_price=Decimal(price), base_unit=base_unit, status="active") UnitConversion.objects.create(tenant=tenant, product=p, unit=box_unit, rate=Decimal("24")) out[code] = p # 目录外商品(不授权) out["SECRET"] = baker.make(Product, tenant=tenant, code="P999", name="未授权商品", sale_price=Decimal("99"), base_unit=base_unit, status="active") return out @pytest.fixture def account(db, tenant, customer): a = baker.make(StorefrontAccount, tenant=tenant, customer=customer, phone="13900000001", display_name="张三便利店") a.set_password("store12345") a.save(update_fields=["password_hash"]) return a @pytest.fixture def granted(db, tenant, customer, products): """授权 P001/P002 给客户(P003、P999 不授权)。""" for code in ("P001", "P002"): CustomerProductAuth.objects.create( tenant=tenant, customer=customer, product=products[code], is_active=True ) return True def _login(client, phone="13900000001", password="store12345", tenant="test"): return client.post("/api/v1/storefront/login/", {"phone": phone, "password": password, "tenant_code": tenant}, format="json") # ---------- 会话 token ---------- def test_session_token_roundtrip(db, account): token = issue_session_token(account) got = verify_session_token(token) assert got is not None assert got.id == account.id def test_session_token_rejects_tampering(db, account): token = issue_session_token(account) bad = token[:-1] + ("0" if token[-1] != "0" else "1") assert verify_session_token(bad) is None def test_session_token_rejects_expired(db, account, monkeypatch): token = issue_session_token(account, ttl_hours=-1) assert verify_session_token(token) is None def test_password_hashing(db, account): assert account.check_password("store12345") is True assert account.check_password("wrong") is False assert "store12345" not in account.password_hash # ---------- 登录 ---------- def test_login_success(db, client, pro_plan, account): resp = _login(client) assert resp.status_code == 200, resp.content body = resp.json() assert body["token"] assert body["customer"]["code"] == "C001" def test_login_wrong_password(db, client, pro_plan, account): resp = _login(client, password="nope") assert resp.status_code == 401 assert resp.json()["code"] == "invalid_credentials" def test_login_unknown_phone(db, client, pro_plan, account): resp = _login(client, phone="13000000000") assert resp.status_code == 401 # ---------- 可见性(白名单) ---------- def test_catalog_hides_unauthorized_products(db, client, pro_plan, tenant, account, products, granted): token = _login(client).json()["token"] resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") assert resp.status_code == 200, resp.content codes = {r["code"] for r in resp.json()["results"]} assert codes == {"P001", "P002"} assert "P999" not in codes # 未授权商品不可见 assert "P003" not in codes def test_catalog_requires_login(db, client, pro_plan, tenant, granted): resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code) assert resp.status_code == 401 def test_catalog_search(db, client, pro_plan, tenant, account, granted): token = _login(client).json()["token"] resp = client.get("/api/v1/storefront/catalog/?search=可乐", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") codes = [r["code"] for r in resp.json()["results"]] assert codes == ["P001"] # ---------- 商城价(复用取价引擎) ---------- def test_catalog_price_uses_customer_specific_price(db, client, pro_plan, tenant, account, customer, products, granted): CustomerProductPrice.objects.create( tenant=tenant, customer=customer, product=products["P001"], price=Decimal("2.50") ) token = _login(client).json()["token"] resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") row = next(r for r in resp.json()["results"] if r["code"] == "P001") assert Decimal(row["price"]) == Decimal("2.50") assert row["price_source"] == "customer" def test_catalog_price_falls_back_to_default(db, client, pro_plan, tenant, account, products, granted): token = _login(client).json()["token"] resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") row = next(r for r in resp.json()["results"] if r["code"] == "P002") assert Decimal(row["price"]) == Decimal("3.00") assert row["price_source"] == "default" def test_catalog_lists_units_with_prices(db, client, pro_plan, tenant, account, products, granted): token = _login(client).json()["token"] resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") row = next(r for r in resp.json()["results"] if r["code"] == "P001") units = {u["unit_name"]: u for u in row["units"]} assert Decimal(units["箱"]["rate"]) == Decimal("24") assert Decimal(units["瓶"]["price"]) == Decimal("3.00") assert Decimal(units["箱"]["price"]) == Decimal("72.00") # ---------- 下单 ---------- def test_submit_order_creates_storefront_order(db, client, pro_plan, tenant, account, products, granted): token = _login(client).json()["token"] resp = client.post("/api/v1/storefront/orders/", { "lines": [{"product_id": products["P001"].id, "quantity": 10}], "remark": "明天送", }, format="json", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") assert resp.status_code == 201, resp.content body = resp.json() assert body["status"] == "submitted" assert Decimal(body["total_amount"]) == Decimal("30.00") order = StorefrontOrder.objects.get(pk=body["id"]) assert order.lines.count() == 1 assert order.lines.first().quantity == Decimal("10") def test_submit_order_with_box_unit(db, client, pro_plan, tenant, account, products, granted): """按箱下单:数量折基本单位,单价按箱价。""" box = UnitConversion.objects.get(product=products["P001"]).unit token = _login(client).json()["token"] resp = client.post("/api/v1/storefront/orders/", { "lines": [{"product_id": products["P001"].id, "quantity": 2, "source_unit": box.id}], }, format="json", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") assert resp.status_code == 201, resp.content order = StorefrontOrder.objects.get(pk=resp.json()["id"]) line = order.lines.first() assert line.quantity == Decimal("48") # 2 × 24 assert line.source_quantity == Decimal("2") assert Decimal(line.unit_price) == Decimal("72") # 3 × 24 assert Decimal(order.total_amount) == Decimal("144") def test_submit_order_rejects_unauthorized_product(db, client, pro_plan, tenant, account, products, granted): token = _login(client).json()["token"] resp = client.post("/api/v1/storefront/orders/", { "lines": [{"product_id": products["SECRET"].id, "quantity": 1}], }, format="json", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") assert resp.status_code == 403 assert resp.json()["code"] == "product_not_authorized" assert StorefrontOrder.objects.count() == 0 def test_submit_order_blocks_over_credit(db, client, pro_plan, tenant, account, products, granted): """额度不足:提交即拦(402),不生成订单。""" from apps.finance.models import Receivable baker.make(Receivable, tenant=tenant, customer=account.customer, bill_no="RC-OLD", bill_date=date.today(), total_amount=Decimal("9900"), status="open") token = _login(client).json()["token"] resp = client.post("/api/v1/storefront/orders/", { "lines": [{"product_id": products["P001"].id, "quantity": 100}], # 300 元 }, format="json", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") assert resp.status_code == 402, resp.content assert resp.json()["code"] == "credit_limit_exceeded" assert StorefrontOrder.objects.count() == 0 def test_submit_order_rejects_zero_qty(db, client, pro_plan, tenant, account, products, granted): token = _login(client).json()["token"] resp = client.post("/api/v1/storefront/orders/", { "lines": [{"product_id": products["P001"].id, "quantity": 0}], }, format="json", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") assert resp.status_code == 400 def test_my_orders_list(db, client, pro_plan, tenant, account, products, granted): token = _login(client).json()["token"] client.post("/api/v1/storefront/orders/", { "lines": [{"product_id": products["P001"].id, "quantity": 5}], }, format="json", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") resp = client.get("/api/v1/storefront/orders/", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") assert resp.status_code == 200 assert len(resp.json()["results"]) == 1 # ---------- 内部端流转 ---------- def test_admin_confirm_creates_sales_order_draft(db, auth_client, pro_plan, tenant, account, products, granted, warehouse): order = sf_services.submit_order( account.customer, account=account, lines=[{"product_id": products["P001"].id, "quantity": 10}], ) resp = auth_client.post(f"/api/v1/storefront/admin/orders/{order.id}/confirm/", {}, format="json") assert resp.status_code == 200, resp.content body = resp.json() assert body["ok"] is True assert body["sales_order_id"] from apps.sales.models import SalesOrder so = SalesOrder.objects.get(pk=body["sales_order_id"]) assert so.state == "draft" # 草稿,未过账 assert so.lines.count() == 1 order.refresh_from_db() assert order.status == "confirmed" def test_admin_confirm_picks_default_warehouse(db, auth_client, pro_plan, tenant, account, products, granted, warehouse): """不传 warehouse 时自动选默认仓库。""" order = sf_services.submit_order( account.customer, lines=[{"product_id": products["P001"].id, "quantity": 1}], ) resp = auth_client.post(f"/api/v1/storefront/admin/orders/{order.id}/confirm/", {}, format="json") assert resp.status_code == 200, resp.content def test_admin_reject_order(db, auth_client, pro_plan, tenant, account, products, granted): order = sf_services.submit_order( account.customer, lines=[{"product_id": products["P001"].id, "quantity": 1}], ) resp = auth_client.post(f"/api/v1/storefront/admin/orders/{order.id}/reject/", {"reason": "库存不足"}, format="json") assert resp.status_code == 200, resp.content order.refresh_from_db() assert order.status == "rejected" assert "库存不足" in order.remark def test_admin_orders_list_default_submitted(db, auth_client, pro_plan, tenant, account, products, granted, warehouse): o1 = sf_services.submit_order( account.customer, lines=[{"product_id": products["P001"].id, "quantity": 1}], ) o2 = sf_services.submit_order( account.customer, lines=[{"product_id": products["P002"].id, "quantity": 1}], ) sf_services.confirm_order(o2, warehouse=warehouse) resp = auth_client.get("/api/v1/storefront/admin/orders/") assert resp.status_code == 200 orders = resp.json()["results"] assert len(orders) == 1 assert orders[0]["id"] == o1.id def test_admin_orders_pagination(db, auth_client, pro_plan, tenant, account, products, granted, warehouse): """P2-1:admin_orders 支持 page/page_size 翻页,count 为过滤后总数。""" ids = [] for _ in range(3): o = sf_services.submit_order( account.customer, lines=[{"product_id": products["P001"].id, "quantity": 1}], ) ids.append(o.id) p1 = auth_client.get( "/api/v1/storefront/admin/orders/", {"status": "submitted", "page": 1, "page_size": 2}, ) assert p1.status_code == 200, p1.content b1 = p1.json() assert b1["count"] == 3 assert len(b1["results"]) == 2 p2 = auth_client.get( "/api/v1/storefront/admin/orders/", {"status": "submitted", "page": 2, "page_size": 2}, ) assert p2.status_code == 200, p2.content b2 = p2.json() assert b2["count"] == 3 assert len(b2["results"]) == 1 # 两页无重叠 got = {r["id"] for r in b1["results"]} | {r["id"] for r in b2["results"]} assert got == set(ids) def test_admin_confirm_twice_rejected(db, auth_client, pro_plan, tenant, account, products, granted, warehouse): order = sf_services.submit_order( account.customer, lines=[{"product_id": products["P001"].id, "quantity": 1}], ) auth_client.post(f"/api/v1/storefront/admin/orders/{order.id}/confirm/", {}, format="json") resp2 = auth_client.post(f"/api/v1/storefront/admin/orders/{order.id}/confirm/", {}, format="json") assert resp2.status_code == 400 # ---------- 授权管理 ---------- def test_admin_grant_and_revoke(db, auth_client, tenant, customer, products): resp = auth_client.post("/api/v1/storefront/admin/auths/", { "customer_id": customer.id, "product_ids": [products["P001"].id, products["P002"].id], }, format="json") assert resp.status_code == 201, resp.content assert resp.json()["created"] == 2 listing = auth_client.get( f"/api/v1/storefront/admin/auths/?customer_id={customer.id}" ).json()["results"] assert len(listing) == 2 auth_id = listing[0]["id"] resp2 = auth_client.delete(f"/api/v1/storefront/admin/auths/?id={auth_id}") assert resp2.status_code == 200 # 撤销后不可见 assert not CustomerProductAuth.objects.filter( pk=auth_id, is_active=True, is_deleted=False ).exists() def test_admin_create_account(db, auth_client, tenant, customer): resp = auth_client.post("/api/v1/storefront/admin/accounts/", { "customer_id": customer.id, "phone": "13700000001", "password": "abc12345", }, format="json") assert resp.status_code == 201, resp.content acc = StorefrontAccount.objects.get(phone="13700000001") assert acc.check_password("abc12345") # ---------- 租户隔离 ---------- def test_cross_tenant_product_not_visible(db, client, pro_plan, tenant, other_tenant, account, granted, products): """另一租户的商品不会出现在本租户商城目录里。""" other_p = baker.make(Product, tenant=other_tenant, code="X001", name="别家商品", sale_price=Decimal("1"), status="active") CustomerProductAuth.objects.create( tenant=other_tenant, customer=account.customer, product=other_p, is_active=True ) # 即便误建了跨租户授权 token = _login(client).json()["token"] resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") codes = {r["code"] for r in resp.json()["results"]} assert "X001" not in codes def test_catalog_excludes_inactive_products(db, client, pro_plan, tenant, account, products, granted): products["P001"].status = "inactive" products["P001"].save() token = _login(client).json()["token"] resp = client.get("/api/v1/storefront/catalog/", HTTP_X_TENANT_ID=tenant.code, HTTP_AUTHORIZATION=f"Storefront {token}") codes = {r["code"] for r in resp.json()["results"]} assert "P001" not in codes # ---------- billing 功能开关 ---------- def test_storefront_blocked_on_free_plan(db, client, tenant, account, products, granted): """免费版无订货商城 → 登录被 billing 功能开关拒绝(403 + 升级引导)。""" from apps.billing.models import Plan, Subscription, seed_plans seed_plans() free = Plan.objects.get(code="free") Subscription.objects.create(tenant=tenant, plan=free, status="active", period_start=date.today(), period_end=date.today()) resp = _login(client) assert resp.status_code == 403, resp.content assert resp.json()["code"] == "quota_exceeded" assert resp.json()["kind"] == "storefront" def test_storefront_allowed_on_pro_plan(db, client, tenant, account, products, granted): from apps.billing.models import Plan, Subscription, seed_plans seed_plans() pro = Plan.objects.get(code="pro") Subscription.objects.create(tenant=tenant, plan=pro, status="active", period_start=date.today(), period_end=date.today()) resp = _login(client) assert resp.status_code == 200, resp.content