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)
This commit is contained in:
2026-09-15 23:18:05 +03:30
parent 61b7c91b02
commit 831e656fa3
32 changed files with 1036 additions and 77 deletions
+48
View File
@@ -181,6 +181,9 @@ class Product(models.Model):
# «ارزش کل انبار» بدون پیمایشِ همه‌ی کالاها قابل‌جمع‌زدن باشد.
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)
@@ -257,7 +260,20 @@ class Invoice(models.Model):
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=_("مبلغ نهایی"))
@@ -326,6 +342,12 @@ class Transaction(models.Model):
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 همان چیزی است که گزارش‌های جدید از آن می‌خوانند.
@@ -403,6 +425,14 @@ class LedgerEntry(models.Model):
)
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 (نه تنگ‌تر) عمداً — این فیلد قبلا آزاد بود و داده‌ی
# واقعیِ قدیمی با متنِ فارسیِ دلخواه دارد؛ تنگ‌کردنش موقعِ مهاجرت روی
@@ -438,6 +468,24 @@ class LedgerEntry(models.Model):
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:
"""ترجمه‌ی دسته‌ی «عادی» به اصطلاحِ حسابداریِ حرفه‌ای."""