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)
|
|
|
|
|
|
|
2026-09-15 23:18:05 +03:30
|
|
|
|
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):
|
2026-09-15 23:18:05 +03:30
|
|
|
|
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),
|
|
|
|
|
|
)
|
2026-09-15 23:18:05 +03:30
|
|
|
|
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)
|
|
|
|
|
|
|
2026-09-15 23:18:05 +03:30
|
|
|
|
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-15 23:18:05 +03:30
|
|
|
|
["خلاصه", "دفتر روزنامه", "تراز حسابها", "بدهکاران", "فاکتورها", "دفتر معین", "انبار"],
|
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)
|
|
|
|
|
|
|
2026-09-15 23:18:05 +03:30
|
|
|
|
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)
|
2026-09-15 23:18:05 +03:30
|
|
|
|
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)
|