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
+55 -7
View File
@@ -426,7 +426,40 @@ class ExcelExportService:
money_columns={4, 5, 6},
)
# --- برگه ۶: انبار ---
# --- برگه ۶: دفتر معین ---
# همان چیزی که «معین» یعنی: به‌ازای هر حسابِ معینِ دارای گردش، تکِ
# تکِ اسناد به‌ترتیبِ تاریخ با مانده‌ی در جریان — دقیقا فرمتی که
# حسابدار وقتی می‌گوید «دفتر معین رو بفرست» انتظارش را دارد.
ws = wb.create_sheet("دفتر معین")
subsidiary_rows: list[list] = []
for account in Account.objects.filter(user=user, level=Account.Level.MOIN).order_by("code"):
statement = SubsidiaryLedgerService.statement(user, account, start, end)
if not statement["rows"]:
continue
subsidiary_rows.append([f"{account.code} — {account.name}", "", "", "", "", "", ""])
subsidiary_rows.append(["مانده‌ی ابتدای دوره", "", "", "", "", "", statement["opening_balance"]])
for row in statement["rows"]:
subsidiary_rows.append(
[
row["date"].date().isoformat() if hasattr(row["date"], "date") else row["date"],
row["number"],
row["type"],
row["description"],
row["debit"],
row["credit"],
row["balance"],
]
)
subsidiary_rows.append(["مانده‌ی پایانِ دوره", "", "", "", "", "", statement["closing_balance"]])
subsidiary_rows.append([])
ExcelExportService._write(
ws,
["تاریخ", "شماره سند", "نوع", "شرح", "بدهکار", "بستانکار", "مانده"],
subsidiary_rows,
money_columns={4, 5, 6},
)
# --- برگه ۷: انبار ---
ws = wb.create_sheet("انبار")
ExcelExportService._write(
ws,
@@ -489,9 +522,14 @@ class LedgerBookService:
end_date: date | None = None,
carry_balance: bool = True,
) -> "LedgerBook":
"""فایل تازه باز می‌کند: فایلِ جاریِ قبلی بسته و فقط-خواندنی می‌شود،
و اگر خواسته شود مانده‌ی پایانیِ آن به‌عنوان مانده‌ی ابتدای فایلِ
جدید منتقل می‌گردد (کارِ استانداردِ «انتقال مانده» سرِ سال مالی)."""
"""فایل تازه باز می‌کند و آن را فایلِ جاری می‌کند؛ اگر خواسته شود
مانده‌ی پایانیِ فایلِ قبلی به‌عنوان مانده‌ی ابتدای فایلِ جدید منتقل
می‌شود (کارِ استانداردِ «انتقال مانده» سرِ سال مالی).
برخلافِ قبل، فایلِ قبلی خودکار بایگانی (بسته) نمی‌شود — فقط از حالتِ
«جاری» خارج می‌شود و همچنان در فهرست و قابل‌ویرایش باقی می‌ماند؛
بایگانی‌کردنش یک اقدامِ جداگانه و دستی است (به LedgerBookService.
archive_book مراجعه کن)."""
from apps.bizapps.models import LedgerBook
previous = LedgerBook.objects.select_for_update().filter(user=user, is_active=True).first()
@@ -500,9 +538,7 @@ class LedgerBookService:
if carry_balance:
opening = LedgerBookService.closing_balance(previous)
previous.is_active = False
previous.is_closed = True
previous.closed_at = timezone.now()
previous.save(update_fields=["is_active", "is_closed", "closed_at", "updated_at"])
previous.save(update_fields=["is_active", "updated_at"])
return LedgerBook.objects.create(
id=str(uuid.uuid4()),
@@ -514,6 +550,18 @@ class LedgerBookService:
is_active=True,
)
@staticmethod
def archive_book(book: "LedgerBook") -> "LedgerBook":
"""بایگانی‌کردنِ دستیِ یک فایل — از این پس فقط-خواندنی می‌شود.
فایلِ جاری هم قابل‌بایگانی است (کاربر آگاهانه انتخاب کرده)، فقط بعد
از آن یک فایلِ جاریِ تازه لازم است (اولین فراخوانیِ بعدیِ
ensure_active_book خودش می‌سازد)."""
book.is_active = False
book.is_closed = True
book.closed_at = timezone.now()
book.save(update_fields=["is_active", "is_closed", "closed_at", "updated_at"])
return book
@staticmethod
def closing_balance(book: "LedgerBook") -> float:
"""مانده‌ی پایانیِ یک فایل = مانده‌ی ابتدا + درآمدها − هزینه‌ها."""