273 lines
10 KiB
Python
273 lines
10 KiB
Python
"""P0 #2 多单位换算 / #3 自动取价 / #4 最低售价+抹零 集成测试。"""
|
||
|
||
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.catalog import services as catalog_services
|
||
from apps.partner.models import Customer, PriceLevel, CustomerProductPrice
|
||
from apps.sales import services as sales_services
|
||
from apps.sales.models import SalesBill
|
||
from apps.core.services import compute_round_off, BelowMinPrice
|
||
|
||
|
||
@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):
|
||
from apps.inventory.models import Warehouse
|
||
return baker.make(Warehouse, tenant=tenant, code="WH01", name="主仓")
|
||
|
||
|
||
@pytest.fixture
|
||
def product(db, tenant):
|
||
return baker.make(
|
||
Product, tenant=tenant, code="COLA", name="可乐",
|
||
sale_price=Decimal("5"), cost_price=Decimal("3"),
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def box_unit(db, tenant, product):
|
||
"""1 箱 = 24 瓶。"""
|
||
base = baker.make(Unit, tenant=tenant, code="bottle", name="瓶", is_base=True)
|
||
product.base_unit = base
|
||
product.save()
|
||
box = baker.make(Unit, tenant=tenant, code="box", name="箱", is_base=False)
|
||
UnitConversion.objects.create(tenant=tenant, product=product, unit=box, rate=Decimal("24"))
|
||
product.refresh_from_db()
|
||
return box
|
||
|
||
|
||
@pytest.fixture
|
||
def customer(db, tenant):
|
||
return baker.make(Customer, tenant=tenant, code="C001", name="张三商店")
|
||
|
||
|
||
def _stock_in(tenant, warehouse, product, qty):
|
||
from apps.inventory import services as inv_services
|
||
inv_services.inbound(
|
||
tenant=tenant, warehouse=warehouse, product=product,
|
||
quantity=Decimal(str(qty)), unit_cost=Decimal("3"),
|
||
)
|
||
|
||
|
||
# ---------- #2 多单位换算 ----------
|
||
|
||
|
||
def test_to_base_with_conversion(tenant, product, box_unit):
|
||
assert catalog_services.to_base(product, box_unit, Decimal("2")) == Decimal("48")
|
||
|
||
|
||
def test_to_base_missing_conversion_raises(tenant, product):
|
||
other = baker.make(Unit, tenant=tenant, code="kg", name="千克", is_base=False)
|
||
with pytest.raises(catalog_services.UnitConversionNotFound):
|
||
catalog_services.to_base(product, other, Decimal("1"))
|
||
|
||
|
||
def test_sales_line_with_unit_conversion(tenant, warehouse, customer, product, box_unit):
|
||
"""2 箱 × 96 元/箱 → 基本数量 48 瓶、金额 192、按基本单位出库。"""
|
||
_stock_in(tenant, warehouse, product, 100)
|
||
bill = sales_services.create_sales_bill(
|
||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||
lines=[{"product": product, "quantity": 2, "unit_price": 96,
|
||
"source_unit": box_unit}],
|
||
)
|
||
line = bill.lines.first()
|
||
assert line.quantity == Decimal("48") # 基本单位
|
||
assert line.source_quantity == Decimal("2") # 录入数量
|
||
assert line.source_unit == box_unit
|
||
assert line.amount == Decimal("192")
|
||
assert bill.total_amount == Decimal("192")
|
||
|
||
sales_services.confirm_sales_bill(bill)
|
||
from apps.inventory.models import Stock
|
||
assert Stock.objects.get(product=product).on_hand == Decimal("52")
|
||
|
||
|
||
def test_sales_line_unit_without_price_converts_default_price(tenant, warehouse, customer, product, box_unit):
|
||
"""带单位不传价:默认价 5 元/瓶 × 24 = 120 元/箱。"""
|
||
_stock_in(tenant, warehouse, product, 100)
|
||
bill = sales_services.create_sales_bill(
|
||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||
lines=[{"product": product, "quantity": 1, "source_unit": box_unit}],
|
||
)
|
||
line = bill.lines.first()
|
||
assert line.unit_price == Decimal("120")
|
||
assert line.amount == Decimal("120")
|
||
|
||
|
||
def test_source_unit_min_price_compares_once(tenant, warehouse, customer, product, box_unit):
|
||
"""最低售价以基本单位计价,录入单位价格只换算一次。"""
|
||
product.min_sale_price = Decimal("5")
|
||
product.save(update_fields=["min_sale_price"])
|
||
with pytest.raises(BelowMinPrice):
|
||
sales_services.create_sales_bill(
|
||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||
lines=[{"product": product, "quantity": 1, "unit_price": 96,
|
||
"source_unit": box_unit}],
|
||
)
|
||
|
||
|
||
# ---------- #3 自动取价 ----------
|
||
|
||
|
||
def test_quote_priority_customer_price(tenant, customer, product):
|
||
CustomerProductPrice.objects.create(
|
||
tenant=tenant, customer=customer, product=product, price=Decimal("4.2")
|
||
)
|
||
q = __import__("apps.partner.services", fromlist=["quote_price"]).quote_price(
|
||
tenant=tenant, customer=customer, product=product
|
||
)
|
||
assert q["price"] == Decimal("4.2")
|
||
assert q["source"] == "customer"
|
||
|
||
|
||
def test_quote_priority_level_price(tenant, customer, product):
|
||
level = baker.make(PriceLevel, tenant=tenant, code="VIP", name="VIP",
|
||
discount_rate=Decimal("0.9"))
|
||
customer.price_level = level
|
||
customer.save()
|
||
q = __import__("apps.partner.services", fromlist=["quote_price"]).quote_price(
|
||
tenant=tenant, customer=customer, product=product
|
||
)
|
||
assert q["price"] == Decimal("4.5000")
|
||
assert q["source"] == "level"
|
||
|
||
|
||
def test_quote_priority_last_price(tenant, warehouse, customer, product):
|
||
_stock_in(tenant, warehouse, product, 100)
|
||
b1 = sales_services.create_sales_bill(
|
||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||
lines=[{"product": product, "quantity": 1, "unit_price": 4.8}],
|
||
)
|
||
sales_services.confirm_sales_bill(b1)
|
||
q = __import__("apps.partner.services", fromlist=["quote_price"]).quote_price(
|
||
tenant=tenant, customer=customer, product=product
|
||
)
|
||
assert q["price"] == Decimal("4.8")
|
||
assert q["source"] == "last"
|
||
assert q["last_price"] == Decimal("4.8")
|
||
assert q["min_price"] == Decimal("4.8")
|
||
assert q["max_price"] == Decimal("4.8")
|
||
|
||
|
||
def test_quote_default_when_nothing(tenant, customer, product):
|
||
q = __import__("apps.partner.services", fromlist=["quote_price"]).quote_price(
|
||
tenant=tenant, customer=customer, product=product
|
||
)
|
||
assert q["price"] == Decimal("5")
|
||
assert q["source"] == "default"
|
||
|
||
|
||
def test_create_bill_auto_price(tenant, warehouse, customer, product):
|
||
_stock_in(tenant, warehouse, product, 100)
|
||
CustomerProductPrice.objects.create(
|
||
tenant=tenant, customer=customer, product=product, price=Decimal("4.2")
|
||
)
|
||
bill = sales_services.create_sales_bill(
|
||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||
lines=[{"product": product, "quantity": 10}], # 不传价
|
||
)
|
||
assert bill.lines.first().unit_price == Decimal("4.2")
|
||
assert bill.total_amount == Decimal("42")
|
||
|
||
|
||
def test_price_quote_api(db, auth_client, tenant, customer, product):
|
||
resp = auth_client.get(
|
||
f"/api/v1/partner/price-quote/?customer_id={customer.id}&product_ids={product.id}"
|
||
)
|
||
assert resp.status_code == 200, resp.content
|
||
data = resp.json()
|
||
assert data["results"][0]["price"] == "5.0000"
|
||
assert data["results"][0]["source"] == "default"
|
||
|
||
|
||
def test_credit_usage_api(db, auth_client, tenant, customer):
|
||
from apps.finance.models import Receivable
|
||
baker.make(
|
||
Receivable, tenant=tenant, customer=customer, bill_no="RC1",
|
||
bill_date=date.today(), total_amount=Decimal("100"), paid_amount=Decimal("40"),
|
||
status="partial",
|
||
)
|
||
resp = auth_client.get(f"/api/v1/partner/credit-usage/{customer.id}/")
|
||
assert resp.status_code == 200, resp.content
|
||
data = resp.json()
|
||
assert data["outstanding"] == "60.0000"
|
||
|
||
|
||
# ---------- #4 最低售价 + 抹零 ----------
|
||
|
||
|
||
def test_min_price_blocks_and_allows_override(tenant, warehouse, customer, product):
|
||
product.min_sale_price = Decimal("4")
|
||
product.save()
|
||
_stock_in(tenant, warehouse, product, 100)
|
||
with pytest.raises(BelowMinPrice):
|
||
sales_services.create_sales_bill(
|
||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||
lines=[{"product": product, "quantity": 1, "unit_price": 3.5}],
|
||
)
|
||
# 审批放行
|
||
bill = sales_services.create_sales_bill(
|
||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||
lines=[{"product": product, "quantity": 1, "unit_price": 3.5,
|
||
"allow_below_min": True}],
|
||
)
|
||
assert bill.total_amount == Decimal("3.5")
|
||
|
||
|
||
def test_round_off_to_yuan(tenant, warehouse, customer, product):
|
||
_stock_in(tenant, warehouse, product, 100)
|
||
bill = sales_services.create_sales_bill(
|
||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||
lines=[{"product": product, "quantity": 3, "unit_price": 5.52}], # 16.56
|
||
round_to="1",
|
||
)
|
||
assert bill.total_amount == Decimal("16.0000")
|
||
assert bill.round_off == Decimal("0.5600")
|
||
|
||
|
||
def test_round_off_noop_at_cents(tenant, warehouse, customer, product):
|
||
_stock_in(tenant, warehouse, product, 100)
|
||
bill = sales_services.create_sales_bill(
|
||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||
lines=[{"product": product, "quantity": 1, "unit_price": 5}],
|
||
round_to="0.01",
|
||
)
|
||
assert bill.round_off == Decimal("0")
|
||
assert bill.total_amount == Decimal("5")
|
||
|
||
|
||
def test_compute_round_off_matrix():
|
||
assert compute_round_off(Decimal("100.56"), Decimal("1")) == Decimal("0.5600")
|
||
assert compute_round_off(Decimal("100.56"), Decimal("0.1")) == Decimal("0.0600")
|
||
assert compute_round_off(Decimal("100.56"), Decimal("0.01")) == Decimal("0.0000")
|
||
assert compute_round_off(Decimal("100"), None) == Decimal("0")
|
||
|
||
|
||
def test_receivable_amount_after_round_off(tenant, warehouse, customer, product):
|
||
from apps.finance.models import Receivable
|
||
_stock_in(tenant, warehouse, product, 100)
|
||
bill = sales_services.create_sales_bill(
|
||
tenant=tenant, customer=customer, warehouse=warehouse,
|
||
lines=[{"product": product, "quantity": 3, "unit_price": 5.52}],
|
||
round_to="1",
|
||
)
|
||
sales_services.confirm_sales_bill(bill)
|
||
recv = Receivable.objects.get(source_ref=bill.bill_no)
|
||
assert recv.total_amount == bill.total_amount == Decimal("16.0000")
|