Files
dealerhub/backend/tests/test_full_finance.py
T

649 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""完整财务深度总账及业财一体化集成测试:
1. 会计科目初始化与期间管理
2. 记账凭证录入、借贷平衡校验、状态流转(草稿/已过账/已作废)
3. 业财一体化:销售应收、销售成本、采购应付、收付款核销自动生成记账凭证
4. 期末损益结转与结账/反结账
5. 财务三大报表(科目余额表试算平衡、资产负债表等式平衡、利润表)与明细账
6. 财务 REST API 接口测试(初始化科目、录入凭证、过账、结账、报表查询)
"""
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.partner.models import Customer, Supplier
from apps.inventory.models import Warehouse
from apps.catalog.models import Product
from apps.finance import services as fs
from apps.finance.models import (
Account, Period, Voucher, VoucherEntry,
Receivable, Payable, Receipt, Payment, Allocation,
)
@pytest.fixture
def auth_client(db, user, tenant):
client = APIClient()
refresh = RefreshToken.for_user(user)
client.credentials(
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}",
HTTP_X_TENANT_ID=tenant.code,
)
return client
@pytest.fixture
def customer(tenant):
return baker.make(Customer, tenant=tenant, code="C001", name="测试客户A")
@pytest.fixture
def supplier(tenant):
return baker.make(Supplier, tenant=tenant, code="S001", name="测试供应商A")
# ============================================================
# 1. 会计科目与期间
# ============================================================
@pytest.mark.django_db
def test_init_chart_of_accounts(tenant):
count1 = fs.init_chart_of_accounts(tenant)
assert count1 == 22
assert Account.objects.filter(tenant=tenant).count() == 22
# 幂等性:重复执行不重复创建
count2 = fs.init_chart_of_accounts(tenant)
assert count2 == 0
assert Account.objects.filter(tenant=tenant).count() == 22
# 验证关键科目存在
cash = fs.get_account(tenant, "1001")
assert cash.name == "现金"
assert cash.category == "asset"
assert cash.balance_type == "debit"
ar = fs.get_account(tenant, "1122")
assert ar.aux_customer is True
ap = fs.get_account(tenant, "2202")
assert ap.aux_supplier is True
@pytest.mark.django_db
def test_accounting_periods(tenant):
today = date(2026, 9, 15)
period = fs.period_of(tenant, today)
assert period.code == "2026-09"
assert period.start_date == date(2026, 9, 1)
assert period.end_date == date(2026, 9, 30)
assert period.status == "open"
# 跨月批量初始化
n = fs.ensure_periods(tenant, date(2026, 1, 1), date(2026, 3, 1))
assert n == 3
assert Period.objects.filter(tenant=tenant, code="2026-01").exists()
# ============================================================
# 2. 凭证服务与借贷校验
# ============================================================
@pytest.mark.django_db
def test_voucher_create_and_balance_validation(tenant, customer):
fs.init_chart_of_accounts(tenant)
today = date(2026, 9, 10)
# 1. 借贷不平衡时必须抛出异常
with pytest.raises(fs.VoucherError, match="借贷不平衡"):
fs.create_voucher(
tenant=tenant,
voucher_date=today,
entries=[
{"account_code": "1001", "debit": Decimal("100"), "credit": 0},
{"account_code": "1002", "debit": 0, "credit": Decimal("90")},
],
)
# 2. 借贷同时有值必须抛异常
with pytest.raises(fs.VoucherError, match="借贷不能同时有值"):
fs.create_voucher(
tenant=tenant,
voucher_date=today,
entries=[
{"account_code": "1001", "debit": Decimal("100"), "credit": Decimal("50")},
{"account_code": "1002", "debit": 0, "credit": Decimal("50")},
],
)
# 3. 辅助核算缺失时必须抛异常
with pytest.raises(fs.VoucherError, match="需要关联客户辅助核算"):
fs.create_voucher(
tenant=tenant,
voucher_date=today,
entries=[
{"account_code": "1122", "debit": Decimal("500"), "credit": 0, "customer_id": None},
{"account_code": "6001", "debit": 0, "credit": Decimal("500")},
],
)
# 4. 正常凭证创建
voucher = fs.create_voucher(
tenant=tenant,
voucher_date=today,
summary="提现备用金",
entries=[
{"account_code": "1001", "debit": Decimal("500"), "credit": 0},
{"account_code": "1002", "debit": 0, "credit": Decimal("500")},
],
)
assert voucher.status == "draft"
assert voucher.bill_no.startswith("V202609")
assert voucher.total_debit == Decimal("500")
assert voucher.total_credit == Decimal("500")
assert voucher.entries.count() == 2
@pytest.mark.django_db
def test_voucher_posting_and_cancel_lifecycle(tenant, user):
fs.init_chart_of_accounts(tenant)
today = date(2026, 9, 10)
voucher = fs.create_voucher(
tenant=tenant,
voucher_date=today,
summary="日常报销",
entries=[
{"account_code": "6602", "debit": Decimal("200"), "credit": 0},
{"account_code": "1002", "debit": 0, "credit": Decimal("200")},
],
)
assert voucher.status == "draft"
# 过账
posted_v = fs.post_voucher(voucher, user=user)
assert posted_v.status == "posted"
assert posted_v.posted_by == user
assert posted_v.posted_at is not None
# 重复过账抛异常
with pytest.raises(fs.VoucherError, match="只能对草稿凭证过账"):
fs.post_voucher(posted_v)
# 作废
cancelled_v = fs.cancel_voucher(posted_v, user=user)
assert cancelled_v.status == "cancelled"
# ============================================================
# 3. 业财一体化自动凭证
# ============================================================
@pytest.mark.django_db
def test_auto_voucher_from_sales_and_cogs(tenant, customer):
fs.init_chart_of_accounts(tenant)
today = date(2026, 9, 10)
# 1. 产生销售应收 -> 自动生成凭证
recv = fs.create_receivable_from_sale(
tenant=tenant,
customer=customer,
total_amount=Decimal("1200.00"),
source_ref="XS202609100001",
bill_date=today,
)
# 验证自动生成的凭证存在且已过账
v_sale = Voucher.objects.get(tenant=tenant, source_type="sale", source_ref=recv.bill_no)
assert v_sale.status == "posted"
assert v_sale.total_debit == Decimal("1200.00")
# 分录 1:借 1122 应收账款 (挂客户)
e1 = v_sale.entries.get(account__code="1122")
assert e1.debit == Decimal("1200.00")
assert e1.customer == customer
# 分录 2:贷 6001 主营业务收入
e2 = v_sale.entries.get(account__code="6001")
assert e2.credit == Decimal("1200.00")
# 2. 产生销售成本凭证
v_cogs = fs.voucher_for_sale_cogs(
tenant=tenant,
sales_bill_no="XS202609100001",
bill_date=today,
cost_amount=Decimal("800.00"),
auto_post=True,
)
assert v_cogs.status == "posted"
assert v_cogs.total_debit == Decimal("800.00")
assert v_cogs.entries.get(account__code="6401").debit == Decimal("800.00")
assert v_cogs.entries.get(account__code="1403").credit == Decimal("800.00")
@pytest.mark.django_db
def test_auto_voucher_from_purchase_and_payment(tenant, supplier):
fs.init_chart_of_accounts(tenant)
today = date(2026, 9, 10)
# 1. 采购应付 -> 自动生成采购凭证
pay = fs.create_payable_from_purchase(
tenant=tenant,
supplier=supplier,
total_amount=Decimal("2000.00"),
source_ref="PB202609100001",
bill_date=today,
)
v_pay = Voucher.objects.get(tenant=tenant, source_type="purchase", source_ref=pay.bill_no)
assert v_pay.status == "posted"
assert v_pay.entries.get(account__code="1403").debit == Decimal("2000.00")
e_ap = v_pay.entries.get(account__code="2202")
assert e_ap.credit == Decimal("2000.00")
assert e_ap.supplier == supplier
# 2. 付款核销 -> 自动生成付款凭证
payment = baker.make(
Payment, tenant=tenant, supplier=supplier,
bill_no="PMT-101", bill_date=today, amount=Decimal("2000.00"),
)
fs.allocate_payment(payment, [(pay, Decimal("2000.00"))])
v_pmt = Voucher.objects.get(tenant=tenant, source_type="payment", source_ref=payment.bill_no)
assert v_pmt.status == "posted"
assert v_pmt.entries.get(account__code="2202").debit == Decimal("2000.00")
assert v_pmt.entries.get(account__code="1002").credit == Decimal("2000.00")
@pytest.mark.django_db
def test_auto_voucher_from_receipt(tenant, customer):
fs.init_chart_of_accounts(tenant)
today = date(2026, 9, 10)
recv = fs.create_receivable_from_sale(
tenant=tenant,
customer=customer,
total_amount=Decimal("1500.00"),
source_ref="XS-002",
bill_date=today,
)
receipt = baker.make(
Receipt, tenant=tenant, customer=customer,
bill_no="RCT-101", bill_date=today, amount=Decimal("1500.00"),
method="银行转账",
)
fs.allocate_receipt(receipt, [(recv, Decimal("1500.00"))])
v_rct = Voucher.objects.get(tenant=tenant, source_type="receipt", source_ref=receipt.bill_no)
assert v_rct.status == "posted"
assert v_rct.entries.get(account__code="1002").debit == Decimal("1500.00")
assert v_rct.entries.get(account__code="1122").credit == Decimal("1500.00")
# ============================================================
# 4. 期末损益结转与结账/反结账
# ============================================================
@pytest.mark.django_db
def test_period_close_and_reopen(tenant, customer, user):
fs.init_chart_of_accounts(tenant)
p = fs.period_of(tenant, date(2026, 9, 1))
# 制造一笔收入:1000 元(借:应收 1000 贷:主营业务收入 1000)
fs.create_voucher(
tenant=tenant, voucher_date=date(2026, 9, 5),
entries=[
{"account_code": "1122", "debit": Decimal("1000"), "credit": 0, "customer_id": customer.id},
{"account_code": "6001", "debit": 0, "credit": Decimal("1000")},
],
auto_post=True,
)
# 制造两笔费用:主营业务成本 600 元,管理费用 100 元
fs.create_voucher(
tenant=tenant, voucher_date=date(2026, 9, 6),
entries=[
{"account_code": "6401", "debit": Decimal("600"), "credit": 0},
{"account_code": "1403", "debit": 0, "credit": Decimal("600")},
],
auto_post=True,
)
fs.create_voucher(
tenant=tenant, voucher_date=date(2026, 9, 7),
entries=[
{"account_code": "6602", "debit": Decimal("100"), "credit": 0},
{"account_code": "1002", "debit": 0, "credit": Decimal("100")},
],
auto_post=True,
)
# 此时未结账:净利润应为 1000 - 600 - 100 = 300
p_stmt = fs.income_statement(tenant, p)
assert p_stmt["total_revenue"] == Decimal("1000")
assert p_stmt["total_expense"] == Decimal("700")
assert p_stmt["net_profit"] == Decimal("300")
# 执行期末结账
close_voucher = fs.close_period(p, user=user)
assert close_voucher is not None
assert close_voucher.status == "posted"
p.refresh_from_db()
assert p.status == "closed"
assert p.closed_by == user
# 结转凭证分录检查:
# 借:6001 主营业务收入 1000
# 贷:6401 主营业务成本 600
# 贷:6602 管理费用 100
# 贷:4103 本年利润 300 (净利润)
assert close_voucher.entries.get(account__code="6001").debit == Decimal("1000")
assert close_voucher.entries.get(account__code="6401").credit == Decimal("600")
assert close_voucher.entries.get(account__code="6602").credit == Decimal("100")
profit_entry = close_voucher.entries.get(account__code="4103")
assert profit_entry.credit == Decimal("300")
# 结账后,已结账期间不允许创建凭证
with pytest.raises(fs.VoucherError, match="已结账,禁止"):
fs.create_voucher(
tenant=tenant, voucher_date=date(2026, 9, 15),
entries=[
{"account_code": "1001", "debit": Decimal("10"), "credit": 0},
{"account_code": "1002", "debit": 0, "credit": Decimal("10")},
],
)
# 反结账
fs.reopen_period(p, user=user)
p.refresh_from_db()
assert p.status == "open"
assert p.closed_at is None
# 原结账凭证应被自动作废
close_voucher.refresh_from_db()
assert close_voucher.status == "cancelled"
# ============================================================
# 5. 财务报表平衡与明细账
# ============================================================
@pytest.mark.django_db
def test_financial_reports_and_balance_invariants(tenant, customer, supplier):
fs.init_chart_of_accounts(tenant)
p = fs.period_of(tenant, date(2026, 9, 1))
# 1. 股东实收资本注资 100,000 元(借:银行存款 100000,贷:实收资本 100000)
fs.create_voucher(
tenant=tenant, voucher_date=date(2026, 9, 1),
entries=[
{"account_code": "1002", "debit": Decimal("100000"), "credit": 0},
{"account_code": "4001", "debit": 0, "credit": Decimal("100000")},
],
auto_post=True,
)
# 2. 采购商品 20,000 元(借:库存商品 20000,贷:应付账款 20000)
fs.create_voucher(
tenant=tenant, voucher_date=date(2026, 9, 2),
entries=[
{"account_code": "1403", "debit": Decimal("20000"), "credit": 0},
{"account_code": "2202", "debit": 0, "credit": Decimal("20000"), "supplier_id": supplier.id},
],
auto_post=True,
)
# 3. 销售商品 30,000 元(借:应收账款 30000,贷:主营业务收入 30000)
fs.create_voucher(
tenant=tenant, voucher_date=date(2026, 9, 3),
entries=[
{"account_code": "1122", "debit": Decimal("30000"), "credit": 0, "customer_id": customer.id},
{"account_code": "6001", "debit": 0, "credit": Decimal("30000")},
],
auto_post=True,
)
# 4. 结转销售成本 15,000 元(借:主营业务成本 15000,贷:库存商品 15000)
fs.create_voucher(
tenant=tenant, voucher_date=date(2026, 9, 3),
entries=[
{"account_code": "6401", "debit": Decimal("15000"), "credit": 0},
{"account_code": "1403", "debit": 0, "credit": Decimal("15000")},
],
auto_post=True,
)
# 5. 收到客户货款 30,000 元(借:银行存款 30000,贷:应收账款 30000)
fs.create_voucher(
tenant=tenant, voucher_date=date(2026, 9, 5),
entries=[
{"account_code": "1002", "debit": Decimal("30000"), "credit": 0},
{"account_code": "1122", "debit": 0, "credit": Decimal("30000"), "customer_id": customer.id},
],
auto_post=True,
)
# 6. 支付供应商货款 10,000 元(借:应付账款 10000,贷:银行存款 10000)
fs.create_voucher(
tenant=tenant, voucher_date=date(2026, 9, 6),
entries=[
{"account_code": "2202", "debit": Decimal("10000"), "credit": 0, "supplier_id": supplier.id},
{"account_code": "1002", "debit": 0, "credit": Decimal("10000")},
],
auto_post=True,
)
# === 校验 1:科目余额表(试算平衡)===
tb = fs.trial_balance(tenant, p)
assert tb["is_balanced"] is True
assert tb["totals"]["period_debit"] == tb["totals"]["period_credit"]
assert tb["totals"]["ending_debit"] == tb["totals"]["ending_credit"]
# === 校验 2:资产负债表等式 ===
# 资产类:
# 银行存款 = 100000 (注资) + 30000 (收客户) - 10000 (付供应商) = 120000
# 应收账款 = 30000 - 30000 = 0
# 库存商品 = 20000 (入库) - 15000 (出库) = 5000
# 资产总计 = 125000
# 负债类:
# 应付账款 = 20000 - 10000 = 10000
# 所有者权益类:
# 实收资本 = 100000
# 未结转本期利润 = 收入 30000 - 成本 15000 = 15000
# 负债及所有者权益总计 = 10000 + 100000 + 15000 = 125000
bs = fs.balance_sheet(tenant, p)
assert bs["asset_total"] == Decimal("125000")
assert bs["liability_total"] == Decimal("10000")
assert bs["equity_total"] == Decimal("100000")
assert bs["unclosed_profit"] == Decimal("15000")
assert bs["balanced"] is True
# === 校验 3:利润表 ===
stmt = fs.income_statement(tenant, p)
assert stmt["total_revenue"] == Decimal("30000")
assert stmt["cogs"] == Decimal("15000")
assert stmt["gross_profit"] == Decimal("15000")
assert stmt["net_profit"] == Decimal("15000")
# === 校验 4:银行存款明细账 ===
bank_acc = fs.get_account(tenant, "1002")
ledger = fs.account_ledger(tenant, bank_acc, p)
assert ledger["account_code"] == "1002"
assert len(ledger["lines"]) == 3 # 注资、收客户、付供应商
assert ledger["ending_balance"] == Decimal("120000")
# ============================================================
# 6. REST API 接口测试
# ============================================================
@pytest.mark.django_db
def test_receipt_allocation_api(auth_client, tenant, customer, other_tenant):
"""收款核销 REST action 调用既有服务并隔离租户/客户。"""
recv = fs.create_receivable_from_sale(
tenant=tenant, customer=customer, total_amount=Decimal("400.00")
)
receipt = baker.make(
Receipt, tenant=tenant, customer=customer,
bill_no="RCT-API-001", bill_date=date.today(), amount=Decimal("400.00"),
)
response = auth_client.post(
f"/api/v1/finance/receipts/{receipt.id}/allocate/",
{"allocations": [{"receivable": recv.id, "amount": "400.00"}]},
format="json",
)
assert response.status_code == 200, response.content
assert response.json()["ok"] is True
assert response.json()["receipt"]["status"] == "posted"
assert response.json()["allocations"][0]["receivable"] == recv.id
recv.refresh_from_db()
assert recv.status == "paid"
assert Allocation.objects.filter(receipt=receipt, receivable=recv).count() == 1
other_customer = baker.make(Customer, tenant=other_tenant, code="OTHER-C1")
foreign_recv = baker.make(
Receivable, tenant=other_tenant, customer=other_customer,
bill_no="RC-OTHER-001", bill_date=date.today(), total_amount=Decimal("400.00"),
)
second_receipt = baker.make(
Receipt, tenant=tenant, customer=customer,
bill_no="RCT-API-002", bill_date=date.today(), amount=Decimal("400.00"),
)
response = auth_client.post(
f"/api/v1/finance/receipts/{second_receipt.id}/allocate/",
{"allocations": [{"receivable": foreign_recv.id, "amount": "400.00"}]},
format="json",
)
assert response.status_code == 400, response.content
assert "allocations" in response.json()
assert Allocation.objects.filter(receipt=second_receipt).count() == 0
same_tenant_customer = baker.make(Customer, tenant=tenant, code="C-SAME-TENANT")
mismatched_recv = baker.make(
Receivable, tenant=tenant, customer=same_tenant_customer,
bill_no="RC-API-MISMATCH", bill_date=date.today(), total_amount=Decimal("400.00"),
)
third_receipt = baker.make(
Receipt, tenant=tenant, customer=customer,
bill_no="RCT-API-003", bill_date=date.today(), amount=Decimal("400.00"),
)
response = auth_client.post(
f"/api/v1/finance/receipts/{third_receipt.id}/allocate/",
{"allocations": [{"receivable": mismatched_recv.id, "amount": "400.00"}]},
format="json",
)
assert response.status_code == 400, response.content
assert "客户" in response.json().get("detail", "")
assert Allocation.objects.filter(receipt=third_receipt).count() == 0
@pytest.mark.django_db
def test_payment_allocation_api(auth_client, tenant, supplier, other_tenant):
"""付款核销 REST action 校验供应商一致性并返回创建的核销明细。"""
payable = fs.create_payable_from_purchase(
tenant=tenant, supplier=supplier, total_amount=Decimal("250.00")
)
payment = baker.make(
Payment, tenant=tenant, supplier=supplier,
bill_no="PMT-API-001", bill_date=date.today(), amount=Decimal("250.00"),
)
response = auth_client.post(
f"/api/v1/finance/payments/{payment.id}/allocate/",
{"allocations": [{"payable_id": payable.id, "amount": "250.00"}]},
format="json",
)
assert response.status_code == 200, response.content
assert response.json()["ok"] is True
assert response.json()["payment"]["status"] == "posted"
assert response.json()["allocations"][0]["payable"] == payable.id
payable.refresh_from_db()
assert payable.status == "paid"
assert Allocation.objects.filter(payment=payment, payable=payable).count() == 1
other_supplier = baker.make(Supplier, tenant=tenant, code="S-OTHER")
foreign_payable = baker.make(
Payable, tenant=tenant, supplier=other_supplier,
bill_no="PY-OTHER-001", bill_date=date.today(), total_amount=Decimal("250.00"),
)
second_payment = baker.make(
Payment, tenant=tenant, supplier=supplier,
bill_no="PMT-API-002", bill_date=date.today(), amount=Decimal("250.00"),
)
response = auth_client.post(
f"/api/v1/finance/payments/{second_payment.id}/allocate/",
{"allocations": [{"payable": foreign_payable.id, "amount": "250.00"}]},
format="json",
)
assert response.status_code == 400, response.content
assert "供应商" in response.json().get("detail", "")
assert Allocation.objects.filter(payment=second_payment).count() == 0
@pytest.mark.django_db
def test_finance_api_endpoints(auth_client, tenant, customer):
# 1. 初始化科目接口
resp = auth_client.post("/api/v1/finance/accounts/init-chart/")
assert resp.status_code == 200, resp.content
assert resp.json()["ok"] is True
assert resp.json()["created_count"] == 22
# 2. 查询科目列表
resp = auth_client.get("/api/v1/finance/accounts/?search=银行存款")
assert resp.status_code == 200
results = resp.json()["results"]
assert len(results) == 1
assert results[0]["code"] == "1002"
# 3. 创建凭证接口 (带有借贷分录)
payload = {
"voucher_date": "2026-09-08",
"summary": "API手工凭证",
"auto_post": False,
"entries": [
{"account_code": "1001", "debit": "600.00", "credit": "0", "summary": "现金收入"},
{"account_code": "1122", "debit": "0", "credit": "600.00", "summary": "冲销应收", "customer_id": customer.id},
],
}
resp = auth_client.post("/api/v1/finance/vouchers/", payload, format="json")
assert resp.status_code == 201, resp.content
v_data = resp.json()
v_id = v_data["id"]
assert v_data["bill_no"].startswith("V202609")
assert v_data["status"] == "draft"
# 4. 凭证过账接口
resp = auth_client.post(f"/api/v1/finance/vouchers/{v_id}/post/")
assert resp.status_code == 200
assert resp.json()["status"] == "posted"
# 5. 科目余额表报表接口
resp = auth_client.get("/api/v1/finance/reports/trial-balance/?period=2026-09")
assert resp.status_code == 200
tb_data = resp.json()
assert tb_data["is_balanced"] is True
assert len(tb_data["rows"]) > 0
# 6. 资产负债表报表接口
resp = auth_client.get("/api/v1/finance/reports/balance-sheet/?period=2026-09")
assert resp.status_code == 200
bs_data = resp.json()
assert bs_data["balanced"] is True
# 现金借方+600 与 应收借方-600 相互抵消,总资产变动为 0
assert Decimal(str(bs_data["asset_total"])) == Decimal("0")
assert len(bs_data["assets"]) == 2
# 7. 期间结账接口
period = Period.objects.get(tenant=tenant, code="2026-09")
resp = auth_client.post(f"/api/v1/finance/periods/{period.id}/close/")
assert resp.status_code == 200
assert resp.json()["status"] == "closed"
# 8. 期间反结账接口
resp = auth_client.post(f"/api/v1/finance/periods/{period.id}/reopen/")
assert resp.status_code == 200
assert resp.json()["status"] == "open"