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