363 lines
15 KiB
Python
363 lines
15 KiB
Python
"""批次 D4 · 税率测试。
|
|
|
|
覆盖:价内税拆分数学、行税率解析优先级、开单落税率与税额、凭证销项/进项拆分、
|
|
抹零在税后、报表口径(收入为不含税)、税率 0 时完全兼容老行为。
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from model_bakery import baker
|
|
|
|
from apps.catalog.models import Product
|
|
from apps.inventory.models import Warehouse
|
|
from apps.partner.models import Customer, Supplier
|
|
from apps.core.services import compute_tax, resolve_tax_rate
|
|
from apps.sales import services as sales_services
|
|
from apps.sales.models import SalesBill
|
|
from apps.purchase import services as purchase_services
|
|
from apps.finance.models import Account, Voucher
|
|
from apps.finance import services as fin_services
|
|
|
|
|
|
@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("100000"))
|
|
|
|
|
|
@pytest.fixture
|
|
def supplier(db, tenant):
|
|
return baker.make(Supplier, tenant=tenant, code="S001", name="上游厂")
|
|
|
|
|
|
@pytest.fixture
|
|
def chart(db, tenant):
|
|
"""初始化会计科目(凭证测试需要)。"""
|
|
fin_services.init_chart_of_accounts(tenant)
|
|
return True
|
|
|
|
|
|
# ============================================================
|
|
# 价内税拆分
|
|
# ============================================================
|
|
|
|
|
|
def test_compute_tax_exact_13pct():
|
|
"""113 含税 @13% → 100 + 13(整除,无尾差)。"""
|
|
out = compute_tax(Decimal("113"), Decimal("0.13"))
|
|
assert out["net"] == Decimal("100.0000")
|
|
assert out["tax"] == Decimal("13.0000")
|
|
assert out["net"] + out["tax"] == out["gross"]
|
|
|
|
|
|
def test_compute_tax_zero_rate():
|
|
out = compute_tax(Decimal("100"), Decimal("0"))
|
|
assert out["net"] == Decimal("100.0000")
|
|
assert out["tax"] == Decimal("0.0000")
|
|
|
|
|
|
def test_compute_tax_none_rate():
|
|
out = compute_tax(Decimal("50"), None)
|
|
assert out["tax"] == Decimal("0.0000")
|
|
assert out["net"] == Decimal("50.0000")
|
|
|
|
|
|
@pytest.mark.parametrize("amount,rate,expect_tax", [
|
|
("100", "0.13", "11.5044"), # 100/1.13 → 净 88.4956
|
|
("50.5", "0.06", "2.8585"),
|
|
("1000", "0.09", "82.5688"),
|
|
("9.9", "0.03", "0.2883"),
|
|
])
|
|
def test_compute_tax_various_rates(amount, rate, expect_tax):
|
|
out = compute_tax(Decimal(amount), Decimal(rate))
|
|
assert out["tax"] == Decimal(expect_tax)
|
|
# 不留尾差:净 + 税 = 含税
|
|
assert out["net"] + out["tax"] == out["gross"]
|
|
|
|
|
|
def test_compute_tax_never_negative_or_over():
|
|
"""税额必须落在 [0, 含税额] 区间。"""
|
|
for amt in ("0.01", "1", "100"):
|
|
for rate in ("0", "0.01", "0.13", "0.5"):
|
|
out = compute_tax(Decimal(amt), Decimal(rate))
|
|
assert Decimal("0") <= out["tax"] <= out["gross"]
|
|
|
|
|
|
# ============================================================
|
|
# 税率解析优先级
|
|
# ============================================================
|
|
|
|
|
|
def test_resolve_tax_rate_line_overrides_product(db, tenant):
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
tax_rate=Decimal("0.13"))
|
|
assert resolve_tax_rate(p, {"tax_rate": "0.06"}) == Decimal("0.06")
|
|
|
|
|
|
def test_resolve_tax_rate_falls_back_to_product(db, tenant):
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
tax_rate=Decimal("0.09"))
|
|
assert resolve_tax_rate(p, {}) == Decimal("0.09")
|
|
|
|
|
|
def test_resolve_tax_rate_default_zero_when_unset(db, tenant):
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
tax_rate=Decimal("0"))
|
|
assert resolve_tax_rate(p, {}) == Decimal("0")
|
|
|
|
|
|
# ============================================================
|
|
# 开单落税率/税额
|
|
# ============================================================
|
|
|
|
|
|
def test_sales_bill_lines_carry_tax(db, tenant, warehouse, customer):
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
sale_price=Decimal("113"), tax_rate=Decimal("0.13"))
|
|
bill = sales_services.create_sales_bill(
|
|
tenant=tenant, customer=customer, warehouse=warehouse,
|
|
lines=[{"product": p, "quantity": 1, "unit_price": "113"}],
|
|
)
|
|
line = bill.lines.first()
|
|
assert line.tax_rate == Decimal("0.1300")
|
|
assert line.tax_amount == Decimal("13.0000")
|
|
# 单据总额是含税金额(价内口径不变)
|
|
assert bill.total_amount == Decimal("113.0000")
|
|
|
|
|
|
def test_sales_bill_line_tax_override(db, tenant, warehouse, customer):
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="农产品",
|
|
sale_price=Decimal("106"), tax_rate=Decimal("0.13"))
|
|
bill = sales_services.create_sales_bill(
|
|
tenant=tenant, customer=customer, warehouse=warehouse,
|
|
lines=[{"product": p, "quantity": 1, "unit_price": "106",
|
|
"tax_rate": "0.06"}],
|
|
)
|
|
assert bill.lines.first().tax_amount == Decimal("6.0000") # 106/1.06
|
|
|
|
|
|
def test_purchase_bill_lines_carry_tax(db, tenant, warehouse, supplier):
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
tax_rate=Decimal("0.13"))
|
|
bill = purchase_services.create_purchase_bill(
|
|
tenant=tenant, supplier=supplier, warehouse=warehouse,
|
|
lines=[{"product": p, "quantity": 10, "unit_price": "11.3"}],
|
|
)
|
|
line = bill.lines.first()
|
|
assert line.tax_rate == Decimal("0.1300")
|
|
assert line.tax_amount == Decimal("13.0000") # 113 / 1.13
|
|
assert bill.total_amount == Decimal("113.0000")
|
|
|
|
|
|
def test_zero_rate_bill_has_zero_tax(db, tenant, warehouse, customer):
|
|
"""税率 0(默认商品):行为与 D4 之前完全一致。"""
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="免税品",
|
|
sale_price=Decimal("100"), tax_rate=Decimal("0"))
|
|
bill = sales_services.create_sales_bill(
|
|
tenant=tenant, customer=customer, warehouse=warehouse,
|
|
lines=[{"product": p, "quantity": 1, "unit_price": "100"}],
|
|
)
|
|
assert bill.lines.first().tax_amount == Decimal("0.0000")
|
|
assert bill.total_amount == Decimal("100.0000")
|
|
|
|
|
|
def test_round_off_applied_after_tax(db, tenant, warehouse, customer):
|
|
"""抹零在税后:行金额含税 → 合计 → 抹零 → 应收净额。"""
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
sale_price=Decimal("113.6"), tax_rate=Decimal("0.13"))
|
|
bill = sales_services.create_sales_bill(
|
|
tenant=tenant, customer=customer, warehouse=warehouse,
|
|
lines=[{"product": p, "quantity": 1, "unit_price": "113.6"}],
|
|
round_to="1",
|
|
)
|
|
assert bill.total_amount == Decimal("113") # 113.6 抹到 113
|
|
assert bill.round_off == Decimal("0.6")
|
|
# 行税额仍按未抹零的含税金额计算
|
|
assert bill.lines.first().tax_amount == Decimal("13.0690")
|
|
|
|
|
|
# ============================================================
|
|
# 凭证:销项税 / 进项税拆分
|
|
# ============================================================
|
|
|
|
|
|
def test_receivable_voucher_splits_output_tax(db, tenant, warehouse, customer, chart):
|
|
from apps.inventory import services as inv
|
|
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
sale_price=Decimal("113"), tax_rate=Decimal("0.13"))
|
|
inv.inbound(tenant=tenant, warehouse=warehouse, product=p,
|
|
quantity=Decimal("10"), unit_cost=Decimal("50"))
|
|
bill = sales_services.create_sales_bill(
|
|
tenant=tenant, customer=customer, warehouse=warehouse,
|
|
lines=[{"product": p, "quantity": 1, "unit_price": "113"}],
|
|
)
|
|
sales_services.confirm_sales_bill(bill)
|
|
|
|
v = Voucher.objects.filter(tenant=tenant, source_type="sale").first()
|
|
assert v is not None
|
|
codes = {e.account.code: e for e in v.entries.select_related("account")}
|
|
assert Decimal(codes["1122"].debit) == Decimal("113.0000") # 应收全额
|
|
assert Decimal(codes["6001"].credit) == Decimal("100.0000") # 收入不含税
|
|
assert Decimal(codes["2221"].credit) == Decimal("13.0000") # 销项税
|
|
# 借贷平衡
|
|
total_debit = sum(Decimal(e.debit) for e in v.entries.all())
|
|
total_credit = sum(Decimal(e.credit) for e in v.entries.all())
|
|
assert total_debit == total_credit
|
|
|
|
|
|
def test_payable_voucher_splits_input_tax(db, tenant, warehouse, supplier, chart):
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
tax_rate=Decimal("0.13"))
|
|
bill = purchase_services.create_purchase_bill(
|
|
tenant=tenant, supplier=supplier, warehouse=warehouse,
|
|
lines=[{"product": p, "quantity": 10, "unit_price": "11.3"}],
|
|
)
|
|
purchase_services.confirm_purchase_bill(bill)
|
|
|
|
v = Voucher.objects.filter(tenant=tenant, source_type="purchase").first()
|
|
assert v is not None
|
|
codes = {e.account.code: e for e in v.entries.select_related("account")}
|
|
assert Decimal(codes["1403"].debit) == Decimal("100.0000") # 存货不含税
|
|
assert Decimal(codes["2222"].debit) == Decimal("13.0000") # 进项税
|
|
assert Decimal(codes["2202"].credit) == Decimal("113.0000") # 应付全额
|
|
total_debit = sum(Decimal(e.debit) for e in v.entries.all())
|
|
total_credit = sum(Decimal(e.credit) for e in v.entries.all())
|
|
assert total_debit == total_credit
|
|
|
|
|
|
def test_voucher_without_tax_keeps_two_entries(db, tenant, warehouse, customer, chart):
|
|
"""税率 0 时不产生税费分录(老行为不变)。"""
|
|
from apps.inventory import services as inv
|
|
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="免税品",
|
|
sale_price=Decimal("100"), tax_rate=Decimal("0"))
|
|
inv.inbound(tenant=tenant, warehouse=warehouse, product=p,
|
|
quantity=Decimal("10"), unit_cost=Decimal("50"))
|
|
bill = sales_services.create_sales_bill(
|
|
tenant=tenant, customer=customer, warehouse=warehouse,
|
|
lines=[{"product": p, "quantity": 1, "unit_price": "100"}],
|
|
)
|
|
sales_services.confirm_sales_bill(bill)
|
|
|
|
v = Voucher.objects.filter(tenant=tenant, source_type="sale").first()
|
|
codes = {e.account.code for e in v.entries.select_related("account")}
|
|
assert "2221" not in codes
|
|
assert codes == {"1122", "6001"}
|
|
|
|
|
|
# ============================================================
|
|
# 报表口径
|
|
# ============================================================
|
|
|
|
|
|
def test_income_statement_revenue_is_net_of_tax(db, tenant, warehouse, customer, chart):
|
|
"""利润表营业收入取 6001 贷方发生额 = 不含税净额。"""
|
|
from apps.inventory import services as inv
|
|
from apps.finance import services as fin
|
|
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
sale_price=Decimal("113"), tax_rate=Decimal("0.13"))
|
|
inv.inbound(tenant=tenant, warehouse=warehouse, product=p,
|
|
quantity=Decimal("10"), unit_cost=Decimal("50"))
|
|
bill = sales_services.create_sales_bill(
|
|
tenant=tenant, customer=customer, warehouse=warehouse,
|
|
lines=[{"product": p, "quantity": 1, "unit_price": "113"}],
|
|
)
|
|
sales_services.confirm_sales_bill(bill)
|
|
|
|
period = fin.current_period(tenant)
|
|
data = fin.income_statement(tenant, period)
|
|
assert Decimal(data["total_revenue"]) == Decimal("100.0000") # 不含税
|
|
|
|
|
|
def test_tax_accounts_exist_in_chart(db, tenant, chart):
|
|
codes = set(Account.objects.filter(tenant=tenant).values_list("code", flat=True))
|
|
assert "2221" in codes # 销项
|
|
assert "2222" in codes # 进项
|
|
|
|
|
|
def test_balance_sheet_stays_balanced_with_tax(db, tenant, warehouse, customer, chart):
|
|
from apps.inventory import services as inv
|
|
from apps.finance import services as fin
|
|
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
sale_price=Decimal("113"), tax_rate=Decimal("0.13"))
|
|
inv.inbound(tenant=tenant, warehouse=warehouse, product=p,
|
|
quantity=Decimal("10"), unit_cost=Decimal("50"))
|
|
bill = sales_services.create_sales_bill(
|
|
tenant=tenant, customer=customer, warehouse=warehouse,
|
|
lines=[{"product": p, "quantity": 1, "unit_price": "113"}],
|
|
)
|
|
sales_services.confirm_sales_bill(bill)
|
|
|
|
period = fin.current_period(tenant)
|
|
bs = fin.balance_sheet(tenant, period)
|
|
assert bs["balanced"] is True
|
|
|
|
|
|
# ============================================================
|
|
# API 级:税率随开单接口落地
|
|
# ============================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def auth_client(db, user, tenant):
|
|
from rest_framework.test import APIClient
|
|
from rest_framework_simplejwt.tokens import RefreshToken
|
|
|
|
c = APIClient()
|
|
refresh = RefreshToken.for_user(user)
|
|
c.credentials(
|
|
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
|
|
HTTP_X_TENANT_ID=tenant.code,
|
|
)
|
|
return c
|
|
|
|
|
|
def test_units_api_exposes_tax_rate(db, auth_client, tenant):
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
sale_price=Decimal("113"), tax_rate=Decimal("0.13"))
|
|
resp = auth_client.get(f"/api/v1/catalog/products/{p.id}/units/")
|
|
assert resp.status_code == 200, resp.content
|
|
assert Decimal(resp.json()["tax_rate"]) == Decimal("0.13")
|
|
|
|
|
|
def test_create_bill_api_returns_tax_fields(db, auth_client, tenant, warehouse, customer):
|
|
from apps.inventory import services as inv
|
|
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
sale_price=Decimal("113"), tax_rate=Decimal("0.13"))
|
|
inv.inbound(tenant=tenant, warehouse=warehouse, product=p,
|
|
quantity=Decimal("10"), unit_cost=Decimal("50"))
|
|
resp = auth_client.post("/api/v1/sales/bills/create-bill/", {
|
|
"customer": customer.id, "warehouse": warehouse.id,
|
|
"lines": [{"product": p.id, "quantity": 1, "unit_price": "113"}],
|
|
}, format="json")
|
|
assert resp.status_code == 201, resp.content
|
|
body = resp.json()
|
|
line = body["lines"][0]
|
|
assert Decimal(line["tax_rate"]) == Decimal("0.13")
|
|
assert Decimal(line["tax_amount"]) == Decimal("13.0000")
|
|
assert Decimal(body["tax_total"]) == Decimal("13.0000")
|
|
# 单据总额仍是含税(价内口径)
|
|
assert Decimal(body["total_amount"]) == Decimal("113.0000")
|
|
|
|
|
|
def test_purchase_create_bill_api_tax_fields(db, auth_client, tenant, warehouse, supplier):
|
|
p = baker.make(Product, tenant=tenant, code="P001", name="可乐",
|
|
tax_rate=Decimal("0.13"))
|
|
resp = auth_client.post("/api/v1/purchase/bills/create-bill/", {
|
|
"supplier": supplier.id, "warehouse": warehouse.id,
|
|
"lines": [{"product": p.id, "quantity": 10, "unit_price": "11.3"}],
|
|
}, format="json")
|
|
assert resp.status_code == 201, resp.content
|
|
line = resp.json()["lines"][0]
|
|
assert Decimal(line["tax_amount"]) == Decimal("13.0000")
|