This commit is contained in:
2026-09-04 13:05:54 +03:30
parent 463b812e3e
commit 17082a5ea9
43 changed files with 5663 additions and 285 deletions
+88 -7
View File
@@ -19,13 +19,21 @@ from apps.bizapps.models import (
Transaction,
)
from apps.bizapps.api.accounting import (
ACCOUNT_CASH,
ACCOUNT_PAYABLE,
ACCOUNT_RECEIVABLE,
ACCOUNT_SALES,
ChartOfAccountsService,
ExcelExportService,
)
logger = logging.getLogger(__name__)
# کدهای حساب — دفترکل ساده طبق سند فنی (بدون نیاز به جدول Chart of Accounts
# جدا، چون این اپ برای کسب‌وکارهای خرد است نه حسابداری شرکتی کامل).
ACCOUNT_CASH = "1000" # صندوق
ACCOUNT_RECEIVABLE = "1100" # مطالبات (فروش اعتباری — نسخه‌ی آتی)
ACCOUNT_SALES = "4000" # فروش
class InsufficientStockError(Exception):
"""فروشِ بیش از موجودیِ انبار — عمدا یک استثنای جدا (نه ValueError عمومی)
تا ویو بتواند دقیقا همین حالت را به پیامِ ۴۰۰ قابل‌فهم تبدیل کند."""
class InvoiceService:
@@ -118,6 +126,16 @@ class InvoiceService:
if product is not None:
product = Product.objects.select_for_update().get(pk=product.pk)
# سیاستِ صریح: فروشِ بیش از موجودی جلویش گرفته می‌شود، نه
# این‌که موجودی بی‌سروصدا منفی شود. اگر کاربر واقعا جنس را
# دارد ولی در انبار ثبت نکرده، اول باید موجودی را اصلاح کند —
# وگرنه ارزشِ انبار و بهای تمام‌شده هر دو غلط می‌مانند.
if quantity > product.quantity:
raise InsufficientStockError(
f"موجودی «{product.name}» کافی نیست — "
f"موجودی فعلی: {product.quantity:g} {product.unit or ''}، "
f"درخواستی: {quantity:g}."
)
product.quantity -= quantity
product.recompute_purchase_value()
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
@@ -135,6 +153,7 @@ class InvoiceService:
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
is_credit = payment_type == Invoice.PaymentType.CREDIT
debit_code = ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH
Transaction.objects.create(
id=str(uuid.uuid4()),
user=user,
@@ -144,8 +163,10 @@ class InvoiceService:
transaction_type=(
Transaction.TransactionType.CREDIT_SALE if is_credit else Transaction.TransactionType.CASH_SALE
),
debit_account=ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH,
debit_account=debit_code,
credit_account=ACCOUNT_SALES,
debit_account_ref=ChartOfAccountsService.get(user, debit_code),
credit_account_ref=ChartOfAccountsService.get(user, ACCOUNT_SALES),
amount=invoice.final_amount,
description=f"فروش فاکتور {invoice_number}",
)
@@ -158,6 +179,9 @@ class InvoiceService:
customer.save(
update_fields=["total_purchases_count", "total_purchase_amount", "last_purchase_date", "updated_at"]
)
# حسابِ تفصیلیِ اختصاصیِ مشتری زیرِ «حساب‌های دریافتنی» — از همین
# اولین فاکتور ساخته می‌شود تا بدهی‌اش در درختِ حساب‌ها ردیابی شود.
ChartOfAccountsService.ensure_customer_account(user, customer)
# طبق درخواست: فروشِ قسطی/نسیه باید خودکار به دفتر درآمد/هزینه به‌عنوان
# یک ردیفِ بدهی‌دار (نه صرفا یک عدد در لیست فاکتورها) وصل شود — همان
@@ -175,6 +199,7 @@ class InvoiceService:
counterparty_name=invoice.buyer_name,
counterparty_phone=invoice.buyer_phone,
linked_invoice=invoice,
account=LedgerService.account_for_category(user, LedgerEntry.Category.SALE_CREDIT),
)
if invoice.buyer_phone:
LedgerService.recompute_customer_balance(user, invoice.buyer_phone)
@@ -252,6 +277,9 @@ class InvoiceService:
transaction_type=original.transaction_type if original else Transaction.TransactionType.CASH_SALE,
debit_account=original.credit_account if original else ACCOUNT_SALES,
credit_account=original.debit_account if original else ACCOUNT_CASH,
# سندِ معکوس: جای بدهکار و بستانکارِ سندِ اصلی عوض می‌شود.
debit_account_ref=original.credit_account_ref if original else None,
credit_account_ref=original.debit_account_ref if original else None,
amount=-(original.amount if original else invoice.final_amount),
description=f"لغو فاکتور {invoice.invoice_number}",
)
@@ -350,6 +378,51 @@ class LedgerService:
این سرویس تضمین می‌کند مانده‌ی بدهیِ هر مشتری (Customer.outstanding_balance)
همیشه با جمعِ واقعیِ ردیف‌های «فروش قسطی»ِ همان شماره‌تماس هم‌خوان بماند."""
@staticmethod
def account_for_category(user: User, category: str):
"""دسته‌ی «عادی» را به حسابِ معینِ متناظرش در درختِ حساب‌ها نگاشت
می‌کند — همان پلی که لایه‌ی ساده را به لایه‌ی حرفه‌ای وصل می‌کند."""
code = LedgerEntry.CATEGORY_ACCOUNT_CODES.get(category)
if not code:
return None
return ChartOfAccountsService.get(user, code)
@staticmethod
def sync_entry_account(user: User, entry: LedgerEntry) -> None:
"""حسابِ معینِ یک ردیف را با دسته‌اش هم‌خوان می‌کند — بعد از هر
ساخت/ویرایش صدا زده می‌شود تا دسته و حساب هیچ‌وقت واگرا نشوند."""
account = LedgerService.account_for_category(user, entry.category)
if entry.account_id != (account.id if account else None):
entry.account = account
entry.save(update_fields=["account", "updated_at"])
@staticmethod
@transaction.atomic
def post_installment_transaction(user: User, installment: LedgerInstallment) -> None:
"""هر قسطِ دریافتی/پرداختی یک سندِ دوطرفه هم می‌سازد: صندوق بدهکار،
حساب دریافتنی بستانکار (و برای بدهی‌های خودمان، برعکس)."""
entry = installment.ledger_entry
is_receivable = entry.category == LedgerEntry.Category.SALE_CREDIT
cash = ChartOfAccountsService.get(user, ACCOUNT_CASH)
counter_code = ACCOUNT_RECEIVABLE if is_receivable else ACCOUNT_PAYABLE
counter = ChartOfAccountsService.get(user, counter_code)
number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
Transaction.objects.create(
id=str(uuid.uuid4()),
user=user,
transaction_number=number,
transaction_date=timezone.now(),
reference_invoice=entry.linked_invoice,
transaction_type=Transaction.TransactionType.INSTALLMENT,
debit_account=ACCOUNT_CASH if is_receivable else counter_code,
credit_account=counter_code if is_receivable else ACCOUNT_CASH,
debit_account_ref=cash if is_receivable else counter,
credit_account_ref=counter if is_receivable else cash,
amount=installment.amount,
description=f"دریافت قسط — {entry.title}" if is_receivable else f"پرداخت قسط — {entry.title}",
)
@staticmethod
def recompute_customer_balance(user: User, phone: str) -> None:
phone = (phone or "").strip()
@@ -366,9 +439,17 @@ class LedgerService:
customer.outstanding_balance = balance
customer.save(update_fields=["outstanding_balance", "updated_at"])
@staticmethod
def export_xlsx(user: User, start=None, end=None) -> bytes:
"""خروجیِ اکسلِ واقعیِ چندبرگی (خلاصه، دفتر روزنامه، ترازِ حساب‌ها،
بدهکاران، فاکتورها، انبار) — همان چیزی که مستقیم به‌دردِ حسابدارِ
کاربر می‌خورد، برخلافِ CSV تک‌برگیِ خام."""
return ExcelExportService.build(user, start, end)
@staticmethod
def export_csv(user: User, start=None, end=None) -> str:
"""خروجیِ CSV حرفه‌ای — UTF-8 BOM برای بازشدنِ درستِ فارسی در اکسل."""
"""خروجیِ CSV ساده — برای وقتی کاربر فقط یک فایلِ سبکِ تک‌برگی
می‌خواهد. نسخه‌ی کاملِ چندبرگی در export_xlsx است."""
qs = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related("installments")
if start:
qs = qs.filter(entry_date__gte=start)