Files
shopship/apps/bizapps/tests.py
T
mobiniqow 831e656fa3 Fix OTP/SMS, ledger accounting, order tracking, and add reviews app
- Add MessageTemplate.runtime_field_labels so mobile's per-field
  execution-time labels are actually persisted (were silently dropped)
- Fix AdvanceOrderStageView reading the wrong extra-input key, and be
  honest about SMS send failures instead of always claiming success
- Add admin gift-gem SMS notification via Kavenegar
- Post real double-entry Transactions for plain (non-invoice) ledger
  entries so trial balance, the معین screen, and CSV/XLSX exports
  actually include everyday income/expense entries; backfill existing
  historical entries via data migration
- Rewrite CSV ledger export to produce an actual دفتر معین structure
  (per-account running balance) instead of a flat row dump
- Add invoice percent/promotional discounts, tax presets, ledger
  discount-percent handling, product SKU/category/expiry fields, and
  manual ledger-book archiving (replacing auto-archive on new book)
- Add reviews app (models, API, admin) for the comments/reviews feature
- Update tests for the above; fix two stale assertions left over from
  earlier work in this branch (معین sheet name, book auto-close)
2026-09-15 23:18:05 +03:30

1068 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import io
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
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 (
Account,
Customer,
Invoice,
LedgerBook,
LedgerEntry,
LedgerInstallment,
Product,
Transaction,
)
# این ماژول لایه‌ی «هماهنگیِ عادی/حرفه‌ای»ِ دفتر درآمد و هزینه را پوشش می‌دهد —
# دقیقا همان بخشی که کاربر صریحا خواسته «تست‌نویسی برایش خیلی مهم است»، چون
# باگ اینجا یعنی مانده‌ی بدهیِ نمایش‌داده‌شده به کاربر با واقعیت فرق کند.
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
def test_export_csv_filters_by_date_range(self):
self._create_and_post(
entry_type=LedgerEntry.EntryType.INCOME,
title="داخل بازه",
amount=100,
category=LedgerEntry.Category.SALE_CASH,
entry_date=date(2026, 1, 15),
)
self._create_and_post(
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)
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)
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_deactivates_but_does_not_archive_the_previous_one(self):
"""بایگانی‌کردن یک اقدامِ دستیِ جداگانه است (LedgerBookService.
archive_book) — باز کردنِ فایلِ تازه فقط فایلِ قبلی را از حالتِ
«جاری» خارج می‌کند، نه این‌که خودکار بایگانی‌اش کند."""
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)
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)