This commit is contained in:
2026-09-04 13:05:54 +03:30
parent 463b812e3e
commit 17082a5ea9
43 changed files with 5663 additions and 285 deletions
+740 -2
View File
@@ -1,3 +1,4 @@
import io
import uuid
from datetime import date
@@ -7,8 +8,28 @@ from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.account.models import User
from apps.bizapps.api.accounting import (
ACCOUNT_CASH,
ACCOUNT_RECEIVABLE,
ACCOUNT_SALES,
DEFAULT_CHART,
ChartOfAccountsService,
CustomerStatementService,
ExcelExportService,
LedgerBookService,
SubsidiaryLedgerService,
)
from apps.bizapps.api.services import InvoiceService, LedgerService
from apps.bizapps.models import Customer, LedgerEntry, LedgerInstallment
from apps.bizapps.models import (
Account,
Customer,
Invoice,
LedgerBook,
LedgerEntry,
LedgerInstallment,
Product,
Transaction,
)
# این ماژول لایه‌ی «هماهنگیِ عادی/حرفه‌ای»ِ دفتر درآمد و هزینه را پوشش می‌دهد —
# دقیقا همان بخشی که کاربر صریحا خواسته «تست‌نویسی برایش خیلی مهم است»، چون
@@ -290,7 +311,724 @@ class LedgerApiTests(TestCase):
)
self.assertEqual(response.status_code, 400)
def test_export_returns_csv(self):
def test_export_defaults_to_xlsx(self):
"""پیش‌فرضِ خروجی، اکسلِ چندبرگی است — چون همان چیزی است که مستقیم
به‌دردِ حسابدارِ کاربر می‌خورد، نه CSV خام."""
response = self.client.get(reverse("bizapps:ledger-export"))
self.assertEqual(response.status_code, 200)
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)
self.assertIn("text/csv", response["Content-Type"])
# ---------------------------------------------------------------------------
# لایه‌ی حسابداریِ حرفه‌ای — درختِ حساب‌ها (کل/معین/تفصیلی)، ترازِ حساب‌ها،
# صورت‌حسابِ مشتری، و خروجیِ اکسلِ چندبرگی.
# ---------------------------------------------------------------------------
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,
["خلاصه", "دفتر روزنامه", "تراز حساب‌ها", "بدهکاران", "فاکتورها", "انبار"],
)
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_closes_the_previous_one(self):
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.assertTrue(old.is_closed)
self.assertIsNotNone(old.closed_at)
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)