Files

529 lines
30 KiB
Python
Raw Permalink Normal View History

2026-08-24 15:54:08 +03:30
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
2026-09-04 13:05:54 +03:30
# ریزاپ‌های کسب‌وکار کوچک — مشتریان، دفتر درآمد/هزینه، انبار، فاکتور.
# دقیقا هم‌الگو با apps.banker: id همان UUID تولیدشده در خودِ اپ فلاتر است
# (نه auto-increment)، هر رکورد به user صاحبش محدود می‌شود.
#
# ساختار حسابداری دو لایه است:
# لایه‌ی «عادی» — کاربر فقط یک دسته‌ی آشنا انتخاب می‌کند (فروش نقدی،
# فروش قسطی، اجاره، حقوق و…).
# لایه‌ی «حرفه‌ای» — پشتِ صحنه همان دسته به یک حسابِ معینِ واقعی از
# Account (کل → معین → تفصیلی) نگاشت می‌شود و سندِ
# دوطرفه در Transaction ثبت می‌گردد.
# کاربر هیچ‌وقت مجبور نیست لایه‌ی دوم را ببیند، ولی گزارش/خروجی اکسل و
# ترازِ حساب‌ها از همان لایه ساخته می‌شود.
class Account(models.Model):
"""درختِ حساب‌ها (فایل‌بندیِ حسابداری) — سه سطحِ استانداردِ ایرانی:
حساب کل (level=1) مثلا «دارایی‌های جاری»
└─ حساب معین (level=2) مثلا «صندوق»، «حساب‌های دریافتنی»
└─ حساب تفصیلی (level=3) مثلا یک مشتریِ خاص
هر کاربر درختِ خودش را دارد (با یک درختِ پیش‌فرضِ آماده که موقعِ اولین
استفاده ساخته می‌شود) تا کسب‌وکارهای مختلف بتوانند حساب‌های دلخواهِ
خودشان را هم اضافه کنند بدون این‌که روی هم اثر بگذارند."""
class Level(models.IntegerChoices):
KOL = 1, _("حساب کل")
MOIN = 2, _("حساب معین")
TAFSILI = 3, _("حساب تفصیلی")
class AccountType(models.TextChoices):
ASSET = "asset", _("دارایی")
LIABILITY = "liability", _("بدهی")
EQUITY = "equity", _("سرمایه")
INCOME = "income", _("درآمد")
EXPENSE = "expense", _("هزینه")
# ماهیتِ طبیعیِ حساب — تعیین می‌کند در ترازِ حساب‌ها، مانده‌ی بدهکار
# نشانه‌ی افزایش است یا بستانکار. برای دارایی/هزینه بدهکار، برای
# بدهی/سرمایه/درآمد بستانکار.
DEBIT_NATURE_TYPES = {AccountType.ASSET, AccountType.EXPENSE}
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="accounts")
code = models.CharField(max_length=20, verbose_name=_("کد حساب"))
name = models.CharField(max_length=150, verbose_name=_("نام حساب"))
level = models.PositiveSmallIntegerField(choices=Level.choices, verbose_name=_("سطح"))
account_type = models.CharField(max_length=15, choices=AccountType.choices, verbose_name=_("نوع حساب"))
parent = models.ForeignKey(
"self", null=True, blank=True, on_delete=models.CASCADE, related_name="children", verbose_name=_("حساب والد")
)
# حساب‌های سیستمی (بخشی از درختِ پیش‌فرض) نباید حذف شوند، چون سرویس‌ها
# با کدشان به آن‌ها ارجاع می‌دهند.
is_system = models.BooleanField(default=False, verbose_name=_("حساب سیستمی"))
is_active = models.BooleanField(default=True, verbose_name=_("فعال"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("حساب")
verbose_name_plural = _("درخت حساب‌ها")
ordering = ["code"]
constraints = [models.UniqueConstraint(fields=["user", "code"], name="unique_user_account_code")]
indexes = [models.Index(fields=["user", "level"])]
def __str__(self):
return f"{self.code} — {self.name}"
@property
def is_debit_nature(self) -> bool:
return self.account_type in self.DEBIT_NATURE_TYPES
@property
def full_path(self) -> str:
"""مسیرِ کاملِ حساب در درخت — برای نمایش در گزارش/اکسل."""
parts = [self.name]
node = self.parent
while node is not None:
parts.append(node.name)
node = node.parent
return " / ".join(reversed(parts))
class LedgerBook(models.Model):
"""«فایل» دفتر درآمد و هزینه — معادلِ فایلِ مالیِ یک دوره (معمولا یک سال
مالی) در نرم‌افزارهای حسابداری.
چرا لازم است: بدون آن، همه‌ی ثبت‌های چند سال در یک لیستِ بی‌انتها قاطی
می‌شوند و «سود امسال» عملا قابل‌محاسبه نیست. با فایل، کاربر سرِ سال
مالیِ تازه یک فایل جدید باز می‌کند، فایل قبلی بسته و فقط-خواندنی می‌شود
و گزارش‌های هر دوره تمیز از هم جدا می‌مانند.
"""
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ledger_books")
name = models.CharField(max_length=120, verbose_name=_("نام فایل"))
start_date = models.DateField(verbose_name=_("از تاریخ"))
end_date = models.DateField(null=True, blank=True, verbose_name=_("تا تاریخ"))
# مانده‌ی نقدیِ ابتدای دوره — معمولا مانده‌ی پایانِ فایلِ قبلی.
opening_balance = models.FloatField(default=0, verbose_name=_("مانده ابتدای دوره"))
# فقط یک فایل در هر لحظه «جاری» است؛ ثبت‌های تازه به همان می‌روند.
is_active = models.BooleanField(default=True, verbose_name=_("فایل جاری"))
# فایلِ بسته‌شده فقط-خواندنی است تا گزارشِ دوره‌ی قبل عوض نشود.
is_closed = models.BooleanField(default=False, verbose_name=_("بسته‌شده"))
closed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("فایل دفتر")
verbose_name_plural = _("فایل‌های دفتر")
ordering = ["-start_date", "-created_at"]
indexes = [models.Index(fields=["user", "is_active"])]
def __str__(self):
return self.name
2026-08-24 15:54:08 +03:30
class Customer(models.Model):
2026-09-04 13:05:54 +03:30
class SocialPlatform(models.TextChoices):
INSTAGRAM = "instagram", _("اینستاگرام")
TELEGRAM = "telegram", _("تلگرام")
WHATSAPP = "whatsapp", _("واتساپ")
BALE = "bale", _("بله")
EITAA = "eitaa", _("ایتا")
OTHER = "other", _("سایر")
2026-08-24 15:54:08 +03:30
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="customers")
name = models.CharField(max_length=150, verbose_name=_("نام"))
phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره تماس"))
2026-09-04 13:05:54 +03:30
# مخاطبِ اصلیِ این اپ کسب‌وکارهای اینستاگرامی/تلگرامی‌اند؛ برای خیلی از
# آن‌ها آیدیِ شبکه‌ی اجتماعی راهِ ارتباطیِ اصلی است، نه ایمیل.
social_platform = models.CharField(
max_length=15, choices=SocialPlatform.choices, blank=True, verbose_name=_("پلتفرم ارتباطی")
)
social_handle = models.CharField(max_length=150, blank=True, verbose_name=_("آیدی/نام کاربری"))
2026-08-24 15:54:08 +03:30
email = models.CharField(max_length=150, blank=True, verbose_name=_("ایمیل"))
address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس"))
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
2026-09-04 13:05:54 +03:30
# این سه فیلد cache هستند — منبعِ حقیقت، فاکتورها و ردیف‌های دفترند؛
# سرویس‌ها بعد از هر تغییر بازمحاسبه‌شان می‌کنند.
total_purchases_count = models.PositiveIntegerField(default=0, verbose_name=_("تعداد خرید"))
total_purchase_amount = models.FloatField(default=0, verbose_name=_("مجموع خرید"))
last_purchase_date = models.DateTimeField(null=True, blank=True, verbose_name=_("آخرین خرید"))
outstanding_balance = models.FloatField(default=0, verbose_name=_("مانده بدهی"))
# حسابِ تفصیلیِ اختصاصیِ این مشتری زیرِ «حساب‌های دریافتنی» — همان
# چیزی که فروشِ قسطیِ او را در درختِ حساب‌ها قابل‌ردیابی می‌کند.
account = models.OneToOneField(
Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="customer",
verbose_name=_("حساب تفصیلی"),
)
2026-08-24 15:54:08 +03:30
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("مشتری")
verbose_name_plural = _("مشتریان")
ordering = ["name"]
2026-09-04 13:05:54 +03:30
indexes = [models.Index(fields=["user", "phone"])]
2026-08-24 15:54:08 +03:30
def __str__(self):
return self.name
class Product(models.Model):
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="products")
name = models.CharField(max_length=150, verbose_name=_("نام کالا"))
sku = models.CharField(max_length=100, blank=True, verbose_name=_("کد کالا"))
category = models.CharField(max_length=100, blank=True, verbose_name=_("دسته"))
2026-09-04 13:05:54 +03:30
unit = models.CharField(max_length=30, blank=True, default="عدد", verbose_name=_("واحد شمارش"))
2026-08-24 15:54:08 +03:30
quantity = models.FloatField(default=0, verbose_name=_("موجودی"))
2026-09-04 13:05:54 +03:30
unit_price = models.FloatField(default=0, verbose_name=_("قیمت فروش"))
purchase_price = models.FloatField(default=0, verbose_name=_("قیمت خرید"))
# ارزشِ ریالیِ موجودیِ فعلی (quantity × purchase_price) — cache شده تا
# «ارزش کل انبار» بدون پیمایشِ همه‌ی کالاها قابل‌جمع‌زدن باشد.
total_purchase_value = models.FloatField(default=0, verbose_name=_("ارزش موجودی"))
2026-08-24 15:54:08 +03:30
low_stock_threshold = models.FloatField(default=0, verbose_name=_("آستانه‌ی هشدار موجودی کم"))
is_active = models.BooleanField(default=True, verbose_name=_("فعال"))
production_date = models.DateField(null=True, blank=True, verbose_name=_("تاریخ تولید"))
expiry_date = models.DateField(null=True, blank=True, verbose_name=_("تاریخ انقضا"))
2026-08-24 15:54:08 +03:30
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("کالا")
verbose_name_plural = _("انبار")
ordering = ["name"]
2026-09-04 13:05:54 +03:30
indexes = [models.Index(fields=["user", "category"])]
2026-08-24 15:54:08 +03:30
def __str__(self):
return self.name
2026-09-04 13:05:54 +03:30
def recompute_purchase_value(self) -> None:
self.total_purchase_value = (self.quantity or 0) * (self.purchase_price or 0)
@property
def is_low_stock(self) -> bool:
return self.low_stock_threshold > 0 and self.quantity <= self.low_stock_threshold
2026-08-24 15:54:08 +03:30
class StockMovement(models.Model):
2026-09-04 13:05:54 +03:30
"""تاریخچه‌ی تغییرات موجودی — هر تعدیل (دستیِ +/-، ویرایش مستقیم فرم، یا
فروش/لغوِ فاکتور) یک ردیف اینجا ثبت می‌کند تا معلوم باشد موجودی کِی،
چقدر و به چه دلیل تغییر کرده."""
2026-08-24 15:54:08 +03:30
id = models.AutoField(primary_key=True)
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="movements")
delta = models.FloatField(verbose_name=_("تغییر"))
quantity_after = models.FloatField(verbose_name=_("موجودی بعد از تغییر"))
note = models.CharField(max_length=255, blank=True, verbose_name=_("یادداشت"))
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("تغییر موجودی")
verbose_name_plural = _("تاریخچه‌ی موجودی")
ordering = ["-created_at"]
def __str__(self):
return f"{self.product.name} — {self.delta:+g} — {self.created_at:%Y-%m-%d %H:%M}"
2026-09-04 13:05:54 +03:30
class Invoice(models.Model):
class PaymentType(models.TextChoices):
CASH = "cash", _("نقدی")
CREDIT = "credit", _("قسطی/نسیه")
class PaymentStatus(models.TextChoices):
UNPAID = "unpaid", _("پرداخت‌نشده")
PAID = "paid", _("پرداخت‌شده")
class OrderStatus(models.TextChoices):
PENDING_PAYMENT = "pending_payment", _("در انتظار پرداخت")
READY_TO_SHIP = "ready_to_ship", _("آماده‌ی ارسال")
SHIPPED = "shipped", _("ارسال‌شده")
DELIVERED = "delivered", _("تحویل‌شده")
2026-08-24 15:54:08 +03:30
CANCELLED = "cancelled", _("لغوشده")
2026-09-04 13:05:54 +03:30
class ShippingMethod(models.TextChoices):
PICKUP = "pickup", _("تحویل حضوری")
POST = "post", _("پست")
COURIER = "courier", _("پیک")
2026-08-24 15:54:08 +03:30
id = models.CharField(max_length=36, primary_key=True)
2026-09-04 13:05:54 +03:30
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="invoices")
invoice_number = models.CharField(max_length=30, verbose_name=_("شماره فاکتور"))
invoice_date = models.DateTimeField(verbose_name=_("تاریخ فاکتور"))
customer = models.ForeignKey(
Customer, null=True, blank=True, on_delete=models.SET_NULL, related_name="invoices"
)
buyer_name = models.CharField(max_length=150, blank=True, verbose_name=_("نام خریدار"))
buyer_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره خریدار"))
buyer_address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس خریدار"))
seller_name = models.CharField(max_length=150, blank=True, verbose_name=_("نام فروشنده"))
seller_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره فروشنده"))
seller_address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس فروشنده"))
total_amount = models.FloatField(default=0, verbose_name=_("جمع کل"))
# درصدِ تخفیفِ کلیِ فاکتور — منبعِ حقیقت همین درصد است (هم‌الگو با
# InvoiceItem.discount_percent)؛ discount_amount فقط cache شده تا در
# گزارش/اکسل بدون محاسبه‌ی دوباره قابل‌نمایش باشد.
discount_percent = models.FloatField(default=0, verbose_name=_("درصد تخفیف"))
2026-09-04 13:05:54 +03:30
discount_amount = models.FloatField(default=0, verbose_name=_("تخفیف"))
# تخفیفِ ویژه/دوره‌ای (مثلا «فروش فوق‌العاده») — جدا از تخفیفِ معمولیِ
# بالا تا هر دو در خروجی فاکتور با عنوانِ خودشان دیده شوند. یا با درصد
# (روی مبلغ بعد از تخفیف معمولی) یا با مبلغ ثابت پر می‌شود، نه هر دو.
extra_discount_label = models.CharField(
max_length=100, blank=True, verbose_name=_("عنوان تخفیف ویژه"),
help_text=_("مثلا: فروش فوق‌العاده، تخفیف دوره‌ای"),
)
extra_discount_percent = models.FloatField(default=0, verbose_name=_("درصد تخفیف ویژه"))
extra_discount_amount = models.FloatField(default=0, verbose_name=_("مبلغ تخفیف ویژه"))
2026-09-04 13:05:54 +03:30
# هر ردیف: {"title": "مالیات بر ارزش افزوده", "percent": 9}
tax_lines = models.JSONField(default=list, blank=True, verbose_name=_("ردیف‌های مالیات"))
final_amount = models.FloatField(default=0, verbose_name=_("مبلغ نهایی"))
payment_type = models.CharField(max_length=10, choices=PaymentType.choices, default=PaymentType.CASH)
payment_status = models.CharField(max_length=10, choices=PaymentStatus.choices, default=PaymentStatus.UNPAID)
order_status = models.CharField(max_length=20, choices=OrderStatus.choices, default=OrderStatus.READY_TO_SHIP)
shipping_method = models.CharField(max_length=10, choices=ShippingMethod.choices, default=ShippingMethod.PICKUP)
tracking_code = models.CharField(max_length=100, blank=True, verbose_name=_("کد رهگیری"))
2026-08-24 15:54:08 +03:30
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
2026-09-04 13:05:54 +03:30
paid_at = models.DateTimeField(null=True, blank=True, verbose_name=_("زمان پرداخت"))
created_at = models.DateTimeField(auto_now_add=True)
2026-08-24 15:54:08 +03:30
updated_at = models.DateTimeField(auto_now=True)
class Meta:
2026-09-04 13:05:54 +03:30
verbose_name = _("فاکتور")
verbose_name_plural = _("فاکتورها")
ordering = ["-invoice_date"]
constraints = [
models.UniqueConstraint(fields=["user", "invoice_number"], name="unique_user_invoice_number")
]
indexes = [models.Index(fields=["user", "invoice_date"])]
def __str__(self):
return f"{self.invoice_number} — {self.buyer_name}"
class InvoiceItem(models.Model):
id = models.CharField(max_length=36, primary_key=True)
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(Product, null=True, blank=True, on_delete=models.SET_NULL, related_name="+")
description = models.CharField(max_length=255, verbose_name=_("شرح"))
quantity = models.FloatField(verbose_name=_("تعداد"))
unit_price = models.FloatField(verbose_name=_("قیمت واحد"))
discount_percent = models.FloatField(default=0, verbose_name=_("درصد تخفیف"))
total_price = models.FloatField(default=0, verbose_name=_("جمع"))
class Meta:
verbose_name = _("قلم فاکتور")
verbose_name_plural = _("اقلام فاکتور")
def __str__(self):
return f"{self.description} × {self.quantity:g}"
def compute_total(self) -> None:
gross = (self.quantity or 0) * (self.unit_price or 0)
self.total_price = gross * (1 - (self.discount_percent or 0) / 100)
class Transaction(models.Model):
"""سندِ حسابداریِ دوطرفه — هر رویدادِ مالی (فروش، لغو، دریافتِ قسط)
یک ردیف اینجا می‌سازد با حسابِ بدهکار و حسابِ بستانکار. ترازِ حساب‌ها و
خروجیِ اکسل از روی همین جدول ساخته می‌شوند."""
class TransactionType(models.TextChoices):
CASH_SALE = "cash_sale", _("فروش نقدی")
CREDIT_SALE = "credit_sale", _("فروش نسیه")
INSTALLMENT = "installment", _("دریافت قسط")
PURCHASE = "purchase", _("خرید")
EXPENSE = "expense", _("هزینه")
ADJUSTMENT = "adjustment", _("تعدیل")
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transactions")
transaction_number = models.CharField(max_length=30, verbose_name=_("شماره سند"))
transaction_date = models.DateTimeField(verbose_name=_("تاریخ سند"))
reference_invoice = models.ForeignKey(
Invoice, null=True, blank=True, on_delete=models.SET_NULL, related_name="transactions"
)
# سندِ خودکارِ یک ردیفِ دفترِ ساده (غیرِ فاکتوری) — CASCADE عمدی است: اگر
# خودِ ردیف حذف شود، سندش هم باید حذف شود، وگرنه تراز حساب‌ها یک سندِ
# یتیم و اشتباه نگه می‌دارد.
reference_ledger_entry = models.ForeignKey(
"LedgerEntry", null=True, blank=True, on_delete=models.CASCADE, related_name="transactions"
)
2026-09-04 13:05:54 +03:30
transaction_type = models.CharField(max_length=20, choices=TransactionType.choices)
# کدِ حسابِ متنی برای سازگاری با داده‌ی قدیمی نگه داشته شده؛ debit/credit
# به‌صورتِ FK به Account همان چیزی است که گزارش‌های جدید از آن می‌خوانند.
debit_account = models.CharField(max_length=20, verbose_name=_("کد حساب بدهکار"))
credit_account = models.CharField(max_length=20, verbose_name=_("کد حساب بستانکار"))
debit_account_ref = models.ForeignKey(
Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="debit_transactions",
verbose_name=_("حساب بدهکار"),
)
credit_account_ref = models.ForeignKey(
Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="credit_transactions",
verbose_name=_("حساب بستانکار"),
)
amount = models.FloatField(verbose_name=_("مبلغ"))
description = models.CharField(max_length=255, blank=True, verbose_name=_("شرح"))
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("سند حسابداری")
verbose_name_plural = _("اسناد حسابداری")
ordering = ["-transaction_date"]
indexes = [models.Index(fields=["user", "transaction_date"])]
def __str__(self):
return f"{self.transaction_number} — {self.amount:,.0f}"
class LedgerEntry(models.Model):
class EntryType(models.TextChoices):
INCOME = "income", _("درآمد")
EXPENSE = "expense", _("هزینه")
class Category(models.TextChoices):
# درآمدها
SALE_CASH = "sale_cash", _("فروش نقدی")
SALE_CREDIT = "sale_credit", _("فروش قسطی/نسیه")
SERVICE_INCOME = "service_income", _("درآمد خدمات")
OTHER_INCOME = "other_income", _("سایر درآمدها")
# هزینه‌ها
PURCHASE_CASH = "purchase_cash", _("خرید نقدی")
PURCHASE_CREDIT = "purchase_credit", _("خرید نسیه")
RENT = "rent", _("اجاره")
SALARY = "salary", _("حقوق")
UTILITY = "utility", _("قبض و آب‌وبرق")
TRANSPORT = "transport", _("حمل‌ونقل")
OTHER_EXPENSE = "other_expense", _("سایر هزینه‌ها")
# فقط این دو دسته «بدهی‌دار»ند — یعنی مبلغِ کل، طرفِ حساب و اقساط دارند.
# بقیه یک ثبتِ ساده‌ی نقدی‌اند.
DEBT_CATEGORIES = {Category.SALE_CREDIT, Category.PURCHASE_CREDIT}
# نگاشتِ لایه‌ی «عادی» به حسابِ معینِ لایه‌ی «حرفه‌ای» — کلیدِ اتصالِ
# دسته‌بندیِ ساده‌ی کاربر به درختِ حساب‌ها.
CATEGORY_ACCOUNT_CODES = {
Category.SALE_CASH: "4001",
Category.SALE_CREDIT: "4001",
Category.SERVICE_INCOME: "4002",
Category.OTHER_INCOME: "4003",
Category.PURCHASE_CASH: "5001",
Category.PURCHASE_CREDIT: "5001",
Category.RENT: "5002",
Category.SALARY: "5003",
Category.UTILITY: "5004",
Category.TRANSPORT: "5005",
Category.OTHER_EXPENSE: "5006",
}
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ledger_entries")
# null مجاز است تا ثبت‌های قدیمیِ پیش از افزودنِ «فایل» بی‌صاحب نمانند؛
# سرویس‌ها هنگام ساخت، همیشه فایلِ جاری را ست می‌کنند.
book = models.ForeignKey(
LedgerBook, null=True, blank=True, on_delete=models.CASCADE,
related_name="entries", verbose_name=_("فایل دفتر"),
)
entry_type = models.CharField(max_length=10, choices=EntryType.choices, verbose_name=_("نوع"))
title = models.CharField(max_length=150, verbose_name=_("عنوان"))
# amount همیشه مبلغِ نهاییِ ثبت‌شده است (همان چیزی که در گزارش/جمعِ
# درآمد-هزینه استفاده می‌شود) — سه فیلدِ زیر فقط یک ماشین‌حسابِ اختیاری
# برای رسیدن به آن هستند، تا کاربر مجبور نباشد خودش دستی حساب کند:
# تخفیف روی مبلغِ پایه (پیش از مالیات) اعمال می‌شود، بعد مالیات روی
# مبلغِ تخفیف‌خورده — یعنی دقیقا «تخفیف خارج از مالیات و ارزش‌افزوده».
base_amount = models.FloatField(default=0, blank=True, verbose_name=_("مبلغ پایه (پیش از تخفیف/مالیات)"))
discount_percent = models.FloatField(default=0, blank=True, verbose_name=_("درصد تخفیف (پیش از مالیات)"))
tax_percent = models.FloatField(default=0, blank=True, verbose_name=_("درصد مالیات/ارزش‌افزوده"))
2026-09-04 13:05:54 +03:30
amount = models.FloatField(verbose_name=_("مبلغ"))
# max_length=100 (نه تنگ‌تر) عمداً — این فیلد قبلا آزاد بود و داده‌ی
# واقعیِ قدیمی با متنِ فارسیِ دلخواه دارد؛ تنگ‌کردنش موقعِ مهاجرت روی
# پستگرس می‌تواند «value too long» بدهد.
category = models.CharField(
max_length=100, choices=Category.choices, blank=True, verbose_name=_("دسته")
)
entry_date = models.DateField(verbose_name=_("تاریخ"))
note = models.TextField(blank=True, verbose_name=_("یادداشت"))
# فیلدهای بدهی‌دار — فقط وقتی category در DEBT_CATEGORIES باشد معنی دارند.
total_amount = models.FloatField(default=0, verbose_name=_("مبلغ کل تعهد"))
counterparty_name = models.CharField(max_length=150, blank=True, verbose_name=_("طرف حساب"))
counterparty_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره طرف حساب"))
linked_invoice = models.ForeignKey(
Invoice, null=True, blank=True, on_delete=models.SET_NULL, related_name="ledger_entries"
)
account = models.ForeignKey(
Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="ledger_entries",
verbose_name=_("حساب معین"),
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("ردیف دفتر درآمد/هزینه")
verbose_name_plural = _("دفتر درآمد و هزینه")
ordering = ["-entry_date"]
indexes = [models.Index(fields=["user", "entry_date"])]
def __str__(self):
return f"{self.title} ({self.get_entry_type_display()})"
@property
def is_debt(self) -> bool:
return self.category in self.DEBT_CATEGORIES
@property
def discount_amount(self) -> float:
return (self.base_amount or 0) * (self.discount_percent or 0) / 100
@property
def net_before_tax(self) -> float:
return (self.base_amount or 0) - self.discount_amount
@property
def tax_amount(self) -> float:
return self.net_before_tax * (self.tax_percent or 0) / 100
@property
def computed_amount(self) -> float:
"""مبلغِ نهایی که از روی مبلغِ پایه، تخفیف و مالیات محاسبه می‌شود —
برای پیش‌نمایش سمتِ کلاینت؛ مقدارِ واقعا ذخیره‌شده همیشه amount است."""
return self.net_before_tax + self.tax_amount
2026-09-04 13:05:54 +03:30
@property
def professional_type(self) -> str:
"""ترجمه‌ی دسته‌ی «عادی» به اصطلاحِ حسابداریِ حرفه‌ای."""
if self.category == self.Category.SALE_CREDIT:
return "receivable"
if self.category == self.Category.PURCHASE_CREDIT:
return "payable"
return "simple"
@property
def paid_amount(self) -> float:
if not self.is_debt:
return 0
return sum(i.amount for i in self.installments.all())
@property
def remaining_amount(self) -> float:
if not self.is_debt:
return 0
return (self.total_amount or 0) - self.paid_amount
class LedgerInstallment(models.Model):
"""هر قسطِ پرداخت‌شده روی یک ردیفِ بدهی‌دار. مانده‌ی هر بدهی همیشه از
اینجا محاسبه می‌شود (نه ذخیره‌ی مستقیم) تا هیچ‌وقت با واقعیت فاصله نگیرد."""
id = models.CharField(max_length=36, primary_key=True)
ledger_entry = models.ForeignKey(LedgerEntry, on_delete=models.CASCADE, related_name="installments")
amount = models.FloatField(verbose_name=_("مبلغ قسط"))
paid_date = models.DateField(verbose_name=_("تاریخ پرداخت"))
reference = models.CharField(max_length=150, blank=True, verbose_name=_("شماره مرجع"))
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("قسط")
verbose_name_plural = _("اقساط")
ordering = ["paid_date"]
2026-08-24 15:54:08 +03:30
def __str__(self):
2026-09-04 13:05:54 +03:30
return f"{self.amount:,.0f} — {self.paid_date}"