Merge branch 'claude/app-50-bugs-fixes-1531ef' into main

This commit is contained in:
2026-09-15 23:21:02 +03:30
32 changed files with 1036 additions and 77 deletions
+4
View File
@@ -39,6 +39,10 @@ CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
SMS_PATTERN_NAME= SMS_PATTERN_NAME=
# آدرسی که با POST، قالب+شماره+کد را برایش می‌فرستیم: # آدرسی که با POST، قالب+شماره+کد را برایش می‌فرستیم:
SMS_TOKEN_URL= SMS_TOKEN_URL=
# قالبِ پیامکِ «هدیه‌ی جم از پنل ادمین» — یک قالبِ جداگانه در کاوه‌نگار
# بساز که فقط یک token عددی (تعداد جمِ هدیه‌داده‌شده) دارد، مثلا:
# «{token} جم به حساب شما در پیامک‌بان اضافه شد.» و نام همان قالب را اینجا بگذار.
SMS_GIFT_PATTERN_NAME=
# ── ارسال گروهی چندکاناله (apps.messaging) ───────────── # ── ارسال گروهی چندکاناله (apps.messaging) ─────────────
# هرکدام خالی بماند، آن کانال فقط needs_manual برمی‌گرداند (صف deep-link # هرکدام خالی بماند، آن کانال فقط needs_manual برمی‌گرداند (صف deep-link
+30 -9
View File
@@ -14,20 +14,19 @@ logger = logging.getLogger(__name__)
REQUEST_TIMEOUT_SECONDS = 10 REQUEST_TIMEOUT_SECONDS = 10
def _call_token_url(phone: str, code: str) -> bool: def _call_lookup(phone: str, template: str, token: str, *, log_label: str = "OTP") -> bool:
""" """
ارسال OTP با استفاده از API کاوه‌نگار ارسال پیامکِ پترنی (lookup) با API کاوه‌نگار — همان endpoint برای OTP و
هر پترنِ تک‌token دیگری (مثلا هدیه‌ی جم) به کار می‌رود.
""" """
api_key = settings.KAVENEGAR_API_KEY api_key = settings.KAVENEGAR_API_KEY
template = settings.SMS_PATTERN_NAME
# ✅ لاگ برای دیباگ logger.info(f"📤 Sending {log_label} to {phone} with template: {template}")
logger.info(f"📤 Sending OTP to {phone} with template: {template}")
if not api_key or not template: if not api_key or not template:
logger.error("❌ KAVENEGAR_API_KEY یا SMS_PATTERN_NAME در .env تنظیم نشده است") logger.error(f"❌ KAVENEGAR_API_KEY یا نام قالبِ «{log_label}» در .env تنظیم نشده است")
logger.error(f" KAVENEGAR_API_KEY: {'✅' if api_key else '❌'}") logger.error(f" KAVENEGAR_API_KEY: {'✅' if api_key else '❌'}")
logger.error(f" SMS_PATTERN_NAME: {'✅' if template else '❌'}") logger.error(f" نام قالب: {'✅' if template else '❌'}")
return False return False
# ساخت آدرس کامل # ساخت آدرس کامل
@@ -36,7 +35,7 @@ def _call_token_url(phone: str, code: str) -> bool:
# پیلود مورد نیاز کاوه‌نگار # پیلود مورد نیاز کاوه‌نگار
payload = { payload = {
"receptor": phone, "receptor": phone,
"token": code, "token": token,
"template": template, "template": template,
} }
@@ -49,7 +48,7 @@ def _call_token_url(phone: str, code: str) -> bool:
if response.status_code == 200: if response.status_code == 200:
return_status = response_data.get("return", {}).get("status") return_status = response_data.get("return", {}).get("status")
if return_status == 200: if return_status == 200:
logger.info(f"✅ OTP sent to {phone} via Kavenegar") logger.info(f"✅ {log_label} sent to {phone} via Kavenegar")
return True return True
else: else:
error_msg = response_data.get("return", {}).get("message", "Unknown error") error_msg = response_data.get("return", {}).get("message", "Unknown error")
@@ -67,6 +66,10 @@ def _call_token_url(phone: str, code: str) -> bool:
return False return False
def _call_token_url(phone: str, code: str) -> bool:
return _call_lookup(phone, settings.SMS_PATTERN_NAME, code, log_label="OTP")
def send_otp_message(phone: str, code: str) -> None: def send_otp_message(phone: str, code: str) -> None:
"""ارسال غیربلاکینگ کد تایید""" """ارسال غیربلاکینگ کد تایید"""
@@ -76,4 +79,22 @@ def send_otp_message(phone: str, code: str) -> None:
except Exception as exc: except Exception as exc:
logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}") logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}")
Thread(target=_send, daemon=True).start()
def send_gift_gem_sms(phone: str, amount: int) -> None:
"""اطلاع‌رسانیِ غیربلاکینگِ «هدیه‌ی جم از پنل ادمین» — دقیقا هم‌الگوی
send_otp_message، فقط با قالبِ جداگانه‌ی SMS_GIFT_PATTERN_NAME و
token=تعدادِ جمِ هدیه‌داده‌شده. اگر آن قالب در .env تنظیم نشده باشد،
فقط لاگ می‌کند و هدیه‌دادنِ جم را (که قبلا در دیتابیس ثبت شده) بلاک
نمی‌کند."""
def _send():
try:
_call_lookup(
phone, settings.SMS_GIFT_PATTERN_NAME, str(amount), log_label="هدیه‌ی جم"
)
except Exception as exc:
logger.error(f"❌ خطای غیرمنتظره در ارسال پیامکِ هدیه‌ی جم: {exc}")
Thread(target=_send, daemon=True).start() Thread(target=_send, daemon=True).start()
+1 -1
View File
@@ -47,7 +47,7 @@ class ContactCrudSerializer(serializers.ModelSerializer):
class MessageTemplateCrudSerializer(serializers.ModelSerializer): class MessageTemplateCrudSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = MessageTemplate model = MessageTemplate
fields = ["id", "group", "name", "body", "mode"] fields = ["id", "group", "name", "body", "mode", "runtime_field_labels"]
def validate_group(self, group: ContactGroup | None) -> ContactGroup | None: def validate_group(self, group: ContactGroup | None) -> ContactGroup | None:
if group is None: if group is None:
+1 -1
View File
@@ -34,7 +34,7 @@ class ContactSerializer(serializers.ModelSerializer):
class MessageTemplateSerializer(serializers.ModelSerializer): class MessageTemplateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = MessageTemplate model = MessageTemplate
fields = ["id", "group_id", "name", "body", "mode"] fields = ["id", "group_id", "name", "body", "mode", "runtime_field_labels"]
extra_kwargs = _ID_NO_UNIQUE_CHECK extra_kwargs = _ID_NO_UNIQUE_CHECK
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-09-14 23:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('banker', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='messagetemplate',
name='runtime_field_labels',
field=models.JSONField(blank=True, default=list, verbose_name='فیلدهای زمان اجرا'),
),
]
+5
View File
@@ -100,6 +100,11 @@ class MessageTemplate(models.Model):
name = models.CharField(max_length=150, verbose_name=_("نام قالب")) name = models.CharField(max_length=150, verbose_name=_("نام قالب"))
body = models.TextField(verbose_name=_("متن قالب")) body = models.TextField(verbose_name=_("متن قالب"))
mode = models.CharField(max_length=10, choices=Mode.choices) mode = models.CharField(max_length=10, choices=Mode.choices)
# فیلدهای «زمان اجرا» — مقدارشان از دیتای مخاطب خوانده نمی‌شود، هر بار
# موقع ارسال (یا رسیدن سفارش به مرحله‌ای که این قالب رویش است) از کاربر
# پرسیده می‌شود. لیست است چون یک قالب می‌تواند چند فیلدِ این‌چنینی
# داشته باشد (مثلا هم «کد رهگیری» هم «مبلغ نهایی»).
runtime_field_labels = models.JSONField(default=list, blank=True, verbose_name=_("فیلدهای زمان اجرا"))
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
+2
View File
@@ -5,6 +5,7 @@ from django.urls import path, reverse
from django.utils.html import format_html from django.utils.html import format_html
from apps.account.models import User from apps.account.models import User
from apps.account.send_sms import send_gift_gem_sms
from .api.services import GemWalletService from .api.services import GemWalletService
from .forms import GemGiftForm from .forms import GemGiftForm
@@ -207,6 +208,7 @@ class GemTransactionAdmin(admin.ModelAdmin):
GemTransaction.Reason.ADMIN_ADJUSTMENT, GemTransaction.Reason.ADMIN_ADJUSTMENT,
note=note, note=note,
) )
send_gift_gem_sms(user.phone, amount)
if users: if users:
messages.success( messages.success(
+55 -7
View File
@@ -426,7 +426,40 @@ class ExcelExportService:
money_columns={4, 5, 6}, 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("انبار") ws = wb.create_sheet("انبار")
ExcelExportService._write( ExcelExportService._write(
ws, ws,
@@ -489,9 +522,14 @@ class LedgerBookService:
end_date: date | None = None, end_date: date | None = None,
carry_balance: bool = True, carry_balance: bool = True,
) -> "LedgerBook": ) -> "LedgerBook":
"""فایل تازه باز می‌کند: فایلِ جاریِ قبلی بسته و فقط-خواندنی می‌شود، """فایل تازه باز می‌کند و آن را فایلِ جاری می‌کند؛ اگر خواسته شود
و اگر خواسته شود مانده‌ی پایانیِ آن به‌عنوان مانده‌ی ابتدای فایلِ مانده‌ی پایانیِ فایلِ قبلی به‌عنوان مانده‌ی ابتدای فایلِ جدید منتقل
جدید منتقل می‌گردد (کارِ استانداردِ «انتقال مانده» سرِ سال مالی).""" می‌شود (کارِ استانداردِ «انتقال مانده» سرِ سال مالی).
برخلافِ قبل، فایلِ قبلی خودکار بایگانی (بسته) نمی‌شود — فقط از حالتِ
«جاری» خارج می‌شود و همچنان در فهرست و قابل‌ویرایش باقی می‌ماند؛
بایگانی‌کردنش یک اقدامِ جداگانه و دستی است (به LedgerBookService.
archive_book مراجعه کن)."""
from apps.bizapps.models import LedgerBook from apps.bizapps.models import LedgerBook
previous = LedgerBook.objects.select_for_update().filter(user=user, is_active=True).first() previous = LedgerBook.objects.select_for_update().filter(user=user, is_active=True).first()
@@ -500,9 +538,7 @@ class LedgerBookService:
if carry_balance: if carry_balance:
opening = LedgerBookService.closing_balance(previous) opening = LedgerBookService.closing_balance(previous)
previous.is_active = False previous.is_active = False
previous.is_closed = True previous.save(update_fields=["is_active", "updated_at"])
previous.closed_at = timezone.now()
previous.save(update_fields=["is_active", "is_closed", "closed_at", "updated_at"])
return LedgerBook.objects.create( return LedgerBook.objects.create(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
@@ -514,6 +550,18 @@ class LedgerBookService:
is_active=True, 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 @staticmethod
def closing_balance(book: "LedgerBook") -> float: 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) category_display = serializers.CharField(source="get_category_display", read_only=True)
account_name = serializers.CharField(source="account.name", read_only=True, default="") account_name = serializers.CharField(source="account.name", read_only=True, default="")
account_code = serializers.CharField(source="account.code", 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: class Meta:
model = LedgerEntry model = LedgerEntry
@@ -88,6 +91,8 @@ class LedgerEntrySerializer(serializers.ModelSerializer):
"id", "entry_type", "title", "amount", "category", "category_display", "id", "entry_type", "title", "amount", "category", "category_display",
"entry_date", "note", "total_amount", "counterparty_name", "counterparty_phone", "entry_date", "note", "total_amount", "counterparty_name", "counterparty_phone",
"linked_invoice", "account_name", "account_code", "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", "is_debt", "professional_type", "paid_amount", "remaining_amount",
"installments", "updated_at", "installments", "updated_at",
] ]
@@ -112,7 +117,8 @@ class ProductSerializer(serializers.ModelSerializer):
fields = [ fields = [
"id", "name", "sku", "category", "unit", "quantity", "id", "name", "sku", "category", "unit", "quantity",
"unit_price", "purchase_price", "total_purchase_value", "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"] 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", "id", "invoice_number", "invoice_date", "customer",
"buyer_name", "buyer_phone", "buyer_address", "buyer_name", "buyer_phone", "buyer_address",
"seller_name", "seller_phone", "seller_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_type", "payment_type_display",
"payment_status", "payment_status_display", "payment_status", "payment_status_display",
"order_status", "order_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_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_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="") 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) 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) tax_lines = serializers.ListField(child=serializers.DictField(), required=False, default=list)
payment_type = serializers.ChoiceField(choices=Invoice.PaymentType.choices, required=False) payment_type = serializers.ChoiceField(choices=Invoice.PaymentType.choices, required=False)
shipping_method = serializers.ChoiceField(choices=Invoice.ShippingMethod.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.account.models import User
from apps.banker.models import Contact from apps.banker.models import Contact
from apps.bizapps.models import ( from apps.bizapps.models import (
Account,
Customer, Customer,
Invoice, Invoice,
InvoiceItem, InvoiceItem,
@@ -26,6 +27,7 @@ from apps.bizapps.api.accounting import (
ACCOUNT_SALES, ACCOUNT_SALES,
ChartOfAccountsService, ChartOfAccountsService,
ExcelExportService, ExcelExportService,
SubsidiaryLedgerService,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -92,7 +94,9 @@ class InvoiceService:
seller_name=data.get("seller_name", ""), seller_name=data.get("seller_name", ""),
seller_phone=data.get("seller_phone", ""), seller_phone=data.get("seller_phone", ""),
seller_address=data.get("seller_address", ""), 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", []), tax_lines=data.get("tax_lines", []),
payment_type=payment_type, payment_type=payment_type,
shipping_method=data.get("shipping_method", Invoice.ShippingMethod.PICKUP), shipping_method=data.get("shipping_method", Invoice.ShippingMethod.PICKUP),
@@ -146,10 +150,27 @@ class InvoiceService:
note=f"فروش فاکتور {invoice_number}", 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) tax_amount = sum(subtotal * float(line.get("percent", 0)) / 100 for line in invoice.tax_lines)
invoice.total_amount = subtotal invoice.total_amount = subtotal
invoice.final_amount = subtotal - invoice.discount_amount + tax_amount invoice.final_amount = (
invoice.save(update_fields=["total_amount", "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") transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
is_credit = payment_type == Invoice.PaymentType.CREDIT is_credit = payment_type == Invoice.PaymentType.CREDIT
@@ -396,6 +417,70 @@ class LedgerService:
entry.account = account entry.account = account
entry.save(update_fields=["account", "updated_at"]) 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 @staticmethod
@transaction.atomic @transaction.atomic
def post_installment_transaction(user: User, installment: LedgerInstallment) -> None: def post_installment_transaction(user: User, installment: LedgerInstallment) -> None:
@@ -448,46 +533,48 @@ class LedgerService:
@staticmethod @staticmethod
def export_csv(user: User, start=None, end=None) -> str: def export_csv(user: User, start=None, end=None) -> str:
"""خروجیِ CSV ساده — برای وقتی کاربر فقط یک فایلِ سبکِ تک‌برگی """خروجیِ 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) شباهتی به دفتر معین نداشت.
"""
buf = io.StringIO() buf = io.StringIO()
buf.write("") # BOM buf.write("") # BOM
writer = csv.writer(buf) writer = csv.writer(buf)
writer.writerow( 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}", "", "", "", "", "", ""])
"پرداخت‌شده",
"مانده",
"یادداشت",
]
)
for e in qs:
writer.writerow( writer.writerow(
[ ["مانده‌ی ابتدای دوره", "", "", "", "", "", f"{statement['opening_balance']:.0f}"]
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,
]
) )
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() return buf.getvalue()
+44
View File
@@ -109,6 +109,7 @@ class LedgerEntryListCreateView(_OwnedMixin, generics.ListCreateAPIView):
book = LedgerBookService.ensure_active_book(self.request.user) book = LedgerBookService.ensure_active_book(self.request.user)
entry = serializer.save(user=self.request.user, book=book) entry = serializer.save(user=self.request.user, book=book)
LedgerService.sync_entry_account(self.request.user, entry) LedgerService.sync_entry_account(self.request.user, entry)
LedgerService.post_entry_transaction(self.request.user, entry)
if entry.counterparty_phone: if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, 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): def perform_update(self, serializer):
entry = serializer.save() entry = serializer.save()
LedgerService.sync_entry_account(self.request.user, entry) LedgerService.sync_entry_account(self.request.user, entry)
LedgerService.post_entry_transaction(self.request.user, entry)
if entry.counterparty_phone: if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, 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): class SubsidiaryLedgerView(APIView):
"""دفتر معین یک حساب — گردشِ حساب با مانده‌ی در جریان.""" """دفتر معین یک حساب — گردشِ حساب با مانده‌ی در جریان."""
@@ -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),
]
+48
View File
@@ -181,6 +181,9 @@ class Product(models.Model):
# «ارزش کل انبار» بدون پیمایشِ همه‌ی کالاها قابل‌جمع‌زدن باشد. # «ارزش کل انبار» بدون پیمایشِ همه‌ی کالاها قابل‌جمع‌زدن باشد.
total_purchase_value = models.FloatField(default=0, verbose_name=_("ارزش موجودی")) total_purchase_value = models.FloatField(default=0, verbose_name=_("ارزش موجودی"))
low_stock_threshold = models.FloatField(default=0, verbose_name=_("آستانه‌ی هشدار موجودی کم")) low_stock_threshold = models.FloatField(default=0, verbose_name=_("آستانه‌ی هشدار موجودی کم"))
is_active = models.BooleanField(default=True, verbose_name=_("فعال"))
production_date = models.DateField(null=True, blank=True, verbose_name=_("تاریخ تولید"))
expiry_date = models.DateField(null=True, blank=True, verbose_name=_("تاریخ انقضا"))
notes = models.TextField(blank=True, verbose_name=_("یادداشت")) notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -257,7 +260,20 @@ class Invoice(models.Model):
seller_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره فروشنده")) seller_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره فروشنده"))
seller_address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس فروشنده")) seller_address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس فروشنده"))
total_amount = models.FloatField(default=0, verbose_name=_("جمع کل")) total_amount = models.FloatField(default=0, verbose_name=_("جمع کل"))
# درصدِ تخفیفِ کلیِ فاکتور — منبعِ حقیقت همین درصد است (هم‌الگو با
# InvoiceItem.discount_percent)؛ discount_amount فقط cache شده تا در
# گزارش/اکسل بدون محاسبه‌ی دوباره قابل‌نمایش باشد.
discount_percent = models.FloatField(default=0, verbose_name=_("درصد تخفیف"))
discount_amount = models.FloatField(default=0, verbose_name=_("تخفیف")) discount_amount = models.FloatField(default=0, verbose_name=_("تخفیف"))
# تخفیفِ ویژه/دوره‌ای (مثلا «فروش فوق‌العاده») — جدا از تخفیفِ معمولیِ
# بالا تا هر دو در خروجی فاکتور با عنوانِ خودشان دیده شوند. یا با درصد
# (روی مبلغ بعد از تخفیف معمولی) یا با مبلغ ثابت پر می‌شود، نه هر دو.
extra_discount_label = models.CharField(
max_length=100, blank=True, verbose_name=_("عنوان تخفیف ویژه"),
help_text=_("مثلا: فروش فوق‌العاده، تخفیف دوره‌ای"),
)
extra_discount_percent = models.FloatField(default=0, verbose_name=_("درصد تخفیف ویژه"))
extra_discount_amount = models.FloatField(default=0, verbose_name=_("مبلغ تخفیف ویژه"))
# هر ردیف: {"title": "مالیات بر ارزش افزوده", "percent": 9} # هر ردیف: {"title": "مالیات بر ارزش افزوده", "percent": 9}
tax_lines = models.JSONField(default=list, blank=True, verbose_name=_("ردیف‌های مالیات")) tax_lines = models.JSONField(default=list, blank=True, verbose_name=_("ردیف‌های مالیات"))
final_amount = models.FloatField(default=0, verbose_name=_("مبلغ نهایی")) final_amount = models.FloatField(default=0, verbose_name=_("مبلغ نهایی"))
@@ -326,6 +342,12 @@ class Transaction(models.Model):
reference_invoice = models.ForeignKey( reference_invoice = models.ForeignKey(
Invoice, null=True, blank=True, on_delete=models.SET_NULL, related_name="transactions" Invoice, null=True, blank=True, on_delete=models.SET_NULL, related_name="transactions"
) )
# سندِ خودکارِ یک ردیفِ دفترِ ساده (غیرِ فاکتوری) — CASCADE عمدی است: اگر
# خودِ ردیف حذف شود، سندش هم باید حذف شود، وگرنه تراز حساب‌ها یک سندِ
# یتیم و اشتباه نگه می‌دارد.
reference_ledger_entry = models.ForeignKey(
"LedgerEntry", null=True, blank=True, on_delete=models.CASCADE, related_name="transactions"
)
transaction_type = models.CharField(max_length=20, choices=TransactionType.choices) transaction_type = models.CharField(max_length=20, choices=TransactionType.choices)
# کدِ حسابِ متنی برای سازگاری با داده‌ی قدیمی نگه داشته شده؛ debit/credit # کدِ حسابِ متنی برای سازگاری با داده‌ی قدیمی نگه داشته شده؛ debit/credit
# به‌صورتِ FK به Account همان چیزی است که گزارش‌های جدید از آن می‌خوانند. # به‌صورتِ FK به Account همان چیزی است که گزارش‌های جدید از آن می‌خوانند.
@@ -403,6 +425,14 @@ class LedgerEntry(models.Model):
) )
entry_type = models.CharField(max_length=10, choices=EntryType.choices, verbose_name=_("نوع")) entry_type = models.CharField(max_length=10, choices=EntryType.choices, verbose_name=_("نوع"))
title = models.CharField(max_length=150, verbose_name=_("عنوان")) title = models.CharField(max_length=150, verbose_name=_("عنوان"))
# amount همیشه مبلغِ نهاییِ ثبت‌شده است (همان چیزی که در گزارش/جمعِ
# درآمد-هزینه استفاده می‌شود) — سه فیلدِ زیر فقط یک ماشین‌حسابِ اختیاری
# برای رسیدن به آن هستند، تا کاربر مجبور نباشد خودش دستی حساب کند:
# تخفیف روی مبلغِ پایه (پیش از مالیات) اعمال می‌شود، بعد مالیات روی
# مبلغِ تخفیف‌خورده — یعنی دقیقا «تخفیف خارج از مالیات و ارزش‌افزوده».
base_amount = models.FloatField(default=0, blank=True, verbose_name=_("مبلغ پایه (پیش از تخفیف/مالیات)"))
discount_percent = models.FloatField(default=0, blank=True, verbose_name=_("درصد تخفیف (پیش از مالیات)"))
tax_percent = models.FloatField(default=0, blank=True, verbose_name=_("درصد مالیات/ارزش‌افزوده"))
amount = models.FloatField(verbose_name=_("مبلغ")) amount = models.FloatField(verbose_name=_("مبلغ"))
# max_length=100 (نه تنگ‌تر) عمداً — این فیلد قبلا آزاد بود و داده‌ی # max_length=100 (نه تنگ‌تر) عمداً — این فیلد قبلا آزاد بود و داده‌ی
# واقعیِ قدیمی با متنِ فارسیِ دلخواه دارد؛ تنگ‌کردنش موقعِ مهاجرت روی # واقعیِ قدیمی با متنِ فارسیِ دلخواه دارد؛ تنگ‌کردنش موقعِ مهاجرت روی
@@ -438,6 +468,24 @@ class LedgerEntry(models.Model):
def is_debt(self) -> bool: def is_debt(self) -> bool:
return self.category in self.DEBT_CATEGORIES return self.category in self.DEBT_CATEGORIES
@property
def discount_amount(self) -> float:
return (self.base_amount or 0) * (self.discount_percent or 0) / 100
@property
def net_before_tax(self) -> float:
return (self.base_amount or 0) - self.discount_amount
@property
def tax_amount(self) -> float:
return self.net_before_tax * (self.tax_percent or 0) / 100
@property
def computed_amount(self) -> float:
"""مبلغِ نهایی که از روی مبلغِ پایه، تخفیف و مالیات محاسبه می‌شود —
برای پیش‌نمایش سمتِ کلاینت؛ مقدارِ واقعا ذخیره‌شده همیشه amount است."""
return self.net_before_tax + self.tax_amount
@property @property
def professional_type(self) -> str: def professional_type(self) -> str:
"""ترجمه‌ی دسته‌ی «عادی» به اصطلاحِ حسابداریِ حرفه‌ای.""" """ترجمه‌ی دسته‌ی «عادی» به اصطلاحِ حسابداریِ حرفه‌ای."""
+43 -10
View File
@@ -177,19 +177,24 @@ class LedgerServiceTests(TestCase):
self.customer.refresh_from_db() self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 0) self.assertEqual(self.customer.outstanding_balance, 0)
def _create_and_post(self, **kwargs):
"""معادلِ دستیِ کاری که ویوی API انجام می‌دهد: بعد از ساختِ ردیف،
حساب را هماهنگ و سندِ دوطرفه‌اش را می‌زند — بدونِ این سند، ردیف در
هیچ گزارشِ حرفه‌ای (تراز/دفتر معین/CSV) دیده نمی‌شود."""
entry = LedgerEntry.objects.create(id=str(uuid.uuid4()), user=self.user, **kwargs)
LedgerService.sync_entry_account(self.user, entry)
LedgerService.post_entry_transaction(self.user, entry)
return entry
def test_export_csv_filters_by_date_range(self): def test_export_csv_filters_by_date_range(self):
LedgerEntry.objects.create( self._create_and_post(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME, entry_type=LedgerEntry.EntryType.INCOME,
title="داخل بازه", title="داخل بازه",
amount=100, amount=100,
category=LedgerEntry.Category.SALE_CASH, category=LedgerEntry.Category.SALE_CASH,
entry_date=date(2026, 1, 15), entry_date=date(2026, 1, 15),
) )
LedgerEntry.objects.create( self._create_and_post(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.EXPENSE, entry_type=LedgerEntry.EntryType.EXPENSE,
title="خارج از بازه", title="خارج از بازه",
amount=50, amount=50,
@@ -200,6 +205,31 @@ class LedgerServiceTests(TestCase):
self.assertIn("داخل بازه", csv_text) self.assertIn("داخل بازه", csv_text)
self.assertNotIn("خارج از بازه", csv_text) self.assertNotIn("خارج از بازه", csv_text)
def test_post_entry_transaction_covers_plain_income_and_expense(self):
"""قبلا فقط فاکتور/قسط سند می‌خورد؛ یک ثبتِ سادهِ «فروش نقدی» یا
«اجاره» (که کارِ روزمرهِ اکثرِ کاربرهاست) اصلا در ترازِ حساب‌ها
دیده نمی‌شد. این تست دقیقا همان رگرسیون را قفل می‌کند."""
self._create_and_post(
entry_type=LedgerEntry.EntryType.INCOME,
title="فروش نقدی امروز",
amount=200,
category=LedgerEntry.Category.SALE_CASH,
entry_date=date.today(),
)
self._create_and_post(
entry_type=LedgerEntry.EntryType.EXPENSE,
title="اجاره مغازه",
amount=80,
category=LedgerEntry.Category.RENT,
entry_date=date.today(),
)
rows = ChartOfAccountsService.trial_balance(self.user)
by_code = {r["code"]: r for r in rows}
self.assertIn(ACCOUNT_CASH, by_code)
# فروشِ نقدی صندوق را بدهکار می‌کند، اجاره بستانکار — مانده‌ی خالص
# صندوق باید ۲۰۰ منهای ۸۰ باشد.
self.assertEqual(by_code[ACCOUNT_CASH]["balance"], 120)
class InvoiceLedgerIntegrationTests(TestCase): class InvoiceLedgerIntegrationTests(TestCase):
"""اتصالِ فاکتور <-> دفتر: طبق درخواست، فقط فروشِ قسطی باید خودکار یک """اتصالِ فاکتور <-> دفتر: طبق درخواست، فقط فروشِ قسطی باید خودکار یک
@@ -553,7 +583,7 @@ class ExcelExportTests(TestCase):
wb = self._workbook() wb = self._workbook()
self.assertEqual( self.assertEqual(
wb.sheetnames, wb.sheetnames,
["خلاصه", "دفتر روزنامه", "تراز حساب‌ها", "بدهکاران", "فاکتورها", "انبار"], ["خلاصه", "دفتر روزنامه", "تراز حساب‌ها", "بدهکاران", "فاکتورها", "دفتر معین", "انبار"],
) )
def test_sheets_are_right_to_left(self): def test_sheets_are_right_to_left(self):
@@ -825,15 +855,18 @@ class LedgerBookTests(TestCase):
orphan.refresh_from_db() orphan.refresh_from_db()
self.assertEqual(orphan.book_id, book.id) self.assertEqual(orphan.book_id, book.id)
def test_opening_a_new_book_closes_the_previous_one(self): def test_opening_a_new_book_deactivates_but_does_not_archive_the_previous_one(self):
"""بایگانی‌کردن یک اقدامِ دستیِ جداگانه است (LedgerBookService.
archive_book) — باز کردنِ فایلِ تازه فقط فایلِ قبلی را از حالتِ
«جاری» خارج می‌کند، نه این‌که خودکار بایگانی‌اش کند."""
old = LedgerBookService.ensure_active_book(self.user) old = LedgerBookService.ensure_active_book(self.user)
new = LedgerBookService.open_new_book( new = LedgerBookService.open_new_book(
self.user, name="سال ۱۴۰۶", start_date=date(2027, 3, 21) self.user, name="سال ۱۴۰۶", start_date=date(2027, 3, 21)
) )
old.refresh_from_db() old.refresh_from_db()
self.assertFalse(old.is_active) self.assertFalse(old.is_active)
self.assertTrue(old.is_closed) self.assertFalse(old.is_closed)
self.assertIsNotNone(old.closed_at) self.assertIsNone(old.closed_at)
self.assertTrue(new.is_active) self.assertTrue(new.is_active)
# همیشه دقیقا یک فایل جاری # همیشه دقیقا یک فایل جاری
self.assertEqual(LedgerBook.objects.filter(user=self.user, is_active=True).count(), 1) self.assertEqual(LedgerBook.objects.filter(user=self.user, is_active=True).count(), 1)
+4
View File
@@ -14,6 +14,8 @@ from .api.views import (
InvoiceStatusView, InvoiceStatusView,
LedgerEntryDetailView, LedgerEntryDetailView,
LedgerEntryListCreateView, LedgerEntryListCreateView,
LedgerBookArchiveView,
LedgerBookDetailView,
LedgerBookListView, LedgerBookListView,
LedgerBookSummaryView, LedgerBookSummaryView,
LedgerExportView, LedgerExportView,
@@ -42,6 +44,8 @@ urlpatterns = [
path("ledger/books/", LedgerBookListView.as_view(), name="ledger-book-list"), path("ledger/books/", LedgerBookListView.as_view(), name="ledger-book-list"),
path("ledger/books/open/", OpenLedgerBookView.as_view(), name="ledger-book-open"), path("ledger/books/open/", OpenLedgerBookView.as_view(), name="ledger-book-open"),
path("ledger/books/<str:pk>/summary/", LedgerBookSummaryView.as_view(), name="ledger-book-summary"), path("ledger/books/<str:pk>/summary/", LedgerBookSummaryView.as_view(), name="ledger-book-summary"),
path("ledger/books/<str:pk>/archive/", LedgerBookArchiveView.as_view(), name="ledger-book-archive"),
path("ledger/books/<str:pk>/", LedgerBookDetailView.as_view(), name="ledger-book-detail"),
path("ledger/export/", LedgerExportView.as_view(), name="ledger-export"), path("ledger/export/", LedgerExportView.as_view(), name="ledger-export"),
path("ledger/", LedgerEntryListCreateView.as_view(), name="ledger-list"), path("ledger/", LedgerEntryListCreateView.as_view(), name="ledger-list"),
path( path(
+39 -4
View File
@@ -1,4 +1,6 @@
import random
import re import re
import string
import uuid import uuid
from django.db import transaction from django.db import transaction
@@ -6,13 +8,37 @@ from django.db import transaction
from apps.account.models import User from apps.account.models import User
from apps.banker.models import Contact from apps.banker.models import Contact
from apps.messaging.api.services import BulkMessageService 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 from apps.orders.models import Order, OrderStage, OrderStageLog
_FIELD_PATTERN = re.compile(r"\{(.*?)\}") _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: 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 @staticmethod
def _render(order: Order, stage: OrderStage, extra_input_value: str = "") -> str: def _render(order: Order, stage: OrderStage, extra_input_value: str = "") -> str:
body = stage.template.body if stage.template else "" body = stage.template.body if stage.template else ""
@@ -36,7 +62,12 @@ class OrderStageService:
@staticmethod @staticmethod
@transaction.atomic @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) order = Order.objects.select_for_update().get(pk=order_id, user=user)
if order.is_completed: if order.is_completed:
raise ValueError("این سفارش قبلا به آخرین مرحله رسیده است.") raise ValueError("این سفارش قبلا به آخرین مرحله رسیده است.")
@@ -60,13 +91,17 @@ class OrderStageService:
contact = OrderStageService._upsert_contact(user, order) contact = OrderStageService._upsert_contact(user, order)
rendered = OrderStageService._render(order, next_stage, extra_input_value) rendered = OrderStageService._render(order, next_stage, extra_input_value)
message_sent = False
if rendered: if rendered:
BulkMessageService.create_and_dispatch( bulk = BulkMessageService.create_and_dispatch(
user=user, user=user,
channels=[Channel.SMS], channels=[Channel.SMS],
contact_ids=[contact.id], contact_ids=[contact.id],
body_override=rendered, body_override=rendered,
) )
message_sent = bulk.recipients.filter(
channel=Channel.SMS, status=MessageRecipient.Status.SENT
).exists()
order.current_stage = next_stage order.current_stage = next_stage
order.is_completed = next_stage.id == stages[-1].id order.is_completed = next_stage.id == stages[-1].id
@@ -80,4 +115,4 @@ class OrderStageService:
message_body=rendered, message_body=rendered,
extra_input_value=extra_input_value, 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 rest_framework.views import APIView
from apps.billing.permissions import HasActiveFeature 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 apps.orders.models import Order, OrderStage, OrderStageLog
from .serializers import OrderSerializer, OrderStageLogSerializer, OrderStageSerializer from .serializers import OrderSerializer, OrderStageLogSerializer, OrderStageSerializer
@@ -34,6 +34,10 @@ class OrderStageListCreateView(_UserScopedListCreateView):
model = OrderStage model = OrderStage
serializer_class = OrderStageSerializer serializer_class = OrderStageSerializer
def get_queryset(self):
OrderStageService.ensure_default_stage(self.request.user)
return super().get_queryset()
class OrderStageDetailView(_UserScopedDetailView): class OrderStageDetailView(_UserScopedDetailView):
model = OrderStage model = OrderStage
@@ -44,6 +48,12 @@ class OrderListCreateView(_UserScopedListCreateView):
model = Order model = Order
serializer_class = OrderSerializer 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): class OrderDetailView(_UserScopedDetailView):
model = Order model = Order
@@ -62,12 +72,31 @@ class AdvanceOrderStageView(APIView):
raise NotFound("سفارش یافت نشد.") raise NotFound("سفارش یافت نشد.")
extra_input_value = request.data.get("extra_input_value", "") extra_input_value = request.data.get("extra_input_value", "")
try: try:
order = OrderStageService.advance(request.user, pk, extra_input_value) order, message_sent = OrderStageService.advance(request.user, pk, extra_input_value)
except Order.DoesNotExist: except Order.DoesNotExist:
raise NotFound("سفارش یافت نشد.") raise NotFound("سفارش یافت نشد.")
except ValueError as exc: except ValueError as exc:
raise ValidationError(str(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): class OrderStageLogListView(generics.ListAPIView):
+6
View File
@@ -2,6 +2,7 @@ from django.urls import path
from .api.views import ( from .api.views import (
AdvanceOrderStageView, AdvanceOrderStageView,
GenerateOrderTrackingCodeView,
OrderDetailView, OrderDetailView,
OrderListCreateView, OrderListCreateView,
OrderStageDetailView, OrderStageDetailView,
@@ -16,5 +17,10 @@ urlpatterns = [
path("orders/", OrderListCreateView.as_view(), name="order-list"), path("orders/", OrderListCreateView.as_view(), name="order-list"),
path("orders/<str:pk>/", OrderDetailView.as_view(), name="order-detail"), 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>/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"), path("orders/<str:pk>/logs/", OrderStageLogListView.as_view(), name="order-logs"),
] ]
View File
+46
View File
@@ -0,0 +1,46 @@
from django.contrib import admin
from django.db.models import Count
from .models import Review, ReviewLike
class ReviewLikeInline(admin.TabularInline):
model = ReviewLike
extra = 0
readonly_fields = ("user", "created_at")
can_delete = False
def has_add_permission(self, request, obj=None):
return False
@admin.register(Review)
class ReviewAdmin(admin.ModelAdmin):
list_display = ("user", "body_preview", "like_count", "is_approved", "created_at")
list_filter = ("is_approved", "created_at")
list_editable = ("is_approved",)
search_fields = ("user__phone", "user__name", "body")
readonly_fields = ("user", "created_at", "updated_at")
inlines = [ReviewLikeInline]
actions = ["approve_reviews", "reject_reviews"]
def get_queryset(self, request):
return super().get_queryset(request).annotate(_like_count=Count("likes"))
@admin.display(description="متن نظر")
def body_preview(self, obj: Review) -> str:
return obj.body if len(obj.body) <= 60 else f"{obj.body[:60]}…"
@admin.display(description="تعداد لایک", ordering="_like_count")
def like_count(self, obj: Review) -> int:
return obj._like_count
@admin.action(description="تاییدِ نظرهای انتخاب‌شده")
def approve_reviews(self, request, queryset):
updated = queryset.update(is_approved=True)
self.message_user(request, f"{updated} نظر تایید شد.")
@admin.action(description="رد کردنِ نظرهای انتخاب‌شده")
def reject_reviews(self, request, queryset):
updated = queryset.update(is_approved=False)
self.message_user(request, f"{updated} نظر رد شد.")
View File
+53
View File
@@ -0,0 +1,53 @@
from rest_framework import serializers
from apps.reviews.models import Review
def _display_name(user) -> str:
if user.name:
return user.name
# حریمِ خصوصی: برای کاربرِ بی‌نام، فقط ۴ رقمِ آخرِ شماره نشان داده می‌شود.
return f"کاربر ...{user.phone[-4:]}"
class ReviewSerializer(serializers.ModelSerializer):
user_display = serializers.SerializerMethodField()
like_count = serializers.IntegerField(read_only=True)
liked_by_me = serializers.SerializerMethodField()
is_mine = serializers.SerializerMethodField()
class Meta:
model = Review
fields = [
"id", "user_display", "body", "is_approved",
"like_count", "liked_by_me", "is_mine", "created_at", "updated_at",
]
read_only_fields = ["is_approved", "created_at", "updated_at"]
def get_user_display(self, obj: Review) -> str:
return _display_name(obj.user)
def get_liked_by_me(self, obj: Review) -> bool:
request = self.context.get("request")
if request is None or not request.user.is_authenticated:
return False
# get_liked_ids (اگر در context باشد) از یک کوئریِ batch پر می‌شود تا
# برای فهرست، به‌ازای هر نظر یک کوئریِ جدا نزنیم.
liked_ids = self.context.get("liked_ids")
if liked_ids is not None:
return obj.id in liked_ids
return obj.likes.filter(user=request.user).exists()
def get_is_mine(self, obj: Review) -> bool:
request = self.context.get("request")
return bool(request and request.user.is_authenticated and obj.user_id == request.user.id)
class ReviewWriteSerializer(serializers.Serializer):
body = serializers.CharField(max_length=1000)
def validate_body(self, value: str) -> str:
value = value.strip()
if not value:
raise serializers.ValidationError("متنِ نظر نمی‌تواند خالی باشد.")
return value
+88
View File
@@ -0,0 +1,88 @@
from django.db import IntegrityError
from django.db.models import Count
from rest_framework import status
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.reviews.models import Review, ReviewLike
from .serializers import ReviewSerializer, ReviewWriteSerializer
class ReviewListView(APIView):
"""فهرستِ عمومیِ نظراتِ تاییدشده — پرلایک‌ترین‌ها اول."""
permission_classes = [IsAuthenticated]
def get(self, request):
reviews = (
Review.objects.filter(is_approved=True)
.select_related("user")
.annotate(like_count=Count("likes"))
.order_by("-like_count", "-created_at")
)
liked_ids = set(
ReviewLike.objects.filter(user=request.user, review__in=reviews).values_list(
"review_id", flat=True
)
)
serializer = ReviewSerializer(
reviews, many=True, context={"request": request, "liked_ids": liked_ids}
)
return Response(serializer.data)
class MyReviewView(APIView):
"""نظرِ خودِ کاربر — صرف‌نظر از تایید یا نبودنِ آن. یک کاربر همیشه دقیقا
صفر یا یک نظر دارد؛ نوشتنِ دوباره همان نظر را جایگزین و تاییدش را
ریست می‌کند."""
permission_classes = [IsAuthenticated]
def get(self, request):
review = Review.objects.filter(user=request.user).first()
if review is None:
return Response(None)
return Response(ReviewSerializer(review, context={"request": request}).data)
def patch(self, request):
serializer = ReviewWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
review, _created = Review.objects.update_or_create(
user=request.user,
defaults={
"body": serializer.validated_data["body"],
"is_approved": False,
},
)
return Response(
ReviewSerializer(review, context={"request": request}).data,
status=status.HTTP_200_OK,
)
def delete(self, request):
Review.objects.filter(user=request.user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ReviewLikeToggleView(APIView):
"""لایک/آن‌لایکِ یک نظرِ تاییدشده — لایکِ نظرِ تاییدنشده‌ی خودِ کسی معنی
ندارد، چون در فهرستِ عمومی اصلا دیده نمی‌شود."""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
review = Review.objects.filter(pk=pk, is_approved=True).first()
if review is None:
raise NotFound("نظر یافت نشد.")
deleted, _ = ReviewLike.objects.filter(review=review, user=request.user).delete()
if not deleted:
try:
ReviewLike.objects.create(review=review, user=request.user)
except IntegrityError:
pass # درخواستِ هم‌زمانِ دوم — بی‌ضرر، نتیجه هرحال «لایک‌شده» است.
like_count = review.likes.count()
liked_by_me = review.likes.filter(user=request.user).exists()
return Response({"like_count": like_count, "liked_by_me": liked_by_me})
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ReviewsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.reviews"
verbose_name = "نظرات کاربران"
+47
View File
@@ -0,0 +1,47 @@
# Generated by Django 5.1.15 on 2026-09-15 00:47
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Review',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField(max_length=1000, verbose_name='متن نظر')),
('is_approved', models.BooleanField(default=False, verbose_name='تاییدشده')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'نظر',
'verbose_name_plural': 'نظرات',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ReviewLike',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='reviews.review')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='review_likes', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'لایک نظر',
'verbose_name_plural': 'لایک\u200cهای نظر',
'constraints': [models.UniqueConstraint(fields=('review', 'user'), name='unique_review_like')],
},
),
]
View File
+50
View File
@@ -0,0 +1,50 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# «نظرات» — یک دیوارِ بازخوردِ ساده، عمداً بدونِ گفت‌وگو: هر کاربر فقط یک
# نظر دارد (نوشتنِ دوباره همان نظر را جایگزین می‌کند، نه یک ردیفِ تازه)،
# نظرات فقط لایک می‌خورند (نه ریپلای)، و فقط بعد از تاییدِ ادمین در فهرستِ
# عمومی دیده می‌شوند — نوشتن/ویرایشِ دوباره وضعیتِ تایید را برمی‌گرداند به
# «در انتظار» تا کسی نتواند یک نظرِ تاییدشده را بی‌بازبینی به چیزِ دیگری
# تغییر بدهد.
class Review(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="review"
)
body = models.TextField(max_length=1000, verbose_name=_("متن نظر"))
is_approved = models.BooleanField(default=False, verbose_name=_("تاییدشده"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("نظر")
verbose_name_plural = _("نظرات")
ordering = ["-created_at"]
def __str__(self):
return f"{self.user.phone} — {self.body[:30]}"
@property
def like_count(self) -> int:
return self.likes.count()
class ReviewLike(models.Model):
review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name="likes")
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="review_likes"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("لایک نظر")
verbose_name_plural = _("لایک‌های نظر")
constraints = [
models.UniqueConstraint(fields=["review", "user"], name="unique_review_like")
]
def __str__(self):
return f"{self.user.phone} ← {self.review_id}"
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from .api.views import MyReviewView, ReviewLikeToggleView, ReviewListView
app_name = "reviews"
urlpatterns = [
path("", ReviewListView.as_view(), name="list"),
path("mine/", MyReviewView.as_view(), name="mine"),
path("<int:pk>/like/", ReviewLikeToggleView.as_view(), name="like"),
]
+5
View File
@@ -58,6 +58,7 @@ LOCAL_APPS = [
"apps.landing", "apps.landing",
"apps.orders", "apps.orders",
"apps.telemetry", "apps.telemetry",
"apps.reviews",
] ]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
@@ -204,6 +205,10 @@ SIMPLE_JWT = {
# ============================================ # ============================================
KAVENEGAR_API_KEY = config("KAVENEGAR_API_KEY", default="") KAVENEGAR_API_KEY = config("KAVENEGAR_API_KEY", default="")
SMS_PATTERN_NAME = config("SMS_PATTERN_NAME", default="shopshipotp") SMS_PATTERN_NAME = config("SMS_PATTERN_NAME", default="shopshipotp")
# قالبِ پیامکِ «هدیه‌ی جم از پنل ادمین» — باید جدا از SMS_PATTERN_NAME در
# کاوه‌نگار ثبت شود (لوکاپ/پترن فقط با یک token عدد کار می‌کند، همان
# تعداد جمِ هدیه‌داده‌شده).
SMS_GIFT_PATTERN_NAME = config("SMS_GIFT_PATTERN_NAME", default="")
# برای سازگاری با کدهای قدیمی (استفاده نمی‌شود) # برای سازگاری با کدهای قدیمی (استفاده نمی‌شود)
SMS_TOKEN_URL = config("SMS_TOKEN_URL", default="") SMS_TOKEN_URL = config("SMS_TOKEN_URL", default="")
+1
View File
@@ -13,6 +13,7 @@ urlpatterns = [
path("api/notifications/", include("apps.notifications.urls")), path("api/notifications/", include("apps.notifications.urls")),
path("api/orders/", include("apps.orders.urls")), path("api/orders/", include("apps.orders.urls")),
path("api/telemetry/", include("apps.telemetry.urls")), path("api/telemetry/", include("apps.telemetry.urls")),
path("api/reviews/", include("apps.reviews.urls")),
# فایل‌های استاتیک قبلی landing/articles/*.html حذف شده‌اند — چون # فایل‌های استاتیک قبلی landing/articles/*.html حذف شده‌اند — چون
# WhiteNoise میدل‌ور همیشه قبل از urls.py بررسی می‌شود و اگر آن فایل‌ها # WhiteNoise میدل‌ور همیشه قبل از urls.py بررسی می‌شود و اگر آن فایل‌ها
# هنوز وجود داشتند، هیچ‌وقت به این view دینامیک نمی‌رسیدیم. # هنوز وجود داشتند، هیچ‌وقت به این view دینامیک نمی‌رسیدیم.