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
@@ -0,0 +1,63 @@
# Generated by Django 5.1.15 on 2026-09-14 23:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0003_ledgerbook_ledgerentry_book_and_more'),
]
operations = [
migrations.AddField(
model_name='invoice',
name='discount_percent',
field=models.FloatField(default=0, verbose_name='درصد تخفیف'),
),
migrations.AddField(
model_name='invoice',
name='extra_discount_amount',
field=models.FloatField(default=0, verbose_name='مبلغ تخفیف ویژه'),
),
migrations.AddField(
model_name='invoice',
name='extra_discount_label',
field=models.CharField(blank=True, help_text='مثلا: فروش فوق\u200cالعاده، تخفیف دوره\u200cای', max_length=100, verbose_name='عنوان تخفیف ویژه'),
),
migrations.AddField(
model_name='invoice',
name='extra_discount_percent',
field=models.FloatField(default=0, verbose_name='درصد تخفیف ویژه'),
),
migrations.AddField(
model_name='ledgerentry',
name='base_amount',
field=models.FloatField(blank=True, default=0, verbose_name='مبلغ پایه (پیش از تخفیف/مالیات)'),
),
migrations.AddField(
model_name='ledgerentry',
name='discount_percent',
field=models.FloatField(blank=True, default=0, verbose_name='درصد تخفیف (پیش از مالیات)'),
),
migrations.AddField(
model_name='ledgerentry',
name='tax_percent',
field=models.FloatField(blank=True, default=0, verbose_name='درصد مالیات/ارزش\u200cافزوده'),
),
migrations.AddField(
model_name='product',
name='expiry_date',
field=models.DateField(blank=True, null=True, verbose_name='تاریخ انقضا'),
),
migrations.AddField(
model_name='product',
name='is_active',
field=models.BooleanField(default=True, verbose_name='فعال'),
),
migrations.AddField(
model_name='product',
name='production_date',
field=models.DateField(blank=True, null=True, verbose_name='تاریخ تولید'),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.15 on 2026-09-15 10:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0004_invoice_discount_percent_and_more'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='reference_ledger_entry',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='bizapps.ledgerentry'),
),
]
@@ -0,0 +1,168 @@
"""داده‌ی قدیمی: ردیف‌های سادهٔ دفتر (غیرِ فاکتوری) که قبل از این نسخه ثبت
شده‌اند، هیچ‌وقت سندِ دوطرفه نگرفته بودند — یعنی در ترازِ حساب‌ها/دفتر
معین/خروجیِ CSV دیده نمی‌شدند. این مهاجرت همان سندهایی را که از این پس
به‌صورت خودکار موقعِ ساخت/ویرایشِ هر ردیف ساخته می‌شوند، برای داده‌ی
قدیمی هم می‌سازد.
عمداً کدِ حسابداریِ فعلی (apps.bizapps.api.accounting/services) را ایمپورت
نمی‌کند — مهاجرت‌ها باید با مدلِ همان لحظه (historical model) کار کنند،
نه با کدِ همیشه‌درحال‌تغییرِ اپ؛ به همین خاطر ثابت‌ها و منطقِ لازم همین‌جا
تکرار شده‌اند.
"""
import uuid
from django.db import migrations
from django.utils import timezone
CATEGORY_ACCOUNT_CODES = {
"sale_cash": "4001",
"sale_credit": "4001",
"service_income": "4002",
"other_income": "4003",
"purchase_cash": "5001",
"purchase_credit": "5001",
"rent": "5002",
"salary": "5003",
"utility": "5004",
"transport": "5005",
"other_expense": "5006",
}
DEBT_CATEGORIES = {"sale_credit", "purchase_credit"}
ACCOUNT_CASH = "1001"
ACCOUNT_RECEIVABLE = "1100"
ACCOUNT_PAYABLE = "2100"
# (code, name, level, account_type, parent_code) — فقط زیرمجموعه‌ای که این
# بک‌فیل واقعاً لازم دارد؛ اگر حسابِ کاربر از قبل ساخته شده باشد، دوباره
# ساخته نمی‌شود (get_or_create).
NEEDED_ACCOUNTS = [
("1000", "دارایی‌ها", 1, "asset", None),
("1001", "صندوق", 2, "asset", "1000"),
("1100", "حساب‌های دریافتنی", 2, "asset", "1000"),
("2000", "بدهی‌ها", 1, "liability", None),
("2100", "حساب‌های پرداختنی", 2, "liability", "2000"),
("4000", "درآمدها", 1, "income", None),
("4001", "فروش کالا", 2, "income", "4000"),
("4002", "درآمد خدمات", 2, "income", "4000"),
("4003", "سایر درآمدها", 2, "income", "4000"),
("5000", "هزینه‌ها", 1, "expense", None),
("5001", "بهای تمام‌شده‌ی کالای فروش‌رفته", 2, "expense", "5000"),
("5002", "اجاره", 2, "expense", "5000"),
("5003", "حقوق و دستمزد", 2, "expense", "5000"),
("5004", "قبض و آب‌وبرق", 2, "expense", "5000"),
("5005", "حمل‌ونقل", 2, "expense", "5000"),
("5006", "سایر هزینه‌ها", 2, "expense", "5000"),
]
def _ensure_account(Account, user_id, code, by_code):
if code in by_code:
return by_code[code]
existing = Account.objects.filter(user_id=user_id, code=code).first()
if existing is not None:
by_code[code] = existing
return existing
spec = next((a for a in NEEDED_ACCOUNTS if a[0] == code), None)
if spec is None:
return None
_, name, level, account_type, parent_code = spec
parent = _ensure_account(Account, user_id, parent_code, by_code) if parent_code else None
account = Account.objects.create(
id=str(uuid.uuid4()),
user_id=user_id,
code=code,
name=name,
level=level,
account_type=account_type,
parent=parent,
is_system=True,
is_active=True,
)
by_code[code] = account
return account
def backfill_transactions(apps, schema_editor):
LedgerEntry = apps.get_model("bizapps", "LedgerEntry")
Account = apps.get_model("bizapps", "Account")
Transaction = apps.get_model("bizapps", "Transaction")
entries = (
LedgerEntry.objects.filter(linked_invoice__isnull=True)
.exclude(transactions__isnull=False)
.order_by("user_id", "entry_date")
)
accounts_by_user: dict[str, dict[str, object]] = {}
counters: dict[str, int] = {}
for entry in entries:
code = CATEGORY_ACCOUNT_CODES.get(entry.category)
if not code:
continue
is_debt = entry.category in DEBT_CATEGORIES
amount = (entry.total_amount or entry.amount) if is_debt else entry.amount
if not amount:
continue
by_code = accounts_by_user.setdefault(entry.user_id, {})
account = _ensure_account(Account, entry.user_id, code, by_code)
if account is None:
continue
if entry.category == "sale_credit":
debit = _ensure_account(Account, entry.user_id, ACCOUNT_RECEIVABLE, by_code)
credit = account
ttype = "credit_sale"
elif entry.category == "purchase_credit":
debit = account
credit = _ensure_account(Account, entry.user_id, ACCOUNT_PAYABLE, by_code)
ttype = "purchase"
elif entry.entry_type == "income":
debit = _ensure_account(Account, entry.user_id, ACCOUNT_CASH, by_code)
credit = account
ttype = "cash_sale"
else:
debit = account
credit = _ensure_account(Account, entry.user_id, ACCOUNT_CASH, by_code)
ttype = "expense"
if debit is None or credit is None:
continue
counters[entry.user_id] = counters.get(entry.user_id, 0) + 1
number = f"LED-BKFL-{counters[entry.user_id]:06d}"
naive_dt = timezone.datetime.combine(entry.entry_date, timezone.datetime.min.time())
aware_dt = timezone.make_aware(naive_dt) if timezone.is_naive(naive_dt) else naive_dt
Transaction.objects.create(
id=str(uuid.uuid4()),
user_id=entry.user_id,
transaction_number=number,
transaction_date=aware_dt,
reference_ledger_entry_id=entry.id,
transaction_type=ttype,
debit_account=debit.code,
credit_account=credit.code,
debit_account_ref_id=debit.id,
credit_account_ref_id=credit.id,
amount=amount,
description=entry.title,
)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("bizapps", "0005_transaction_reference_ledger_entry"),
]
operations = [
migrations.RunPython(backfill_transactions, noop),
]