831e656fa3
- 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)
529 lines
30 KiB
Python
529 lines
30 KiB
Python
from django.conf import settings
|
||
from django.db import models
|
||
from django.utils.translation import gettext_lazy as _
|
||
|
||
# ریزاپهای کسبوکار کوچک — مشتریان، دفتر درآمد/هزینه، انبار، فاکتور.
|
||
# دقیقا همالگو با 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
|
||
|
||
|
||
class Customer(models.Model):
|
||
class SocialPlatform(models.TextChoices):
|
||
INSTAGRAM = "instagram", _("اینستاگرام")
|
||
TELEGRAM = "telegram", _("تلگرام")
|
||
WHATSAPP = "whatsapp", _("واتساپ")
|
||
BALE = "bale", _("بله")
|
||
EITAA = "eitaa", _("ایتا")
|
||
OTHER = "other", _("سایر")
|
||
|
||
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=_("شماره تماس"))
|
||
# مخاطبِ اصلیِ این اپ کسبوکارهای اینستاگرامی/تلگرامیاند؛ برای خیلی از
|
||
# آنها آیدیِ شبکهی اجتماعی راهِ ارتباطیِ اصلی است، نه ایمیل.
|
||
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=_("آیدی/نام کاربری"))
|
||
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=_("یادداشت"))
|
||
# این سه فیلد 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=_("حساب تفصیلی"),
|
||
)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = _("مشتری")
|
||
verbose_name_plural = _("مشتریان")
|
||
ordering = ["name"]
|
||
indexes = [models.Index(fields=["user", "phone"])]
|
||
|
||
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=_("دسته"))
|
||
unit = models.CharField(max_length=30, blank=True, default="عدد", verbose_name=_("واحد شمارش"))
|
||
quantity = models.FloatField(default=0, verbose_name=_("موجودی"))
|
||
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=_("ارزش موجودی"))
|
||
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=_("تاریخ انقضا"))
|
||
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = _("کالا")
|
||
verbose_name_plural = _("انبار")
|
||
ordering = ["name"]
|
||
indexes = [models.Index(fields=["user", "category"])]
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
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
|
||
|
||
|
||
class StockMovement(models.Model):
|
||
"""تاریخچهی تغییرات موجودی — هر تعدیل (دستیِ +/-، ویرایش مستقیم فرم، یا
|
||
فروش/لغوِ فاکتور) یک ردیف اینجا ثبت میکند تا معلوم باشد موجودی کِی،
|
||
چقدر و به چه دلیل تغییر کرده."""
|
||
|
||
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}"
|
||
|
||
|
||
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", _("تحویلشده")
|
||
CANCELLED = "cancelled", _("لغوشده")
|
||
|
||
class ShippingMethod(models.TextChoices):
|
||
PICKUP = "pickup", _("تحویل حضوری")
|
||
POST = "post", _("پست")
|
||
COURIER = "courier", _("پیک")
|
||
|
||
id = models.CharField(max_length=36, primary_key=True)
|
||
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=_("درصد تخفیف"))
|
||
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=_("مبلغ تخفیف ویژه"))
|
||
# هر ردیف: {"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=_("کد رهگیری"))
|
||
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
|
||
paid_at = models.DateTimeField(null=True, blank=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 = ["-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"
|
||
)
|
||
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=_("درصد مالیات/ارزشافزوده"))
|
||
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
|
||
|
||
@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"]
|
||
|
||
def __str__(self):
|
||
return f"{self.amount:,.0f} — {self.paid_date}"
|