Merge branch 'claude/app-50-bugs-fixes-1531ef' into main
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
"""ارسال غیربلاکینگ کد تایید"""
|
"""ارسال غیربلاکینگ کد تایید"""
|
||||||
|
|
||||||
@@ -77,3 +80,21 @@ def send_otp_message(phone: str, code: str) -> None:
|
|||||||
logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}")
|
logger.error(f"❌ خطای غیرمنتظره در ارسال OTP: {exc}")
|
||||||
|
|
||||||
Thread(target=_send, daemon=True).start()
|
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()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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='فیلدهای زمان اجرا'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
"""ماندهی پایانیِ یک فایل = ماندهی ابتدا + درآمدها − هزینهها."""
|
"""ماندهی پایانیِ یک فایل = ماندهی ابتدا + درآمدها − هزینهها."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+123
-36
@@ -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(["تاریخ", "شماره سند", "نوع", "شرح", "بدهکار", "بستانکار", "مانده"])
|
||||||
|
|
||||||
|
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(
|
||||||
|
["ماندهی ابتدای دوره", "", "", "", "", "", f"{statement['opening_balance']:.0f}"]
|
||||||
|
)
|
||||||
|
for row in statement["rows"]:
|
||||||
|
row_date = row["date"]
|
||||||
writer.writerow(
|
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}",
|
||||||
"مبلغ کل تعهد",
|
|
||||||
"پرداختشده",
|
|
||||||
"مانده",
|
|
||||||
"یادداشت",
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
for e in qs:
|
|
||||||
writer.writerow(
|
writer.writerow(
|
||||||
[
|
["ماندهی پایانِ دوره", "", "", "", "", "", f"{statement['closing_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,
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
writer.writerow([])
|
||||||
|
|
||||||
|
if not wrote_any_row:
|
||||||
|
writer.writerow(["در این بازه هیچ حسابی گردش نداشته.", "", "", "", "", "", ""])
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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} نظر رد شد.")
|
||||||
@@ -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
|
||||||
@@ -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})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.reviews"
|
||||||
|
verbose_name = "نظرات کاربران"
|
||||||
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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="")
|
||||||
|
|||||||
@@ -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 دینامیک نمیرسیدیم.
|
||||||
|
|||||||
Reference in New Issue
Block a user