adding
This commit is contained in:
+740
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user