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
+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):
"""دفتر معین یک حساب — گردشِ حساب با مانده‌ی در جریان."""