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
+39 -4
View File
@@ -1,4 +1,6 @@
import random
import re
import string
import uuid
from django.db import transaction
@@ -6,13 +8,37 @@ from django.db import transaction
from apps.account.models import User
from apps.banker.models import Contact
from apps.messaging.api.services import BulkMessageService
from apps.messaging.models import Channel
from apps.messaging.models import Channel, MessageRecipient
from apps.orders.models import Order, OrderStage, OrderStageLog
_FIELD_PATTERN = re.compile(r"\{(.*?)\}")
_TRACKING_CODE_ALPHABET = string.ascii_uppercase + string.digits
def generate_tracking_code() -> str:
"""کد پیگیریِ کوتاه و خوانا برای مشتری — نه شماره‌ی پستیِ واقعی (آن را
وقتی از پستخانه گرفت دستی جایگزین می‌کند)، بلکه یک مرجع سریع تا از
همان مرحله‌ی اول سفارش یک کدِ پیگیری داشته باشد."""
return "TRK-" + "".join(random.choices(_TRACKING_CODE_ALPHABET, k=6))
class OrderStageService:
@staticmethod
def ensure_default_stage(user: User) -> None:
"""اگر کاربر هنوز هیچ مرحله‌ای تعریف نکرده، یک مرحله‌ی پیش‌فرض
می‌سازد تا لیستِ مراحل هیچ‌وقت کاملا خالی نباشد و اولین سفارش از
همان روزِ اول قابل‌پیگیری باشد. بدونِ قالبِ پیامک (کاربر خودش بعدا
یکی وصل می‌کند) — چون قالبِ پیشِ‌فرضی که خودمان بسازیم به احتمال
زیاد با کسب‌وکارش نمی‌خواند."""
if OrderStage.objects.filter(user=user).exists():
return
OrderStage.objects.create(
id=str(uuid.uuid4()),
user=user,
label="ثبت سفارش",
sort_order=0,
)
@staticmethod
def _render(order: Order, stage: OrderStage, extra_input_value: str = "") -> str:
body = stage.template.body if stage.template else ""
@@ -36,7 +62,12 @@ class OrderStageService:
@staticmethod
@transaction.atomic
def advance(user: User, order_id: str, extra_input_value: str = "") -> Order:
def advance(user: User, order_id: str, extra_input_value: str = "") -> tuple[Order, bool]:
"""برمی‌گرداند: (سفارشِ به‌روزشده، آیا پیامکِ این مرحله واقعا ارسال
شد). قبلا موفقیتِ ثبتِ مرحله را با موفقیتِ ارسالِ پیامک قاطی می‌کرد؛
الان اگر قالب متن نداشته باشد یا کاوه‌نگار ارسال را رد کند، مرحله
همچنان ثبت می‌شود ولی caller می‌تواند به کاربر بگوید که پیامک نرفت
(مثلا چون SENDER_LINE در تنظیمات سرور خالی است)."""
order = Order.objects.select_for_update().get(pk=order_id, user=user)
if order.is_completed:
raise ValueError("این سفارش قبلا به آخرین مرحله رسیده است.")
@@ -60,13 +91,17 @@ class OrderStageService:
contact = OrderStageService._upsert_contact(user, order)
rendered = OrderStageService._render(order, next_stage, extra_input_value)
message_sent = False
if rendered:
BulkMessageService.create_and_dispatch(
bulk = BulkMessageService.create_and_dispatch(
user=user,
channels=[Channel.SMS],
contact_ids=[contact.id],
body_override=rendered,
)
message_sent = bulk.recipients.filter(
channel=Channel.SMS, status=MessageRecipient.Status.SENT
).exists()
order.current_stage = next_stage
order.is_completed = next_stage.id == stages[-1].id
@@ -80,4 +115,4 @@ class OrderStageService:
message_body=rendered,
extra_input_value=extra_input_value,
)
return order
return order, message_sent
+32 -3
View File
@@ -5,7 +5,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from apps.billing.permissions import HasActiveFeature
from apps.orders.api.services import OrderStageService
from apps.orders.api.services import OrderStageService, generate_tracking_code
from apps.orders.models import Order, OrderStage, OrderStageLog
from .serializers import OrderSerializer, OrderStageLogSerializer, OrderStageSerializer
@@ -34,6 +34,10 @@ class OrderStageListCreateView(_UserScopedListCreateView):
model = OrderStage
serializer_class = OrderStageSerializer
def get_queryset(self):
OrderStageService.ensure_default_stage(self.request.user)
return super().get_queryset()
class OrderStageDetailView(_UserScopedDetailView):
model = OrderStage
@@ -44,6 +48,12 @@ class OrderListCreateView(_UserScopedListCreateView):
model = Order
serializer_class = OrderSerializer
def perform_create(self, serializer):
serializer.save(
user=self.request.user,
tracking_code=serializer.validated_data.get("tracking_code") or generate_tracking_code(),
)
class OrderDetailView(_UserScopedDetailView):
model = Order
@@ -62,12 +72,31 @@ class AdvanceOrderStageView(APIView):
raise NotFound("سفارش یافت نشد.")
extra_input_value = request.data.get("extra_input_value", "")
try:
order = OrderStageService.advance(request.user, pk, extra_input_value)
order, message_sent = OrderStageService.advance(request.user, pk, extra_input_value)
except Order.DoesNotExist:
raise NotFound("سفارش یافت نشد.")
except ValueError as exc:
raise ValidationError(str(exc))
return Response(OrderSerializer(order).data, status=status.HTTP_200_OK)
return Response(
{**OrderSerializer(order).data, "message_sent": message_sent},
status=status.HTTP_200_OK,
)
class GenerateOrderTrackingCodeView(APIView):
"""یک کدِ پیگیریِ تازه برای سفارش می‌سازد — برای وقتی هنوز کدِ واقعیِ
پستی را نداری ولی می‌خواهی از همین حالا یک مرجع به مشتری بدهی."""
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "orders"
def post(self, request, pk):
order = Order.objects.filter(pk=pk, user=request.user).first()
if order is None:
raise NotFound("سفارش یافت نشد.")
order.tracking_code = generate_tracking_code()
order.save(update_fields=["tracking_code", "updated_at"])
return Response(OrderSerializer(order).data)
class OrderStageLogListView(generics.ListAPIView):
+6
View File
@@ -2,6 +2,7 @@ from django.urls import path
from .api.views import (
AdvanceOrderStageView,
GenerateOrderTrackingCodeView,
OrderDetailView,
OrderListCreateView,
OrderStageDetailView,
@@ -16,5 +17,10 @@ urlpatterns = [
path("orders/", OrderListCreateView.as_view(), name="order-list"),
path("orders/<str:pk>/", OrderDetailView.as_view(), name="order-detail"),
path("orders/<str:pk>/advance/", AdvanceOrderStageView.as_view(), name="order-advance"),
path(
"orders/<str:pk>/generate-tracking-code/",
GenerateOrderTrackingCodeView.as_view(),
name="order-generate-tracking-code",
),
path("orders/<str:pk>/logs/", OrderStageLogListView.as_view(), name="order-logs"),
]