Files
shopship/apps/bizapps/tests.py
T

1068 lines
46 KiB
Python
Raw Normal View History

2026-09-04 13:05:54 +03:30
import io
2026-08-31 15:11:52 +03:30
import uuid
from datetime import date
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.account.models import User
2026-09-04 13:05:54 +03:30
from apps.bizapps.api.accounting import (
ACCOUNT_CASH,
ACCOUNT_RECEIVABLE,
ACCOUNT_SALES,
DEFAULT_CHART,
ChartOfAccountsService,
CustomerStatementService,
ExcelExportService,
LedgerBookService,
SubsidiaryLedgerService,
)
2026-08-31 15:11:52 +03:30
from apps.bizapps.api.services import InvoiceService, LedgerService
2026-09-04 13:05:54 +03:30
from apps.bizapps.models import (
Account,
Customer,
Invoice,
LedgerBook,
LedgerEntry,
LedgerInstallment,
Product,
Transaction,
)
2026-08-31 15:11:52 +03:30
# این ماژول لایه‌ی «هماهنگیِ عادی/حرفه‌ای»ِ دفتر درآمد و هزینه را پوشش می‌دهد —
# دقیقا همان بخشی که کاربر صریحا خواسته «تست‌نویسی برایش خیلی مهم است»، چون
# باگ اینجا یعنی مانده‌ی بدهیِ نمایش‌داده‌شده به کاربر با واقعیت فرق کند.
def _make_user(phone="09120000000") -> User:
return User.objects.create_user(phone=phone, is_active=True, state=User.State.ACTIVE)
class LedgerEntryModelTests(TestCase):
"""دسته‌بندیِ هر ردیف باید دقیقا به نوعِ حرفه‌ایِ درستش نگاشت شود — این
همان «هماهنگی» است که کاربر مثال زد (فروش قسطی = طلب، نه درآمدِ صرف)."""
def setUp(self):
self.user = _make_user()
def _entry(self, **kwargs):
defaults = dict(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="تست",
amount=100,
entry_date=date.today(),
)
defaults.update(kwargs)
return LedgerEntry.objects.create(**defaults)
def test_sale_credit_is_receivable_debt(self):
entry = self._entry(category=LedgerEntry.Category.SALE_CREDIT, total_amount=100)
self.assertTrue(entry.is_debt)
self.assertEqual(entry.professional_type, "receivable")
def test_purchase_credit_is_payable_debt(self):
entry = self._entry(
entry_type=LedgerEntry.EntryType.EXPENSE,
category=LedgerEntry.Category.PURCHASE_CREDIT,
total_amount=100,
)
self.assertTrue(entry.is_debt)
self.assertEqual(entry.professional_type, "payable")
def test_simple_categories_are_not_debt(self):
for category in [
LedgerEntry.Category.SALE_CASH,
LedgerEntry.Category.SERVICE_INCOME,
LedgerEntry.Category.OTHER_INCOME,
]:
entry = self._entry(category=category)
self.assertFalse(entry.is_debt, category)
self.assertEqual(entry.professional_type, "simple", category)
def test_remaining_amount_tracks_installments(self):
entry = self._entry(category=LedgerEntry.Category.SALE_CREDIT, total_amount=1000)
self.assertEqual(entry.remaining_amount, 1000)
LedgerInstallment.objects.create(
id=str(uuid.uuid4()), ledger_entry=entry, amount=400, paid_date=date.today()
)
entry.refresh_from_db()
self.assertEqual(entry.paid_amount, 400)
self.assertEqual(entry.remaining_amount, 600)
LedgerInstallment.objects.create(
id=str(uuid.uuid4()), ledger_entry=entry, amount=600, paid_date=date.today()
)
entry.refresh_from_db()
self.assertEqual(entry.remaining_amount, 0)
def test_remaining_amount_is_zero_for_non_debt_entries(self):
entry = self._entry(category=LedgerEntry.Category.SALE_CASH)
self.assertEqual(entry.remaining_amount, 0)
class LedgerServiceTests(TestCase):
def setUp(self):
self.user = _make_user()
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="مشتری", phone="09121112222"
)
def test_recompute_balance_sums_open_receivables(self):
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="فاکتور ۱",
amount=500,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=500,
counterparty_phone="09121112222",
)
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="فاکتور ۲",
amount=300,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=300,
counterparty_phone="09121112222",
)
LedgerService.recompute_customer_balance(self.user, "09121112222")
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 800)
def test_balance_drops_as_installments_are_paid(self):
entry = LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="فاکتور",
amount=1000,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=1000,
counterparty_phone="09121112222",
)
LedgerService.recompute_customer_balance(self.user, "09121112222")
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 1000)
LedgerInstallment.objects.create(
id=str(uuid.uuid4()), ledger_entry=entry, amount=1000, paid_date=date.today()
)
LedgerService.recompute_customer_balance(self.user, "09121112222")
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 0)
def test_ignores_other_users_entries(self):
other = _make_user(phone="09129998888")
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=other,
entry_type=LedgerEntry.EntryType.INCOME,
title="فاکتور مالِ یکی دیگه",
amount=999,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=999,
counterparty_phone="09121112222",
)
LedgerService.recompute_customer_balance(self.user, "09121112222")
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 0)
def _create_and_post(self, **kwargs):
"""معادلِ دستیِ کاری که ویوی API انجام می‌دهد: بعد از ساختِ ردیف،
حساب را هماهنگ و سندِ دوطرفه‌اش را می‌زند — بدونِ این سند، ردیف در
هیچ گزارشِ حرفه‌ای (تراز/دفتر معین/CSV) دیده نمی‌شود."""
entry = LedgerEntry.objects.create(id=str(uuid.uuid4()), user=self.user, **kwargs)
LedgerService.sync_entry_account(self.user, entry)
LedgerService.post_entry_transaction(self.user, entry)
return entry
2026-08-31 15:11:52 +03:30
def test_export_csv_filters_by_date_range(self):
self._create_and_post(
2026-08-31 15:11:52 +03:30
entry_type=LedgerEntry.EntryType.INCOME,
title="داخل بازه",
amount=100,
category=LedgerEntry.Category.SALE_CASH,
entry_date=date(2026, 1, 15),
)
self._create_and_post(
2026-08-31 15:11:52 +03:30
entry_type=LedgerEntry.EntryType.EXPENSE,
title="خارج از بازه",
amount=50,
category=LedgerEntry.Category.RENT,
entry_date=date(2026, 3, 1),
)
csv_text = LedgerService.export_csv(self.user, start=date(2026, 1, 1), end=date(2026, 1, 31))
self.assertIn("داخل بازه", csv_text)
self.assertNotIn("خارج از بازه", csv_text)
def test_post_entry_transaction_covers_plain_income_and_expense(self):
"""قبلا فقط فاکتور/قسط سند می‌خورد؛ یک ثبتِ سادهِ «فروش نقدی» یا
«اجاره» (که کارِ روزمرهِ اکثرِ کاربرهاست) اصلا در ترازِ حساب‌ها
دیده نمی‌شد. این تست دقیقا همان رگرسیون را قفل می‌کند."""
self._create_and_post(
entry_type=LedgerEntry.EntryType.INCOME,
title="فروش نقدی امروز",
amount=200,
category=LedgerEntry.Category.SALE_CASH,
entry_date=date.today(),
)
self._create_and_post(
entry_type=LedgerEntry.EntryType.EXPENSE,
title="اجاره مغازه",
amount=80,
category=LedgerEntry.Category.RENT,
entry_date=date.today(),
)
rows = ChartOfAccountsService.trial_balance(self.user)
by_code = {r["code"]: r for r in rows}
self.assertIn(ACCOUNT_CASH, by_code)
# فروشِ نقدی صندوق را بدهکار می‌کند، اجاره بستانکار — مانده‌ی خالص
# صندوق باید ۲۰۰ منهای ۸۰ باشد.
self.assertEqual(by_code[ACCOUNT_CASH]["balance"], 120)
2026-08-31 15:11:52 +03:30
class InvoiceLedgerIntegrationTests(TestCase):
"""اتصالِ فاکتور <-> دفتر: طبق درخواست، فقط فروشِ قسطی باید خودکار یک
ردیفِ بدهی‌دار در دفتر بسازد؛ فروشِ نقدی نباید دفتر را شلوغ کند."""
def setUp(self):
self.user = _make_user()
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="مشتری قسطی", phone="09123334444"
)
def _invoice_data(self, payment_type):
return {
"customer": self.customer,
"buyer_name": self.customer.name,
"buyer_phone": self.customer.phone,
"buyer_address": "",
"items": [
{"product": None, "description": "کالای آزاد", "quantity": 1, "unit_price": 100, "discount_percent": 0}
],
"payment_type": payment_type,
}
def test_credit_invoice_creates_linked_receivable_entry(self):
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
entry = LedgerEntry.objects.get(linked_invoice=invoice)
self.assertEqual(entry.category, LedgerEntry.Category.SALE_CREDIT)
self.assertEqual(entry.total_amount, invoice.final_amount)
self.assertEqual(entry.remaining_amount, invoice.final_amount)
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, invoice.final_amount)
def test_cash_invoice_does_not_create_ledger_entry(self):
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("cash"))
self.assertFalse(LedgerEntry.objects.filter(linked_invoice=invoice).exists())
def test_marking_invoice_paid_settles_the_linked_entry(self):
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
InvoiceService.update_status(self.user, invoice.id, payment_status="paid")
entry = LedgerEntry.objects.get(linked_invoice=invoice)
self.assertEqual(entry.remaining_amount, 0)
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 0)
def test_cancelling_unpaid_credit_invoice_removes_the_entry(self):
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
InvoiceService.cancel_invoice(self.user, invoice.id)
self.assertFalse(LedgerEntry.objects.filter(linked_invoice=invoice).exists())
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 0)
def test_cancelling_partially_paid_credit_invoice_keeps_the_entry(self):
invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit"))
entry = LedgerEntry.objects.get(linked_invoice=invoice)
LedgerInstallment.objects.create(
id=str(uuid.uuid4()), ledger_entry=entry, amount=50, paid_date=date.today()
)
invoice.order_status = invoice.OrderStatus.PENDING_PAYMENT
invoice.save(update_fields=["order_status"])
InvoiceService.cancel_invoice(self.user, invoice.id)
# قسطی که واقعا پرداخت شده نباید محو شود — تاریخچه‌ی مالی دست‌نخورده می‌ماند.
self.assertTrue(LedgerEntry.objects.filter(linked_invoice=invoice).exists())
class LedgerApiTests(TestCase):
"""چند نقطه‌ی حیاتیِ سطح API — اعتبارسنجی اضافه‌شدنِ قسطِ بیش از حد،
و این‌که خروجیِ CSV واقعا با content-type درست برمی‌گردد."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
token = str(RefreshToken.for_user(self.user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
def test_create_debt_entry_without_total_amount_is_rejected(self):
response = self.client.post(
reverse("bizapps:ledger-list"),
{
"entry_type": "income",
"title": "فروش قسطی بدون مبلغ کل",
"amount": 100,
"category": "sale_credit",
"entry_date": date.today().isoformat(),
},
format="json",
)
self.assertEqual(response.status_code, 400)
def test_installment_exceeding_remaining_amount_is_rejected(self):
entry = LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="فروش قسطی",
amount=1000,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=1000,
)
response = self.client.post(
reverse("bizapps:ledger-installment-list", kwargs={"entry_id": entry.id}),
{"amount": 5000, "paid_date": date.today().isoformat()},
format="json",
)
self.assertEqual(response.status_code, 400)
2026-09-04 13:05:54 +03:30
def test_export_defaults_to_xlsx(self):
"""پیش‌فرضِ خروجی، اکسلِ چندبرگی است — چون همان چیزی است که مستقیم
به‌دردِ حسابدارِ کاربر می‌خورد، نه CSV خام."""
2026-08-31 15:11:52 +03:30
response = self.client.get(reverse("bizapps:ledger-export"))
self.assertEqual(response.status_code, 200)
2026-09-04 13:05:54 +03:30
self.assertIn("spreadsheetml.sheet", response["Content-Type"])
self.assertIn(".xlsx", response["Content-Disposition"])
# امضای فایلِ zip — یک xlsx واقعی، نه متنِ خالی با هدرِ اشتباه.
self.assertEqual(response.content[:2], b"PK")
def test_export_csv_still_available_via_query_param(self):
response = self.client.get(reverse("bizapps:ledger-export"), {"type": "csv"})
self.assertEqual(response.status_code, 200)
2026-08-31 15:11:52 +03:30
self.assertIn("text/csv", response["Content-Type"])
2026-09-04 13:05:54 +03:30
# ---------------------------------------------------------------------------
# لایه‌ی حسابداریِ حرفه‌ای — درختِ حساب‌ها (کل/معین/تفصیلی)، ترازِ حساب‌ها،
# صورت‌حسابِ مشتری، و خروجیِ اکسلِ چندبرگی.
# ---------------------------------------------------------------------------
class ChartOfAccountsTests(TestCase):
def setUp(self):
self.user = _make_user()
def test_ensure_chart_creates_default_tree(self):
ChartOfAccountsService.ensure_chart(self.user)
accounts = Account.objects.filter(user=self.user)
self.assertEqual(accounts.count(), len(DEFAULT_CHART))
self.assertTrue(accounts.filter(level=Account.Level.KOL).exists())
self.assertTrue(accounts.filter(level=Account.Level.MOIN).exists())
def test_ensure_chart_is_idempotent(self):
ChartOfAccountsService.ensure_chart(self.user)
first = Account.objects.filter(user=self.user).count()
ChartOfAccountsService.ensure_chart(self.user)
self.assertEqual(Account.objects.filter(user=self.user).count(), first)
def test_moin_accounts_are_linked_to_their_kol_parent(self):
ChartOfAccountsService.ensure_chart(self.user)
cash = Account.objects.get(user=self.user, code=ACCOUNT_CASH)
self.assertEqual(cash.level, Account.Level.MOIN)
self.assertIsNotNone(cash.parent)
self.assertEqual(cash.parent.level, Account.Level.KOL)
self.assertIn("دارایی", cash.full_path)
def test_chart_is_per_user_not_shared(self):
other = _make_user(phone="09129999999")
ChartOfAccountsService.ensure_chart(self.user)
ChartOfAccountsService.ensure_chart(other)
self.assertEqual(Account.objects.filter(user=self.user).count(), len(DEFAULT_CHART))
self.assertEqual(Account.objects.filter(user=other).count(), len(DEFAULT_CHART))
def test_customer_gets_tafsili_account_under_receivables(self):
customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
account = ChartOfAccountsService.ensure_customer_account(self.user, customer)
self.assertEqual(account.level, Account.Level.TAFSILI)
self.assertEqual(account.parent.code, ACCOUNT_RECEIVABLE)
self.assertTrue(account.code.startswith(f"{ACCOUNT_RECEIVABLE}-"))
customer.refresh_from_db()
self.assertEqual(customer.account_id, account.id)
def test_customer_account_is_not_duplicated(self):
customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
first = ChartOfAccountsService.ensure_customer_account(self.user, customer)
second = ChartOfAccountsService.ensure_customer_account(self.user, customer)
self.assertEqual(first.id, second.id)
def test_debit_nature_matches_account_type(self):
ChartOfAccountsService.ensure_chart(self.user)
self.assertTrue(Account.objects.get(user=self.user, code=ACCOUNT_CASH).is_debit_nature)
self.assertFalse(Account.objects.get(user=self.user, code=ACCOUNT_SALES).is_debit_nature)
class LedgerAccountMappingTests(TestCase):
"""هر دسته‌ی «عادی» باید به حسابِ معینِ درست نگاشت شود — این پلِ بینِ
لایه‌ی ساده و لایه‌ی حرفه‌ای است و اگر بشکند، ترازِ حساب‌ها بی‌معنی می‌شود."""
def setUp(self):
self.user = _make_user()
def test_every_category_maps_to_an_existing_account(self):
ChartOfAccountsService.ensure_chart(self.user)
for category in LedgerEntry.Category.values:
account = LedgerService.account_for_category(self.user, category)
self.assertIsNotNone(account, f"دسته‌ی {category} به هیچ حسابی نگاشت نشد")
self.assertEqual(account.level, Account.Level.MOIN)
def test_income_categories_map_to_income_accounts(self):
ChartOfAccountsService.ensure_chart(self.user)
account = LedgerService.account_for_category(self.user, LedgerEntry.Category.SALE_CASH)
self.assertEqual(account.account_type, Account.AccountType.INCOME)
def test_expense_categories_map_to_expense_accounts(self):
ChartOfAccountsService.ensure_chart(self.user)
account = LedgerService.account_for_category(self.user, LedgerEntry.Category.RENT)
self.assertEqual(account.account_type, Account.AccountType.EXPENSE)
class TrialBalanceTests(TestCase):
def setUp(self):
self.user = _make_user()
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
self.product = Product.objects.create(
id=str(uuid.uuid4()), user=self.user, name="کالا", quantity=10, unit_price=100, purchase_price=60
)
def test_cash_sale_posts_to_cash_and_sales(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"payment_type": Invoice.PaymentType.CASH,
},
)
rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)}
self.assertIn(ACCOUNT_CASH, rows)
self.assertIn(ACCOUNT_SALES, rows)
self.assertEqual(rows[ACCOUNT_CASH]["debit"], 100)
self.assertEqual(rows[ACCOUNT_SALES]["credit"], 100)
def test_credit_sale_posts_to_receivable_not_cash(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"payment_type": Invoice.PaymentType.CREDIT,
},
)
rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)}
self.assertIn(ACCOUNT_RECEIVABLE, rows)
self.assertEqual(rows[ACCOUNT_RECEIVABLE]["debit"], 100)
self.assertNotIn(ACCOUNT_CASH, rows)
def test_balance_respects_account_nature(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"payment_type": Invoice.PaymentType.CASH,
},
)
rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)}
# صندوق (دارایی، ماهیت بدهکار) و فروش (درآمد، ماهیت بستانکار) هر دو
# باید مانده‌ی مثبت نشان دهند، نه یکی منفی.
self.assertEqual(rows[ACCOUNT_CASH]["balance"], 100)
self.assertEqual(rows[ACCOUNT_SALES]["balance"], 100)
def test_empty_accounts_are_omitted(self):
rows = ChartOfAccountsService.trial_balance(self.user)
self.assertEqual(rows, [])
class CustomerStatementTests(TestCase):
def setUp(self):
self.user = _make_user()
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
self.product = Product.objects.create(
id=str(uuid.uuid4()), user=self.user, name="کالا", quantity=10, unit_price=100, purchase_price=60
)
def test_statement_gathers_invoices_and_open_debts(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"buyer_phone": "09121112222",
"payment_type": Invoice.PaymentType.CREDIT,
},
)
self.customer.refresh_from_db()
statement = CustomerStatementService.build(self.user, self.customer)
self.assertEqual(len(statement["invoices"]), 1)
self.assertEqual(len(statement["debts"]), 1)
self.assertEqual(statement["debts"][0]["remaining_amount"], 100)
self.assertEqual(statement["customer"]["outstanding_balance"], 100)
# حسابِ تفصیلیِ مشتری هم باید در صورت‌حساب بیاید.
self.assertTrue(statement["customer"]["account_code"])
def test_statement_reflects_installment_payments(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"buyer_phone": "09121112222",
"payment_type": Invoice.PaymentType.CREDIT,
},
)
entry = LedgerEntry.objects.get(user=self.user, category=LedgerEntry.Category.SALE_CREDIT)
LedgerInstallment.objects.create(
id=str(uuid.uuid4()), ledger_entry=entry, amount=40, paid_date=date.today()
)
LedgerService.recompute_customer_balance(self.user, "09121112222")
self.customer.refresh_from_db()
statement = CustomerStatementService.build(self.user, self.customer)
self.assertEqual(statement["debts"][0]["paid_amount"], 40)
self.assertEqual(statement["debts"][0]["remaining_amount"], 60)
self.assertEqual(len(statement["debts"][0]["installments"]), 1)
class ExcelExportTests(TestCase):
def setUp(self):
self.user = _make_user()
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
self.product = Product.objects.create(
id=str(uuid.uuid4()),
user=self.user,
name="کالا",
quantity=10,
unit_price=100,
purchase_price=60,
total_purchase_value=600,
)
def _workbook(self):
from openpyxl import load_workbook
content = ExcelExportService.build(self.user)
return load_workbook(io.BytesIO(content))
def test_workbook_has_all_expected_sheets(self):
wb = self._workbook()
self.assertEqual(
wb.sheetnames,
["خلاصه", "دفتر روزنامه", "تراز حساب‌ها", "بدهکاران", "فاکتورها", "دفتر معین", "انبار"],
2026-09-04 13:05:54 +03:30
)
def test_sheets_are_right_to_left(self):
wb = self._workbook()
for name in wb.sheetnames:
self.assertTrue(wb[name].sheet_view.rightToLeft, f"برگه‌ی {name} راست‌چین نیست")
def test_inventory_sheet_lists_products(self):
wb = self._workbook()
rows = list(wb["انبار"].iter_rows(min_row=2, values_only=True))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0][0], "کالا")
def test_summary_reports_inventory_value(self):
wb = self._workbook()
summary = {r[0]: r[1] for r in wb["خلاصه"].iter_rows(min_row=2, values_only=True)}
self.assertEqual(summary["ارزش موجودی انبار"], 600)
def test_debtors_sheet_lists_only_customers_with_balance(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"buyer_phone": "09121112222",
"payment_type": Invoice.PaymentType.CREDIT,
},
)
wb = self._workbook()
rows = list(wb["بدهکاران"].iter_rows(min_row=2, values_only=True))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0][0], "علی")
self.assertEqual(rows[0][3], 100)
def test_invoice_sheet_lists_invoices(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 2, "unit_price": 100}],
"customer": self.customer,
"payment_type": Invoice.PaymentType.CASH,
},
)
wb = self._workbook()
rows = list(wb["فاکتورها"].iter_rows(min_row=2, values_only=True))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0][6], 200) # مبلغ نهایی
class InstallmentPostsTransactionTests(TestCase):
"""هر قسط باید علاوه بر به‌روزکردنِ مانده، یک سندِ حسابداریِ واقعی هم
بسازد — وگرنه ترازِ حساب‌ها بعد از دریافتِ قسط غلط می‌ماند."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
self.entry = LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="فروش قسطی",
amount=1000,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=1000,
counterparty_phone="09121112222",
)
def test_installment_creates_balanced_transaction(self):
response = self.client.post(
reverse("bizapps:ledger-installment-list", kwargs={"entry_id": self.entry.id}),
{"amount": 400, "paid_date": date.today().isoformat()},
format="json",
)
self.assertEqual(response.status_code, 201)
txn = Transaction.objects.get(user=self.user, transaction_type=Transaction.TransactionType.INSTALLMENT)
self.assertEqual(txn.amount, 400)
# دریافتِ قسط: صندوق بدهکار، حساب دریافتنی بستانکار.
self.assertEqual(txn.debit_account, ACCOUNT_CASH)
self.assertEqual(txn.credit_account, ACCOUNT_RECEIVABLE)
self.assertIsNotNone(txn.debit_account_ref)
self.assertIsNotNone(txn.credit_account_ref)
def test_installment_shows_up_in_trial_balance(self):
self.client.post(
reverse("bizapps:ledger-installment-list", kwargs={"entry_id": self.entry.id}),
{"amount": 400, "paid_date": date.today().isoformat()},
format="json",
)
rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)}
self.assertEqual(rows[ACCOUNT_CASH]["debit"], 400)
class BusinessDashboardTests(TestCase):
"""داشبورد، همان «اتصالِ بخش‌ها» را در یک پاسخ جمع می‌کند."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
self.product = Product.objects.create(
id=str(uuid.uuid4()),
user=self.user,
name="کالا",
quantity=2,
unit_price=100,
purchase_price=60,
total_purchase_value=120,
low_stock_threshold=5,
)
def test_dashboard_aggregates_across_modules(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"buyer_phone": "09121112222",
"payment_type": Invoice.PaymentType.CREDIT,
},
)
response = self.client.get(reverse("bizapps:dashboard"))
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data["open_receivable"], 100)
self.assertEqual(data["invoice_count"], 1)
self.assertEqual(data["customer_count"], 1)
self.assertEqual(data["debtor_count"], 1)
# موجودی از ۲ به ۱ رسیده و زیرِ آستانه‌ی ۵ است.
self.assertEqual(data["low_stock_count"], 1)
class NegativeStockGuardTests(TestCase):
"""سیاستِ صریحِ موجودی: فروشِ بیش از موجودی رد می‌شود و هیچ اثری
(نه فاکتور، نه سند، نه کسرِ موجودی) باقی نمی‌گذارد."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
self.product = Product.objects.create(
id=str(uuid.uuid4()), user=self.user, name="کالا", unit="عدد",
quantity=2, unit_price=100, purchase_price=60,
)
def test_selling_more_than_stock_is_rejected(self):
from apps.bizapps.api.services import InsufficientStockError
with self.assertRaises(InsufficientStockError):
InvoiceService.create_invoice(
self.user,
{"items": [{"product": self.product, "quantity": 5, "unit_price": 100}]},
)
def test_rejected_sale_leaves_no_partial_data(self):
from apps.bizapps.api.services import InsufficientStockError
with self.assertRaises(InsufficientStockError):
InvoiceService.create_invoice(
self.user,
{"items": [{"product": self.product, "quantity": 5, "unit_price": 100}]},
)
self.product.refresh_from_db()
self.assertEqual(self.product.quantity, 2)
self.assertEqual(Invoice.objects.filter(user=self.user).count(), 0)
self.assertEqual(Transaction.objects.filter(user=self.user).count(), 0)
def test_selling_exactly_the_remaining_stock_is_allowed(self):
InvoiceService.create_invoice(
self.user,
{"items": [{"product": self.product, "quantity": 2, "unit_price": 100}]},
)
self.product.refresh_from_db()
self.assertEqual(self.product.quantity, 0)
def test_api_returns_readable_400_not_500(self):
response = self.client.post(
reverse("bizapps:invoice-list"),
{"items": [{"product": self.product.id, "quantity": 5, "unit_price": 100}]},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("موجودی", str(response.data))
class CustomerSocialFieldTests(TestCase):
"""راهِ ارتباطیِ شبکه‌ی اجتماعی — برای مخاطبِ اصلیِ این اپ (کسب‌وکارهای
اینستاگرامی/تلگرامی) کاربردی‌تر از ایمیل است."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
def test_customer_can_store_platform_and_handle(self):
response = self.client.post(
reverse("bizapps:customer-list"),
{
"id": str(uuid.uuid4()),
"name": "فروشگاه گلها",
"phone": "09121112222",
"social_platform": Customer.SocialPlatform.INSTAGRAM,
"social_handle": "@golha.shop",
},
format="json",
)
self.assertEqual(response.status_code, 201)
customer = Customer.objects.get(user=self.user)
self.assertEqual(customer.social_platform, "instagram")
self.assertEqual(customer.social_handle, "@golha.shop")
def test_social_fields_are_optional(self):
response = self.client.post(
reverse("bizapps:customer-list"),
{"id": str(uuid.uuid4()), "name": "بدون شبکه اجتماعی"},
format="json",
)
self.assertEqual(response.status_code, 201)
def test_invalid_platform_is_rejected(self):
response = self.client.post(
reverse("bizapps:customer-list"),
{
"id": str(uuid.uuid4()),
"name": "تست",
"social_platform": "myspace",
},
format="json",
)
self.assertEqual(response.status_code, 400)
class LedgerBookTests(TestCase):
"""«فایل» دفتر — باز کردن فایل تازه، بستن فایل قبلی، انتقال مانده."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
def _entry(self, amount, entry_type=LedgerEntry.EntryType.INCOME, book=None):
return LedgerEntry.objects.create(
id=str(uuid.uuid4()), user=self.user, book=book,
entry_type=entry_type, title="ثبت", amount=amount,
category=LedgerEntry.Category.SALE_CASH, entry_date=date.today(),
)
def test_active_book_is_created_on_demand(self):
book = LedgerBookService.ensure_active_book(self.user)
self.assertTrue(book.is_active)
self.assertFalse(book.is_closed)
self.assertEqual(LedgerBook.objects.filter(user=self.user).count(), 1)
def test_ensure_active_book_is_idempotent(self):
first = LedgerBookService.ensure_active_book(self.user)
second = LedgerBookService.ensure_active_book(self.user)
self.assertEqual(first.id, second.id)
def test_orphan_entries_are_adopted_by_the_first_book(self):
orphan = self._entry(500)
self.assertIsNone(orphan.book_id)
book = LedgerBookService.ensure_active_book(self.user)
orphan.refresh_from_db()
self.assertEqual(orphan.book_id, book.id)
def test_opening_a_new_book_deactivates_but_does_not_archive_the_previous_one(self):
"""بایگانی‌کردن یک اقدامِ دستیِ جداگانه است (LedgerBookService.
archive_book) — باز کردنِ فایلِ تازه فقط فایلِ قبلی را از حالتِ
«جاری» خارج می‌کند، نه این‌که خودکار بایگانی‌اش کند."""
2026-09-04 13:05:54 +03:30
old = LedgerBookService.ensure_active_book(self.user)
new = LedgerBookService.open_new_book(
self.user, name="سال ۱۴۰۶", start_date=date(2027, 3, 21)
)
old.refresh_from_db()
self.assertFalse(old.is_active)
self.assertFalse(old.is_closed)
self.assertIsNone(old.closed_at)
2026-09-04 13:05:54 +03:30
self.assertTrue(new.is_active)
# همیشه دقیقا یک فایل جاری
self.assertEqual(LedgerBook.objects.filter(user=self.user, is_active=True).count(), 1)
def test_balance_is_carried_into_the_new_book(self):
old = LedgerBookService.ensure_active_book(self.user)
self._entry(1000, book=old)
self._entry(300, entry_type=LedgerEntry.EntryType.EXPENSE, book=old)
new = LedgerBookService.open_new_book(
self.user, name="دوره بعد", start_date=date(2027, 3, 21), carry_balance=True
)
self.assertEqual(new.opening_balance, 700)
def test_balance_is_not_carried_when_not_requested(self):
old = LedgerBookService.ensure_active_book(self.user)
self._entry(1000, book=old)
new = LedgerBookService.open_new_book(
self.user, name="دوره بعد", start_date=date(2027, 3, 21), carry_balance=False
)
self.assertEqual(new.opening_balance, 0)
def test_summary_includes_opening_and_closing(self):
book = LedgerBookService.ensure_active_book(self.user)
book.opening_balance = 500
book.save()
self._entry(1000, book=book)
self._entry(200, entry_type=LedgerEntry.EntryType.EXPENSE, book=book)
summary = LedgerBookService.summary(book)
self.assertEqual(summary["opening_balance"], 500)
self.assertEqual(summary["total_income"], 1000)
self.assertEqual(summary["total_expense"], 200)
self.assertEqual(summary["net"], 800)
self.assertEqual(summary["closing_balance"], 1300)
def test_new_entries_go_into_the_active_book(self):
book = LedgerBookService.ensure_active_book(self.user)
response = self.client.post(
reverse("bizapps:ledger-list"),
{
"id": str(uuid.uuid4()),
"entry_type": LedgerEntry.EntryType.INCOME,
"title": "فروش",
"amount": 400,
"category": LedgerEntry.Category.SALE_CASH,
"entry_date": date.today().isoformat(),
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(LedgerEntry.objects.get(user=self.user).book_id, book.id)
def test_entry_list_can_be_scoped_to_one_book(self):
"""قراردادی که اپ به آن تکیه می‌کند: ?book=<id> فقط ثبت‌های همان
دوره را برمی‌گرداند.
بدون این فیلتر، صفحه‌ی دفتر ثبت‌های همه‌ی دوره‌ها را با هم نشان
می‌داد و جمعِ درآمد/هزینه‌ی بالای صفحه با مانده‌ی فایلِ جاری
نمی‌خواند."""
old = LedgerBookService.ensure_active_book(self.user)
self._entry(1000, book=old)
new_book = LedgerBookService.open_new_book(
self.user, name="دوره بعد", start_date=date(2027, 3, 21)
)
self._entry(250, book=new_book)
url = reverse("bizapps:ledger-list")
scoped = self.client.get(url, {"book": new_book.id})
self.assertEqual(scoped.status_code, 200)
self.assertEqual([e["amount"] for e in scoped.data], [250])
# بدون پارامتر، همه‌ی دوره‌ها — برای گزارش‌های چنددوره‌ای.
everything = self.client.get(url)
self.assertEqual(len(everything.data), 2)
def test_scoped_summary_ignores_the_other_book(self):
old = LedgerBookService.ensure_active_book(self.user)
self._entry(1000, book=old)
new_book = LedgerBookService.open_new_book(
self.user, name="دوره بعد", start_date=date(2027, 3, 21)
)
self._entry(250, book=new_book)
response = self.client.get(
reverse("bizapps:ledger-book-summary", kwargs={"pk": new_book.id})
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["total_income"], 250)
# مانده‌ی ابتدا از فایل قبل منتقل شده، پس مانده‌ی پایانی ۱۲۵۰ است.
self.assertEqual(response.data["opening_balance"], 1000)
self.assertEqual(response.data["closing_balance"], 1250)
def test_open_book_endpoint_rejects_end_before_start(self):
response = self.client.post(
reverse("bizapps:ledger-book-open"),
{"name": "بد", "start_date": "2027-03-21", "end_date": "2026-01-01"},
format="json",
)
self.assertEqual(response.status_code, 400)
def test_book_list_always_returns_at_least_one(self):
response = self.client.get(reverse("bizapps:ledger-book-list"))
self.assertEqual(response.status_code, 200)
self.assertGreaterEqual(len(response.data), 1)
class SubsidiaryLedgerTests(TestCase):
"""دفتر معین — گردشِ یک حساب با مانده‌ی در جریان، همان چیزی که حسابدار
انتظار دارد ببیند."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
self.product = Product.objects.create(
id=str(uuid.uuid4()), user=self.user, name="کالا",
quantity=100, unit_price=100, purchase_price=60,
)
ChartOfAccountsService.ensure_chart(self.user)
self.cash = Account.objects.get(user=self.user, code=ACCOUNT_CASH)
self.sales = Account.objects.get(user=self.user, code=ACCOUNT_SALES)
def _sale(self, qty=1, price=100, credit=False):
return InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": qty, "unit_price": price}],
"customer": self.customer,
"buyer_phone": "09121112222",
"payment_type": Invoice.PaymentType.CREDIT if credit else Invoice.PaymentType.CASH,
},
)
def test_statement_lists_rows_with_running_balance(self):
# قیمتِ واحد عمدا از خودِ کالا برداشته می‌شود (ضدِ دستکاری از سمت
# کلاینت)، پس هر فروشِ یک‌عددی ۱۰۰ است — مانده باید تجمعی جلو برود.
self._sale()
self._sale()
statement = SubsidiaryLedgerService.statement(self.user, self.cash)
balances = [r["balance"] for r in statement["rows"]]
self.assertEqual(len(statement["rows"]), 2)
self.assertEqual(balances, [100, 200])
self.assertEqual(statement["closing_balance"], 200)
def test_debit_and_credit_columns_are_separated(self):
self._sale(price=100)
cash_stmt = SubsidiaryLedgerService.statement(self.user, self.cash)
sales_stmt = SubsidiaryLedgerService.statement(self.user, self.sales)
self.assertEqual(cash_stmt["rows"][0]["debit"], 100)
self.assertEqual(cash_stmt["rows"][0]["credit"], 0)
self.assertEqual(sales_stmt["rows"][0]["credit"], 100)
self.assertEqual(sales_stmt["rows"][0]["debit"], 0)
def test_credit_nature_account_balance_grows_on_credit(self):
self._sale(price=100)
sales_stmt = SubsidiaryLedgerService.statement(self.user, self.sales)
# فروش ماهیت بستانکار دارد؛ مانده باید مثبت باشد نه منفی.
self.assertEqual(sales_stmt["closing_balance"], 100)
self.assertFalse(sales_stmt["account"]["is_debit_nature"])
def test_totals_match_rows(self):
self._sale()
self._sale()
statement = SubsidiaryLedgerService.statement(self.user, self.cash)
self.assertEqual(statement["total_debit"], 200)
self.assertEqual(statement["total_credit"], 0)
self.assertEqual(
statement["total_debit"], sum(r["debit"] for r in statement["rows"])
)
def test_empty_account_returns_empty_statement(self):
statement = SubsidiaryLedgerService.statement(self.user, self.cash)
self.assertEqual(statement["rows"], [])
self.assertEqual(statement["closing_balance"], 0)
def test_api_endpoint_returns_statement(self):
self._sale(price=100)
response = self.client.get(
reverse("bizapps:account-subsidiary-ledger", kwargs={"pk": self.cash.id})
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["account"]["code"], ACCOUNT_CASH)
self.assertEqual(len(response.data["rows"]), 1)
def test_other_users_account_is_not_reachable(self):
other = _make_user(phone="09129998888")
ChartOfAccountsService.ensure_chart(other)
foreign = Account.objects.get(user=other, code=ACCOUNT_CASH)
response = self.client.get(
reverse("bizapps:account-subsidiary-ledger", kwargs={"pk": foreign.id})
)
self.assertEqual(response.status_code, 404)