diff --git a/.env.example b/.env.example index d75cfe4..2144e93 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,10 @@ CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com SMS_PATTERN_NAME= # آدرسی که با POST، قالب+شماره+کد را برایش می‌فرستیم: SMS_TOKEN_URL= +# قالبِ پیامکِ «هدیه‌ی جم از پنل ادمین» — یک قالبِ جداگانه در کاوه‌نگار +# بساز که فقط یک token عددی (تعداد جمِ هدیه‌داده‌شده) دارد، مثلا: +# «{token} جم به حساب شما در پیامک‌بان اضافه شد.» و نام همان قالب را اینجا بگذار. +SMS_GIFT_PATTERN_NAME= # ── ارسال گروهی چندکاناله (apps.messaging) ───────────── # هرکدام خالی بماند، آن کانال فقط needs_manual برمی‌گرداند (صف deep-link diff --git a/apps/account/send_sms.py b/apps/account/send_sms.py index 4bf08fa..a198ba7 100644 --- a/apps/account/send_sms.py +++ b/apps/account/send_sms.py @@ -14,20 +14,19 @@ logger = logging.getLogger(__name__) 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 - template = settings.SMS_PATTERN_NAME - # ✅ لاگ برای دیباگ - logger.info(f"📤 Sending OTP to {phone} with template: {template}") + logger.info(f"📤 Sending {log_label} to {phone} with template: {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" SMS_PATTERN_NAME: {'✅' if template else '❌'}") + logger.error(f" نام قالب: {'✅' if template else '❌'}") return False # ساخت آدرس کامل @@ -36,7 +35,7 @@ def _call_token_url(phone: str, code: str) -> bool: # پیلود مورد نیاز کاوه‌نگار payload = { "receptor": phone, - "token": code, + "token": token, "template": template, } @@ -49,7 +48,7 @@ def _call_token_url(phone: str, code: str) -> bool: if response.status_code == 200: return_status = response_data.get("return", {}).get("status") 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 else: 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 +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: """ارسال غیربلاکینگ کد تایید""" @@ -76,4 +79,22 @@ def send_otp_message(phone: str, code: str) -> None: except Exception as 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() \ No newline at end of file diff --git a/apps/banker/api/crud_serializers.py b/apps/banker/api/crud_serializers.py index b750fa2..61ab8cd 100644 --- a/apps/banker/api/crud_serializers.py +++ b/apps/banker/api/crud_serializers.py @@ -47,7 +47,7 @@ class ContactCrudSerializer(serializers.ModelSerializer): class MessageTemplateCrudSerializer(serializers.ModelSerializer): class Meta: 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: if group is None: diff --git a/apps/banker/api/serializers.py b/apps/banker/api/serializers.py index 5a6e2e5..5f1f9b7 100644 --- a/apps/banker/api/serializers.py +++ b/apps/banker/api/serializers.py @@ -34,7 +34,7 @@ class ContactSerializer(serializers.ModelSerializer): class MessageTemplateSerializer(serializers.ModelSerializer): class Meta: 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 diff --git a/apps/banker/migrations/0002_messagetemplate_runtime_field_labels.py b/apps/banker/migrations/0002_messagetemplate_runtime_field_labels.py new file mode 100644 index 0000000..44d4d55 --- /dev/null +++ b/apps/banker/migrations/0002_messagetemplate_runtime_field_labels.py @@ -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='فیلدهای زمان اجرا'), + ), + ] diff --git a/apps/banker/models.py b/apps/banker/models.py index 008baf6..9ed186c 100644 --- a/apps/banker/models.py +++ b/apps/banker/models.py @@ -100,6 +100,11 @@ class MessageTemplate(models.Model): name = models.CharField(max_length=150, verbose_name=_("نام قالب")) body = models.TextField(verbose_name=_("متن قالب")) 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) class Meta: diff --git a/apps/billing/admin.py b/apps/billing/admin.py index 6d10f96..0e38177 100644 --- a/apps/billing/admin.py +++ b/apps/billing/admin.py @@ -5,6 +5,7 @@ from django.urls import path, reverse from django.utils.html import format_html from apps.account.models import User +from apps.account.send_sms import send_gift_gem_sms from .api.services import GemWalletService from .forms import GemGiftForm @@ -207,6 +208,7 @@ class GemTransactionAdmin(admin.ModelAdmin): GemTransaction.Reason.ADMIN_ADJUSTMENT, note=note, ) + send_gift_gem_sms(user.phone, amount) if users: messages.success( diff --git a/apps/bizapps/api/accounting.py b/apps/bizapps/api/accounting.py index b258d69..790eb2b 100644 --- a/apps/bizapps/api/accounting.py +++ b/apps/bizapps/api/accounting.py @@ -426,7 +426,40 @@ class ExcelExportService: money_columns={4, 5, 6}, ) - # --- برگه ۶: انبار --- + # --- برگه ۶: دفتر معین --- + # همان چیزی که «معین» یعنی: به‌ازای هر حسابِ معینِ دارای گردش، تکِ + # تکِ اسناد به‌ترتیبِ تاریخ با مانده‌ی در جریان — دقیقا فرمتی که + # حسابدار وقتی می‌گوید «دفتر معین رو بفرست» انتظارش را دارد. + ws = wb.create_sheet("دفتر معین") + subsidiary_rows: list[list] = [] + for account in Account.objects.filter(user=user, level=Account.Level.MOIN).order_by("code"): + statement = SubsidiaryLedgerService.statement(user, account, start, end) + if not statement["rows"]: + continue + subsidiary_rows.append([f"{account.code} — {account.name}", "", "", "", "", "", ""]) + subsidiary_rows.append(["مانده‌ی ابتدای دوره", "", "", "", "", "", statement["opening_balance"]]) + for row in statement["rows"]: + subsidiary_rows.append( + [ + row["date"].date().isoformat() if hasattr(row["date"], "date") else row["date"], + row["number"], + row["type"], + row["description"], + row["debit"], + row["credit"], + row["balance"], + ] + ) + subsidiary_rows.append(["مانده‌ی پایانِ دوره", "", "", "", "", "", statement["closing_balance"]]) + subsidiary_rows.append([]) + ExcelExportService._write( + ws, + ["تاریخ", "شماره سند", "نوع", "شرح", "بدهکار", "بستانکار", "مانده"], + subsidiary_rows, + money_columns={4, 5, 6}, + ) + + # --- برگه ۷: انبار --- ws = wb.create_sheet("انبار") ExcelExportService._write( ws, @@ -489,9 +522,14 @@ class LedgerBookService: end_date: date | None = None, carry_balance: bool = True, ) -> "LedgerBook": - """فایل تازه باز می‌کند: فایلِ جاریِ قبلی بسته و فقط-خواندنی می‌شود، - و اگر خواسته شود مانده‌ی پایانیِ آن به‌عنوان مانده‌ی ابتدای فایلِ - جدید منتقل می‌گردد (کارِ استانداردِ «انتقال مانده» سرِ سال مالی).""" + """فایل تازه باز می‌کند و آن را فایلِ جاری می‌کند؛ اگر خواسته شود + مانده‌ی پایانیِ فایلِ قبلی به‌عنوان مانده‌ی ابتدای فایلِ جدید منتقل + می‌شود (کارِ استانداردِ «انتقال مانده» سرِ سال مالی). + + برخلافِ قبل، فایلِ قبلی خودکار بایگانی (بسته) نمی‌شود — فقط از حالتِ + «جاری» خارج می‌شود و همچنان در فهرست و قابل‌ویرایش باقی می‌ماند؛ + بایگانی‌کردنش یک اقدامِ جداگانه و دستی است (به LedgerBookService. + archive_book مراجعه کن).""" from apps.bizapps.models import LedgerBook previous = LedgerBook.objects.select_for_update().filter(user=user, is_active=True).first() @@ -500,9 +538,7 @@ class LedgerBookService: if carry_balance: opening = LedgerBookService.closing_balance(previous) previous.is_active = False - previous.is_closed = True - previous.closed_at = timezone.now() - previous.save(update_fields=["is_active", "is_closed", "closed_at", "updated_at"]) + previous.save(update_fields=["is_active", "updated_at"]) return LedgerBook.objects.create( id=str(uuid.uuid4()), @@ -514,6 +550,18 @@ class LedgerBookService: is_active=True, ) + @staticmethod + def archive_book(book: "LedgerBook") -> "LedgerBook": + """بایگانی‌کردنِ دستیِ یک فایل — از این پس فقط-خواندنی می‌شود. + فایلِ جاری هم قابل‌بایگانی است (کاربر آگاهانه انتخاب کرده)، فقط بعد + از آن یک فایلِ جاریِ تازه لازم است (اولین فراخوانیِ بعدیِ + ensure_active_book خودش می‌سازد).""" + book.is_active = False + book.is_closed = True + book.closed_at = timezone.now() + book.save(update_fields=["is_active", "is_closed", "closed_at", "updated_at"]) + return book + @staticmethod def closing_balance(book: "LedgerBook") -> float: """مانده‌ی پایانیِ یک فایل = مانده‌ی ابتدا + درآمدها − هزینه‌ها.""" diff --git a/apps/bizapps/api/serializers.py b/apps/bizapps/api/serializers.py index a95e0e1..5331bcb 100644 --- a/apps/bizapps/api/serializers.py +++ b/apps/bizapps/api/serializers.py @@ -81,6 +81,9 @@ class LedgerEntrySerializer(serializers.ModelSerializer): category_display = serializers.CharField(source="get_category_display", read_only=True) account_name = serializers.CharField(source="account.name", read_only=True, default="") account_code = serializers.CharField(source="account.code", read_only=True, default="") + discount_amount = serializers.FloatField(read_only=True) + tax_amount = serializers.FloatField(read_only=True) + computed_amount = serializers.FloatField(read_only=True) class Meta: model = LedgerEntry @@ -88,6 +91,8 @@ class LedgerEntrySerializer(serializers.ModelSerializer): "id", "entry_type", "title", "amount", "category", "category_display", "entry_date", "note", "total_amount", "counterparty_name", "counterparty_phone", "linked_invoice", "account_name", "account_code", + "base_amount", "discount_percent", "tax_percent", + "discount_amount", "tax_amount", "computed_amount", "is_debt", "professional_type", "paid_amount", "remaining_amount", "installments", "updated_at", ] @@ -112,7 +117,8 @@ class ProductSerializer(serializers.ModelSerializer): fields = [ "id", "name", "sku", "category", "unit", "quantity", "unit_price", "purchase_price", "total_purchase_value", - "low_stock_threshold", "is_low_stock", "notes", "updated_at", + "low_stock_threshold", "is_low_stock", "is_active", + "production_date", "expiry_date", "notes", "updated_at", ] read_only_fields = ["total_purchase_value", "is_low_stock", "updated_at"] @@ -153,7 +159,9 @@ class InvoiceSerializer(serializers.ModelSerializer): "id", "invoice_number", "invoice_date", "customer", "buyer_name", "buyer_phone", "buyer_address", "seller_name", "seller_phone", "seller_address", - "total_amount", "discount_amount", "tax_lines", "final_amount", + "total_amount", "discount_percent", "discount_amount", + "extra_discount_label", "extra_discount_percent", "extra_discount_amount", + "tax_lines", "final_amount", "payment_type", "payment_type_display", "payment_status", "payment_status_display", "order_status", "order_status_display", @@ -188,7 +196,17 @@ class CreateInvoiceSerializer(serializers.Serializer): seller_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="") seller_phone = serializers.CharField(max_length=20, required=False, allow_blank=True, default="") seller_address = serializers.CharField(max_length=255, required=False, allow_blank=True, default="") + # درصد اولویت دارد: اگر discount_percent مثبت باشد، discount_amount از + # روی همان محاسبه می‌شود (هم‌الگو با تخفیفِ درصدیِ هر قلم فاکتور)؛ + # discount_amount خامْ فقط برای سازگاری با کلاینت‌های قدیمی نگه داشته + # شده — اگر درصد صفر باشد همان مقدار مستقیم استفاده می‌شود. + discount_percent = serializers.FloatField(required=False, default=0, min_value=0, max_value=100) discount_amount = serializers.FloatField(required=False, default=0, min_value=0) + # تخفیفِ ویژه/دوره‌ای — جدا از تخفیفِ بالا، روی مبلغِ بعد از آن اعمال + # می‌شود. یا با درصد یا با مبلغِ ثابت پر می‌شود. + extra_discount_label = serializers.CharField(max_length=100, required=False, allow_blank=True, default="") + extra_discount_percent = serializers.FloatField(required=False, default=0, min_value=0, max_value=100) + extra_discount_amount = serializers.FloatField(required=False, default=0, min_value=0) tax_lines = serializers.ListField(child=serializers.DictField(), required=False, default=list) payment_type = serializers.ChoiceField(choices=Invoice.PaymentType.choices, required=False) shipping_method = serializers.ChoiceField(choices=Invoice.ShippingMethod.choices, required=False) diff --git a/apps/bizapps/api/services.py b/apps/bizapps/api/services.py index 6432c52..4906709 100644 --- a/apps/bizapps/api/services.py +++ b/apps/bizapps/api/services.py @@ -9,6 +9,7 @@ from django.utils import timezone from apps.account.models import User from apps.banker.models import Contact from apps.bizapps.models import ( + Account, Customer, Invoice, InvoiceItem, @@ -26,6 +27,7 @@ from apps.bizapps.api.accounting import ( ACCOUNT_SALES, ChartOfAccountsService, ExcelExportService, + SubsidiaryLedgerService, ) logger = logging.getLogger(__name__) @@ -92,7 +94,9 @@ class InvoiceService: seller_name=data.get("seller_name", ""), seller_phone=data.get("seller_phone", ""), seller_address=data.get("seller_address", ""), - discount_amount=data.get("discount_amount", 0), + discount_percent=data.get("discount_percent", 0), + extra_discount_label=data.get("extra_discount_label", ""), + extra_discount_percent=data.get("extra_discount_percent", 0), tax_lines=data.get("tax_lines", []), payment_type=payment_type, shipping_method=data.get("shipping_method", Invoice.ShippingMethod.PICKUP), @@ -146,10 +150,27 @@ class InvoiceService: note=f"فروش فاکتور {invoice_number}", ) + if invoice.discount_percent: + invoice.discount_amount = subtotal * invoice.discount_percent / 100 + else: + invoice.discount_amount = data.get("discount_amount", 0) + + after_discount = subtotal - invoice.discount_amount + if invoice.extra_discount_percent: + invoice.extra_discount_amount = after_discount * invoice.extra_discount_percent / 100 + else: + invoice.extra_discount_amount = data.get("extra_discount_amount", 0) + tax_amount = sum(subtotal * float(line.get("percent", 0)) / 100 for line in invoice.tax_lines) invoice.total_amount = subtotal - invoice.final_amount = subtotal - invoice.discount_amount + tax_amount - invoice.save(update_fields=["total_amount", "final_amount"]) + invoice.final_amount = ( + subtotal - invoice.discount_amount - invoice.extra_discount_amount + tax_amount + ) + invoice.save( + update_fields=[ + "total_amount", "discount_amount", "extra_discount_amount", "final_amount", + ] + ) transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX") is_credit = payment_type == Invoice.PaymentType.CREDIT @@ -396,6 +417,70 @@ class LedgerService: entry.account = account entry.save(update_fields=["account", "updated_at"]) + @staticmethod + @transaction.atomic + def post_entry_transaction(user: User, entry: LedgerEntry) -> None: + """سندِ دوطرفه‌ی یک ثبتِ ساده (غیرِ فاکتوری) — قبلا فقط فاکتورها و + اقساط سند می‌خوردند، پس ثبتِ مستقیمِ درآمد/هزینه (که کارِ اصلیِ + بیشترِ کاربرهاست) هیچ‌وقت وارد تراز حساب‌ها/دفتر معین نمی‌شد. اینجا + سندِ قبلیِ همین ردیف (اگر بود) پاک و دوباره ساخته می‌شود — ساده‌تر و + کم‌خطاتر از آپدیتِ جزئی. + + فاکتورهای فروش (linked_invoice) را عمدا رد می‌کند — سندشان از قبل، + موقعِ ساختِ فاکتور، ساخته شده؛ دوباره‌سندزدن یعنی دوبار حساب کردن. + """ + Transaction.objects.filter(reference_ledger_entry=entry).delete() + if entry.linked_invoice_id: + return + + account = entry.account or LedgerService.account_for_category(user, entry.category) + if account is None: + return # دسته‌ی ناشناخته یا خالی — چیزی برای سند زدن نیست. + + is_debt = entry.category in LedgerEntry.DEBT_CATEGORIES + amount = (entry.total_amount or entry.amount) if is_debt else entry.amount + if not amount: + return + + if entry.category == LedgerEntry.Category.SALE_CREDIT: + debit_ref = ChartOfAccountsService.get(user, ACCOUNT_RECEIVABLE) + credit_ref = account + elif entry.category == LedgerEntry.Category.PURCHASE_CREDIT: + debit_ref = account + credit_ref = ChartOfAccountsService.get(user, ACCOUNT_PAYABLE) + elif entry.entry_type == LedgerEntry.EntryType.INCOME: + debit_ref = ChartOfAccountsService.get(user, ACCOUNT_CASH) + credit_ref = account + else: + debit_ref = account + credit_ref = ChartOfAccountsService.get(user, ACCOUNT_CASH) + + transaction_type = ( + Transaction.TransactionType.CREDIT_SALE + if entry.category == LedgerEntry.Category.SALE_CREDIT + else Transaction.TransactionType.PURCHASE + if entry.category == LedgerEntry.Category.PURCHASE_CREDIT + else Transaction.TransactionType.CASH_SALE + if entry.entry_type == LedgerEntry.EntryType.INCOME + else Transaction.TransactionType.EXPENSE + ) + number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "LED") + naive_dt = timezone.datetime.combine(entry.entry_date, timezone.datetime.min.time()) + Transaction.objects.create( + id=str(uuid.uuid4()), + user=user, + transaction_number=number, + transaction_date=timezone.make_aware(naive_dt) if timezone.is_naive(naive_dt) else naive_dt, + reference_ledger_entry=entry, + transaction_type=transaction_type, + debit_account=debit_ref.code if debit_ref else "", + credit_account=credit_ref.code if credit_ref else "", + debit_account_ref=debit_ref, + credit_account_ref=credit_ref, + amount=amount, + description=entry.title, + ) + @staticmethod @transaction.atomic def post_installment_transaction(user: User, installment: LedgerInstallment) -> None: @@ -448,46 +533,48 @@ class LedgerService: @staticmethod def export_csv(user: User, start=None, end=None) -> str: - """خروجیِ CSV ساده — برای وقتی کاربر فقط یک فایلِ سبکِ تک‌برگی - می‌خواهد. نسخه‌ی کاملِ چندبرگی در export_xlsx است.""" - qs = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related("installments") - if start: - qs = qs.filter(entry_date__gte=start) - if end: - qs = qs.filter(entry_date__lte=end) - + """خروجیِ CSV به‌شکلِ واقعیِ دفتر معین — به‌ازای هر حسابِ معینِ + دارای گردش: سربرگِ حساب، مانده‌ی ابتدای دوره، سندهای آن حساب + به‌ترتیبِ تاریخ (با بدهکار/بستانکار/مانده‌ی در جریان)، و مانده‌ی + پایانِ دوره — دقیقا همان ساختاری که در خروجیِ اکسلِ چندبرگی هم + هست، فقط سبک‌تر و تک‌برگی. قبلا این خروجی فقط یک دامپِ خامِ + ردیف‌های دفترِ ساده بود (بدونِ حساب/بدهکار/بستانکار/مانده) که هیچ + شباهتی به دفتر معین نداشت. + """ buf = io.StringIO() buf.write("") # BOM writer = csv.writer(buf) - writer.writerow( - [ - "تاریخ", - "نوع", - "دسته", - "عنوان", - "مبلغ", - "طرف حساب", - "شماره طرف حساب", - "مبلغ کل تعهد", - "پرداخت‌شده", - "مانده", - "یادداشت", - ] - ) - for e in qs: + writer.writerow(["تاریخ", "شماره سند", "نوع", "شرح", "بدهکار", "بستانکار", "مانده"]) + + accounts = Account.objects.filter(user=user, level=Account.Level.MOIN).order_by("code") + wrote_any_row = False + for account in accounts: + statement = SubsidiaryLedgerService.statement(user, account, start, end) + if not statement["rows"]: + continue + wrote_any_row = True + writer.writerow([f"{account.code} — {account.name}", "", "", "", "", "", ""]) writer.writerow( - [ - e.entry_date.isoformat(), - e.get_entry_type_display(), - e.get_category_display() if e.category else "", - e.title, - f"{e.amount:.0f}", - e.counterparty_name, - e.counterparty_phone, - f"{e.total_amount:.0f}" if e.is_debt else "", - f"{e.paid_amount:.0f}" if e.is_debt else "", - f"{e.remaining_amount:.0f}" if e.is_debt else "", - e.note, - ] + ["مانده‌ی ابتدای دوره", "", "", "", "", "", f"{statement['opening_balance']:.0f}"] ) + for row in statement["rows"]: + row_date = row["date"] + writer.writerow( + [ + row_date.date().isoformat() if hasattr(row_date, "date") else row_date, + row["number"], + row["type"], + row["description"], + f"{row['debit']:.0f}" if row["debit"] else "", + f"{row['credit']:.0f}" if row["credit"] else "", + f"{row['balance']:.0f}", + ] + ) + writer.writerow( + ["مانده‌ی پایانِ دوره", "", "", "", "", "", f"{statement['closing_balance']:.0f}"] + ) + writer.writerow([]) + + if not wrote_any_row: + writer.writerow(["در این بازه هیچ حسابی گردش نداشته.", "", "", "", "", "", ""]) return buf.getvalue() diff --git a/apps/bizapps/api/views.py b/apps/bizapps/api/views.py index 7d38013..e289367 100644 --- a/apps/bizapps/api/views.py +++ b/apps/bizapps/api/views.py @@ -109,6 +109,7 @@ class LedgerEntryListCreateView(_OwnedMixin, generics.ListCreateAPIView): book = LedgerBookService.ensure_active_book(self.request.user) entry = serializer.save(user=self.request.user, book=book) LedgerService.sync_entry_account(self.request.user, entry) + LedgerService.post_entry_transaction(self.request.user, entry) if entry.counterparty_phone: LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone) @@ -123,6 +124,7 @@ class LedgerEntryDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView): def perform_update(self, serializer): entry = serializer.save() LedgerService.sync_entry_account(self.request.user, entry) + LedgerService.post_entry_transaction(self.request.user, entry) if entry.counterparty_phone: LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone) @@ -473,6 +475,48 @@ class LedgerBookSummaryView(APIView): ) +class LedgerBookDetailView(generics.RetrieveUpdateDestroyAPIView): + """ویرایش/حذفِ یک فایل دفتر. فایلِ بایگانی‌شده فقط-خواندنی است — نه + ویرایش و نه حذف. حذف فقط برای فایلِ خالی (بدون هیچ ثبتی) مجاز است تا + داده‌ی مالی با حذفِ فایل به‌طور آبشاری از بین نرود.""" + + serializer_class = LedgerBookSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return LedgerBook.objects.filter(user=self.request.user) + + def _guard_editable(self, book: LedgerBook): + if book.is_closed: + raise ValidationError("فایلِ بایگانی‌شده فقط-خواندنی است.") + + def perform_update(self, serializer): + self._guard_editable(serializer.instance) + serializer.save() + + def perform_destroy(self, instance): + self._guard_editable(instance) + if instance.entries.exists(): + raise ValidationError("فقط فایلِ خالی (بدون هیچ ثبتی) قابل‌حذف است.") + instance.delete() + + +class LedgerBookArchiveView(APIView): + """بایگانی‌کردنِ دستیِ یک فایل دفتر — از این پس فقط-خواندنی می‌شود و در + انتخابِ فایلِ دفتر برای فاکتورهای تازه نشان داده نمی‌شود.""" + + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + book = LedgerBook.objects.filter(user=request.user, pk=pk).first() + if book is None: + raise NotFound("فایل دفتر یافت نشد.") + if book.is_closed: + raise ValidationError("این فایل قبلا بایگانی شده.") + book = LedgerBookService.archive_book(book) + return Response(LedgerBookSerializer(book).data) + + class SubsidiaryLedgerView(APIView): """دفتر معین یک حساب — گردشِ حساب با مانده‌ی در جریان.""" diff --git a/apps/bizapps/migrations/0004_invoice_discount_percent_and_more.py b/apps/bizapps/migrations/0004_invoice_discount_percent_and_more.py new file mode 100644 index 0000000..cde38f1 --- /dev/null +++ b/apps/bizapps/migrations/0004_invoice_discount_percent_and_more.py @@ -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='تاریخ تولید'), + ), + ] diff --git a/apps/bizapps/migrations/0005_transaction_reference_ledger_entry.py b/apps/bizapps/migrations/0005_transaction_reference_ledger_entry.py new file mode 100644 index 0000000..e144d25 --- /dev/null +++ b/apps/bizapps/migrations/0005_transaction_reference_ledger_entry.py @@ -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'), + ), + ] diff --git a/apps/bizapps/migrations/0006_backfill_ledger_entry_transactions.py b/apps/bizapps/migrations/0006_backfill_ledger_entry_transactions.py new file mode 100644 index 0000000..2ca5db5 --- /dev/null +++ b/apps/bizapps/migrations/0006_backfill_ledger_entry_transactions.py @@ -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), + ] diff --git a/apps/bizapps/models.py b/apps/bizapps/models.py index 88782f5..397d08d 100644 --- a/apps/bizapps/models.py +++ b/apps/bizapps/models.py @@ -181,6 +181,9 @@ class Product(models.Model): # «ارزش کل انبار» بدون پیمایشِ همه‌ی کالاها قابل‌جمع‌زدن باشد. total_purchase_value = 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=_("یادداشت")) 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_address = models.CharField(max_length=255, blank=True, 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=_("تخفیف")) + # تخفیفِ ویژه/دوره‌ای (مثلا «فروش فوق‌العاده») — جدا از تخفیفِ معمولیِ + # بالا تا هر دو در خروجی فاکتور با عنوانِ خودشان دیده شوند. یا با درصد + # (روی مبلغ بعد از تخفیف معمولی) یا با مبلغ ثابت پر می‌شود، نه هر دو. + 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} tax_lines = models.JSONField(default=list, blank=True, verbose_name=_("ردیف‌های مالیات")) final_amount = models.FloatField(default=0, verbose_name=_("مبلغ نهایی")) @@ -326,6 +342,12 @@ class Transaction(models.Model): reference_invoice = models.ForeignKey( 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) # کدِ حسابِ متنی برای سازگاری با داده‌ی قدیمی نگه داشته شده؛ debit/credit # به‌صورتِ FK به Account همان چیزی است که گزارش‌های جدید از آن می‌خوانند. @@ -403,6 +425,14 @@ class LedgerEntry(models.Model): ) entry_type = models.CharField(max_length=10, choices=EntryType.choices, 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=_("مبلغ")) # max_length=100 (نه تنگ‌تر) عمداً — این فیلد قبلا آزاد بود و داده‌ی # واقعیِ قدیمی با متنِ فارسیِ دلخواه دارد؛ تنگ‌کردنش موقعِ مهاجرت روی @@ -438,6 +468,24 @@ class LedgerEntry(models.Model): def is_debt(self) -> bool: 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 def professional_type(self) -> str: """ترجمه‌ی دسته‌ی «عادی» به اصطلاحِ حسابداریِ حرفه‌ای.""" diff --git a/apps/bizapps/tests.py b/apps/bizapps/tests.py index 7a6cee6..6647c17 100644 --- a/apps/bizapps/tests.py +++ b/apps/bizapps/tests.py @@ -177,19 +177,24 @@ class LedgerServiceTests(TestCase): self.customer.refresh_from_db() 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): - LedgerEntry.objects.create( - id=str(uuid.uuid4()), - user=self.user, + self._create_and_post( entry_type=LedgerEntry.EntryType.INCOME, title="داخل بازه", amount=100, category=LedgerEntry.Category.SALE_CASH, entry_date=date(2026, 1, 15), ) - LedgerEntry.objects.create( - id=str(uuid.uuid4()), - user=self.user, + self._create_and_post( entry_type=LedgerEntry.EntryType.EXPENSE, title="خارج از بازه", amount=50, @@ -200,6 +205,31 @@ class LedgerServiceTests(TestCase): self.assertIn("داخل بازه", 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): """اتصالِ فاکتور <-> دفتر: طبق درخواست، فقط فروشِ قسطی باید خودکار یک @@ -553,7 +583,7 @@ class ExcelExportTests(TestCase): wb = self._workbook() self.assertEqual( wb.sheetnames, - ["خلاصه", "دفتر روزنامه", "تراز حساب‌ها", "بدهکاران", "فاکتورها", "انبار"], + ["خلاصه", "دفتر روزنامه", "تراز حساب‌ها", "بدهکاران", "فاکتورها", "دفتر معین", "انبار"], ) def test_sheets_are_right_to_left(self): @@ -825,15 +855,18 @@ class LedgerBookTests(TestCase): orphan.refresh_from_db() 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) new = LedgerBookService.open_new_book( self.user, name="سال ۱۴۰۶", start_date=date(2027, 3, 21) ) old.refresh_from_db() self.assertFalse(old.is_active) - self.assertTrue(old.is_closed) - self.assertIsNotNone(old.closed_at) + self.assertFalse(old.is_closed) + self.assertIsNone(old.closed_at) self.assertTrue(new.is_active) # همیشه دقیقا یک فایل جاری self.assertEqual(LedgerBook.objects.filter(user=self.user, is_active=True).count(), 1) diff --git a/apps/bizapps/urls.py b/apps/bizapps/urls.py index 6ad1701..d5cab40 100644 --- a/apps/bizapps/urls.py +++ b/apps/bizapps/urls.py @@ -14,6 +14,8 @@ from .api.views import ( InvoiceStatusView, LedgerEntryDetailView, LedgerEntryListCreateView, + LedgerBookArchiveView, + LedgerBookDetailView, LedgerBookListView, LedgerBookSummaryView, LedgerExportView, @@ -42,6 +44,8 @@ urlpatterns = [ path("ledger/books/", LedgerBookListView.as_view(), name="ledger-book-list"), path("ledger/books/open/", OpenLedgerBookView.as_view(), name="ledger-book-open"), path("ledger/books//summary/", LedgerBookSummaryView.as_view(), name="ledger-book-summary"), + path("ledger/books//archive/", LedgerBookArchiveView.as_view(), name="ledger-book-archive"), + path("ledger/books//", LedgerBookDetailView.as_view(), name="ledger-book-detail"), path("ledger/export/", LedgerExportView.as_view(), name="ledger-export"), path("ledger/", LedgerEntryListCreateView.as_view(), name="ledger-list"), path( diff --git a/apps/orders/api/services.py b/apps/orders/api/services.py index eb69b54..0a50924 100644 --- a/apps/orders/api/services.py +++ b/apps/orders/api/services.py @@ -1,4 +1,6 @@ +import random import re +import string import uuid from django.db import transaction @@ -6,13 +8,37 @@ from django.db import transaction from apps.account.models import User from apps.banker.models import Contact from apps.messaging.api.services import BulkMessageService -from apps.messaging.models import Channel +from apps.messaging.models import Channel, MessageRecipient from apps.orders.models import Order, OrderStage, OrderStageLog _FIELD_PATTERN = re.compile(r"\{(.*?)\}") +_TRACKING_CODE_ALPHABET = string.ascii_uppercase + string.digits + + +def generate_tracking_code() -> str: + """کد پیگیریِ کوتاه و خوانا برای مشتری — نه شماره‌ی پستیِ واقعی (آن را + وقتی از پستخانه گرفت دستی جایگزین می‌کند)، بلکه یک مرجع سریع تا از + همان مرحله‌ی اول سفارش یک کدِ پیگیری داشته باشد.""" + return "TRK-" + "".join(random.choices(_TRACKING_CODE_ALPHABET, k=6)) class OrderStageService: + @staticmethod + def ensure_default_stage(user: User) -> None: + """اگر کاربر هنوز هیچ مرحله‌ای تعریف نکرده، یک مرحله‌ی پیش‌فرض + می‌سازد تا لیستِ مراحل هیچ‌وقت کاملا خالی نباشد و اولین سفارش از + همان روزِ اول قابل‌پیگیری باشد. بدونِ قالبِ پیامک (کاربر خودش بعدا + یکی وصل می‌کند) — چون قالبِ پیشِ‌فرضی که خودمان بسازیم به احتمال + زیاد با کسب‌وکارش نمی‌خواند.""" + if OrderStage.objects.filter(user=user).exists(): + return + OrderStage.objects.create( + id=str(uuid.uuid4()), + user=user, + label="ثبت سفارش", + sort_order=0, + ) + @staticmethod def _render(order: Order, stage: OrderStage, extra_input_value: str = "") -> str: body = stage.template.body if stage.template else "" @@ -36,7 +62,12 @@ class OrderStageService: @staticmethod @transaction.atomic - def advance(user: User, order_id: str, extra_input_value: str = "") -> Order: + def advance(user: User, order_id: str, extra_input_value: str = "") -> tuple[Order, bool]: + """برمی‌گرداند: (سفارشِ به‌روزشده، آیا پیامکِ این مرحله واقعا ارسال + شد). قبلا موفقیتِ ثبتِ مرحله را با موفقیتِ ارسالِ پیامک قاطی می‌کرد؛ + الان اگر قالب متن نداشته باشد یا کاوه‌نگار ارسال را رد کند، مرحله + همچنان ثبت می‌شود ولی caller می‌تواند به کاربر بگوید که پیامک نرفت + (مثلا چون SENDER_LINE در تنظیمات سرور خالی است).""" order = Order.objects.select_for_update().get(pk=order_id, user=user) if order.is_completed: raise ValueError("این سفارش قبلا به آخرین مرحله رسیده است.") @@ -60,13 +91,17 @@ class OrderStageService: contact = OrderStageService._upsert_contact(user, order) rendered = OrderStageService._render(order, next_stage, extra_input_value) + message_sent = False if rendered: - BulkMessageService.create_and_dispatch( + bulk = BulkMessageService.create_and_dispatch( user=user, channels=[Channel.SMS], contact_ids=[contact.id], body_override=rendered, ) + message_sent = bulk.recipients.filter( + channel=Channel.SMS, status=MessageRecipient.Status.SENT + ).exists() order.current_stage = next_stage order.is_completed = next_stage.id == stages[-1].id @@ -80,4 +115,4 @@ class OrderStageService: message_body=rendered, extra_input_value=extra_input_value, ) - return order + return order, message_sent diff --git a/apps/orders/api/views.py b/apps/orders/api/views.py index af66bd4..51c1aef 100644 --- a/apps/orders/api/views.py +++ b/apps/orders/api/views.py @@ -5,7 +5,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.billing.permissions import HasActiveFeature -from apps.orders.api.services import OrderStageService +from apps.orders.api.services import OrderStageService, generate_tracking_code from apps.orders.models import Order, OrderStage, OrderStageLog from .serializers import OrderSerializer, OrderStageLogSerializer, OrderStageSerializer @@ -34,6 +34,10 @@ class OrderStageListCreateView(_UserScopedListCreateView): model = OrderStage serializer_class = OrderStageSerializer + def get_queryset(self): + OrderStageService.ensure_default_stage(self.request.user) + return super().get_queryset() + class OrderStageDetailView(_UserScopedDetailView): model = OrderStage @@ -44,6 +48,12 @@ class OrderListCreateView(_UserScopedListCreateView): model = Order serializer_class = OrderSerializer + def perform_create(self, serializer): + serializer.save( + user=self.request.user, + tracking_code=serializer.validated_data.get("tracking_code") or generate_tracking_code(), + ) + class OrderDetailView(_UserScopedDetailView): model = Order @@ -62,12 +72,31 @@ class AdvanceOrderStageView(APIView): raise NotFound("سفارش یافت نشد.") extra_input_value = request.data.get("extra_input_value", "") try: - order = OrderStageService.advance(request.user, pk, extra_input_value) + order, message_sent = OrderStageService.advance(request.user, pk, extra_input_value) except Order.DoesNotExist: raise NotFound("سفارش یافت نشد.") except ValueError as exc: raise ValidationError(str(exc)) - return Response(OrderSerializer(order).data, status=status.HTTP_200_OK) + return Response( + {**OrderSerializer(order).data, "message_sent": message_sent}, + status=status.HTTP_200_OK, + ) + + +class GenerateOrderTrackingCodeView(APIView): + """یک کدِ پیگیریِ تازه برای سفارش می‌سازد — برای وقتی هنوز کدِ واقعیِ + پستی را نداری ولی می‌خواهی از همین حالا یک مرجع به مشتری بدهی.""" + + permission_classes = [IsAuthenticated, HasActiveFeature] + feature_key = "orders" + + def post(self, request, pk): + order = Order.objects.filter(pk=pk, user=request.user).first() + if order is None: + raise NotFound("سفارش یافت نشد.") + order.tracking_code = generate_tracking_code() + order.save(update_fields=["tracking_code", "updated_at"]) + return Response(OrderSerializer(order).data) class OrderStageLogListView(generics.ListAPIView): diff --git a/apps/orders/urls.py b/apps/orders/urls.py index 7a19427..1b9fdf7 100644 --- a/apps/orders/urls.py +++ b/apps/orders/urls.py @@ -2,6 +2,7 @@ from django.urls import path from .api.views import ( AdvanceOrderStageView, + GenerateOrderTrackingCodeView, OrderDetailView, OrderListCreateView, OrderStageDetailView, @@ -16,5 +17,10 @@ urlpatterns = [ path("orders/", OrderListCreateView.as_view(), name="order-list"), path("orders//", OrderDetailView.as_view(), name="order-detail"), path("orders//advance/", AdvanceOrderStageView.as_view(), name="order-advance"), + path( + "orders//generate-tracking-code/", + GenerateOrderTrackingCodeView.as_view(), + name="order-generate-tracking-code", + ), path("orders//logs/", OrderStageLogListView.as_view(), name="order-logs"), ] diff --git a/apps/reviews/__init__.py b/apps/reviews/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reviews/admin.py b/apps/reviews/admin.py new file mode 100644 index 0000000..afaa7c9 --- /dev/null +++ b/apps/reviews/admin.py @@ -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} نظر رد شد.") diff --git a/apps/reviews/api/__init__.py b/apps/reviews/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reviews/api/serializers.py b/apps/reviews/api/serializers.py new file mode 100644 index 0000000..34a589c --- /dev/null +++ b/apps/reviews/api/serializers.py @@ -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 diff --git a/apps/reviews/api/views.py b/apps/reviews/api/views.py new file mode 100644 index 0000000..044b322 --- /dev/null +++ b/apps/reviews/api/views.py @@ -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}) diff --git a/apps/reviews/apps.py b/apps/reviews/apps.py new file mode 100644 index 0000000..388b609 --- /dev/null +++ b/apps/reviews/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ReviewsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.reviews" + verbose_name = "نظرات کاربران" diff --git a/apps/reviews/migrations/0001_initial.py b/apps/reviews/migrations/0001_initial.py new file mode 100644 index 0000000..ad54686 --- /dev/null +++ b/apps/reviews/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/apps/reviews/migrations/__init__.py b/apps/reviews/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/reviews/models.py b/apps/reviews/models.py new file mode 100644 index 0000000..8032b26 --- /dev/null +++ b/apps/reviews/models.py @@ -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}" diff --git a/apps/reviews/urls.py b/apps/reviews/urls.py new file mode 100644 index 0000000..2abb0dd --- /dev/null +++ b/apps/reviews/urls.py @@ -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("/like/", ReviewLikeToggleView.as_view(), name="like"), +] diff --git a/config/settings.py b/config/settings.py index fa399dc..2218058 100644 --- a/config/settings.py +++ b/config/settings.py @@ -58,6 +58,7 @@ LOCAL_APPS = [ "apps.landing", "apps.orders", "apps.telemetry", + "apps.reviews", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -204,6 +205,10 @@ SIMPLE_JWT = { # ============================================ KAVENEGAR_API_KEY = config("KAVENEGAR_API_KEY", default="") 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="") diff --git a/config/urls.py b/config/urls.py index 0fea798..ead6c1f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path("api/notifications/", include("apps.notifications.urls")), path("api/orders/", include("apps.orders.urls")), path("api/telemetry/", include("apps.telemetry.urls")), + path("api/reviews/", include("apps.reviews.urls")), # فایل‌های استاتیک قبلی landing/articles/*.html حذف شده‌اند — چون # WhiteNoise میدل‌ور همیشه قبل از urls.py بررسی می‌شود و اگر آن فایل‌ها # هنوز وجود داشتند، هیچ‌وقت به این view دینامیک نمی‌رسیدیم.