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:
"""مانده‌ی پایانیِ یک فایل = مانده‌ی ابتدا + درآمدها − هزینه‌ها."""
+20 -2
View File
@@ -81,6 +81,9 @@ class LedgerEntrySerializer(serializers.ModelSerializer):
category_display = serializers.CharField(source="get_category_display", read_only=True)
account_name = serializers.CharField(source="account.name", read_only=True, default="")
account_code = serializers.CharField(source="account.code", read_only=True, default="")
discount_amount = serializers.FloatField(read_only=True)
tax_amount = serializers.FloatField(read_only=True)
computed_amount = serializers.FloatField(read_only=True)
class Meta:
model = LedgerEntry
@@ -88,6 +91,8 @@ class LedgerEntrySerializer(serializers.ModelSerializer):
"id", "entry_type", "title", "amount", "category", "category_display",
"entry_date", "note", "total_amount", "counterparty_name", "counterparty_phone",
"linked_invoice", "account_name", "account_code",
"base_amount", "discount_percent", "tax_percent",
"discount_amount", "tax_amount", "computed_amount",
"is_debt", "professional_type", "paid_amount", "remaining_amount",
"installments", "updated_at",
]
@@ -112,7 +117,8 @@ class ProductSerializer(serializers.ModelSerializer):
fields = [
"id", "name", "sku", "category", "unit", "quantity",
"unit_price", "purchase_price", "total_purchase_value",
"low_stock_threshold", "is_low_stock", "notes", "updated_at",
"low_stock_threshold", "is_low_stock", "is_active",
"production_date", "expiry_date", "notes", "updated_at",
]
read_only_fields = ["total_purchase_value", "is_low_stock", "updated_at"]
@@ -153,7 +159,9 @@ class InvoiceSerializer(serializers.ModelSerializer):
"id", "invoice_number", "invoice_date", "customer",
"buyer_name", "buyer_phone", "buyer_address",
"seller_name", "seller_phone", "seller_address",
"total_amount", "discount_amount", "tax_lines", "final_amount",
"total_amount", "discount_percent", "discount_amount",
"extra_discount_label", "extra_discount_percent", "extra_discount_amount",
"tax_lines", "final_amount",
"payment_type", "payment_type_display",
"payment_status", "payment_status_display",
"order_status", "order_status_display",
@@ -188,7 +196,17 @@ class CreateInvoiceSerializer(serializers.Serializer):
seller_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
seller_phone = serializers.CharField(max_length=20, required=False, allow_blank=True, default="")
seller_address = serializers.CharField(max_length=255, required=False, allow_blank=True, default="")
# درصد اولویت دارد: اگر discount_percent مثبت باشد، discount_amount از
# روی همان محاسبه می‌شود (هم‌الگو با تخفیفِ درصدیِ هر قلم فاکتور)؛
# discount_amount خامْ فقط برای سازگاری با کلاینت‌های قدیمی نگه داشته
# شده — اگر درصد صفر باشد همان مقدار مستقیم استفاده می‌شود.
discount_percent = serializers.FloatField(required=False, default=0, min_value=0, max_value=100)
discount_amount = serializers.FloatField(required=False, default=0, min_value=0)
# تخفیفِ ویژه/دوره‌ای — جدا از تخفیفِ بالا، روی مبلغِ بعد از آن اعمال
# می‌شود. یا با درصد یا با مبلغِ ثابت پر می‌شود.
extra_discount_label = serializers.CharField(max_length=100, required=False, allow_blank=True, default="")
extra_discount_percent = serializers.FloatField(required=False, default=0, min_value=0, max_value=100)
extra_discount_amount = serializers.FloatField(required=False, default=0, min_value=0)
tax_lines = serializers.ListField(child=serializers.DictField(), required=False, default=list)
payment_type = serializers.ChoiceField(choices=Invoice.PaymentType.choices, required=False)
shipping_method = serializers.ChoiceField(choices=Invoice.ShippingMethod.choices, required=False)
+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()
+44
View File
@@ -109,6 +109,7 @@ class LedgerEntryListCreateView(_OwnedMixin, generics.ListCreateAPIView):
book = LedgerBookService.ensure_active_book(self.request.user)
entry = serializer.save(user=self.request.user, book=book)
LedgerService.sync_entry_account(self.request.user, entry)
LedgerService.post_entry_transaction(self.request.user, entry)
if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone)
@@ -123,6 +124,7 @@ class LedgerEntryDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
def perform_update(self, serializer):
entry = serializer.save()
LedgerService.sync_entry_account(self.request.user, entry)
LedgerService.post_entry_transaction(self.request.user, entry)
if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone)
@@ -473,6 +475,48 @@ class LedgerBookSummaryView(APIView):
)
class LedgerBookDetailView(generics.RetrieveUpdateDestroyAPIView):
"""ویرایش/حذفِ یک فایل دفتر. فایلِ بایگانی‌شده فقط-خواندنی است — نه
ویرایش و نه حذف. حذف فقط برای فایلِ خالی (بدون هیچ ثبتی) مجاز است تا
داده‌ی مالی با حذفِ فایل به‌طور آبشاری از بین نرود."""
serializer_class = LedgerBookSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return LedgerBook.objects.filter(user=self.request.user)
def _guard_editable(self, book: LedgerBook):
if book.is_closed:
raise ValidationError("فایلِ بایگانی‌شده فقط-خواندنی است.")
def perform_update(self, serializer):
self._guard_editable(serializer.instance)
serializer.save()
def perform_destroy(self, instance):
self._guard_editable(instance)
if instance.entries.exists():
raise ValidationError("فقط فایلِ خالی (بدون هیچ ثبتی) قابل‌حذف است.")
instance.delete()
class LedgerBookArchiveView(APIView):
"""بایگانی‌کردنِ دستیِ یک فایل دفتر — از این پس فقط-خواندنی می‌شود و در
انتخابِ فایلِ دفتر برای فاکتورهای تازه نشان داده نمی‌شود."""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
book = LedgerBook.objects.filter(user=request.user, pk=pk).first()
if book is None:
raise NotFound("فایل دفتر یافت نشد.")
if book.is_closed:
raise ValidationError("این فایل قبلا بایگانی شده.")
book = LedgerBookService.archive_book(book)
return Response(LedgerBookSerializer(book).data)
class SubsidiaryLedgerView(APIView):
"""دفتر معین یک حساب — گردشِ حساب با مانده‌ی در جریان."""