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
+127 -40
View File
@@ -9,6 +9,7 @@ from django.utils import timezone
from apps.account.models import User
from apps.banker.models import Contact
from apps.bizapps.models import (
Account,
Customer,
Invoice,
InvoiceItem,
@@ -26,6 +27,7 @@ from apps.bizapps.api.accounting import (
ACCOUNT_SALES,
ChartOfAccountsService,
ExcelExportService,
SubsidiaryLedgerService,
)
logger = logging.getLogger(__name__)
@@ -92,7 +94,9 @@ class InvoiceService:
seller_name=data.get("seller_name", ""),
seller_phone=data.get("seller_phone", ""),
seller_address=data.get("seller_address", ""),
discount_amount=data.get("discount_amount", 0),
discount_percent=data.get("discount_percent", 0),
extra_discount_label=data.get("extra_discount_label", ""),
extra_discount_percent=data.get("extra_discount_percent", 0),
tax_lines=data.get("tax_lines", []),
payment_type=payment_type,
shipping_method=data.get("shipping_method", Invoice.ShippingMethod.PICKUP),
@@ -146,10 +150,27 @@ class InvoiceService:
note=f"فروش فاکتور {invoice_number}",
)
if invoice.discount_percent:
invoice.discount_amount = subtotal * invoice.discount_percent / 100
else:
invoice.discount_amount = data.get("discount_amount", 0)
after_discount = subtotal - invoice.discount_amount
if invoice.extra_discount_percent:
invoice.extra_discount_amount = after_discount * invoice.extra_discount_percent / 100
else:
invoice.extra_discount_amount = data.get("extra_discount_amount", 0)
tax_amount = sum(subtotal * float(line.get("percent", 0)) / 100 for line in invoice.tax_lines)
invoice.total_amount = subtotal
invoice.final_amount = subtotal - invoice.discount_amount + tax_amount
invoice.save(update_fields=["total_amount", "final_amount"])
invoice.final_amount = (
subtotal - invoice.discount_amount - invoice.extra_discount_amount + tax_amount
)
invoice.save(
update_fields=[
"total_amount", "discount_amount", "extra_discount_amount", "final_amount",
]
)
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
is_credit = payment_type == Invoice.PaymentType.CREDIT
@@ -396,6 +417,70 @@ class LedgerService:
entry.account = account
entry.save(update_fields=["account", "updated_at"])
@staticmethod
@transaction.atomic
def post_entry_transaction(user: User, entry: LedgerEntry) -> None:
"""سندِ دوطرفه‌ی یک ثبتِ ساده (غیرِ فاکتوری) — قبلا فقط فاکتورها و
اقساط سند می‌خوردند، پس ثبتِ مستقیمِ درآمد/هزینه (که کارِ اصلیِ
بیشترِ کاربرهاست) هیچ‌وقت وارد تراز حساب‌ها/دفتر معین نمی‌شد. اینجا
سندِ قبلیِ همین ردیف (اگر بود) پاک و دوباره ساخته می‌شود — ساده‌تر و
کم‌خطاتر از آپدیتِ جزئی.
فاکتورهای فروش (linked_invoice) را عمدا رد می‌کند — سندشان از قبل،
موقعِ ساختِ فاکتور، ساخته شده؛ دوباره‌سندزدن یعنی دوبار حساب کردن.
"""
Transaction.objects.filter(reference_ledger_entry=entry).delete()
if entry.linked_invoice_id:
return
account = entry.account or LedgerService.account_for_category(user, entry.category)
if account is None:
return # دسته‌ی ناشناخته یا خالی — چیزی برای سند زدن نیست.
is_debt = entry.category in LedgerEntry.DEBT_CATEGORIES
amount = (entry.total_amount or entry.amount) if is_debt else entry.amount
if not amount:
return
if entry.category == LedgerEntry.Category.SALE_CREDIT:
debit_ref = ChartOfAccountsService.get(user, ACCOUNT_RECEIVABLE)
credit_ref = account
elif entry.category == LedgerEntry.Category.PURCHASE_CREDIT:
debit_ref = account
credit_ref = ChartOfAccountsService.get(user, ACCOUNT_PAYABLE)
elif entry.entry_type == LedgerEntry.EntryType.INCOME:
debit_ref = ChartOfAccountsService.get(user, ACCOUNT_CASH)
credit_ref = account
else:
debit_ref = account
credit_ref = ChartOfAccountsService.get(user, ACCOUNT_CASH)
transaction_type = (
Transaction.TransactionType.CREDIT_SALE
if entry.category == LedgerEntry.Category.SALE_CREDIT
else Transaction.TransactionType.PURCHASE
if entry.category == LedgerEntry.Category.PURCHASE_CREDIT
else Transaction.TransactionType.CASH_SALE
if entry.entry_type == LedgerEntry.EntryType.INCOME
else Transaction.TransactionType.EXPENSE
)
number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "LED")
naive_dt = timezone.datetime.combine(entry.entry_date, timezone.datetime.min.time())
Transaction.objects.create(
id=str(uuid.uuid4()),
user=user,
transaction_number=number,
transaction_date=timezone.make_aware(naive_dt) if timezone.is_naive(naive_dt) else naive_dt,
reference_ledger_entry=entry,
transaction_type=transaction_type,
debit_account=debit_ref.code if debit_ref else "",
credit_account=credit_ref.code if credit_ref else "",
debit_account_ref=debit_ref,
credit_account_ref=credit_ref,
amount=amount,
description=entry.title,
)
@staticmethod
@transaction.atomic
def post_installment_transaction(user: User, installment: LedgerInstallment) -> None:
@@ -448,46 +533,48 @@ class LedgerService:
@staticmethod
def export_csv(user: User, start=None, end=None) -> str:
"""خروجیِ CSV ساده — برای وقتی کاربر فقط یک فایلِ سبکِ تک‌برگی
می‌خواهد. نسخه‌ی کاملِ چندبرگی در export_xlsx است."""
qs = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related("installments")
if start:
qs = qs.filter(entry_date__gte=start)
if end:
qs = qs.filter(entry_date__lte=end)
"""خروجیِ CSV به‌شکلِ واقعیِ دفتر معین — به‌ازای هر حسابِ معینِ
دارای گردش: سربرگِ حساب، مانده‌ی ابتدای دوره، سندهای آن حساب
به‌ترتیبِ تاریخ (با بدهکار/بستانکار/مانده‌ی در جریان)، و مانده‌ی
پایانِ دوره — دقیقا همان ساختاری که در خروجیِ اکسلِ چندبرگی هم
هست، فقط سبک‌تر و تک‌برگی. قبلا این خروجی فقط یک دامپِ خامِ
ردیف‌های دفترِ ساده بود (بدونِ حساب/بدهکار/بستانکار/مانده) که هیچ
شباهتی به دفتر معین نداشت.
"""
buf = io.StringIO()
buf.write("") # BOM
writer = csv.writer(buf)
writer.writerow(
[
"تاریخ",
"نوع",
"دسته",
"عنوان",
"مبلغ",
"طرف حساب",
"شماره طرف حساب",
"مبلغ کل تعهد",
"پرداخت‌شده",
"مانده",
"یادداشت",
]
)
for e in qs:
writer.writerow(["تاریخ", "شماره سند", "نوع", "شرح", "بدهکار", "بستانکار", "مانده"])
accounts = Account.objects.filter(user=user, level=Account.Level.MOIN).order_by("code")
wrote_any_row = False
for account in accounts:
statement = SubsidiaryLedgerService.statement(user, account, start, end)
if not statement["rows"]:
continue
wrote_any_row = True
writer.writerow([f"{account.code} — {account.name}", "", "", "", "", "", ""])
writer.writerow(
[
e.entry_date.isoformat(),
e.get_entry_type_display(),
e.get_category_display() if e.category else "",
e.title,
f"{e.amount:.0f}",
e.counterparty_name,
e.counterparty_phone,
f"{e.total_amount:.0f}" if e.is_debt else "",
f"{e.paid_amount:.0f}" if e.is_debt else "",
f"{e.remaining_amount:.0f}" if e.is_debt else "",
e.note,
]
["مانده‌ی ابتدای دوره", "", "", "", "", "", f"{statement['opening_balance']:.0f}"]
)
for row in statement["rows"]:
row_date = row["date"]
writer.writerow(
[
row_date.date().isoformat() if hasattr(row_date, "date") else row_date,
row["number"],
row["type"],
row["description"],
f"{row['debit']:.0f}" if row["debit"] else "",
f"{row['credit']:.0f}" if row["credit"] else "",
f"{row['balance']:.0f}",
]
)
writer.writerow(
["مانده‌ی پایانِ دوره", "", "", "", "", "", f"{statement['closing_balance']:.0f}"]
)
writer.writerow([])
if not wrote_any_row:
writer.writerow(["در این بازه هیچ حسابی گردش نداشته.", "", "", "", "", "", ""])
return buf.getvalue()