"""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")