From 17082a5ea910c226293b942d0768299316558d33 Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Fri, 4 Sep 2026 13:05:54 +0330 Subject: [PATCH] adding --- apps/account/migrations/0001_initial.py | 60 ++ apps/banker/migrations/0001_initial.py | 117 +++ apps/billing/admin.py | 83 +- apps/billing/api/serializers.py | 14 +- apps/billing/api/services.py | 3 + apps/billing/api/views.py | 16 +- apps/billing/forms.py | 92 ++ apps/billing/migrations/0001_initial.py | 128 +++ .../migrations/0002_featureplan_is_free.py | 18 + .../migrations/0003_seed_feature_plans.py | 71 ++ .../migrations/0004_free_tier_split.py | 53 ++ apps/billing/models.py | 8 + apps/billing/providers/bazaar.py | 285 +++--- .../templates/admin/billing/gem_gift.html | 17 + .../billing/gemtransaction/change_list.html | 7 + apps/billing/tests.py | 195 +++- apps/bizapps/admin.py | 76 +- apps/bizapps/api/accounting.py | 627 ++++++++++++ apps/bizapps/api/serializers.py | 235 ++++- apps/bizapps/api/services.py | 95 +- apps/bizapps/api/views.py | 501 ++++++++-- apps/bizapps/migrations/0001_initial.py | 246 +++++ ..._social_handle_customer_social_platform.py | 23 + ...03_ledgerbook_ledgerentry_book_and_more.py | 46 + apps/bizapps/models.py | 452 ++++++++- apps/bizapps/tests.py | 742 ++++++++++++++- apps/bizapps/urls.py | 57 +- apps/landing/migrations/0001_initial.py | 35 + .../landing/migrations/0002_articles_batch.py | 898 ++++++++++++++++++ .../0003_restore_earlier_articles.py | 343 +++++++ apps/messaging/api/serializers.py | 15 + apps/messaging/api/views.py | 39 +- apps/messaging/migrations/0001_initial.py | 54 ++ apps/messaging/tests.py | 58 ++ apps/messaging/urls.py | 2 + apps/notifications/migrations/0001_initial.py | 46 + apps/orders/migrations/0001_initial.py | 72 ++ apps/telemetry/migrations/0001_initial.py | 114 +++ config/settings.py | 3 + config/urls.py | 1 + kangelo_db | 0 requirements.txt | 1 + sad | 0 43 files changed, 5663 insertions(+), 285 deletions(-) create mode 100644 apps/account/migrations/0001_initial.py create mode 100644 apps/banker/migrations/0001_initial.py create mode 100644 apps/billing/forms.py create mode 100644 apps/billing/migrations/0001_initial.py create mode 100644 apps/billing/migrations/0002_featureplan_is_free.py create mode 100644 apps/billing/migrations/0003_seed_feature_plans.py create mode 100644 apps/billing/migrations/0004_free_tier_split.py create mode 100644 apps/billing/templates/admin/billing/gem_gift.html create mode 100644 apps/billing/templates/admin/billing/gemtransaction/change_list.html create mode 100644 apps/bizapps/api/accounting.py create mode 100644 apps/bizapps/migrations/0001_initial.py create mode 100644 apps/bizapps/migrations/0002_customer_social_handle_customer_social_platform.py create mode 100644 apps/bizapps/migrations/0003_ledgerbook_ledgerentry_book_and_more.py create mode 100644 apps/landing/migrations/0001_initial.py create mode 100644 apps/landing/migrations/0002_articles_batch.py create mode 100644 apps/landing/migrations/0003_restore_earlier_articles.py create mode 100644 apps/messaging/migrations/0001_initial.py create mode 100644 apps/messaging/tests.py create mode 100644 apps/notifications/migrations/0001_initial.py create mode 100644 apps/orders/migrations/0001_initial.py create mode 100644 apps/telemetry/migrations/0001_initial.py create mode 100644 kangelo_db create mode 100644 sad diff --git a/apps/account/migrations/0001_initial.py b/apps/account/migrations/0001_initial.py new file mode 100644 index 0000000..f346480 --- /dev/null +++ b/apps/account/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.15 on 2026-09-02 00:19 + +import apps.account.models +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='TermsAndConditions', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField(blank=True, help_text='این متن در دیالوگ «قوانین و مقررات» صفحه\u200cی ورود نشان داده می\u200cشود.', verbose_name='متن قوانین و مقررات')), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'قوانین و مقررات', + 'verbose_name_plural': 'قوانین و مقررات', + }, + ), + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('phone', models.CharField(max_length=11, unique=True, validators=[apps.account.models.phone_validator], verbose_name='شماره موبایل')), + ('name', models.CharField(blank=True, max_length=100, verbose_name='نام')), + ('state', models.CharField(choices=[('inactive', 'غیرفعال'), ('active', 'فعال'), ('blocked', 'مسدود شده')], default='inactive', max_length=10, verbose_name='وضعیت کاربر')), + ('otp_code', models.CharField(blank=True, max_length=128, null=True, verbose_name='کد OTP (هش\u200cشده)')), + ('otp_expiry', models.DateTimeField(blank=True, null=True, verbose_name='انقضای OTP')), + ('is_active', models.BooleanField(default=True, verbose_name='فعال')), + ('is_staff', models.BooleanField(default=False, verbose_name='کارمند')), + ('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='تاریخ ایجاد')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')), + ('gem_balance', models.PositiveIntegerField(default=0, verbose_name='موجودی جم')), + ('trial_expires_at', models.DateTimeField(blank=True, default=None, null=True, verbose_name='پایان دوره\u200cی رایگان')), + ('referral_code', models.CharField(blank=True, max_length=8, unique=True, verbose_name='کد معرف')), + ('referral_bonus_granted', models.BooleanField(default=False, verbose_name='پاداش معرفی داده\u200cشد')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('referred_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='referrals', to=settings.AUTH_USER_MODEL, verbose_name='معرف')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'کاربر', + 'verbose_name_plural': 'کاربران', + }, + ), + ] diff --git a/apps/banker/migrations/0001_initial.py b/apps/banker/migrations/0001_initial.py new file mode 100644 index 0000000..3be760e --- /dev/null +++ b/apps/banker/migrations/0001_initial.py @@ -0,0 +1,117 @@ +# Generated by Django 5.1.15 on 2026-09-02 00:19 + +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='Contact', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150, verbose_name='نام')), + ('phone', models.CharField(max_length=20, verbose_name='شماره موبایل')), + ('device_contact_id', models.CharField(blank=True, max_length=100, null=True)), + ('field_values', models.JSONField(blank=True, default=dict, verbose_name='مقادیر فیلدهای دلخواه')), + ('channel_ids', models.JSONField(blank=True, default=dict, verbose_name='شناسه\u200cی کانال\u200cها')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'مخاطب', + 'verbose_name_plural': 'مخاطبین', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ContactGroup', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150, verbose_name='نام گروه')), + ('color', models.BigIntegerField(verbose_name='رنگ (ARGB)')), + ('is_general', models.BooleanField(default=False, help_text='گروه پیش\u200cفرض بدون فیلد سفارشی؛ هر کاربر دقیقاً یکی از این\u200cها دارد.', verbose_name='گروه عمومی')), + ('member_ids', models.JSONField(blank=True, default=list, verbose_name='اعضا')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_groups', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'گروه', + 'verbose_name_plural': 'گروه\u200cها', + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='ContactField', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('label', models.CharField(max_length=100, verbose_name='عنوان فیلد')), + ('sort_order', models.PositiveIntegerField(default=0)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_fields', to=settings.AUTH_USER_MODEL)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='banker.contactgroup')), + ], + options={ + 'verbose_name': 'فیلد دلخواه مخاطب', + 'verbose_name_plural': 'فیلدهای دلخواه مخاطبین', + 'ordering': ['group', 'sort_order'], + }, + ), + migrations.CreateModel( + name='MessageTemplate', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150, verbose_name='نام قالب')), + ('body', models.TextField(verbose_name='متن قالب')), + ('mode', models.CharField(choices=[('merge', 'قالب\u200cبندی'), ('quick', 'تمپلیت آماده')], max_length=10)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='templates', to='banker.contactgroup')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='templates', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'قالب پیام', + 'verbose_name_plural': 'قالب\u200cهای پیام', + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='StructuredTemplate', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150, verbose_name='نام قالب')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='structured_templates', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'قالب کلید-مقدار', + 'verbose_name_plural': 'قالب\u200cهای کلید-مقدار', + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='StructuredTemplateField', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('label', models.CharField(max_length=100, verbose_name='عنوان')), + ('sort_order', models.PositiveIntegerField(default=0)), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='banker.structuredtemplate')), + ], + options={ + 'verbose_name': 'فیلد قالب کلید-مقدار', + 'verbose_name_plural': 'فیلدهای قالب کلید-مقدار', + 'ordering': ['template', 'sort_order'], + }, + ), + migrations.AddConstraint( + model_name='contactgroup', + constraint=models.UniqueConstraint(condition=models.Q(('is_general', True)), fields=('user',), name='one_general_group_per_user'), + ), + ] diff --git a/apps/billing/admin.py b/apps/billing/admin.py index 96928cd..6d10f96 100644 --- a/apps/billing/admin.py +++ b/apps/billing/admin.py @@ -1,8 +1,13 @@ from django.contrib import admin, messages -from django.shortcuts import redirect +from django.db import transaction as db_transaction +from django.shortcuts import redirect, render from django.urls import path, reverse from django.utils.html import format_html +from apps.account.models import User + +from .api.services import GemWalletService +from .forms import GemGiftForm from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction from .providers.bazaar import BazaarAuthError, build_authorize_url, exchange_code_for_tokens @@ -115,7 +120,9 @@ class FeaturePlanTierInline(admin.TabularInline): @admin.register(FeaturePlan) class FeaturePlanAdmin(admin.ModelAdmin): - list_display = ("name", "key", "is_purchasable") + list_display = ("name", "key", "is_free", "is_purchasable") + list_editable = ("is_free",) + list_filter = ("is_free", "is_purchasable") search_fields = ("name", "key") inlines = [FeaturePlanTierInline] @@ -137,16 +144,84 @@ class FeatureActivationAdmin(admin.ModelAdmin): @admin.register(GemTransaction) class GemTransactionAdmin(admin.ModelAdmin): + """تاریخچه‌ی جم — فقط‌خواندنی، به‌علاوه‌ی ابزارِ «هدیه‌ی جم». + + هدیه از دکمه‌ی بالای همین صفحه انجام می‌شود، نه با ساختنِ دستیِ ردیفِ + تراکنش: اگر ادمین ردیف را دستی بسازد، User.gem_balance به‌روز نمی‌شود و + موجودی با تاریخچه واگرا می‌شود. مسیرِ درست از GemWalletService رد + می‌شود که هر دو را با هم و داخلِ یک تراکنش تغییر می‌دهد. + """ + list_display = ("user", "amount", "reason", "created_at") list_filter = ("reason",) search_fields = ("user__phone", "note") readonly_fields = ("user", "amount", "reason", "note", "created_at") + change_list_template = "admin/billing/gemtransaction/change_list.html" def has_add_permission(self, request): - # تراکنش جم فقط از سرویس‌های داخلی ساخته می‌شود، نه دستی از ادمین — - # برای اصلاح دستی از GemTransaction.Reason.ADMIN_ADJUSTMENT در شل/سرویس استفاده کن. return False + def get_urls(self): + custom = [ + path( + "gift/", + self.admin_site.admin_view(self.gift_view), + name="billing_gemtransaction_gift", + ), + ] + return custom + super().get_urls() + + def gift_view(self, request): + if request.method == "POST": + form = GemGiftForm(request.POST) + if form.is_valid(): + return self._apply_gift(request, form) + else: + form = GemGiftForm() + return render( + request, + "admin/billing/gem_gift.html", + { + **self.admin_site.each_context(request), + "title": "هدیه‌ی جم", + "form": form, + "opts": self.model._meta, + }, + ) + + @db_transaction.atomic + def _apply_gift(self, request, form: GemGiftForm): + amount = form.cleaned_data["amount"] + note = form.cleaned_data["note"] or f"هدیه‌ی ادمین ({request.user})" + users, missing = form.existing_and_missing() + + if missing and form.cleaned_data["create_missing"]: + created = [User.objects.create(phone=phone) for phone in missing] + users += created + missing = [] + + for user in users: + GemWalletService.add_gems( + user, + amount, + GemTransaction.Reason.ADMIN_ADJUSTMENT, + note=note, + ) + + if users: + messages.success( + request, + f"{amount} جم به {len(users)} شماره اضافه شد.", + ) + if missing: + # هشدار و نه خطا: بقیه‌ی شماره‌ها شارژ شده‌اند و ادمین باید + # بداند دقیقا کدام‌ها جا مانده‌اند. + messages.warning( + request, + "این شماره‌ها کاربر ندارند و رد شدند: " + "، ".join(missing), + ) + return redirect("admin:billing_gemtransaction_changelist") + @admin.register(BazaarPurchase) class BazaarPurchaseAdmin(admin.ModelAdmin): diff --git a/apps/billing/api/serializers.py b/apps/billing/api/serializers.py index 19806fa..6781cbe 100644 --- a/apps/billing/api/serializers.py +++ b/apps/billing/api/serializers.py @@ -33,13 +33,21 @@ class FeaturePlanSerializer(serializers.ModelSerializer): class Meta: model = FeaturePlan - fields = ["key", "name", "tiers", "is_active", "expires_at"] + fields = ["key", "name", "tiers", "is_active", "is_free", "expires_at"] def get_is_active(self, obj: FeaturePlan) -> bool: - return FeatureAccessService.is_active(self.context["request"].user, obj.key) + # مهمان (بدون حساب): فقط ابزارهای رایگان باز است — بدونِ این شاخه، + # AnonymousUser به is_active می‌رسید و خطا می‌داد. + user = self.context["request"].user + if not user.is_authenticated: + return obj.is_free + return FeatureAccessService.is_active(user, obj.key) def get_expires_at(self, obj: FeaturePlan): - return FeatureAccessService.expires_at(self.context["request"].user, obj.key) + user = self.context["request"].user + if not user.is_authenticated: + return None + return FeatureAccessService.expires_at(user, obj.key) class GemPackSerializer(serializers.ModelSerializer): diff --git a/apps/billing/api/services.py b/apps/billing/api/services.py index 2b998a6..bc827ee 100644 --- a/apps/billing/api/services.py +++ b/apps/billing/api/services.py @@ -97,6 +97,9 @@ class ReferralService: class FeatureAccessService: @staticmethod def is_active(user: User, feature_key: str) -> bool: + # ابزارهای رایگان همیشه باز — پیش از دوره‌ی آزمایشی و بعد از آن. + if FeaturePlan.objects.filter(key=feature_key, is_free=True).exists(): + return True if user.is_in_trial: return True return FeatureActivation.objects.filter( diff --git a/apps/billing/api/views.py b/apps/billing/api/views.py index a008792..1b6e6dd 100644 --- a/apps/billing/api/views.py +++ b/apps/billing/api/views.py @@ -1,8 +1,9 @@ from django.db import transaction +from django.db.models import Q from django.utils import timezone from rest_framework import generics, status from rest_framework.exceptions import NotFound -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -38,9 +39,18 @@ class WalletView(APIView): class FeatureListView(generics.ListAPIView): + """فهرستِ ابزارها — عمدا AllowAny: کاربرِ مهمان (بدون حساب) هم باید + بتواند بفهمد کدام ابزار رایگان است. این پاسخ هیچ داده‌ی شخصی ندارد؛ + فقط کاتالوگِ ابزارها و قیمت‌هاست.""" + serializer_class = FeaturePlanSerializer - permission_classes = [IsAuthenticated] - queryset = FeaturePlan.objects.filter(is_purchasable=True).prefetch_related("tiers") + permission_classes = [AllowAny] + # ابزارهای رایگان is_purchasable=False دارند (فروختنی نیستند) ولی باید + # حتما در لیست بیایند — وگرنه اپ آن‌ها را نمی‌شناسد و قفل نشانشان می‌دهد. + queryset = ( + FeaturePlan.objects.filter(Q(is_purchasable=True) | Q(is_free=True)) + .prefetch_related("tiers") + ) class ActivateFeatureView(APIView): diff --git a/apps/billing/forms.py b/apps/billing/forms.py new file mode 100644 index 0000000..b8040af --- /dev/null +++ b/apps/billing/forms.py @@ -0,0 +1,92 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from apps.account.models import User + + +def normalize_iran_phone(raw: str) -> str: + """۰۹۱۲…، +۹۸۹۱۲…، ۰۰۹۸۹۱۲… و ارقام فارسی را به شکلِ یکّه‌ی 09xxxxxxxxx می‌برد. + + ادمین شماره را از هرجایی کپی می‌کند (پیامک، اکسل، پیام‌رسان)؛ اگر شکلِ + ورودی را نرمال نکنیم، «کاربر پیدا نشد» می‌گیرد در حالی که کاربر هست. + """ + digits = { + "۰": "0", "۱": "1", "۲": "2", "۳": "3", "۴": "4", + "۵": "5", "۶": "6", "۷": "7", "۸": "8", "۹": "9", + "٠": "0", "١": "1", "٢": "2", "٣": "3", "٤": "4", + "٥": "5", "٦": "6", "٧": "7", "٨": "8", "٩": "9", + } + value = "".join(digits.get(ch, ch) for ch in (raw or "").strip()) + value = "".join(ch for ch in value if ch.isdigit() or ch == "+") + value = value.lstrip("+") + if value.startswith("0098"): + value = value[4:] + elif value.startswith("98") and len(value) == 12: + value = value[2:] + if value.startswith("9") and len(value) == 10: + value = "0" + value + return value + + +class GemGiftForm(forms.Form): + """هدیه‌دادنِ جم به یک یا چند شماره — از پنل ادمین. + + عمداً روی *شماره* کار می‌کند نه روی انتخابِ کاربر از لیست: ادمین معمولا + شماره را دارد (از پشتیبانی، از یک کمپین)، نه شناسه‌ی کاربر را. + """ + + phones = forms.CharField( + label=_("شماره‌ها"), + widget=forms.Textarea(attrs={"rows": 5, "dir": "ltr"}), + help_text=_( + "هر شماره در یک خط (یا با کاما جدا کن). " + "شکل‌های 09123456789 و +989123456789 هر دو قبول است." + ), + ) + amount = forms.IntegerField( + label=_("تعداد جم"), + min_value=1, + help_text=_("به هر شماره همین مقدار جم اضافه می‌شود."), + ) + note = forms.CharField( + label=_("علت / یادداشت"), + max_length=255, + required=False, + help_text=_("در تاریخچه‌ی تراکنش‌های جم ذخیره می‌شود — مثلا «جبران خطای پرداخت»."), + ) + create_missing = forms.BooleanField( + label=_("اگر کاربر وجود ندارد، ساخته شود"), + required=False, + help_text=_( + "برای هدیه‌ی پیش از ثبت‌نام. کاربر با همان شماره ساخته می‌شود و " + "جم موقع اولین ورودش آماده است." + ), + ) + + def clean_phones(self) -> list[str]: + raw = self.cleaned_data["phones"].replace(",", "\n") + numbers, seen = [], set() + invalid = [] + for line in raw.splitlines(): + if not line.strip(): + continue + phone = normalize_iran_phone(line) + if len(phone) != 11 or not phone.startswith("09"): + invalid.append(line.strip()) + continue + if phone not in seen: + seen.add(phone) + numbers.append(phone) + if invalid: + raise forms.ValidationError( + _("این شماره‌ها معتبر نیستند: %(bad)s") % {"bad": "، ".join(invalid)} + ) + if not numbers: + raise forms.ValidationError(_("حداقل یک شماره وارد کن.")) + return numbers + + def existing_and_missing(self) -> tuple[list[User], list[str]]: + phones = self.cleaned_data["phones"] + users = list(User.objects.filter(phone__in=phones)) + found = {u.phone for u in users} + return users, [p for p in phones if p not in found] diff --git a/apps/billing/migrations/0001_initial.py b/apps/billing/migrations/0001_initial.py new file mode 100644 index 0000000..1791250 --- /dev/null +++ b/apps/billing/migrations/0001_initial.py @@ -0,0 +1,128 @@ +# Generated by Django 5.1.15 on 2026-09-02 00:19 + +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='BazaarSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('access_token', models.CharField(blank=True, help_text='یا توکن ثابت «روش جدید» پیشخان را مستقیم اینجا بچسبان، یا با دکمه\u200cی اتصال زیر بگذار خودش (روش قدیمی/OAuth) پر و بعدا خودکار تمدید شود.', max_length=255, verbose_name='Access Token')), + ('refresh_token', models.CharField(blank=True, help_text='فقط برای روش OAuth — با دکمه\u200cی اتصال خودش پر می\u200cشود، دستی لازم نیست.', max_length=255, verbose_name='Refresh Token')), + ('access_token_expires_at', models.DateTimeField(blank=True, help_text='اگر خالی باشد یعنی توکن ثابتِ روش جدید است و هیچ\u200cوقت خودکار تمدید نمی\u200cشود. اگر پر باشد یعنی از OAuth آمده و پیش از انقضا خودش تمدید خواهد شد.', null=True, verbose_name='انقضای Access Token (فقط OAuth)')), + ('client_id', models.CharField(blank=True, max_length=255, verbose_name='Client ID (روش قدیمی/OAuth)')), + ('client_secret', models.CharField(blank=True, max_length=255, verbose_name='Client Secret (روش قدیمی/OAuth)')), + ('package_name', models.CharField(blank=True, max_length=150, verbose_name='application id اپ (مثلا ir.example.app)')), + ('dynamic_discount_secret', models.CharField(blank=True, help_text='از پیشخان → برنامه\u200cات → پرداخت درون\u200cبرنامه\u200cای → تخفیف پویا. فقط سمت سرور استفاده می\u200cشود (apps.billing.providers.dynamic_discount) — هیچ\u200cوقت نباید در اپ فلاتر ذخیره شود، وگرنه کلید افشا می\u200cشود و کافه\u200cبازار مسئولیتی نمی\u200cپذیرد.', max_length=255, verbose_name='کلید امضای تخفیف پویا')), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'تنظیمات کافه\u200cبازار', + 'verbose_name_plural': 'تنظیمات کافه\u200cبازار', + }, + ), + migrations.CreateModel( + name='FeaturePlan', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.SlugField(unique=True, verbose_name='کلید ریزاپ')), + ('name', models.CharField(max_length=150, verbose_name='نام نمایشی')), + ('is_purchasable', models.BooleanField(default=True, verbose_name='قابل\u200cفروش')), + ], + options={ + 'verbose_name': 'ریزاپ قابل\u200cفعال\u200cسازی', + 'verbose_name_plural': 'ریزاپ\u200cهای قابل\u200cفعال\u200cسازی', + }, + ), + migrations.CreateModel( + name='GemPack', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bazaar_sku', models.CharField(max_length=100, unique=True, verbose_name='SKU کافه\u200cبازار')), + ('display_name', models.CharField(max_length=150, verbose_name='نام نمایشی')), + ('gem_amount', models.PositiveIntegerField(verbose_name='تعداد جم')), + ('is_active', models.BooleanField(default=True, verbose_name='قابل\u200cخرید')), + ('sort_order', models.PositiveIntegerField(default=0)), + ], + options={ + 'verbose_name': 'بسته\u200cی جم', + 'verbose_name_plural': 'بسته\u200cهای جم', + 'ordering': ['sort_order', 'gem_amount'], + }, + ), + migrations.CreateModel( + name='BazaarPurchase', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('purchase_token', models.CharField(max_length=255, unique=True, verbose_name='Purchase Token')), + ('status', models.CharField(choices=[('pending', 'در انتظار تایید'), ('verified', 'تاییدشده'), ('failed', 'ناموفق')], default='pending', max_length=10)), + ('gems_granted', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('verified_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bazaar_purchases', to=settings.AUTH_USER_MODEL)), + ('gem_pack', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='billing.gempack')), + ], + options={ + 'verbose_name': 'خرید کافه\u200cبازار', + 'verbose_name_plural': 'خریدهای کافه\u200cبازار', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='GemTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.IntegerField(verbose_name='مقدار (مثبت/منفی)')), + ('reason', models.CharField(choices=[('purchase', 'خرید از کافه\u200cبازار'), ('feature_activation', 'فعال\u200cسازی ریزاپ'), ('referral_bonus', 'پاداش معرفی'), ('referral_milestone', 'پاداش دستاورد معرفی'), ('admin_adjustment', 'اصلاح دستی ادمین')], max_length=30)), + ('note', models.CharField(blank=True, max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gem_transactions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'تراکنش جم', + 'verbose_name_plural': 'تراکنش\u200cهای جم', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='FeatureActivation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('expires_at', models.DateTimeField(verbose_name='تا تاریخ')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_activations', to=settings.AUTH_USER_MODEL)), + ('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='billing.featureplan')), + ], + options={ + 'verbose_name': 'فعال\u200cسازی ریزاپ', + 'verbose_name_plural': 'فعال\u200cسازی\u200cهای ریزاپ', + 'constraints': [models.UniqueConstraint(fields=('user', 'feature'), name='unique_user_feature_activation')], + }, + ), + migrations.CreateModel( + name='FeaturePlanTier', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('duration_months', models.PositiveSmallIntegerField(verbose_name='مدت (ماه)')), + ('gem_price', models.PositiveIntegerField(verbose_name='قیمت (جم)')), + ('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tiers', to='billing.featureplan')), + ], + options={ + 'verbose_name': 'پلن قیمتی', + 'verbose_name_plural': 'پلن\u200cهای قیمتی', + 'ordering': ['feature', 'duration_months'], + 'constraints': [models.UniqueConstraint(fields=('feature', 'duration_months'), name='unique_feature_duration')], + }, + ), + ] diff --git a/apps/billing/migrations/0002_featureplan_is_free.py b/apps/billing/migrations/0002_featureplan_is_free.py new file mode 100644 index 0000000..98e1e90 --- /dev/null +++ b/apps/billing/migrations/0002_featureplan_is_free.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.15 on 2026-09-02 11:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='featureplan', + name='is_free', + field=models.BooleanField(default=False, help_text='اگر روشن باشد، این ابزار بدون جم و بدون دوره\u200cی آزمایشی در دسترس است.', verbose_name='رایگان'), + ), + ] diff --git a/apps/billing/migrations/0003_seed_feature_plans.py b/apps/billing/migrations/0003_seed_feature_plans.py new file mode 100644 index 0000000..e44e5e0 --- /dev/null +++ b/apps/billing/migrations/0003_seed_feature_plans.py @@ -0,0 +1,71 @@ +from django.db import migrations + +# داده‌ی پایه‌ی فروشگاه — ابزارها، پلن‌های قیمتی، و بسته‌های جم. +# +# این seed قبلا در یک مهاجرتِ جداگانه بود که همراهِ بقیه‌ی فایل‌های مهاجرت +# از دست رفت. نبودش دقیقا همان باگی است که کافه‌بازار گزارش کرد: وقتی هیچ +# FeaturePlan ای در دیتابیس نباشد، اندپوینت features/ لیستِ خالی می‌دهد، +# اپ برای هر ابزار isFeatureActive=false می‌گیرد و پیغام «این ابزار فعال +# نیست» را نشان می‌دهد — حتی در دوره‌ی آزمایشی و حتی بعد از خرید جم. +# +# دو ابزار عمدا رایگان‌اند (is_free=True): «مشتریان» و «دفتر درآمد و هزینه». +# طبق قوانین انتشار کافه‌بازار باید بخشی از امکانات پیش از هر خریدی +# در دسترس باشد؛ این دو، هسته‌ی دفترداریِ اپ‌اند و بدون آن‌ها بقیه‌ی +# ابزارها هم معنی نمی‌دهند، پس بهترین گزینه برای رایگان‌بودن‌اند. +# هر وقت خواستی می‌توانی از پنل ادمین (ریزاپ‌های قابل‌فعال‌سازی → ستون +# «رایگان») این ترکیب را عوض کنی، بدون نیاز به مهاجرت تازه. + +FEATURES = [ + # (key, name, is_free, [(months, gem_price), ...]) + ("customers", "مشتریان", True, []), + ("ledger", "دفتر درآمد و هزینه", True, []), + ("invoice", "فاکتور", False, [(1, 1000), (3, 2700), (6, 5000)]), + ("sms_bank", "پیام گروهی", False, [(1, 1200), (3, 3200), (6, 6000)]), + ("inventory", "انبارداری", False, [(1, 800), (3, 2200), (6, 4000)]), + ("orders", "پیگیری سفارش", False, [(1, 900), (3, 2400), (6, 4500)]), +] + +GEM_PACKS = [ + # (sku, name, gems, sort) + ("gem_pack_500", "۵۰۰ جم", 500, 1), + ("gem_pack_1200", "۱۲۰۰ جم", 1200, 2), + ("gem_pack_3000", "۳۰۰۰ جم", 3000, 3), +] + + +def seed(apps, schema_editor): + FeaturePlan = apps.get_model("billing", "FeaturePlan") + FeaturePlanTier = apps.get_model("billing", "FeaturePlanTier") + GemPack = apps.get_model("billing", "GemPack") + + for key, name, is_free, tiers in FEATURES: + feature, _ = FeaturePlan.objects.update_or_create( + key=key, + defaults={"name": name, "is_free": is_free, "is_purchasable": not is_free}, + ) + for months, price in tiers: + FeaturePlanTier.objects.update_or_create( + feature=feature, duration_months=months, defaults={"gem_price": price} + ) + + for sku, name, gems, sort_order in GEM_PACKS: + GemPack.objects.update_or_create( + bazaar_sku=sku, + defaults={"display_name": name, "gem_amount": gems, "sort_order": sort_order, "is_active": True}, + ) + + +def unseed(apps, schema_editor): + FeaturePlan = apps.get_model("billing", "FeaturePlan") + GemPack = apps.get_model("billing", "GemPack") + FeaturePlan.objects.filter(key__in=[f[0] for f in FEATURES]).delete() + GemPack.objects.filter(bazaar_sku__in=[g[0] for g in GEM_PACKS]).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("billing", "0002_featureplan_is_free"), + ] + + operations = [migrations.RunPython(seed, unseed)] diff --git a/apps/billing/migrations/0004_free_tier_split.py b/apps/billing/migrations/0004_free_tier_split.py new file mode 100644 index 0000000..31d07e1 --- /dev/null +++ b/apps/billing/migrations/0004_free_tier_split.py @@ -0,0 +1,53 @@ +from django.db import migrations + +# تغییرِ ترکیبِ رایگان/پولی طبق تصمیمِ محصول: +# رایگان (بدون اکانت): مشتریان، پیام گروهی، فاکتور +# پولی (فقط با جم): دفتر درآمد و هزینه، انبارداری، پیگیری سفارش +# +# سه ابزارِ رایگان عمدا همان‌هایی‌اند که بدونِ سرور هم معنی دارند (روی خودِ +# گوشی کار می‌کنند) تا واقعا بدونِ حساب کاربری قابل‌استفاده باشند. + +FREE_KEYS = ["customers", "sms_bank", "invoice"] +PAID_KEYS = ["ledger", "inventory", "orders"] + +# قیمتِ ابزارهایی که تازه پولی شده‌اند (اگر tier نداشته باشند). +PAID_TIERS = { + "ledger": [(1, 900), (3, 2400), (6, 4500)], + "inventory": [(1, 800), (3, 2200), (6, 4000)], + "orders": [(1, 900), (3, 2400), (6, 4500)], +} + + +def apply_split(apps, schema_editor): + FeaturePlan = apps.get_model("billing", "FeaturePlan") + FeaturePlanTier = apps.get_model("billing", "FeaturePlanTier") + + FeaturePlan.objects.filter(key__in=FREE_KEYS).update(is_free=True, is_purchasable=False) + # ابزارِ رایگان نباید قیمت داشته باشد. + FeaturePlanTier.objects.filter(feature__key__in=FREE_KEYS).delete() + + FeaturePlan.objects.filter(key__in=PAID_KEYS).update(is_free=False, is_purchasable=True) + for key, tiers in PAID_TIERS.items(): + feature = FeaturePlan.objects.filter(key=key).first() + if feature is None: + continue + for months, price in tiers: + FeaturePlanTier.objects.update_or_create( + feature=feature, duration_months=months, defaults={"gem_price": price} + ) + + +def revert_split(apps, schema_editor): + # برگشت به ترکیبِ قبلی (customers/ledger رایگان بودند). + FeaturePlan = apps.get_model("billing", "FeaturePlan") + FeaturePlan.objects.filter(key__in=["customers", "ledger"]).update(is_free=True, is_purchasable=False) + FeaturePlan.objects.filter(key__in=["invoice", "sms_bank", "inventory", "orders"]).update( + is_free=False, is_purchasable=True + ) + + +class Migration(migrations.Migration): + + dependencies = [("billing", "0003_seed_feature_plans")] + + operations = [migrations.RunPython(apply_split, revert_split)] diff --git a/apps/billing/models.py b/apps/billing/models.py index 47c6b61..3896925 100644 --- a/apps/billing/models.py +++ b/apps/billing/models.py @@ -110,6 +110,14 @@ class FeaturePlan(models.Model): key = models.SlugField(max_length=50, unique=True, verbose_name=_("کلید ریزاپ")) name = models.CharField(max_length=150, verbose_name=_("نام نمایشی")) is_purchasable = models.BooleanField(default=True, verbose_name=_("قابل‌فروش")) + # ابزارِ رایگان همیشه در دسترس است و هیچ‌وقت جم نمی‌خواهد. + # قوانین انتشار کافه‌بازار: بخشی از امکاناتِ برنامه باید رایگان باشد و + # کاربر بتواند پیش از هر خریدی واقعا با اپ کار کند. + is_free = models.BooleanField( + default=False, + verbose_name=_("رایگان"), + help_text=_("اگر روشن باشد، این ابزار بدون جم و بدون دوره‌ی آزمایشی در دسترس است."), + ) class Meta: verbose_name = _("ریزاپ قابل‌فعال‌سازی") diff --git a/apps/billing/providers/bazaar.py b/apps/billing/providers/bazaar.py index 6c2bf0d..a2977f1 100644 --- a/apps/billing/providers/bazaar.py +++ b/apps/billing/providers/bazaar.py @@ -1,35 +1,28 @@ -# apps/billing/providers/bazaar.py """ -تایید خرید کافه‌بازار — سمت سرور، طبق مستندات رسمی کافه‌بازار -(developers.cafebazaar.ir/fa/guidelines/in-app-billing). این تنها API ای -است که اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»، -تا این تابع True برنگرداند هیچ جمی اضافه نمی‌شود. +اتصال به API «پرداخت درون‌برنامه‌ای» کافه‌بازار (devapi v2) — گرفتن/تمدید +access_token و تایید خریدها. - GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name} - /inapp/{product_id}/purchases/{purchase_token}/ +دو روشِ کاملا متفاوت برای احراز هویت با همین API وجود دارد — قاطی‌کردنشان +دقیقا همان چیزی است که باعث خطا می‌شود، پس اینجا صریح از هم جدا شده‌اند: - پاسخ موفق شامل purchaseState=0 (خریداری‌شده، نه refund‌شده) است. + ۱) «روش جدید» (پیشخان → اپ → «API پیشخان بازار» → «دریافت توکن جدید»): + یک توکن ثابت (JWT، عملا بدون انقضا) می‌گیری و مستقیم در access_token + می‌چسبانی؛ access_token_expires_at را خالی می‌گذاری. این توکن باید در + هدر درخواست با کلید CAFEBAZAAR-PISHKHAN-API-SECRET فرستاده شود — نه + به‌صورت access_token در query string. هیچ OAuth/client_id لازم نیست. -دو روش احراز هویت (هر دو طبق مستندات رسمی): + ۲) «روش قدیمی» (OAuth با client_id/client_secret، تمدیدشونده): با + build_authorize_url → exchange_code_for_tokens یک access_token + موقت (و refresh_token) می‌گیری؛ access_token_expires_at پر می‌شود. + این access_token باید به‌صورت query param (?access_token=...) فرستاده + شود. verify_purchase خودش پیش از انقضا با refresh_token تمدیدش می‌کند. - «روش جدید» — توکن ثابتِ پیشخان (.../api/setting-up-new)، در هدر: - CAFEBAZAAR-PISHKHAN-API-SECRET: - این توکن API رفرش ندارد (اگر باطل شود باید دستی از پیشخان توکن تازه گرفت). - - «روش قدیمی» — OAuth2 (.../api/setting-up)، در پارامتر URL: - ?access_token= - با client_id/client_secret/refresh_token قابل تمدید خودکار است — دقیقا - همین رفرش خودکار اینجا با auto_refresh_access_token_if_needed پیاده شده. - -تشخیص این‌که کدام روش فعال است صرفا از روی BazaarSettings.access_token_ -expires_at: خالی=روش جدید (هدر)، پرشده=روش قدیمی/OAuth (پارامتر URL، با -رفرش خودکار پیش از انقضا). - -همه‌چیز از BazaarSettings (پنل ادمین) خوانده می‌شود، نه .env — چون باید بدون -ری‌دیپلوی قابل‌تعویض باشد. +مستندات رسمی: https://developers.cafebazaar.ir/fa/guidelines/in-app-billing """ import logging +from datetime import timedelta +from urllib.parse import urlencode import requests from django.utils import timezone @@ -38,130 +31,210 @@ from apps.billing.models import BazaarSettings logger = logging.getLogger(__name__) -REQUEST_TIMEOUT_SECONDS = 15 -DEVAPI_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api" -OAUTH_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth" -PISHKHAN_TOKEN_HEADER = "CAFEBAZAAR-PISHKHAN-API-SECRET" +AUTHORIZE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth/authorize/" +TOKEN_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth/token/" +VALIDATE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}/inapp/{sku}/purchases/{token}/" -# پیش از این فاصله تا انقضا، توکن OAuth را تمدید می‌کنیم تا وسط یک درخواست -# واقعی منقضی نشود (access_token های کافه‌بازار معمولا ۳۶۰۰ ثانیه عمر دارند). -REFRESH_SAFETY_MARGIN_SECONDS = 120 +REQUEST_TIMEOUT = 15 # ثانیه + +# اگر کمتر از این مدت تا انقضای access_token مانده باشد، پیش از استفاده +# با refresh_token تمدید می‌شود (به‌جای اینکه صبر کنیم واقعا 401 بگیریم). +REFRESH_MARGIN = timedelta(minutes=5) + +# purchaseState طبق مستندات کافه‌بازار (هم‌شکل با Google Play): +# 0 = خریداری‌شده، 1 = لغوشده، 2 = در انتظار (pending) +PURCHASE_STATE_PURCHASED = 0 class BazaarAuthError(Exception): - """احراز هویت با کافه‌بازار ممکن نیست (تنظیمات ناقص یا رفرش ناموفق).""" + """خطای قابل‌نمایش مستقیم به ادمین (پیام‌اش با messages.error در admin.py نشان داده می‌شود).""" -def build_authorize_url(redirect_uri: str) -> str: - """لینک شروع فلوی OAuth (روش قدیمی) — کاربر باید یک‌بار در مرورگر باز و - تایید کند. redirect_uri باید دقیقا همانی باشد که موقع ساخت Client در - پیشخان ثبت شده.""" +def _force_https(url: str) -> str: + """redirect_uri باید دقیقا با چیزی که موقع ساخت Client در پیشخان ثبت + شده یکی باشد — که تقریبا همیشه https است. اگر جنگو پشت ریورس‌پراکسی + باشد و SECURE_PROXY_SSL_HEADER تنظیم نشده باشد، request.build_absolute_uri + می‌تواند اشتباها http:// بسازد؛ این تابع همیشه https را تحمیل می‌کند تا + این باگِ رایج، دلیلِ خطای redirect_uri mismatch نشود. + + اگر واقعا در محیط توسعه‌ی محلی روی http کار می‌کنی و Client هم با + http ثبت شده، این تابع را همین‌جا موقتا حذف کن — برای پروداکشن همیشه + باید https باشد.""" + if url.startswith("http://"): + forced = "https://" + url[len("http://"):] + logger.warning("Bazaar OAuth callback_url ارسالی http بود، به https تبدیل شد: %s -> %s", url, forced) + return forced + return url + + +def build_authorize_url(callback_url: str) -> str: settings_obj = BazaarSettings.get_solo() if not settings_obj.client_id: - raise BazaarAuthError("Client ID در تنظیمات کافه‌بازار خالی است.") - return ( - f"{OAUTH_BASE_URL}/authorize/" - f"?response_type=code&access_type=offline" - f"&redirect_uri={redirect_uri}&client_id={settings_obj.client_id}" - ) + raise BazaarAuthError("ابتدا Client ID را در تنظیمات کافه‌بازار پر و ذخیره کن.") + + params = { + "response_type": "code", + "access_type": "offline", + "redirect_uri": _force_https(callback_url), + "client_id": settings_obj.client_id, + } + return f"{AUTHORIZE_URL}?{urlencode(params)}" -def exchange_code_for_tokens(code: str, redirect_uri: str) -> None: - """گام آخر فلوی OAuth — code گرفته‌شده از ریدایرکت (یا دستی کپی‌شده) را - به access_token/refresh_token تبدیل و در BazaarSettings ذخیره می‌کند.""" +def exchange_code_for_tokens(code: str, callback_url: str) -> None: + """بعد از برگشت کاربر با ?code=... از پیشخان صدا زده می‌شود (oauth_callback_view).""" settings_obj = BazaarSettings.get_solo() if not settings_obj.client_id or not settings_obj.client_secret: - raise BazaarAuthError("Client ID یا Client Secret در تنظیمات کافه‌بازار خالی است.") + raise BazaarAuthError("Client ID و Client Secret باید پیش از اتصال پر و ذخیره شده باشند.") - response = requests.post( - f"{OAUTH_BASE_URL}/token/", - data={ + response = _post_token_request( + { "grant_type": "authorization_code", "code": code, "client_id": settings_obj.client_id, "client_secret": settings_obj.client_secret, - "redirect_uri": redirect_uri, - }, - timeout=REQUEST_TIMEOUT_SECONDS, + "redirect_uri": _force_https(callback_url), + } ) - if not response.ok: - raise BazaarAuthError(f"تبادل کد ناموفق بود: {response.status_code} {response.text}") - - _save_token_response(settings_obj, response.json()) + _save_token_response(settings_obj, response) def _refresh_access_token(settings_obj: BazaarSettings) -> None: - if not settings_obj.client_id or not settings_obj.client_secret or not settings_obj.refresh_token: + if not settings_obj.refresh_token: raise BazaarAuthError( - "برای تمدید خودکار access_token باید client_id/client_secret/refresh_token هر سه ست باشند." + "access_token منقضی شده و refresh_token موجود نیست — از پنل ادمین دوباره «شروع اتصال به کافه‌بازار» را بزن." ) - - response = requests.post( - f"{OAUTH_BASE_URL}/token/", - data={ + response = _post_token_request( + { "grant_type": "refresh_token", "client_id": settings_obj.client_id, "client_secret": settings_obj.client_secret, "refresh_token": settings_obj.refresh_token, - }, - timeout=REQUEST_TIMEOUT_SECONDS, + } ) - if not response.ok: - raise BazaarAuthError(f"تمدید access_token ناموفق بود: {response.status_code} {response.text}") - - _save_token_response(settings_obj, response.json()) + # پاسخِ refresh_token معمولا خودِ refresh_token جدید را برنمی‌گرداند — + # یعنی همان قبلی همچنان معتبر می‌ماند؛ _save_token_response این را رعایت می‌کند. + _save_token_response(settings_obj, response) -def _save_token_response(settings_obj: BazaarSettings, body: dict) -> None: - settings_obj.access_token = body["access_token"] - # رفرش‌گرنت گاهی refresh_token جدید برنمی‌گرداند — قبلی را نگه می‌داریم. - if body.get("refresh_token"): - settings_obj.refresh_token = body["refresh_token"] - expires_in = body.get("expires_in", 3600) - settings_obj.access_token_expires_at = timezone.now() + timezone.timedelta(seconds=expires_in) - settings_obj.save( - update_fields=["access_token", "refresh_token", "access_token_expires_at", "updated_at"] - ) +def _post_token_request(data: dict) -> requests.Response: + try: + return requests.post(TOKEN_URL, data=data, timeout=REQUEST_TIMEOUT) + except requests.RequestException as exc: + logger.exception("درخواست توکن کافه‌بازار با خطای شبکه مواجه شد") + raise BazaarAuthError(f"اتصال به سرور کافه‌بازار برقرار نشد: {exc}") from exc -def _auth_kwargs_for_request(settings_obj: BazaarSettings) -> dict: - """توکنِ سالم برای یک درخواست devapi را برمی‌گرداند — هدر یا پارامتر URL، - بسته به این‌که توکن ثابتِ پیشخان است یا OAuth (و اگر OAuth و نزدیک - انقضاست، همین‌جا اول تمدیدش می‌کند).""" - is_oauth_managed = settings_obj.access_token_expires_at is not None +def _save_token_response(settings_obj: BazaarSettings, response: requests.Response) -> None: + if response.status_code >= 400: + try: + detail = response.json().get("error", response.text) + except ValueError: + detail = response.text + logger.warning("درخواست توکن کافه‌بازار رد شد: %s %s", response.status_code, detail) + # رایج‌ترین دلیلِ همین ارور: عدم تطابق redirect_uri با چیزی که در + # پیشخان ثبت شده (از نظر scheme/http-https یا اسلش انتهایی)، یا + # Client ID/Secret اشتباه. + raise BazaarAuthError( + f"کافه‌بازار درخواست را رد کرد ({response.status_code}): {detail}. " + "معمولا یعنی redirect_uri ثبت‌شده در پیشخان با آدرسی که سرور می‌فرستد یکی نیست " + "(https/http یا اسلش انتهایی را چک کن)، یا Client ID/Secret اشتباه است." + ) - if is_oauth_managed: - margin = timezone.timedelta(seconds=REFRESH_SAFETY_MARGIN_SECONDS) - if timezone.now() >= settings_obj.access_token_expires_at - margin: - _refresh_access_token(settings_obj) - return {"params": {"access_token": settings_obj.access_token}} + try: + data = response.json() + except ValueError as exc: + raise BazaarAuthError("پاسخ کافه‌بازار قابل‌خواندن (JSON) نبود.") from exc - return {"headers": {PISHKHAN_TOKEN_HEADER: settings_obj.access_token}} + settings_obj.access_token = data["access_token"] + settings_obj.access_token_expires_at = timezone.now() + timedelta(seconds=data.get("expires_in", 3600)) + if data.get("refresh_token"): + settings_obj.refresh_token = data["refresh_token"] + settings_obj.save(update_fields=["access_token", "access_token_expires_at", "refresh_token"]) -def verify_purchase(product_id: str, purchase_token: str) -> bool: +def _get_valid_settings() -> BazaarSettings: + """BazaarSettings را با access_token معتبر برمی‌گرداند. اگر روش OAuth + باشد (access_token_expires_at پر است) و توکن نزدیک انقضا/منقضی باشد، + خودش با refresh_token تمدید می‌کند. اگر «روش جدید» (پیشخان، بدون + انقضا) باشد دست‌نخورده برمی‌گردد.""" settings_obj = BazaarSettings.get_solo() - if not settings_obj.access_token or not settings_obj.package_name: - logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده") + if not settings_obj.package_name: + raise BazaarAuthError("Application id اپ در تنظیمات کافه‌بازار خالی است.") + if not settings_obj.access_token: + raise BazaarAuthError("هنوز هیچ access_token ای در تنظیمات کافه‌بازار ثبت نشده.") + + if settings_obj.access_token_expires_at and timezone.now() >= ( + settings_obj.access_token_expires_at - REFRESH_MARGIN + ): + _refresh_access_token(settings_obj) + settings_obj.refresh_from_db() + + return settings_obj + + +def _auth_kwargs(settings_obj: BazaarSettings) -> dict: + """دو روش کاملا متفاوتِ ارسال توکن به API کافه‌بازار وجود دارد و + قاطی‌کردنشان دقیقا همان چیزی است که باعث خطا می‌شود: + + - «روش جدید» (توکن ثابت پیشخان → «API پیشخان بازار» → «دریافت توکن + جدید»؛ در مدل با access_token_expires_at خالی مشخص می‌شود): باید در + هدر درخواست با کلید CAFEBAZAAR-PISHKHAN-API-SECRET فرستاده شود. + - «روش قدیمی» (OAuth با client_id/client_secret؛ access_token_expires_at + پر است): access_token به‌صورت query param فرستاده می‌شود. + + فرستادنِ توکنِ روش جدید به‌صورت query param (یا برعکس) باعث رد شدن + درخواست توسط کافه‌بازار می‌شود، حتی اگر خودِ توکن کاملا معتبر باشد.""" + if settings_obj.access_token_expires_at: + return {"params": {"access_token": settings_obj.access_token}} + return {"headers": {"CAFEBAZAAR-PISHKHAN-API-SECRET": settings_obj.access_token}} + + +def verify_purchase(sku: str, purchase_token: str) -> bool: + """فقط وقتی True برمی‌گرداند که کافه‌بازار خرید را «معتبر و پرداخت‌شده» + تایید کند (purchaseState == 0). هر خطا یا وضعیت نامشخص → False (یعنی + VerifyPurchaseView در views.py آن را ناموفق حساب می‌کند و جمی اضافه + نمی‌شود — امن‌ترین رفتار پیش‌فرض).""" + try: + settings_obj = _get_valid_settings() + except BazaarAuthError: + logger.exception("verify_purchase: تنظیمات/توکن کافه‌بازار آماده نیست") return False - url = f"{DEVAPI_BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/" + url = VALIDATE_URL.format(package_name=settings_obj.package_name, sku=sku, token=purchase_token) + + def _call(s: BazaarSettings) -> requests.Response: + return requests.get(url, timeout=REQUEST_TIMEOUT, **_auth_kwargs(s)) try: - auth_kwargs = _auth_kwargs_for_request(settings_obj) - except BazaarAuthError as exc: - logger.error(f"❌ احراز هویت کافه‌بازار ناموفق: {exc}") + response = _call(settings_obj) + except requests.RequestException: + logger.exception("تایید خرید کافه‌بازار با خطای شبکه مواجه شد") + return False + + if response.status_code == 401: + # توکن بین گرفتنش و همین لحظه باطل شده — این فقط برای روش OAuth + # (که refresh_token دارد) قابل‌جبران است؛ توکن ثابت روش جدید اگر رد + # شود یعنی از پیشخان باطل/عوض شده و باید دستی جایگزین شود. + if not settings_obj.refresh_token: + logger.warning("کافه‌بازار توکن را رد کرد (401) — اگر توکن «روش جدید» است، از پیشخان توکن تازه بگیر و جایگزین کن") + return False + try: + _refresh_access_token(settings_obj) + settings_obj.refresh_from_db() + response = _call(settings_obj) + except BazaarAuthError: + logger.exception("تمدید access_token بعد از 401 هم ناموفق بود") + return False + + if response.status_code != 200: + logger.warning("تایید خرید کافه‌بازار پاسخ غیرمنتظره داد: %s %s", response.status_code, response.text) return False try: - response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS, **auth_kwargs) - response.raise_for_status() - body = response.json() - purchase_state = body.get("purchaseState") - is_valid = purchase_state == 0 - if not is_valid: - logger.warning(f"❌ خرید کافه‌بازار نامعتبر: {body}") - return is_valid - except (requests.RequestException, ValueError) as exc: - logger.error(f"❌ خطا در تایید خرید کافه‌بازار ({product_id}/{purchase_token}): {exc}") + data = response.json() + except ValueError: + logger.warning("پاسخ تایید خرید کافه‌بازار JSON نبود: %s", response.text) return False + + return data.get("purchaseState") == PURCHASE_STATE_PURCHASED \ No newline at end of file diff --git a/apps/billing/templates/admin/billing/gem_gift.html b/apps/billing/templates/admin/billing/gem_gift.html new file mode 100644 index 0000000..5873827 --- /dev/null +++ b/apps/billing/templates/admin/billing/gem_gift.html @@ -0,0 +1,17 @@ +{% extends "admin/base_site.html" %} +{% block content %} +
+

+ به هر شماره‌ای که این‌جا وارد کنی، همان تعداد جم اضافه می‌شود و یک ردیف در + «تراکنش‌های جم» با علتِ «اصلاح دستی ادمین» ثبت می‌گردد. +

+
+ {% csrf_token %} + {{ form.as_p }} +
+ + انصراف +
+
+
+{% endblock %} diff --git a/apps/billing/templates/admin/billing/gemtransaction/change_list.html b/apps/billing/templates/admin/billing/gemtransaction/change_list.html new file mode 100644 index 0000000..d1f27f6 --- /dev/null +++ b/apps/billing/templates/admin/billing/gemtransaction/change_list.html @@ -0,0 +1,7 @@ +{% extends "admin/change_list.html" %} +{% block object-tools-items %} +
  • + هدیه‌ی جم به شماره‌ها +
  • + {{ block.super }} +{% endblock %} diff --git a/apps/billing/tests.py b/apps/billing/tests.py index d58c487..768f3c3 100644 --- a/apps/billing/tests.py +++ b/apps/billing/tests.py @@ -1,13 +1,20 @@ from unittest.mock import Mock, patch import jwt -from django.test import TestCase +from django.test import Client, TestCase from django.urls import reverse from django.utils import timezone from rest_framework.test import APIClient from apps.account.models import User -from apps.billing.models import BazaarPurchase, BazaarSettings, GemPack, GemTransaction +from apps.billing.api.services import FeatureAccessService +from apps.billing.models import ( + BazaarPurchase, + BazaarSettings, + FeaturePlan, + GemPack, + GemTransaction, +) from apps.billing.providers.bazaar import ( BazaarAuthError, build_authorize_url, @@ -90,7 +97,9 @@ class BazaarOAuthTests(TestCase): def test_build_authorize_url_shape(self): url = build_authorize_url("https://example.com/callback/") self.assertIn("client_id=client-123", url) - self.assertIn("redirect_uri=https://example.com/callback/", url) + # urlencode مقدار را percent-encode می‌کند — همین درست است، چون + # redirect_uri باید به‌شکلِ امنِ query-string فرستاده شود. + self.assertIn("redirect_uri=https%3A%2F%2Fexample.com%2Fcallback%2F", url) self.assertIn("response_type=code", url) @patch("apps.billing.providers.bazaar.requests.post") @@ -299,3 +308,183 @@ class PurchaseFlowIntegrationTests(TestCase): second_id = self._create_purchase(token="same-token") self.assertEqual(first_id, second_id) self.assertEqual(BazaarPurchase.objects.filter(purchase_token="same-token").count(), 1) + + +class FreeFeatureAccessTests(TestCase): + """قوانین انتشار کافه‌بازار: بخشی از امکانات باید بدون خرید در دسترس + باشد. این تست‌ها تضمین می‌کنند ابزارهای رایگان همیشه باز بمانند — حتی + بعد از پایانِ دوره‌ی آزمایشی.""" + + def setUp(self): + self.user = User.objects.create_user(phone="+989127777777", password="pass") + self.client = APIClient() + self.client.force_authenticate(self.user) + + def test_seed_creates_features_and_gem_packs(self): + # ریشه‌ی باگی که کافه‌بازار گزارش کرد: نبودِ داده‌ی پایه یعنی هر + # ابزار «فعال نیست» نشان داده می‌شود. + self.assertGreater(FeaturePlan.objects.count(), 0) + self.assertGreater(GemPack.objects.filter(is_active=True).count(), 0) + + def test_at_least_one_feature_is_free(self): + self.assertTrue( + FeaturePlan.objects.filter(is_free=True).exists(), + "حداقل یک ابزار باید رایگان باشد وگرنه انتشار رد می‌شود.", + ) + + def test_free_feature_is_active_without_trial_or_gems(self): + self.user.trial_expires_at = timezone.now() - timezone.timedelta(days=1) + self.user.gem_balance = 0 + self.user.save() + self.assertFalse(self.user.is_in_trial) + + for feature in FeaturePlan.objects.filter(is_free=True): + self.assertTrue( + FeatureAccessService.is_active(self.user, feature.key), + f"ابزار رایگان {feature.key} باید باز باشد.", + ) + + def test_paid_feature_is_locked_after_trial_ends(self): + self.user.trial_expires_at = timezone.now() - timezone.timedelta(days=1) + self.user.save() + paid = FeaturePlan.objects.filter(is_free=False).first() + self.assertIsNotNone(paid) + self.assertFalse(FeatureAccessService.is_active(self.user, paid.key)) + + def test_paid_feature_is_open_during_trial(self): + self.user.trial_expires_at = timezone.now() + timezone.timedelta(days=3) + self.user.save() + paid = FeaturePlan.objects.filter(is_free=False).first() + self.assertTrue(FeatureAccessService.is_active(self.user, paid.key)) + + def test_features_endpoint_reports_free_flag(self): + response = self.client.get(reverse("billing:feature-list")) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + free = [f for f in response.data if f["is_free"]] + self.assertGreater(len(free), 0) + for f in free: + self.assertTrue(f["is_active"]) + + def test_free_features_have_no_price_tiers(self): + # ابزارِ رایگان نباید در فروشگاه قیمت نشان دهد. + for feature in FeaturePlan.objects.filter(is_free=True): + self.assertEqual(feature.tiers.count(), 0) + + def test_expected_free_and_paid_split(self): + """ترکیبِ تصمیم‌گرفته‌شده‌ی محصول — اگر کسی سهوا عوضش کند، همین‌جا + گیر می‌افتد (چون رایگان‌بودنِ این سه، شرطِ انتشار در کافه‌بازار است).""" + free = set(FeaturePlan.objects.filter(is_free=True).values_list("key", flat=True)) + paid = set(FeaturePlan.objects.filter(is_free=False).values_list("key", flat=True)) + self.assertEqual(free, {"customers", "sms_bank", "invoice"}) + self.assertEqual(paid, {"ledger", "inventory", "orders"}) + + def test_paid_features_have_price_tiers(self): + for feature in FeaturePlan.objects.filter(is_free=False, is_purchasable=True): + self.assertGreater( + feature.tiers.count(), 0, f"{feature.key} قیمت ندارد." + ) + + +class GuestFeatureCatalogTests(TestCase): + """کاربرِ مهمان (بدون حساب) باید بتواند کاتالوگ ابزارها را ببیند و + بفهمد کدام رایگان است — وگرنه پیش از ورود همه‌چیز قفل به‌نظر می‌رسد.""" + + def setUp(self): + self.client = APIClient() # عمدا بدون احراز هویت + + def test_anonymous_can_list_features(self): + response = self.client.get(reverse("billing:feature-list")) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.data), 0) + + def test_free_features_report_active_for_anonymous(self): + response = self.client.get(reverse("billing:feature-list")) + by_key = {f["key"]: f for f in response.data} + for key in ["customers", "sms_bank", "invoice"]: + self.assertTrue(by_key[key]["is_free"], key) + self.assertTrue(by_key[key]["is_active"], key) + + def test_paid_features_are_inactive_for_anonymous(self): + response = self.client.get(reverse("billing:feature-list")) + by_key = {f["key"]: f for f in response.data} + for key in ["ledger", "inventory", "orders"]: + self.assertFalse(by_key[key]["is_active"], key) + + def test_anonymous_gets_no_expiry(self): + response = self.client.get(reverse("billing:feature-list")) + for f in response.data: + self.assertIsNone(f["expires_at"]) + + def test_wallet_still_requires_auth(self): + # کاتالوگ عمومی شد، ولی داده‌ی شخصی نه. + response = self.client.get(reverse("billing:wallet")) + self.assertIn(response.status_code, (401, 403)) + + +class GemGiftAdminTests(TestCase): + """هدیه‌ی جم از پنل ادمین — موجودی و تاریخچه باید با هم جلو بروند.""" + + def setUp(self): + self.staff = User.objects.create_superuser(phone="09120000001", password="x") + self.client = Client() + self.client.force_login(self.staff) + self.url = reverse("admin:billing_gemtransaction_gift") + + def test_phone_normalization_accepts_the_shapes_admins_actually_paste(self): + from apps.billing.forms import normalize_iran_phone + + for raw in ["09121234567", "+989121234567", "00989121234567", + "9121234567", "۰۹۱۲۱۲۳۴۵۶۷", "0912 123 4567"]: + self.assertEqual(normalize_iran_phone(raw), "09121234567", raw) + + def test_gift_adds_gems_and_records_history(self): + target = User.objects.create(phone="09121112233") + response = self.client.post( + self.url, + {"phones": "09121112233", "amount": 250, "note": "جبران خطا"}, + ) + self.assertEqual(response.status_code, 302) + target.refresh_from_db() + self.assertEqual(target.gem_balance, 250) + tx = GemTransaction.objects.get(user=target) + self.assertEqual(tx.amount, 250) + self.assertEqual(tx.reason, GemTransaction.Reason.ADMIN_ADJUSTMENT) + self.assertEqual(tx.note, "جبران خطا") + + def test_gift_handles_several_numbers_in_one_go(self): + a = User.objects.create(phone="09121112233") + b = User.objects.create(phone="09124445566") + self.client.post( + self.url, + {"phones": "09121112233\n+989124445566", "amount": 100}, + ) + a.refresh_from_db() + b.refresh_from_db() + self.assertEqual((a.gem_balance, b.gem_balance), (100, 100)) + + def test_unknown_number_is_skipped_but_the_rest_still_get_gems(self): + known = User.objects.create(phone="09121112233") + self.client.post( + self.url, + {"phones": "09121112233\n09129998877", "amount": 50}, + ) + known.refresh_from_db() + self.assertEqual(known.gem_balance, 50) + self.assertFalse(User.objects.filter(phone="09129998877").exists()) + + def test_create_missing_makes_the_user_so_the_gift_waits_for_them(self): + self.client.post( + self.url, + {"phones": "09129998877", "amount": 75, "create_missing": "on"}, + ) + created = User.objects.get(phone="09129998877") + self.assertEqual(created.gem_balance, 75) + + def test_invalid_number_rejects_the_whole_form_rather_than_half_applying(self): + User.objects.create(phone="09121112233") + response = self.client.post( + self.url, {"phones": "09121112233\n12345", "amount": 10} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(GemTransaction.objects.count(), 0) diff --git a/apps/bizapps/admin.py b/apps/bizapps/admin.py index d4a8df0..1c3190c 100644 --- a/apps/bizapps/admin.py +++ b/apps/bizapps/admin.py @@ -1,40 +1,82 @@ from django.contrib import admin -from .models import Appointment, Customer, LedgerEntry, Product, StockMovement +from .models import ( + Account, + Customer, + Invoice, + InvoiceItem, + LedgerEntry, + LedgerInstallment, + Product, + StockMovement, + Transaction, +) + + +@admin.register(Account) +class AccountAdmin(admin.ModelAdmin): + list_display = ("code", "name", "level", "account_type", "parent", "is_system", "is_active") + list_filter = ("level", "account_type", "is_system", "is_active") + search_fields = ("code", "name", "user__phone") + autocomplete_fields = ["parent"] @admin.register(Customer) class CustomerAdmin(admin.ModelAdmin): - list_display = ("name", "phone", "user", "updated_at") - search_fields = ("name", "phone", "email", "user__phone") + list_display = ("name", "phone", "outstanding_balance", "total_purchases_count", "last_purchase_date") + search_fields = ("name", "phone", "user__phone") + readonly_fields = ( + "total_purchases_count", "total_purchase_amount", "last_purchase_date", "outstanding_balance", + ) @admin.register(LedgerEntry) class LedgerEntryAdmin(admin.ModelAdmin): - list_display = ("title", "entry_type", "amount", "entry_date", "user") + list_display = ("title", "entry_type", "category", "amount", "entry_date", "counterparty_name") list_filter = ("entry_type", "category") - search_fields = ("title", "user__phone") + search_fields = ("title", "counterparty_name", "counterparty_phone", "user__phone") + + +@admin.register(LedgerInstallment) +class LedgerInstallmentAdmin(admin.ModelAdmin): + list_display = ("ledger_entry", "amount", "paid_date", "reference") + search_fields = ("reference", "ledger_entry__title") @admin.register(Product) class ProductAdmin(admin.ModelAdmin): - list_display = ("name", "sku", "category", "quantity", "unit_price", "user") + list_display = ("name", "sku", "category", "quantity", "unit", "unit_price", "total_purchase_value") list_filter = ("category",) search_fields = ("name", "sku", "user__phone") + readonly_fields = ("total_purchase_value",) @admin.register(StockMovement) class StockMovementAdmin(admin.ModelAdmin): - list_display = ("product", "delta", "quantity_after", "created_at") - search_fields = ("product__name", "product__user__phone") - readonly_fields = ("product", "delta", "quantity_after", "note", "created_at") - - def has_add_permission(self, request): - return False + list_display = ("product", "delta", "quantity_after", "note", "created_at") + search_fields = ("product__name",) -@admin.register(Appointment) -class AppointmentAdmin(admin.ModelAdmin): - list_display = ("customer_name", "title", "start_at", "status", "user") - list_filter = ("status",) - search_fields = ("customer_name", "customer_phone", "user__phone") +class InvoiceItemInline(admin.TabularInline): + model = InvoiceItem + extra = 0 + + +@admin.register(Invoice) +class InvoiceAdmin(admin.ModelAdmin): + list_display = ( + "invoice_number", "buyer_name", "final_amount", "payment_type", "payment_status", "order_status", + "invoice_date", + ) + list_filter = ("payment_type", "payment_status", "order_status") + search_fields = ("invoice_number", "buyer_name", "buyer_phone", "user__phone") + inlines = [InvoiceItemInline] + + +@admin.register(Transaction) +class TransactionAdmin(admin.ModelAdmin): + list_display = ( + "transaction_number", "transaction_type", "debit_account", "credit_account", "amount", "transaction_date", + ) + list_filter = ("transaction_type",) + search_fields = ("transaction_number", "description", "user__phone") diff --git a/apps/bizapps/api/accounting.py b/apps/bizapps/api/accounting.py new file mode 100644 index 0000000..b258d69 --- /dev/null +++ b/apps/bizapps/api/accounting.py @@ -0,0 +1,627 @@ +""" +لایه‌ی حسابداریِ حرفه‌ای — درختِ حساب‌ها (کل/معین/تفصیلی)، ترازِ حساب‌ها، و +خروجیِ اکسلِ چندبرگی. + +کاربرِ اپ هیچ‌وقت مجبور نیست این لایه را ببیند؛ او فقط یک دسته‌ی آشنا +(«فروش قسطی»، «اجاره»…) انتخاب می‌کند. این ماژول همان انتخاب را به یک +حسابِ معینِ واقعی نگاشت می‌کند تا گزارش‌ها، ترازِ حساب‌ها و خروجیِ اکسل +از یک ساختارِ درستِ حسابداری ساخته شوند. +""" + +import io +import uuid +from datetime import date + +from django.db import transaction as db_transaction +from django.db.models import Q, Sum +from django.utils import timezone + +from apps.account.models import User +from apps.bizapps.models import ( + Account, + Customer, + Invoice, + LedgerBook, + LedgerEntry, + Product, + Transaction, +) + +# درختِ پیش‌فرضِ حساب‌ها برای یک کسب‌وکار خرد — عمداً کوچک نگه داشته شده +# (پنج حساب کل، چند معین زیر هرکدام) چون مخاطبِ این اپ مغازه‌دار است نه +# شرکتِ حسابداری. کاربر می‌تواند از پنل خودش حسابِ تازه اضافه کند. +DEFAULT_CHART = [ + # (code, name, level, type, parent_code) + ("1000", "دارایی‌ها", 1, Account.AccountType.ASSET, None), + ("1001", "صندوق", 2, Account.AccountType.ASSET, "1000"), + ("1002", "حساب بانکی", 2, Account.AccountType.ASSET, "1000"), + ("1100", "حساب‌های دریافتنی", 2, Account.AccountType.ASSET, "1000"), + ("1200", "موجودی انبار", 2, Account.AccountType.ASSET, "1000"), + ("2000", "بدهی‌ها", 1, Account.AccountType.LIABILITY, None), + ("2100", "حساب‌های پرداختنی", 2, Account.AccountType.LIABILITY, "2000"), + ("3000", "سرمایه", 1, Account.AccountType.EQUITY, None), + ("3001", "سرمایه‌ی مالک", 2, Account.AccountType.EQUITY, "3000"), + ("4000", "درآمدها", 1, Account.AccountType.INCOME, None), + ("4001", "فروش کالا", 2, Account.AccountType.INCOME, "4000"), + ("4002", "درآمد خدمات", 2, Account.AccountType.INCOME, "4000"), + ("4003", "سایر درآمدها", 2, Account.AccountType.INCOME, "4000"), + ("5000", "هزینه‌ها", 1, Account.AccountType.EXPENSE, None), + ("5001", "بهای تمام‌شده‌ی کالای فروش‌رفته", 2, Account.AccountType.EXPENSE, "5000"), + ("5002", "اجاره", 2, Account.AccountType.EXPENSE, "5000"), + ("5003", "حقوق و دستمزد", 2, Account.AccountType.EXPENSE, "5000"), + ("5004", "قبض و آب‌وبرق", 2, Account.AccountType.EXPENSE, "5000"), + ("5005", "حمل‌ونقل", 2, Account.AccountType.EXPENSE, "5000"), + ("5006", "سایر هزینه‌ها", 2, Account.AccountType.EXPENSE, "5000"), +] + +ACCOUNT_CASH = "1001" +ACCOUNT_RECEIVABLE = "1100" +ACCOUNT_PAYABLE = "2100" +ACCOUNT_SALES = "4001" +ACCOUNT_INVENTORY = "1200" + + +class ChartOfAccountsService: + @staticmethod + def ensure_chart(user: User) -> None: + """درختِ پیش‌فرض را برای کاربر می‌سازد اگر هنوز ساخته نشده باشد. + idempotent است — اجرای دوباره چیزی را دوباره نمی‌سازد.""" + existing = set(Account.objects.filter(user=user).values_list("code", flat=True)) + if len(existing) >= len(DEFAULT_CHART): + return + + by_code: dict[str, Account] = { + a.code: a for a in Account.objects.filter(user=user) + } + # دو پاس: اول حساب‌های کل (بدون والد)، بعد معین‌ها که به والد نیاز دارند. + for level_pass in (1, 2, 3): + for code, name, level, acc_type, parent_code in DEFAULT_CHART: + if level != level_pass or code in by_code: + continue + account = Account.objects.create( + id=str(uuid.uuid4()), + user=user, + code=code, + name=name, + level=level, + account_type=acc_type, + parent=by_code.get(parent_code) if parent_code else None, + is_system=True, + ) + by_code[code] = account + + @staticmethod + def get(user: User, code: str) -> Account | None: + """حسابِ معین را با کدش برمی‌گرداند؛ اگر درخت هنوز ساخته نشده، + اول می‌سازدش.""" + account = Account.objects.filter(user=user, code=code).first() + if account is None: + ChartOfAccountsService.ensure_chart(user) + account = Account.objects.filter(user=user, code=code).first() + return account + + @staticmethod + def ensure_customer_account(user: User, customer: Customer) -> Account: + """برای هر مشتری یک حسابِ تفصیلی زیرِ «حساب‌های دریافتنی» می‌سازد — + همان چیزی که بدهیِ او را در درختِ حساب‌ها قابل‌ردیابی می‌کند.""" + if customer.account_id: + return customer.account + + parent = ChartOfAccountsService.get(user, ACCOUNT_RECEIVABLE) + # کدِ تفصیلی: 1100-0001, 1100-0002, … + used = Account.objects.filter(user=user, parent=parent).count() + code = f"{ACCOUNT_RECEIVABLE}-{used + 1:04d}" + account = Account.objects.create( + id=str(uuid.uuid4()), + user=user, + code=code, + name=customer.name, + level=Account.Level.TAFSILI, + account_type=Account.AccountType.ASSET, + parent=parent, + is_system=False, + ) + customer.account = account + customer.save(update_fields=["account", "updated_at"]) + return account + + @staticmethod + def trial_balance(user: User, start: date | None = None, end: date | None = None) -> list[dict]: + """ترازِ حساب‌ها — برای هر حسابِ معین، جمعِ بدهکار و بستانکار و + مانده (با در نظر گرفتنِ ماهیتِ طبیعیِ حساب).""" + ChartOfAccountsService.ensure_chart(user) + qs = Transaction.objects.filter(user=user) + if start: + qs = qs.filter(transaction_date__date__gte=start) + if end: + qs = qs.filter(transaction_date__date__lte=end) + + debit = dict( + qs.exclude(debit_account_ref=None) + .values_list("debit_account_ref") + .annotate(total=Sum("amount")) + ) + credit = dict( + qs.exclude(credit_account_ref=None) + .values_list("credit_account_ref") + .annotate(total=Sum("amount")) + ) + + rows = [] + for account in Account.objects.filter(user=user, level=Account.Level.MOIN).order_by("code"): + d = debit.get(account.id, 0) or 0 + c = credit.get(account.id, 0) or 0 + if d == 0 and c == 0: + continue + balance = (d - c) if account.is_debit_nature else (c - d) + rows.append( + { + "code": account.code, + "name": account.name, + "path": account.full_path, + "account_type": account.get_account_type_display(), + "debit": d, + "credit": c, + "balance": balance, + } + ) + return rows + + +class CustomerStatementService: + """صورت‌حسابِ یک مشتری — فاکتورها، بدهی‌های باز، و اقساطِ پرداختی، همه + در یک جا. همان چیزی که «اتصالِ مشتری به فاکتور و حسابداری» را برای + کاربر ملموس می‌کند.""" + + @staticmethod + def build(user: User, customer: Customer) -> dict: + invoices = ( + Invoice.objects.filter(user=user) + .filter(Q(customer=customer) | Q(buyer_phone=customer.phone)) + .exclude(buyer_phone="") + .order_by("-invoice_date") + if customer.phone + else Invoice.objects.filter(user=user, customer=customer).order_by("-invoice_date") + ) + debts = ( + LedgerEntry.objects.filter( + user=user, + category=LedgerEntry.Category.SALE_CREDIT, + counterparty_phone=customer.phone, + ).prefetch_related("installments") + if customer.phone + else LedgerEntry.objects.none() + ) + + debt_rows = [] + for entry in debts: + debt_rows.append( + { + "id": entry.id, + "title": entry.title, + "entry_date": entry.entry_date, + "total_amount": entry.total_amount, + "paid_amount": entry.paid_amount, + "remaining_amount": entry.remaining_amount, + "installments": [ + {"amount": i.amount, "paid_date": i.paid_date, "reference": i.reference} + for i in entry.installments.all() + ], + } + ) + + return { + "customer": { + "id": customer.id, + "name": customer.name, + "phone": customer.phone, + "account_code": customer.account.code if customer.account_id else "", + "outstanding_balance": customer.outstanding_balance, + "total_purchases_count": customer.total_purchases_count, + "total_purchase_amount": customer.total_purchase_amount, + "last_purchase_date": customer.last_purchase_date, + }, + "invoices": [ + { + "id": inv.id, + "invoice_number": inv.invoice_number, + "invoice_date": inv.invoice_date, + "final_amount": inv.final_amount, + "payment_type": inv.get_payment_type_display(), + "payment_status": inv.get_payment_status_display(), + "order_status": inv.get_order_status_display(), + } + for inv in invoices + ], + "debts": debt_rows, + } + + +class ExcelExportService: + """خروجیِ اکسلِ واقعی (.xlsx) و چندبرگی — به‌جای یک CSV تک‌برگیِ خام. + + برگه‌ها: خلاصه، دفتر روزنامه، تراز حساب‌ها، بدهکاران، فاکتورها، انبار. + هدفِ صریح: «به دردِ مشتری بخورد» — یعنی همان چیزی که یک مغازه‌دار + می‌تواند مستقیم برای حسابدارش بفرستد، با ستون‌های فارسیِ راست‌چین، + عددهای هزارگان‌دار، و جمعِ ستون‌ها.""" + + HEADER_FILL = "FF2D2A55" # سرمه‌ای برند + MONEY_FORMAT = "#,##0" + + @staticmethod + def _style_sheet(ws, header_row: int = 1) -> None: + from openpyxl.styles import Alignment, Font, PatternFill + + ws.sheet_view.rightToLeft = True + fill = PatternFill("solid", fgColor=ExcelExportService.HEADER_FILL) + for cell in ws[header_row]: + if cell.value is None: + continue + cell.font = Font(bold=True, color="FFFFFFFF") + cell.fill = fill + cell.alignment = Alignment(horizontal="center", vertical="center") + ws.freeze_panes = ws.cell(row=header_row + 1, column=1) + + @staticmethod + def _autosize(ws) -> None: + for column_cells in ws.columns: + longest = 0 + letter = None + for cell in column_cells: + letter = letter or cell.column_letter + value = cell.value + if value is not None: + longest = max(longest, len(str(value))) + if letter: + ws.column_dimensions[letter].width = min(max(longest + 4, 12), 45) + + @staticmethod + def _write(ws, headers: list[str], rows: list[list], money_columns: set[int] | None = None) -> None: + ws.append(headers) + for row in rows: + ws.append(row) + if money_columns: + for row_cells in ws.iter_rows(min_row=2): + for idx in money_columns: + if idx < len(row_cells): + row_cells[idx].number_format = ExcelExportService.MONEY_FORMAT + ExcelExportService._style_sheet(ws) + ExcelExportService._autosize(ws) + + @staticmethod + def build(user: User, start: date | None = None, end: date | None = None) -> bytes: + from openpyxl import Workbook + + ChartOfAccountsService.ensure_chart(user) + wb = Workbook() + + entries = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related( + "installments", "account" + ) + invoices = Invoice.objects.filter(user=user).order_by("invoice_date") + if start: + entries = entries.filter(entry_date__gte=start) + invoices = invoices.filter(invoice_date__date__gte=start) + if end: + entries = entries.filter(entry_date__lte=end) + invoices = invoices.filter(invoice_date__date__lte=end) + + entries = list(entries) + invoices = list(invoices) + + # --- برگه ۱: خلاصه --- + ws = wb.active + ws.title = "خلاصه" + total_income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME) + total_expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE) + open_receivable = sum( + e.remaining_amount for e in entries if e.category == LedgerEntry.Category.SALE_CREDIT + ) + open_payable = sum( + e.remaining_amount for e in entries if e.category == LedgerEntry.Category.PURCHASE_CREDIT + ) + inventory_value = ( + Product.objects.filter(user=user).aggregate(v=Sum("total_purchase_value"))["v"] or 0 + ) + ExcelExportService._write( + ws, + ["عنوان", "مبلغ"], + [ + ["بازه‌ی گزارش", f"{start or 'ابتدا'} تا {end or 'امروز'}"], + ["مجموع درآمد", total_income], + ["مجموع هزینه", total_expense], + ["سود/زیان خالص", total_income - total_expense], + ["مانده‌ی مطالبات (طلب از مشتریان)", open_receivable], + ["مانده‌ی بدهی‌ها (به تامین‌کنندگان)", open_payable], + ["ارزش موجودی انبار", inventory_value], + ["تعداد فاکتورها", len(invoices)], + ], + money_columns={1}, + ) + + # --- برگه ۲: دفتر روزنامه --- + ws = wb.create_sheet("دفتر روزنامه") + ExcelExportService._write( + ws, + [ + "تاریخ", "نوع", "دسته", "حساب معین", "عنوان", "مبلغ", + "طرف حساب", "شماره طرف حساب", "مبلغ کل تعهد", "پرداخت‌شده", "مانده", "یادداشت", + ], + [ + [ + e.entry_date.isoformat(), + e.get_entry_type_display(), + e.get_category_display() if e.category else "", + e.account.name if e.account_id else "", + e.title, + e.amount, + e.counterparty_name, + e.counterparty_phone, + e.total_amount if e.is_debt else None, + e.paid_amount if e.is_debt else None, + e.remaining_amount if e.is_debt else None, + e.note, + ] + for e in entries + ], + money_columns={5, 8, 9, 10}, + ) + + # --- برگه ۳: تراز حساب‌ها --- + ws = wb.create_sheet("تراز حساب‌ها") + ExcelExportService._write( + ws, + ["کد حساب", "نام حساب", "مسیر", "نوع", "بدهکار", "بستانکار", "مانده"], + [ + [r["code"], r["name"], r["path"], r["account_type"], r["debit"], r["credit"], r["balance"]] + for r in ChartOfAccountsService.trial_balance(user, start, end) + ], + money_columns={4, 5, 6}, + ) + + # --- برگه ۴: بدهکاران --- + ws = wb.create_sheet("بدهکاران") + debtors = [ + c for c in Customer.objects.filter(user=user).order_by("-outstanding_balance") + if c.outstanding_balance + ] + ExcelExportService._write( + ws, + ["نام مشتری", "شماره تماس", "کد حساب تفصیلی", "مانده بدهی", "تعداد خرید", "مجموع خرید", "آخرین خرید"], + [ + [ + c.name, + c.phone, + c.account.code if c.account_id else "", + c.outstanding_balance, + c.total_purchases_count, + c.total_purchase_amount, + c.last_purchase_date.date().isoformat() if c.last_purchase_date else "", + ] + for c in debtors + ], + money_columns={3, 5}, + ) + + # --- برگه ۵: فاکتورها --- + ws = wb.create_sheet("فاکتورها") + ExcelExportService._write( + ws, + ["شماره فاکتور", "تاریخ", "خریدار", "شماره خریدار", "جمع کل", "تخفیف", "مبلغ نهایی", "نوع پرداخت", "وضعیت پرداخت", "وضعیت سفارش"], + [ + [ + inv.invoice_number, + inv.invoice_date.date().isoformat(), + inv.buyer_name, + inv.buyer_phone, + inv.total_amount, + inv.discount_amount, + inv.final_amount, + inv.get_payment_type_display(), + inv.get_payment_status_display(), + inv.get_order_status_display(), + ] + for inv in invoices + ], + money_columns={4, 5, 6}, + ) + + # --- برگه ۶: انبار --- + ws = wb.create_sheet("انبار") + ExcelExportService._write( + ws, + ["نام کالا", "کد کالا", "دسته", "واحد", "موجودی", "قیمت خرید", "قیمت فروش", "ارزش موجودی", "وضعیت"], + [ + [ + p.name, + p.sku, + p.category, + p.unit, + p.quantity, + p.purchase_price, + p.unit_price, + p.total_purchase_value, + "کمبود موجودی" if p.is_low_stock else "", + ] + for p in Product.objects.filter(user=user).order_by("category", "name") + ], + money_columns={5, 6, 7}, + ) + + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +class LedgerBookService: + """مدیریت «فایل»های دفتر — باز کردن فایل تازه، بستن فایل، و تضمینِ + این‌که همیشه دقیقا یک فایلِ جاری وجود دارد.""" + + @staticmethod + def ensure_active_book(user: User) -> "LedgerBook": + """فایلِ جاری را برمی‌گرداند؛ اگر کاربر هنوز هیچ فایلی ندارد، یک + فایلِ پیش‌فرض می‌سازد تا هیچ‌وقت حالتِ «هیچ فایلی نیست» پیش نیاید.""" + from apps.bizapps.models import LedgerBook + + book = LedgerBook.objects.filter(user=user, is_active=True).first() + if book is not None: + return book + + today = timezone.localdate() + book = LedgerBook.objects.create( + id=str(uuid.uuid4()), + user=user, + name=f"دفتر {today.year}", + start_date=today, + is_active=True, + ) + # ثبت‌های قدیمیِ بی‌فایل به همین فایلِ اول وصل می‌شوند تا از + # گزارش‌ها ناپدید نشوند. + LedgerEntry.objects.filter(user=user, book__isnull=True).update(book=book) + return book + + @staticmethod + @db_transaction.atomic + def open_new_book( + user: User, + name: str, + start_date: date, + end_date: date | None = None, + carry_balance: bool = True, + ) -> "LedgerBook": + """فایل تازه باز می‌کند: فایلِ جاریِ قبلی بسته و فقط-خواندنی می‌شود، + و اگر خواسته شود مانده‌ی پایانیِ آن به‌عنوان مانده‌ی ابتدای فایلِ + جدید منتقل می‌گردد (کارِ استانداردِ «انتقال مانده» سرِ سال مالی).""" + from apps.bizapps.models import LedgerBook + + previous = LedgerBook.objects.select_for_update().filter(user=user, is_active=True).first() + opening = 0.0 + if previous is not None: + 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"]) + + return LedgerBook.objects.create( + id=str(uuid.uuid4()), + user=user, + name=name, + start_date=start_date, + end_date=end_date, + opening_balance=opening, + is_active=True, + ) + + @staticmethod + def closing_balance(book: "LedgerBook") -> float: + """مانده‌ی پایانیِ یک فایل = مانده‌ی ابتدا + درآمدها − هزینه‌ها.""" + entries = book.entries.all() + income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME) + expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE) + return (book.opening_balance or 0) + income - expense + + @staticmethod + def summary(book: "LedgerBook") -> dict: + entries = list(book.entries.prefetch_related("installments")) + income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME) + expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE) + return { + "entry_count": len(entries), + "total_income": income, + "total_expense": expense, + "net": income - expense, + "opening_balance": book.opening_balance or 0, + "closing_balance": (book.opening_balance or 0) + income - expense, + "open_receivable": sum( + e.remaining_amount for e in entries if e.category == LedgerEntry.Category.SALE_CREDIT + ), + "open_payable": sum( + e.remaining_amount for e in entries if e.category == LedgerEntry.Category.PURCHASE_CREDIT + ), + } + + +class SubsidiaryLedgerService: + """دفتر معین — برای یک حسابِ معینِ مشخص، همه‌ی گردشِ آن حساب را به‌ترتیبِ + تاریخ با «مانده‌ی در جریان» (running balance) برمی‌گرداند. + + این دقیقا همان چیزی است که حسابدار انتظار دارد ببیند: تاریخ، شرح، + بدهکار، بستانکار، مانده — و مانده بعد از هر ردیف به‌روز می‌شود.""" + + @staticmethod + def statement( + user: User, + account: Account, + start: date | None = None, + end: date | None = None, + ) -> dict: + qs = ( + Transaction.objects.filter(user=user) + .filter(Q(debit_account_ref=account) | Q(credit_account_ref=account)) + .order_by("transaction_date", "created_at") + ) + if start: + qs = qs.filter(transaction_date__date__gte=start) + if end: + qs = qs.filter(transaction_date__date__lte=end) + + # مانده‌ی ابتدای دوره: اثرِ همه‌ی اسنادِ پیش از بازه. + opening = 0.0 + if start: + for t in ( + Transaction.objects.filter(user=user) + .filter(Q(debit_account_ref=account) | Q(credit_account_ref=account)) + .filter(transaction_date__date__lt=start) + ): + opening += SubsidiaryLedgerService._effect(account, t) + + rows = [] + balance = opening + total_debit = 0.0 + total_credit = 0.0 + for t in qs: + debit = t.amount if t.debit_account_ref_id == account.id else 0 + credit = t.amount if t.credit_account_ref_id == account.id else 0 + balance += SubsidiaryLedgerService._effect(account, t) + total_debit += debit + total_credit += credit + rows.append( + { + "id": t.id, + "date": t.transaction_date, + "number": t.transaction_number, + "description": t.description, + "type": t.get_transaction_type_display(), + "invoice_id": t.reference_invoice_id, + "debit": debit, + "credit": credit, + "balance": balance, + } + ) + + return { + "account": { + "id": account.id, + "code": account.code, + "name": account.name, + "path": account.full_path, + "account_type": account.get_account_type_display(), + "is_debit_nature": account.is_debit_nature, + }, + "opening_balance": opening, + "total_debit": total_debit, + "total_credit": total_credit, + "closing_balance": balance, + "rows": rows, + } + + @staticmethod + def _effect(account: Account, t: Transaction) -> float: + """اثرِ یک سند روی مانده‌ی این حساب، با در نظر گرفتنِ ماهیتِ طبیعیِ + حساب — برای حسابِ بدهکار (دارایی/هزینه) بدهکار مثبت است، برای + حسابِ بستانکار (درآمد/بدهی/سرمایه) برعکس.""" + debit = t.amount if t.debit_account_ref_id == account.id else 0 + credit = t.amount if t.credit_account_ref_id == account.id else 0 + return (debit - credit) if account.is_debit_nature else (credit - debit) diff --git a/apps/bizapps/api/serializers.py b/apps/bizapps/api/serializers.py index c056f80..a95e0e1 100644 --- a/apps/bizapps/api/serializers.py +++ b/apps/bizapps/api/serializers.py @@ -1,37 +1,120 @@ from rest_framework import serializers -from apps.bizapps.models import Appointment, Customer, LedgerEntry, Product, StockMovement +from apps.bizapps.models import ( + Account, + Customer, + Invoice, + InvoiceItem, + LedgerBook, + LedgerEntry, + LedgerInstallment, + Product, + StockMovement, + Transaction, +) + + +class AccountSerializer(serializers.ModelSerializer): + full_path = serializers.CharField(read_only=True) + level_display = serializers.CharField(source="get_level_display", read_only=True) + account_type_display = serializers.CharField(source="get_account_type_display", read_only=True) + + class Meta: + model = Account + fields = [ + "id", "code", "name", "level", "level_display", "account_type", + "account_type_display", "parent", "full_path", "is_system", "is_active", + ] + read_only_fields = ["is_system"] class CustomerSerializer(serializers.ModelSerializer): + account_code = serializers.CharField(source="account.code", read_only=True, default="") + class Meta: model = Customer - fields = ["id", "name", "phone", "email", "address", "notes", "updated_at"] - read_only_fields = ["updated_at"] + fields = [ + "id", "name", "phone", "social_platform", "social_handle", + "email", "address", "notes", + "total_purchases_count", "total_purchase_amount", "last_purchase_date", + "outstanding_balance", "account_code", "updated_at", + ] + # این‌ها را سرویس‌ها محاسبه می‌کنند، نه کلاینت — وگرنه مانده‌ی بدهی + # از سمت اپ قابل‌دستکاری می‌شد. + read_only_fields = [ + "total_purchases_count", "total_purchase_amount", "last_purchase_date", + "outstanding_balance", "account_code", "updated_at", + ] + + +class LedgerInstallmentSerializer(serializers.ModelSerializer): + class Meta: + model = LedgerInstallment + fields = ["id", "amount", "paid_date", "reference", "created_at"] + # برخلافِ بقیه‌ی مدل‌ها (که UUID را خودِ اپ فلاتر می‌سازد و می‌فرستد)، + # id قسط را ویو تولید می‌کند — پس نباید از کلاینت الزامی باشد. + read_only_fields = ["id", "created_at"] + + def validate_amount(self, value): + if value <= 0: + raise serializers.ValidationError("مبلغ قسط باید بزرگ‌تر از صفر باشد.") + return value + + def validate(self, attrs): + entry: LedgerEntry = self.context["ledger_entry"] + if not entry.is_debt: + raise serializers.ValidationError("فقط برای ردیف‌های بدهی‌دار می‌توان قسط ثبت کرد.") + amount = attrs.get("amount", 0) + # epsilon کوچک تا خطای گرد‌کردنِ اعشارِ شناور باعثِ ردِ یک قسطِ + # تسویه‌ی دقیق نشود. + if amount > entry.remaining_amount + 1: + raise serializers.ValidationError("مبلغ قسط از مانده‌ی بدهی بیشتر است.") + return attrs class LedgerEntrySerializer(serializers.ModelSerializer): + installments = LedgerInstallmentSerializer(many=True, read_only=True) + is_debt = serializers.BooleanField(read_only=True) + professional_type = serializers.CharField(read_only=True) + paid_amount = serializers.FloatField(read_only=True) + remaining_amount = serializers.FloatField(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_code = serializers.CharField(source="account.code", read_only=True, default="") + class Meta: model = LedgerEntry - fields = ["id", "entry_type", "title", "amount", "category", "entry_date", "note", "updated_at"] - read_only_fields = ["updated_at"] + fields = [ + "id", "entry_type", "title", "amount", "category", "category_display", + "entry_date", "note", "total_amount", "counterparty_name", "counterparty_phone", + "linked_invoice", "account_name", "account_code", + "is_debt", "professional_type", "paid_amount", "remaining_amount", + "installments", "updated_at", + ] + read_only_fields = ["linked_invoice", "account_name", "account_code", "updated_at"] + + def validate(self, attrs): + category = attrs.get("category", getattr(self.instance, "category", "")) + if category in LedgerEntry.DEBT_CATEGORIES: + total = attrs.get("total_amount", getattr(self.instance, "total_amount", 0)) + if not total or total <= 0: + raise serializers.ValidationError( + {"total_amount": "برای فروش قسطی/خرید نسیه، مبلغ کل تعهد الزامی است."} + ) + return attrs class ProductSerializer(serializers.ModelSerializer): + is_low_stock = serializers.BooleanField(read_only=True) + class Meta: model = Product fields = [ - "id", - "name", - "sku", - "category", - "quantity", - "unit_price", - "low_stock_threshold", - "notes", - "updated_at", + "id", "name", "sku", "category", "unit", "quantity", + "unit_price", "purchase_price", "total_purchase_value", + "low_stock_threshold", "is_low_stock", "notes", "updated_at", ] - read_only_fields = ["updated_at"] + read_only_fields = ["total_purchase_value", "is_low_stock", "updated_at"] class StockMovementSerializer(serializers.ModelSerializer): @@ -51,8 +134,122 @@ class AdjustStockSerializer(serializers.Serializer): return value -class AppointmentSerializer(serializers.ModelSerializer): +class InvoiceItemSerializer(serializers.ModelSerializer): class Meta: - model = Appointment - fields = ["id", "customer_name", "customer_phone", "title", "start_at", "status", "notes", "updated_at"] - read_only_fields = ["updated_at"] + model = InvoiceItem + fields = ["id", "product", "description", "quantity", "unit_price", "discount_percent", "total_price"] + read_only_fields = ["total_price"] + + +class InvoiceSerializer(serializers.ModelSerializer): + items = InvoiceItemSerializer(many=True, read_only=True) + payment_type_display = serializers.CharField(source="get_payment_type_display", read_only=True) + payment_status_display = serializers.CharField(source="get_payment_status_display", read_only=True) + order_status_display = serializers.CharField(source="get_order_status_display", read_only=True) + + class Meta: + model = Invoice + fields = [ + "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", + "payment_type", "payment_type_display", + "payment_status", "payment_status_display", + "order_status", "order_status_display", + "shipping_method", "tracking_code", "notes", "paid_at", + "items", "created_at", "updated_at", + ] + read_only_fields = [ + "invoice_number", "total_amount", "final_amount", "paid_at", "created_at", "updated_at", + ] + + +class CreateInvoiceItemSerializer(serializers.Serializer): + product = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all(), required=False, allow_null=True) + description = serializers.CharField(max_length=255, required=False, allow_blank=True, default="") + quantity = serializers.FloatField(min_value=0.0001) + unit_price = serializers.FloatField(required=False, default=0) + discount_percent = serializers.FloatField(required=False, default=0, min_value=0, max_value=100) + + def validate(self, attrs): + if attrs.get("product") is None and not attrs.get("description"): + raise serializers.ValidationError("برای ردیف بدون کالا، شرح الزامی است.") + return attrs + + +class CreateInvoiceSerializer(serializers.Serializer): + items = CreateInvoiceItemSerializer(many=True) + customer = serializers.PrimaryKeyRelatedField(queryset=Customer.objects.all(), required=False, allow_null=True) + invoice_date = serializers.DateTimeField(required=False) + buyer_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="") + buyer_phone = serializers.CharField(max_length=20, required=False, allow_blank=True, default="") + buyer_address = serializers.CharField(max_length=255, 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_address = serializers.CharField(max_length=255, required=False, allow_blank=True, default="") + 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) + tracking_code = serializers.CharField(max_length=100, required=False, allow_blank=True, default="") + notes = serializers.CharField(required=False, allow_blank=True, default="") + send_sms = serializers.BooleanField(required=False, default=False) + + def validate_items(self, value): + if not value: + raise serializers.ValidationError("فاکتور باید حداقل یک قلم کالا داشته باشد.") + return value + + +class UpdateInvoiceStatusSerializer(serializers.Serializer): + order_status = serializers.ChoiceField(choices=Invoice.OrderStatus.choices, required=False) + payment_status = serializers.ChoiceField(choices=Invoice.PaymentStatus.choices, required=False) + tracking_code = serializers.CharField(max_length=100, required=False, allow_blank=True) + + def validate(self, attrs): + if not attrs: + raise serializers.ValidationError("حداقل یکی از فیلدهای وضعیت باید ارسال شود.") + return attrs + + +class TransactionSerializer(serializers.ModelSerializer): + transaction_type_display = serializers.CharField(source="get_transaction_type_display", read_only=True) + debit_account_name = serializers.CharField(source="debit_account_ref.name", read_only=True, default="") + credit_account_name = serializers.CharField(source="credit_account_ref.name", read_only=True, default="") + + class Meta: + model = Transaction + fields = [ + "id", "transaction_number", "transaction_date", "reference_invoice", + "transaction_type", "transaction_type_display", + "debit_account", "credit_account", "debit_account_name", "credit_account_name", + "amount", "description", "created_at", + ] + read_only_fields = fields + + +class LedgerBookSerializer(serializers.ModelSerializer): + class Meta: + model = LedgerBook + fields = [ + "id", "name", "start_date", "end_date", "opening_balance", + "is_active", "is_closed", "closed_at", "created_at", + ] + read_only_fields = ["id", "is_active", "is_closed", "closed_at", "created_at"] + + +class OpenLedgerBookSerializer(serializers.Serializer): + name = serializers.CharField(max_length=120) + start_date = serializers.DateField() + end_date = serializers.DateField(required=False, allow_null=True) + # پیش‌فرض true: انتقالِ مانده سرِ سال مالی کارِ استاندارد است. + carry_balance = serializers.BooleanField(required=False, default=True) + + def validate(self, attrs): + end = attrs.get("end_date") + if end and end <= attrs["start_date"]: + raise serializers.ValidationError( + {"end_date": "تاریخ پایان باید بعد از تاریخ شروع باشد."} + ) + return attrs diff --git a/apps/bizapps/api/services.py b/apps/bizapps/api/services.py index 0888acc..6432c52 100644 --- a/apps/bizapps/api/services.py +++ b/apps/bizapps/api/services.py @@ -19,13 +19,21 @@ from apps.bizapps.models import ( Transaction, ) +from apps.bizapps.api.accounting import ( + ACCOUNT_CASH, + ACCOUNT_PAYABLE, + ACCOUNT_RECEIVABLE, + ACCOUNT_SALES, + ChartOfAccountsService, + ExcelExportService, +) + logger = logging.getLogger(__name__) -# کدهای حساب — دفترکل ساده طبق سند فنی (بدون نیاز به جدول Chart of Accounts -# جدا، چون این اپ برای کسب‌وکارهای خرد است نه حسابداری شرکتی کامل). -ACCOUNT_CASH = "1000" # صندوق -ACCOUNT_RECEIVABLE = "1100" # مطالبات (فروش اعتباری — نسخه‌ی آتی) -ACCOUNT_SALES = "4000" # فروش + +class InsufficientStockError(Exception): + """فروشِ بیش از موجودیِ انبار — عمدا یک استثنای جدا (نه ValueError عمومی) + تا ویو بتواند دقیقا همین حالت را به پیامِ ۴۰۰ قابل‌فهم تبدیل کند.""" class InvoiceService: @@ -118,6 +126,16 @@ class InvoiceService: if product is not None: product = Product.objects.select_for_update().get(pk=product.pk) + # سیاستِ صریح: فروشِ بیش از موجودی جلویش گرفته می‌شود، نه + # این‌که موجودی بی‌سروصدا منفی شود. اگر کاربر واقعا جنس را + # دارد ولی در انبار ثبت نکرده، اول باید موجودی را اصلاح کند — + # وگرنه ارزشِ انبار و بهای تمام‌شده هر دو غلط می‌مانند. + if quantity > product.quantity: + raise InsufficientStockError( + f"موجودی «{product.name}» کافی نیست — " + f"موجودی فعلی: {product.quantity:g} {product.unit or ''}، " + f"درخواستی: {quantity:g}." + ) product.quantity -= quantity product.recompute_purchase_value() product.save(update_fields=["quantity", "total_purchase_value", "updated_at"]) @@ -135,6 +153,7 @@ class InvoiceService: transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX") is_credit = payment_type == Invoice.PaymentType.CREDIT + debit_code = ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH Transaction.objects.create( id=str(uuid.uuid4()), user=user, @@ -144,8 +163,10 @@ class InvoiceService: transaction_type=( Transaction.TransactionType.CREDIT_SALE if is_credit else Transaction.TransactionType.CASH_SALE ), - debit_account=ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH, + debit_account=debit_code, credit_account=ACCOUNT_SALES, + debit_account_ref=ChartOfAccountsService.get(user, debit_code), + credit_account_ref=ChartOfAccountsService.get(user, ACCOUNT_SALES), amount=invoice.final_amount, description=f"فروش فاکتور {invoice_number}", ) @@ -158,6 +179,9 @@ class InvoiceService: customer.save( update_fields=["total_purchases_count", "total_purchase_amount", "last_purchase_date", "updated_at"] ) + # حسابِ تفصیلیِ اختصاصیِ مشتری زیرِ «حساب‌های دریافتنی» — از همین + # اولین فاکتور ساخته می‌شود تا بدهی‌اش در درختِ حساب‌ها ردیابی شود. + ChartOfAccountsService.ensure_customer_account(user, customer) # طبق درخواست: فروشِ قسطی/نسیه باید خودکار به دفتر درآمد/هزینه به‌عنوان # یک ردیفِ بدهی‌دار (نه صرفا یک عدد در لیست فاکتورها) وصل شود — همان @@ -175,6 +199,7 @@ class InvoiceService: counterparty_name=invoice.buyer_name, counterparty_phone=invoice.buyer_phone, linked_invoice=invoice, + account=LedgerService.account_for_category(user, LedgerEntry.Category.SALE_CREDIT), ) if invoice.buyer_phone: LedgerService.recompute_customer_balance(user, invoice.buyer_phone) @@ -252,6 +277,9 @@ class InvoiceService: transaction_type=original.transaction_type if original else Transaction.TransactionType.CASH_SALE, debit_account=original.credit_account if original else ACCOUNT_SALES, credit_account=original.debit_account if original else ACCOUNT_CASH, + # سندِ معکوس: جای بدهکار و بستانکارِ سندِ اصلی عوض می‌شود. + debit_account_ref=original.credit_account_ref if original else None, + credit_account_ref=original.debit_account_ref if original else None, amount=-(original.amount if original else invoice.final_amount), description=f"لغو فاکتور {invoice.invoice_number}", ) @@ -350,6 +378,51 @@ class LedgerService: این سرویس تضمین می‌کند مانده‌ی بدهیِ هر مشتری (Customer.outstanding_balance) همیشه با جمعِ واقعیِ ردیف‌های «فروش قسطی»ِ همان شماره‌تماس هم‌خوان بماند.""" + @staticmethod + def account_for_category(user: User, category: str): + """دسته‌ی «عادی» را به حسابِ معینِ متناظرش در درختِ حساب‌ها نگاشت + می‌کند — همان پلی که لایه‌ی ساده را به لایه‌ی حرفه‌ای وصل می‌کند.""" + code = LedgerEntry.CATEGORY_ACCOUNT_CODES.get(category) + if not code: + return None + return ChartOfAccountsService.get(user, code) + + @staticmethod + def sync_entry_account(user: User, entry: LedgerEntry) -> None: + """حسابِ معینِ یک ردیف را با دسته‌اش هم‌خوان می‌کند — بعد از هر + ساخت/ویرایش صدا زده می‌شود تا دسته و حساب هیچ‌وقت واگرا نشوند.""" + account = LedgerService.account_for_category(user, entry.category) + if entry.account_id != (account.id if account else None): + entry.account = account + entry.save(update_fields=["account", "updated_at"]) + + @staticmethod + @transaction.atomic + def post_installment_transaction(user: User, installment: LedgerInstallment) -> None: + """هر قسطِ دریافتی/پرداختی یک سندِ دوطرفه هم می‌سازد: صندوق بدهکار، + حساب دریافتنی بستانکار (و برای بدهی‌های خودمان، برعکس).""" + entry = installment.ledger_entry + is_receivable = entry.category == LedgerEntry.Category.SALE_CREDIT + cash = ChartOfAccountsService.get(user, ACCOUNT_CASH) + counter_code = ACCOUNT_RECEIVABLE if is_receivable else ACCOUNT_PAYABLE + counter = ChartOfAccountsService.get(user, counter_code) + + number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX") + Transaction.objects.create( + id=str(uuid.uuid4()), + user=user, + transaction_number=number, + transaction_date=timezone.now(), + reference_invoice=entry.linked_invoice, + transaction_type=Transaction.TransactionType.INSTALLMENT, + debit_account=ACCOUNT_CASH if is_receivable else counter_code, + credit_account=counter_code if is_receivable else ACCOUNT_CASH, + debit_account_ref=cash if is_receivable else counter, + credit_account_ref=counter if is_receivable else cash, + amount=installment.amount, + description=f"دریافت قسط — {entry.title}" if is_receivable else f"پرداخت قسط — {entry.title}", + ) + @staticmethod def recompute_customer_balance(user: User, phone: str) -> None: phone = (phone or "").strip() @@ -366,9 +439,17 @@ class LedgerService: customer.outstanding_balance = balance customer.save(update_fields=["outstanding_balance", "updated_at"]) + @staticmethod + def export_xlsx(user: User, start=None, end=None) -> bytes: + """خروجیِ اکسلِ واقعیِ چندبرگی (خلاصه، دفتر روزنامه، ترازِ حساب‌ها، + بدهکاران، فاکتورها، انبار) — همان چیزی که مستقیم به‌دردِ حسابدارِ + کاربر می‌خورد، برخلافِ CSV تک‌برگیِ خام.""" + return ExcelExportService.build(user, start, end) + @staticmethod def export_csv(user: User, start=None, end=None) -> str: - """خروجیِ CSV حرفه‌ای — UTF-8 BOM برای بازشدنِ درستِ فارسی در اکسل.""" + """خروجیِ 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) diff --git a/apps/bizapps/api/views.py b/apps/bizapps/api/views.py index 5a59cf2..7d38013 100644 --- a/apps/bizapps/api/views.py +++ b/apps/bizapps/api/views.py @@ -1,130 +1,489 @@ -from django.db import transaction +import uuid + +from django.db.models import Sum +from django.http import HttpResponse +from django.utils.dateparse import parse_date from rest_framework import generics, status -from rest_framework.exceptions import NotFound +from rest_framework.exceptions import NotFound, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.billing.permissions import HasActiveFeature -from apps.bizapps.models import Appointment, Customer, LedgerEntry, Product, StockMovement - -from .serializers import ( - AdjustStockSerializer, - AppointmentSerializer, - CustomerSerializer, - LedgerEntrySerializer, - ProductSerializer, - StockMovementSerializer, +from apps.bizapps.models import ( + Account, + Customer, + Invoice, + LedgerBook, + LedgerEntry, + LedgerInstallment, + Product, + StockMovement, + Transaction, ) -# همه‌ی این ریزاپ‌ها با جم فعال می‌شوند (یا دوره‌ی آزمایشی) — `feature_key` -# روی هر ویو مشخص می‌کند HasActiveFeature باید چه چیزی را چک کند. -# permission_classes اینجا Base است تا زیرکلاس‌ها فراموش نکنند. +from .accounting import ( + ChartOfAccountsService, + CustomerStatementService, + LedgerBookService, + SubsidiaryLedgerService, +) +from .serializers import ( + AccountSerializer, + AdjustStockSerializer, + CreateInvoiceSerializer, + CustomerSerializer, + InvoiceSerializer, + LedgerBookSerializer, + LedgerEntrySerializer, + LedgerInstallmentSerializer, + OpenLedgerBookSerializer, + ProductSerializer, + StockMovementSerializer, + TransactionSerializer, + UpdateInvoiceStatusSerializer, +) +from .services import InsufficientStockError, InvoiceService, LedgerService -class _UserScopedListCreateView(generics.ListCreateAPIView): - permission_classes = [IsAuthenticated, HasActiveFeature] +class _OwnedMixin: + """همه‌ی منابع این اپ فقط متعلق به کاربرِ درخواست‌دهنده‌اند.""" + + permission_classes = [IsAuthenticated] def get_queryset(self): return self.model.objects.filter(user=self.request.user) + +# ---------------- مشتریان ---------------- + + +class CustomerListCreateView(_OwnedMixin, generics.ListCreateAPIView): + model = Customer + serializer_class = CustomerSerializer + def perform_create(self, serializer): serializer.save(user=self.request.user) -class _UserScopedDetailView(generics.RetrieveUpdateDestroyAPIView): - permission_classes = [IsAuthenticated, HasActiveFeature] +class CustomerDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView): + model = Customer + serializer_class = CustomerSerializer + + +class CustomerStatementView(APIView): + """صورت‌حسابِ کاملِ یک مشتری — فاکتورها + بدهی‌های باز + اقساط، یک‌جا. + همان اتصالِ «مشتری ↔ فاکتور ↔ حسابداری» به‌شکلِ یک پاسخِ واحد.""" + + permission_classes = [IsAuthenticated] + + def get(self, request, pk): + customer = Customer.objects.filter(user=request.user, pk=pk).first() + if customer is None: + raise NotFound("مشتری یافت نشد.") + return Response(CustomerStatementService.build(request.user, customer)) + + +# ---------------- دفتر درآمد و هزینه ---------------- + + +class LedgerEntryListCreateView(_OwnedMixin, generics.ListCreateAPIView): + model = LedgerEntry + serializer_class = LedgerEntrySerializer def get_queryset(self): - return self.model.objects.filter(user=self.request.user) + qs = super().get_queryset().prefetch_related("installments").select_related("account") + # پیش‌فرض فقط فایلِ جاری؟ نه — book= صریح، وگرنه همه؛ تا + # گزارش‌های چنددوره‌ای هم ممکن بماند. + book_id = self.request.query_params.get("book") + if book_id: + qs = qs.filter(book_id=book_id) + start = parse_date(self.request.query_params.get("start", "") or "") + end = parse_date(self.request.query_params.get("end", "") or "") + if start: + qs = qs.filter(entry_date__gte=start) + if end: + qs = qs.filter(entry_date__lte=end) + return qs + + def perform_create(self, serializer): + 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) + if entry.counterparty_phone: + LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone) -class CustomerListCreateView(_UserScopedListCreateView): - model = Customer - serializer_class = CustomerSerializer - feature_key = "customers" - - -class CustomerDetailView(_UserScopedDetailView): - model = Customer - serializer_class = CustomerSerializer - feature_key = "customers" - - -class LedgerEntryListCreateView(_UserScopedListCreateView): +class LedgerEntryDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView): model = LedgerEntry serializer_class = LedgerEntrySerializer - feature_key = "ledger" + + def get_queryset(self): + return super().get_queryset().prefetch_related("installments").select_related("account") + + def perform_update(self, serializer): + entry = serializer.save() + LedgerService.sync_entry_account(self.request.user, entry) + if entry.counterparty_phone: + LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone) + + def perform_destroy(self, instance): + phone = instance.counterparty_phone + instance.delete() + if phone: + LedgerService.recompute_customer_balance(self.request.user, phone) -class LedgerEntryDetailView(_UserScopedDetailView): - model = LedgerEntry - serializer_class = LedgerEntrySerializer - feature_key = "ledger" +class LedgerInstallmentListCreateView(generics.ListCreateAPIView): + serializer_class = LedgerInstallmentSerializer + permission_classes = [IsAuthenticated] + + def _entry(self) -> LedgerEntry: + entry = LedgerEntry.objects.filter( + user=self.request.user, pk=self.kwargs["entry_id"] + ).prefetch_related("installments").first() + if entry is None: + raise NotFound("ردیف دفتر یافت نشد.") + return entry + + def get_serializer_context(self): + context = super().get_serializer_context() + context["ledger_entry"] = self._entry() + return context + + def get_queryset(self): + return self._entry().installments.all() + + def perform_create(self, serializer): + entry = self._entry() + installment = serializer.save(id=str(uuid.uuid4()), ledger_entry=entry) + # هر قسط، هم مانده‌ی مشتری را به‌روز می‌کند هم یک سندِ حسابداری + # دوطرفه می‌سازد — همان اتصالِ دفتر به درختِ حساب‌ها. + LedgerService.post_installment_transaction(self.request.user, installment) + if entry.counterparty_phone: + LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone) -class ProductListCreateView(_UserScopedListCreateView): +class LedgerInstallmentDetailView(generics.DestroyAPIView): + serializer_class = LedgerInstallmentSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return LedgerInstallment.objects.filter(ledger_entry__user=self.request.user) + + def perform_destroy(self, instance): + phone = instance.ledger_entry.counterparty_phone + instance.delete() + if phone: + LedgerService.recompute_customer_balance(self.request.user, phone) + + +class LedgerExportView(APIView): + """خروجیِ دفتر — پیش‌فرض اکسلِ چندبرگی (?type=csv برای نسخه‌ی ساده). + + عمداً `type` و نه `format`: پارامترِ `format` را خودِ DRF برای مذاکره‌ی + محتوا رزرو کرده و `?format=csv` را به‌عنوان درخواستِ رندررِ ثبت‌نشده‌ی + csv تفسیر و ۴۰۴ می‌دهد.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + start = parse_date(request.query_params.get("start", "") or "") + end = parse_date(request.query_params.get("end", "") or "") + + if request.query_params.get("type") == "csv": + content = LedgerService.export_csv(request.user, start, end) + response = HttpResponse(content, content_type="text/csv; charset=utf-8-sig") + response["Content-Disposition"] = 'attachment; filename="ledger.csv"' + return response + + content = LedgerService.export_xlsx(request.user, start, end) + response = HttpResponse( + content, + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response["Content-Disposition"] = 'attachment; filename="shopship-report.xlsx"' + return response + + +# ---------------- درخت حساب‌ها ---------------- + + +class AccountListView(generics.ListCreateAPIView): + serializer_class = AccountSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + ChartOfAccountsService.ensure_chart(self.request.user) + qs = Account.objects.filter(user=self.request.user).select_related("parent") + level = self.request.query_params.get("level") + if level: + qs = qs.filter(level=level) + return qs + + def perform_create(self, serializer): + serializer.save(id=str(uuid.uuid4()), user=self.request.user, is_system=False) + + +class AccountDetailView(generics.RetrieveUpdateDestroyAPIView): + serializer_class = AccountSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Account.objects.filter(user=self.request.user) + + def perform_destroy(self, instance): + # حساب‌های سیستمی پایه‌ی سندهای خودکارند؛ حذفشان گزارش‌ها را می‌شکند. + if instance.is_system: + raise ValidationError({"detail": "حساب‌های سیستمی قابل حذف نیستند."}) + instance.delete() + + +class TrialBalanceView(APIView): + """ترازِ حساب‌ها — جمعِ بدهکار/بستانکار و مانده‌ی هر حسابِ معین.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + start = parse_date(request.query_params.get("start", "") or "") + end = parse_date(request.query_params.get("end", "") or "") + return Response(ChartOfAccountsService.trial_balance(request.user, start, end)) + + +class TransactionListView(generics.ListAPIView): + serializer_class = TransactionSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Transaction.objects.filter(user=self.request.user).select_related( + "debit_account_ref", "credit_account_ref" + ) + + +# ---------------- انبار ---------------- + + +class ProductListCreateView(_OwnedMixin, generics.ListCreateAPIView): model = Product serializer_class = ProductSerializer - feature_key = "inventory" + + def perform_create(self, serializer): + product = serializer.save(user=self.request.user) + product.recompute_purchase_value() + product.save(update_fields=["total_purchase_value", "updated_at"]) -class ProductDetailView(_UserScopedDetailView): +class ProductDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView): model = Product serializer_class = ProductSerializer - feature_key = "inventory" + + def perform_update(self, serializer): + product = serializer.save() + product.recompute_purchase_value() + product.save(update_fields=["total_purchase_value", "updated_at"]) class ProductAdjustStockView(APIView): - """تعدیل اتمیک موجودی (+/-) + یک ردیف تاریخچه — به‌جای این‌که کلاینت - مستقیم quantity جدید را PATCH کند (که هیچ تاریخچه‌ای نمی‌سازد و اگر دو - درخواست هم‌زمان بیایند می‌تواند یکی را گم کند).""" + permission_classes = [IsAuthenticated] - permission_classes = [IsAuthenticated, HasActiveFeature] - feature_key = "inventory" - - @transaction.atomic def post(self, request, pk): - try: - product = Product.objects.select_for_update().get(pk=pk, user=request.user) - except Product.DoesNotExist: + product = Product.objects.filter(user=request.user, pk=pk).first() + if product is None: raise NotFound("کالا یافت نشد.") - serializer = AdjustStockSerializer(data=request.data) serializer.is_valid(raise_exception=True) - delta = serializer.validated_data["delta"] - product.quantity += delta - product.save(update_fields=["quantity", "updated_at"]) + product.quantity += serializer.validated_data["delta"] + product.recompute_purchase_value() + product.save(update_fields=["quantity", "total_purchase_value", "updated_at"]) StockMovement.objects.create( product=product, - delta=delta, + delta=serializer.validated_data["delta"], quantity_after=product.quantity, note=serializer.validated_data.get("note", ""), ) - return Response(ProductSerializer(product).data, status=status.HTTP_200_OK) + return Response(ProductSerializer(product).data) class ProductMovementsView(generics.ListAPIView): - """تاریخچه‌ی تغییرات موجودی یک کالا — جدیدترین اول.""" - serializer_class = StockMovementSerializer - permission_classes = [IsAuthenticated, HasActiveFeature] - feature_key = "inventory" + permission_classes = [IsAuthenticated] def get_queryset(self): return StockMovement.objects.filter( - product_id=self.kwargs["pk"], product__user=self.request.user + product__user=self.request.user, product_id=self.kwargs["pk"] ) -class AppointmentListCreateView(_UserScopedListCreateView): - model = Appointment - serializer_class = AppointmentSerializer - feature_key = "booking" +class InventorySummaryView(APIView): + """خلاصه‌ی انبار — ارزشِ کل، تعداد اقلام، و کالاهای روی آستانه‌ی کمبود.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + products = Product.objects.filter(user=request.user) + low = [p for p in products if p.is_low_stock] + return Response( + { + "total_products": products.count(), + "total_value": products.aggregate(v=Sum("total_purchase_value"))["v"] or 0, + "low_stock_count": len(low), + "low_stock": ProductSerializer(low, many=True).data, + } + ) -class AppointmentDetailView(_UserScopedDetailView): - model = Appointment - serializer_class = AppointmentSerializer - feature_key = "booking" +# ---------------- فاکتور ---------------- + + +class InvoiceListCreateView(_OwnedMixin, generics.ListCreateAPIView): + model = Invoice + serializer_class = InvoiceSerializer + + def get_queryset(self): + return super().get_queryset().prefetch_related("items") + + def create(self, request, *args, **kwargs): + serializer = CreateInvoiceSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = dict(serializer.validated_data) + send_sms = data.pop("send_sms", False) + try: + invoice = InvoiceService.create_invoice(request.user, data, send_sms=send_sms) + except InsufficientStockError as exc: + raise ValidationError({"detail": str(exc)}) + return Response(InvoiceSerializer(invoice).data, status=status.HTTP_201_CREATED) + + +class InvoiceDetailView(_OwnedMixin, generics.RetrieveAPIView): + model = Invoice + serializer_class = InvoiceSerializer + + def get_queryset(self): + return super().get_queryset().prefetch_related("items") + + +class InvoiceStatusView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + serializer = UpdateInvoiceStatusSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + invoice = InvoiceService.update_status(request.user, pk, **serializer.validated_data) + except Invoice.DoesNotExist: + raise NotFound("فاکتور یافت نشد.") + return Response(InvoiceSerializer(invoice).data) + + +class InvoiceCancelView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + try: + invoice = InvoiceService.cancel_invoice(request.user, pk) + except Invoice.DoesNotExist: + raise NotFound("فاکتور یافت نشد.") + except ValueError as exc: + raise ValidationError({"detail": str(exc)}) + return Response(InvoiceSerializer(invoice).data) + + +class BusinessDashboardView(APIView): + """داشبوردِ یکپارچه — همان «اتصالِ بخش‌ها» به‌شکلِ یک پاسخ: فروش، بدهی، + انبار و فاکتورها کنار هم، به‌جای چهار درخواستِ جدا از چهار صفحه.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + entries = LedgerEntry.objects.filter(user=user).prefetch_related("installments") + products = Product.objects.filter(user=user) + invoices = Invoice.objects.filter(user=user) + + income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME) + expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE) + receivable = sum( + e.remaining_amount for e in entries if e.category == LedgerEntry.Category.SALE_CREDIT + ) + payable = sum( + e.remaining_amount for e in entries if e.category == LedgerEntry.Category.PURCHASE_CREDIT + ) + + return Response( + { + "total_income": income, + "total_expense": expense, + "net_profit": income - expense, + "open_receivable": receivable, + "open_payable": payable, + "inventory_value": products.aggregate(v=Sum("total_purchase_value"))["v"] or 0, + "low_stock_count": sum(1 for p in products if p.is_low_stock), + "invoice_count": invoices.count(), + "unpaid_invoice_count": invoices.filter( + payment_status=Invoice.PaymentStatus.UNPAID + ).exclude(order_status=Invoice.OrderStatus.CANCELLED).count(), + "customer_count": Customer.objects.filter(user=user).count(), + "debtor_count": Customer.objects.filter(user=user, outstanding_balance__gt=0).count(), + } + ) + + +# ---------------- فایل‌های دفتر و دفتر معین ---------------- + + +class LedgerBookListView(generics.ListAPIView): + """فهرست فایل‌های دفتر — همیشه حداقل یک فایلِ جاری وجود دارد.""" + + serializer_class = LedgerBookSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + LedgerBookService.ensure_active_book(self.request.user) + return LedgerBook.objects.filter(user=self.request.user) + + +class OpenLedgerBookView(APIView): + """باز کردن فایل تازه — فایلِ جاری بسته می‌شود و (در صورت انتخاب) مانده + به فایل جدید منتقل می‌گردد.""" + + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = OpenLedgerBookSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + book = LedgerBookService.open_new_book( + request.user, + name=serializer.validated_data["name"], + start_date=serializer.validated_data["start_date"], + end_date=serializer.validated_data.get("end_date"), + carry_balance=serializer.validated_data.get("carry_balance", True), + ) + return Response(LedgerBookSerializer(book).data, status=status.HTTP_201_CREATED) + + +class LedgerBookSummaryView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, pk): + book = LedgerBook.objects.filter(user=request.user, pk=pk).first() + if book is None: + raise NotFound("فایل دفتر یافت نشد.") + return Response( + {"book": LedgerBookSerializer(book).data, **LedgerBookService.summary(book)} + ) + + +class SubsidiaryLedgerView(APIView): + """دفتر معین یک حساب — گردشِ حساب با مانده‌ی در جریان.""" + + permission_classes = [IsAuthenticated] + + def get(self, request, pk): + account = Account.objects.filter(user=request.user, pk=pk).first() + if account is None: + raise NotFound("حساب یافت نشد.") + start = parse_date(request.query_params.get("start", "") or "") + end = parse_date(request.query_params.get("end", "") or "") + return Response( + SubsidiaryLedgerService.statement(request.user, account, start, end) + ) diff --git a/apps/bizapps/migrations/0001_initial.py b/apps/bizapps/migrations/0001_initial.py new file mode 100644 index 0000000..d37add2 --- /dev/null +++ b/apps/bizapps/migrations/0001_initial.py @@ -0,0 +1,246 @@ +# Generated by Django 5.1.15 on 2026-09-02 00:19 + +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='Account', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('code', models.CharField(max_length=20, verbose_name='کد حساب')), + ('name', models.CharField(max_length=150, verbose_name='نام حساب')), + ('level', models.PositiveSmallIntegerField(choices=[(1, 'حساب کل'), (2, 'حساب معین'), (3, 'حساب تفصیلی')], verbose_name='سطح')), + ('account_type', models.CharField(choices=[('asset', 'دارایی'), ('liability', 'بدهی'), ('equity', 'سرمایه'), ('income', 'درآمد'), ('expense', 'هزینه')], max_length=15, verbose_name='نوع حساب')), + ('is_system', models.BooleanField(default=False, verbose_name='حساب سیستمی')), + ('is_active', models.BooleanField(default=True, verbose_name='فعال')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='bizapps.account', verbose_name='حساب والد')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'حساب', + 'verbose_name_plural': 'درخت حساب\u200cها', + 'ordering': ['code'], + }, + ), + migrations.CreateModel( + name='Customer', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150, verbose_name='نام')), + ('phone', models.CharField(blank=True, max_length=20, verbose_name='شماره تماس')), + ('email', models.CharField(blank=True, max_length=150, verbose_name='ایمیل')), + ('address', models.CharField(blank=True, max_length=255, verbose_name='آدرس')), + ('notes', models.TextField(blank=True, verbose_name='یادداشت')), + ('total_purchases_count', models.PositiveIntegerField(default=0, verbose_name='تعداد خرید')), + ('total_purchase_amount', models.FloatField(default=0, verbose_name='مجموع خرید')), + ('last_purchase_date', models.DateTimeField(blank=True, null=True, verbose_name='آخرین خرید')), + ('outstanding_balance', models.FloatField(default=0, verbose_name='مانده بدهی')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('account', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='customer', to='bizapps.account', verbose_name='حساب تفصیلی')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customers', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'مشتری', + 'verbose_name_plural': 'مشتریان', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('invoice_number', models.CharField(max_length=30, verbose_name='شماره فاکتور')), + ('invoice_date', models.DateTimeField(verbose_name='تاریخ فاکتور')), + ('buyer_name', models.CharField(blank=True, max_length=150, verbose_name='نام خریدار')), + ('buyer_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره خریدار')), + ('buyer_address', models.CharField(blank=True, max_length=255, verbose_name='آدرس خریدار')), + ('seller_name', models.CharField(blank=True, max_length=150, verbose_name='نام فروشنده')), + ('seller_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره فروشنده')), + ('seller_address', models.CharField(blank=True, max_length=255, verbose_name='آدرس فروشنده')), + ('total_amount', models.FloatField(default=0, verbose_name='جمع کل')), + ('discount_amount', models.FloatField(default=0, verbose_name='تخفیف')), + ('tax_lines', models.JSONField(blank=True, default=list, verbose_name='ردیف\u200cهای مالیات')), + ('final_amount', models.FloatField(default=0, verbose_name='مبلغ نهایی')), + ('payment_type', models.CharField(choices=[('cash', 'نقدی'), ('credit', 'قسطی/نسیه')], default='cash', max_length=10)), + ('payment_status', models.CharField(choices=[('unpaid', 'پرداخت\u200cنشده'), ('paid', 'پرداخت\u200cشده')], default='unpaid', max_length=10)), + ('order_status', models.CharField(choices=[('pending_payment', 'در انتظار پرداخت'), ('ready_to_ship', 'آماده\u200cی ارسال'), ('shipped', 'ارسال\u200cشده'), ('delivered', 'تحویل\u200cشده'), ('cancelled', 'لغوشده')], default='ready_to_ship', max_length=20)), + ('shipping_method', models.CharField(choices=[('pickup', 'تحویل حضوری'), ('post', 'پست'), ('courier', 'پیک')], default='pickup', max_length=10)), + ('tracking_code', models.CharField(blank=True, max_length=100, verbose_name='کد رهگیری')), + ('notes', models.TextField(blank=True, verbose_name='یادداشت')), + ('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='زمان پرداخت')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='bizapps.customer')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'فاکتور', + 'verbose_name_plural': 'فاکتورها', + 'ordering': ['-invoice_date'], + }, + ), + migrations.CreateModel( + name='LedgerEntry', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('entry_type', models.CharField(choices=[('income', 'درآمد'), ('expense', 'هزینه')], max_length=10, verbose_name='نوع')), + ('title', models.CharField(max_length=150, verbose_name='عنوان')), + ('amount', models.FloatField(verbose_name='مبلغ')), + ('category', models.CharField(blank=True, choices=[('sale_cash', 'فروش نقدی'), ('sale_credit', 'فروش قسطی/نسیه'), ('service_income', 'درآمد خدمات'), ('other_income', 'سایر درآمدها'), ('purchase_cash', 'خرید نقدی'), ('purchase_credit', 'خرید نسیه'), ('rent', 'اجاره'), ('salary', 'حقوق'), ('utility', 'قبض و آب\u200cوبرق'), ('transport', 'حمل\u200cونقل'), ('other_expense', 'سایر هزینه\u200cها')], max_length=100, verbose_name='دسته')), + ('entry_date', models.DateField(verbose_name='تاریخ')), + ('note', models.TextField(blank=True, verbose_name='یادداشت')), + ('total_amount', models.FloatField(default=0, verbose_name='مبلغ کل تعهد')), + ('counterparty_name', models.CharField(blank=True, max_length=150, verbose_name='طرف حساب')), + ('counterparty_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره طرف حساب')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ledger_entries', to='bizapps.account', verbose_name='حساب معین')), + ('linked_invoice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ledger_entries', to='bizapps.invoice')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ledger_entries', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'ردیف دفتر درآمد/هزینه', + 'verbose_name_plural': 'دفتر درآمد و هزینه', + 'ordering': ['-entry_date'], + }, + ), + migrations.CreateModel( + name='LedgerInstallment', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('amount', models.FloatField(verbose_name='مبلغ قسط')), + ('paid_date', models.DateField(verbose_name='تاریخ پرداخت')), + ('reference', models.CharField(blank=True, max_length=150, verbose_name='شماره مرجع')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('ledger_entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='installments', to='bizapps.ledgerentry')), + ], + options={ + 'verbose_name': 'قسط', + 'verbose_name_plural': 'اقساط', + 'ordering': ['paid_date'], + }, + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=150, verbose_name='نام کالا')), + ('sku', models.CharField(blank=True, max_length=100, verbose_name='کد کالا')), + ('category', models.CharField(blank=True, max_length=100, verbose_name='دسته')), + ('unit', models.CharField(blank=True, default='عدد', max_length=30, verbose_name='واحد شمارش')), + ('quantity', models.FloatField(default=0, verbose_name='موجودی')), + ('unit_price', models.FloatField(default=0, verbose_name='قیمت فروش')), + ('purchase_price', models.FloatField(default=0, verbose_name='قیمت خرید')), + ('total_purchase_value', models.FloatField(default=0, verbose_name='ارزش موجودی')), + ('low_stock_threshold', models.FloatField(default=0, verbose_name='آستانه\u200cی هشدار موجودی کم')), + ('notes', models.TextField(blank=True, verbose_name='یادداشت')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'کالا', + 'verbose_name_plural': 'انبار', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='InvoiceItem', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('description', models.CharField(max_length=255, verbose_name='شرح')), + ('quantity', models.FloatField(verbose_name='تعداد')), + ('unit_price', models.FloatField(verbose_name='قیمت واحد')), + ('discount_percent', models.FloatField(default=0, verbose_name='درصد تخفیف')), + ('total_price', models.FloatField(default=0, verbose_name='جمع')), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='bizapps.invoice')), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='bizapps.product')), + ], + options={ + 'verbose_name': 'قلم فاکتور', + 'verbose_name_plural': 'اقلام فاکتور', + }, + ), + migrations.CreateModel( + name='StockMovement', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('delta', models.FloatField(verbose_name='تغییر')), + ('quantity_after', models.FloatField(verbose_name='موجودی بعد از تغییر')), + ('note', models.CharField(blank=True, max_length=255, verbose_name='یادداشت')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movements', to='bizapps.product')), + ], + options={ + 'verbose_name': 'تغییر موجودی', + 'verbose_name_plural': 'تاریخچه\u200cی موجودی', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('transaction_number', models.CharField(max_length=30, verbose_name='شماره سند')), + ('transaction_date', models.DateTimeField(verbose_name='تاریخ سند')), + ('transaction_type', models.CharField(choices=[('cash_sale', 'فروش نقدی'), ('credit_sale', 'فروش نسیه'), ('installment', 'دریافت قسط'), ('purchase', 'خرید'), ('expense', 'هزینه'), ('adjustment', 'تعدیل')], max_length=20)), + ('debit_account', models.CharField(max_length=20, verbose_name='کد حساب بدهکار')), + ('credit_account', models.CharField(max_length=20, verbose_name='کد حساب بستانکار')), + ('amount', models.FloatField(verbose_name='مبلغ')), + ('description', models.CharField(blank=True, max_length=255, verbose_name='شرح')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('credit_account_ref', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='credit_transactions', to='bizapps.account', verbose_name='حساب بستانکار')), + ('debit_account_ref', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='debit_transactions', to='bizapps.account', verbose_name='حساب بدهکار')), + ('reference_invoice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to='bizapps.invoice')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'سند حسابداری', + 'verbose_name_plural': 'اسناد حسابداری', + 'ordering': ['-transaction_date'], + }, + ), + migrations.AddIndex( + model_name='account', + index=models.Index(fields=['user', 'level'], name='bizapps_acc_user_id_e1d510_idx'), + ), + migrations.AddConstraint( + model_name='account', + constraint=models.UniqueConstraint(fields=('user', 'code'), name='unique_user_account_code'), + ), + migrations.AddIndex( + model_name='customer', + index=models.Index(fields=['user', 'phone'], name='bizapps_cus_user_id_886a45_idx'), + ), + migrations.AddIndex( + model_name='invoice', + index=models.Index(fields=['user', 'invoice_date'], name='bizapps_inv_user_id_89a273_idx'), + ), + migrations.AddConstraint( + model_name='invoice', + constraint=models.UniqueConstraint(fields=('user', 'invoice_number'), name='unique_user_invoice_number'), + ), + migrations.AddIndex( + model_name='ledgerentry', + index=models.Index(fields=['user', 'entry_date'], name='bizapps_led_user_id_795e45_idx'), + ), + migrations.AddIndex( + model_name='product', + index=models.Index(fields=['user', 'category'], name='bizapps_pro_user_id_adf41e_idx'), + ), + migrations.AddIndex( + model_name='transaction', + index=models.Index(fields=['user', 'transaction_date'], name='bizapps_tra_user_id_7cbd25_idx'), + ), + ] diff --git a/apps/bizapps/migrations/0002_customer_social_handle_customer_social_platform.py b/apps/bizapps/migrations/0002_customer_social_handle_customer_social_platform.py new file mode 100644 index 0000000..3881e84 --- /dev/null +++ b/apps/bizapps/migrations/0002_customer_social_handle_customer_social_platform.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.15 on 2026-09-02 00:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bizapps', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='customer', + name='social_handle', + field=models.CharField(blank=True, max_length=150, verbose_name='آیدی/نام کاربری'), + ), + migrations.AddField( + model_name='customer', + name='social_platform', + field=models.CharField(blank=True, choices=[('instagram', 'اینستاگرام'), ('telegram', 'تلگرام'), ('whatsapp', 'واتساپ'), ('bale', 'بله'), ('eitaa', 'ایتا'), ('other', 'سایر')], max_length=15, verbose_name='پلتفرم ارتباطی'), + ), + ] diff --git a/apps/bizapps/migrations/0003_ledgerbook_ledgerentry_book_and_more.py b/apps/bizapps/migrations/0003_ledgerbook_ledgerentry_book_and_more.py new file mode 100644 index 0000000..84620a4 --- /dev/null +++ b/apps/bizapps/migrations/0003_ledgerbook_ledgerentry_book_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.15 on 2026-09-02 06:54 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bizapps', '0002_customer_social_handle_customer_social_platform'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='LedgerBook', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=120, verbose_name='نام فایل')), + ('start_date', models.DateField(verbose_name='از تاریخ')), + ('end_date', models.DateField(blank=True, null=True, verbose_name='تا تاریخ')), + ('opening_balance', models.FloatField(default=0, verbose_name='مانده ابتدای دوره')), + ('is_active', models.BooleanField(default=True, verbose_name='فایل جاری')), + ('is_closed', models.BooleanField(default=False, verbose_name='بسته\u200cشده')), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ledger_books', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'فایل دفتر', + 'verbose_name_plural': 'فایل\u200cهای دفتر', + 'ordering': ['-start_date', '-created_at'], + }, + ), + migrations.AddField( + model_name='ledgerentry', + name='book', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='bizapps.ledgerbook', verbose_name='فایل دفتر'), + ), + migrations.AddIndex( + model_name='ledgerbook', + index=models.Index(fields=['user', 'is_active'], name='bizapps_led_user_id_0ad2b0_idx'), + ), + ] diff --git a/apps/bizapps/models.py b/apps/bizapps/models.py index d1baaaf..88782f5 100644 --- a/apps/bizapps/models.py +++ b/apps/bizapps/models.py @@ -2,62 +2,184 @@ from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ -# ریزاپ‌های ساده‌ی کسب‌وکار کوچک — مشتریان، دفتر درآمد/هزینه، انبار، -# نوبت‌دهی. دقیقا هم‌الگو با apps.banker: id همان UUID تولیدشده در خودِ اپ -# فلاتر است (نه auto-increment)، هر رکورد به user صاحبش محدود می‌شود. +# ریزاپ‌های کسب‌وکار کوچک — مشتریان، دفتر درآمد/هزینه، انبار، فاکتور. +# دقیقا هم‌الگو با apps.banker: id همان UUID تولیدشده در خودِ اپ فلاتر است +# (نه auto-increment)، هر رکورد به user صاحبش محدود می‌شود. +# +# ساختار حسابداری دو لایه است: +# لایه‌ی «عادی» — کاربر فقط یک دسته‌ی آشنا انتخاب می‌کند (فروش نقدی، +# فروش قسطی، اجاره، حقوق و…). +# لایه‌ی «حرفه‌ای» — پشتِ صحنه همان دسته به یک حسابِ معینِ واقعی از +# Account (کل → معین → تفصیلی) نگاشت می‌شود و سندِ +# دوطرفه در Transaction ثبت می‌گردد. +# کاربر هیچ‌وقت مجبور نیست لایه‌ی دوم را ببیند، ولی گزارش/خروجی اکسل و +# ترازِ حساب‌ها از همان لایه ساخته می‌شود. + + +class Account(models.Model): + """درختِ حساب‌ها (فایل‌بندیِ حسابداری) — سه سطحِ استانداردِ ایرانی: + + حساب کل (level=1) مثلا «دارایی‌های جاری» + └─ حساب معین (level=2) مثلا «صندوق»، «حساب‌های دریافتنی» + └─ حساب تفصیلی (level=3) مثلا یک مشتریِ خاص + + هر کاربر درختِ خودش را دارد (با یک درختِ پیش‌فرضِ آماده که موقعِ اولین + استفاده ساخته می‌شود) تا کسب‌وکارهای مختلف بتوانند حساب‌های دلخواهِ + خودشان را هم اضافه کنند بدون این‌که روی هم اثر بگذارند.""" + + class Level(models.IntegerChoices): + KOL = 1, _("حساب کل") + MOIN = 2, _("حساب معین") + TAFSILI = 3, _("حساب تفصیلی") + + class AccountType(models.TextChoices): + ASSET = "asset", _("دارایی") + LIABILITY = "liability", _("بدهی") + EQUITY = "equity", _("سرمایه") + INCOME = "income", _("درآمد") + EXPENSE = "expense", _("هزینه") + + # ماهیتِ طبیعیِ حساب — تعیین می‌کند در ترازِ حساب‌ها، مانده‌ی بدهکار + # نشانه‌ی افزایش است یا بستانکار. برای دارایی/هزینه بدهکار، برای + # بدهی/سرمایه/درآمد بستانکار. + DEBIT_NATURE_TYPES = {AccountType.ASSET, AccountType.EXPENSE} + + id = models.CharField(max_length=36, primary_key=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="accounts") + code = models.CharField(max_length=20, verbose_name=_("کد حساب")) + name = models.CharField(max_length=150, verbose_name=_("نام حساب")) + level = models.PositiveSmallIntegerField(choices=Level.choices, verbose_name=_("سطح")) + account_type = models.CharField(max_length=15, choices=AccountType.choices, verbose_name=_("نوع حساب")) + parent = models.ForeignKey( + "self", null=True, blank=True, on_delete=models.CASCADE, related_name="children", verbose_name=_("حساب والد") + ) + # حساب‌های سیستمی (بخشی از درختِ پیش‌فرض) نباید حذف شوند، چون سرویس‌ها + # با کدشان به آن‌ها ارجاع می‌دهند. + is_system = models.BooleanField(default=False, verbose_name=_("حساب سیستمی")) + is_active = models.BooleanField(default=True, 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 = ["code"] + constraints = [models.UniqueConstraint(fields=["user", "code"], name="unique_user_account_code")] + indexes = [models.Index(fields=["user", "level"])] + + def __str__(self): + return f"{self.code} — {self.name}" + + @property + def is_debit_nature(self) -> bool: + return self.account_type in self.DEBIT_NATURE_TYPES + + @property + def full_path(self) -> str: + """مسیرِ کاملِ حساب در درخت — برای نمایش در گزارش/اکسل.""" + parts = [self.name] + node = self.parent + while node is not None: + parts.append(node.name) + node = node.parent + return " / ".join(reversed(parts)) + + + +class LedgerBook(models.Model): + """«فایل» دفتر درآمد و هزینه — معادلِ فایلِ مالیِ یک دوره (معمولا یک سال + مالی) در نرم‌افزارهای حسابداری. + + چرا لازم است: بدون آن، همه‌ی ثبت‌های چند سال در یک لیستِ بی‌انتها قاطی + می‌شوند و «سود امسال» عملا قابل‌محاسبه نیست. با فایل، کاربر سرِ سال + مالیِ تازه یک فایل جدید باز می‌کند، فایل قبلی بسته و فقط-خواندنی می‌شود + و گزارش‌های هر دوره تمیز از هم جدا می‌مانند. + """ + + id = models.CharField(max_length=36, primary_key=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ledger_books") + name = models.CharField(max_length=120, verbose_name=_("نام فایل")) + start_date = models.DateField(verbose_name=_("از تاریخ")) + end_date = models.DateField(null=True, blank=True, verbose_name=_("تا تاریخ")) + # مانده‌ی نقدیِ ابتدای دوره — معمولا مانده‌ی پایانِ فایلِ قبلی. + opening_balance = models.FloatField(default=0, verbose_name=_("مانده ابتدای دوره")) + # فقط یک فایل در هر لحظه «جاری» است؛ ثبت‌های تازه به همان می‌روند. + is_active = models.BooleanField(default=True, verbose_name=_("فایل جاری")) + # فایلِ بسته‌شده فقط-خواندنی است تا گزارشِ دوره‌ی قبل عوض نشود. + is_closed = models.BooleanField(default=False, verbose_name=_("بسته‌شده")) + closed_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("فایل دفتر") + verbose_name_plural = _("فایل‌های دفتر") + ordering = ["-start_date", "-created_at"] + indexes = [models.Index(fields=["user", "is_active"])] + + def __str__(self): + return self.name class Customer(models.Model): + class SocialPlatform(models.TextChoices): + INSTAGRAM = "instagram", _("اینستاگرام") + TELEGRAM = "telegram", _("تلگرام") + WHATSAPP = "whatsapp", _("واتساپ") + BALE = "bale", _("بله") + EITAA = "eitaa", _("ایتا") + OTHER = "other", _("سایر") + id = models.CharField(max_length=36, primary_key=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="customers") name = models.CharField(max_length=150, verbose_name=_("نام")) phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره تماس")) + # مخاطبِ اصلیِ این اپ کسب‌وکارهای اینستاگرامی/تلگرامی‌اند؛ برای خیلی از + # آن‌ها آیدیِ شبکه‌ی اجتماعی راهِ ارتباطیِ اصلی است، نه ایمیل. + social_platform = models.CharField( + max_length=15, choices=SocialPlatform.choices, blank=True, verbose_name=_("پلتفرم ارتباطی") + ) + social_handle = models.CharField(max_length=150, blank=True, verbose_name=_("آیدی/نام کاربری")) email = models.CharField(max_length=150, blank=True, verbose_name=_("ایمیل")) address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس")) notes = models.TextField(blank=True, verbose_name=_("یادداشت")) + # این سه فیلد cache هستند — منبعِ حقیقت، فاکتورها و ردیف‌های دفترند؛ + # سرویس‌ها بعد از هر تغییر بازمحاسبه‌شان می‌کنند. + total_purchases_count = models.PositiveIntegerField(default=0, verbose_name=_("تعداد خرید")) + total_purchase_amount = models.FloatField(default=0, verbose_name=_("مجموع خرید")) + last_purchase_date = models.DateTimeField(null=True, blank=True, verbose_name=_("آخرین خرید")) + outstanding_balance = models.FloatField(default=0, verbose_name=_("مانده بدهی")) + # حسابِ تفصیلیِ اختصاصیِ این مشتری زیرِ «حساب‌های دریافتنی» — همان + # چیزی که فروشِ قسطیِ او را در درختِ حساب‌ها قابل‌ردیابی می‌کند. + account = models.OneToOneField( + Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="customer", + verbose_name=_("حساب تفصیلی"), + ) updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = _("مشتری") verbose_name_plural = _("مشتریان") ordering = ["name"] + indexes = [models.Index(fields=["user", "phone"])] def __str__(self): return self.name -class LedgerEntry(models.Model): - class EntryType(models.TextChoices): - INCOME = "income", _("درآمد") - EXPENSE = "expense", _("هزینه") - - id = models.CharField(max_length=36, primary_key=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ledger_entries") - entry_type = models.CharField(max_length=10, choices=EntryType.choices, verbose_name=_("نوع")) - title = models.CharField(max_length=150, verbose_name=_("عنوان")) - amount = models.FloatField(verbose_name=_("مبلغ")) - category = models.CharField(max_length=100, blank=True, verbose_name=_("دسته")) - entry_date = models.DateField(verbose_name=_("تاریخ")) - note = models.TextField(blank=True, verbose_name=_("یادداشت")) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - verbose_name = _("ردیف دفتر درآمد/هزینه") - verbose_name_plural = _("دفتر درآمد و هزینه") - ordering = ["-entry_date"] - - def __str__(self): - return f"{self.title} ({self.get_entry_type_display()})" - - class Product(models.Model): id = models.CharField(max_length=36, primary_key=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="products") name = models.CharField(max_length=150, verbose_name=_("نام کالا")) sku = models.CharField(max_length=100, blank=True, verbose_name=_("کد کالا")) category = models.CharField(max_length=100, blank=True, verbose_name=_("دسته")) + unit = models.CharField(max_length=30, blank=True, default="عدد", verbose_name=_("واحد شمارش")) quantity = models.FloatField(default=0, verbose_name=_("موجودی")) - unit_price = models.FloatField(default=0, verbose_name=_("قیمت واحد")) + unit_price = models.FloatField(default=0, verbose_name=_("قیمت فروش")) + purchase_price = models.FloatField(default=0, verbose_name=_("قیمت خرید")) + # ارزشِ ریالیِ موجودیِ فعلی (quantity × purchase_price) — cache شده تا + # «ارزش کل انبار» بدون پیمایشِ همه‌ی کالاها قابل‌جمع‌زدن باشد. + total_purchase_value = models.FloatField(default=0, verbose_name=_("ارزش موجودی")) low_stock_threshold = models.FloatField(default=0, verbose_name=_("آستانه‌ی هشدار موجودی کم")) notes = models.TextField(blank=True, verbose_name=_("یادداشت")) updated_at = models.DateTimeField(auto_now=True) @@ -66,14 +188,23 @@ class Product(models.Model): verbose_name = _("کالا") verbose_name_plural = _("انبار") ordering = ["name"] + indexes = [models.Index(fields=["user", "category"])] def __str__(self): return self.name + def recompute_purchase_value(self) -> None: + self.total_purchase_value = (self.quantity or 0) * (self.purchase_price or 0) + + @property + def is_low_stock(self) -> bool: + return self.low_stock_threshold > 0 and self.quantity <= self.low_stock_threshold + class StockMovement(models.Model): - """تاریخچه‌ی تغییرات موجودی — هر تعدیل (دستیِ +/- یا ویرایش مستقیم فرم) - یک ردیف اینجا ثبت می‌کند تا معلوم باشد موجودی کِی و چقدر تغییر کرده.""" + """تاریخچه‌ی تغییرات موجودی — هر تعدیل (دستیِ +/-، ویرایش مستقیم فرم، یا + فروش/لغوِ فاکتور) یک ردیف اینجا ثبت می‌کند تا معلوم باشد موجودی کِی، + چقدر و به چه دلیل تغییر کرده.""" id = models.AutoField(primary_key=True) product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="movements") @@ -91,26 +222,259 @@ class StockMovement(models.Model): return f"{self.product.name} — {self.delta:+g} — {self.created_at:%Y-%m-%d %H:%M}" -class Appointment(models.Model): - class Status(models.TextChoices): - SCHEDULED = "scheduled", _("زمان‌بندی‌شده") - DONE = "done", _("انجام‌شده") +class Invoice(models.Model): + class PaymentType(models.TextChoices): + CASH = "cash", _("نقدی") + CREDIT = "credit", _("قسطی/نسیه") + + class PaymentStatus(models.TextChoices): + UNPAID = "unpaid", _("پرداخت‌نشده") + PAID = "paid", _("پرداخت‌شده") + + class OrderStatus(models.TextChoices): + PENDING_PAYMENT = "pending_payment", _("در انتظار پرداخت") + READY_TO_SHIP = "ready_to_ship", _("آماده‌ی ارسال") + SHIPPED = "shipped", _("ارسال‌شده") + DELIVERED = "delivered", _("تحویل‌شده") CANCELLED = "cancelled", _("لغوشده") + class ShippingMethod(models.TextChoices): + PICKUP = "pickup", _("تحویل حضوری") + POST = "post", _("پست") + COURIER = "courier", _("پیک") + id = models.CharField(max_length=36, primary_key=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="appointments") - customer_name = models.CharField(max_length=150, verbose_name=_("نام مشتری")) - customer_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره مشتری")) - title = models.CharField(max_length=150, verbose_name=_("عنوان/خدمت")) - start_at = models.DateTimeField(verbose_name=_("زمان نوبت")) - status = models.CharField(max_length=10, choices=Status.choices, default=Status.SCHEDULED) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="invoices") + invoice_number = models.CharField(max_length=30, verbose_name=_("شماره فاکتور")) + invoice_date = models.DateTimeField(verbose_name=_("تاریخ فاکتور")) + customer = models.ForeignKey( + Customer, null=True, blank=True, on_delete=models.SET_NULL, related_name="invoices" + ) + buyer_name = models.CharField(max_length=150, blank=True, verbose_name=_("نام خریدار")) + buyer_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره خریدار")) + buyer_address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس خریدار")) + seller_name = models.CharField(max_length=150, 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=_("آدرس فروشنده")) + total_amount = models.FloatField(default=0, verbose_name=_("جمع کل")) + 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=_("مبلغ نهایی")) + payment_type = models.CharField(max_length=10, choices=PaymentType.choices, default=PaymentType.CASH) + payment_status = models.CharField(max_length=10, choices=PaymentStatus.choices, default=PaymentStatus.UNPAID) + order_status = models.CharField(max_length=20, choices=OrderStatus.choices, default=OrderStatus.READY_TO_SHIP) + shipping_method = models.CharField(max_length=10, choices=ShippingMethod.choices, default=ShippingMethod.PICKUP) + tracking_code = models.CharField(max_length=100, blank=True, verbose_name=_("کد رهگیری")) notes = models.TextField(blank=True, verbose_name=_("یادداشت")) + paid_at = models.DateTimeField(null=True, blank=True, 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 = ["start_at"] + verbose_name = _("فاکتور") + verbose_name_plural = _("فاکتورها") + ordering = ["-invoice_date"] + constraints = [ + models.UniqueConstraint(fields=["user", "invoice_number"], name="unique_user_invoice_number") + ] + indexes = [models.Index(fields=["user", "invoice_date"])] def __str__(self): - return f"{self.customer_name} — {self.title}" + return f"{self.invoice_number} — {self.buyer_name}" + + +class InvoiceItem(models.Model): + id = models.CharField(max_length=36, primary_key=True) + invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="items") + product = models.ForeignKey(Product, null=True, blank=True, on_delete=models.SET_NULL, related_name="+") + description = models.CharField(max_length=255, verbose_name=_("شرح")) + quantity = models.FloatField(verbose_name=_("تعداد")) + unit_price = models.FloatField(verbose_name=_("قیمت واحد")) + discount_percent = models.FloatField(default=0, verbose_name=_("درصد تخفیف")) + total_price = models.FloatField(default=0, verbose_name=_("جمع")) + + class Meta: + verbose_name = _("قلم فاکتور") + verbose_name_plural = _("اقلام فاکتور") + + def __str__(self): + return f"{self.description} × {self.quantity:g}" + + def compute_total(self) -> None: + gross = (self.quantity or 0) * (self.unit_price or 0) + self.total_price = gross * (1 - (self.discount_percent or 0) / 100) + + +class Transaction(models.Model): + """سندِ حسابداریِ دوطرفه — هر رویدادِ مالی (فروش، لغو، دریافتِ قسط) + یک ردیف اینجا می‌سازد با حسابِ بدهکار و حسابِ بستانکار. ترازِ حساب‌ها و + خروجیِ اکسل از روی همین جدول ساخته می‌شوند.""" + + class TransactionType(models.TextChoices): + CASH_SALE = "cash_sale", _("فروش نقدی") + CREDIT_SALE = "credit_sale", _("فروش نسیه") + INSTALLMENT = "installment", _("دریافت قسط") + PURCHASE = "purchase", _("خرید") + EXPENSE = "expense", _("هزینه") + ADJUSTMENT = "adjustment", _("تعدیل") + + id = models.CharField(max_length=36, primary_key=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transactions") + transaction_number = models.CharField(max_length=30, verbose_name=_("شماره سند")) + transaction_date = models.DateTimeField(verbose_name=_("تاریخ سند")) + reference_invoice = models.ForeignKey( + Invoice, null=True, blank=True, on_delete=models.SET_NULL, related_name="transactions" + ) + transaction_type = models.CharField(max_length=20, choices=TransactionType.choices) + # کدِ حسابِ متنی برای سازگاری با داده‌ی قدیمی نگه داشته شده؛ debit/credit + # به‌صورتِ FK به Account همان چیزی است که گزارش‌های جدید از آن می‌خوانند. + debit_account = models.CharField(max_length=20, verbose_name=_("کد حساب بدهکار")) + credit_account = models.CharField(max_length=20, verbose_name=_("کد حساب بستانکار")) + debit_account_ref = models.ForeignKey( + Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="debit_transactions", + verbose_name=_("حساب بدهکار"), + ) + credit_account_ref = models.ForeignKey( + Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="credit_transactions", + verbose_name=_("حساب بستانکار"), + ) + amount = models.FloatField(verbose_name=_("مبلغ")) + description = models.CharField(max_length=255, blank=True, verbose_name=_("شرح")) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = _("سند حسابداری") + verbose_name_plural = _("اسناد حسابداری") + ordering = ["-transaction_date"] + indexes = [models.Index(fields=["user", "transaction_date"])] + + def __str__(self): + return f"{self.transaction_number} — {self.amount:,.0f}" + + +class LedgerEntry(models.Model): + class EntryType(models.TextChoices): + INCOME = "income", _("درآمد") + EXPENSE = "expense", _("هزینه") + + class Category(models.TextChoices): + # درآمدها + SALE_CASH = "sale_cash", _("فروش نقدی") + SALE_CREDIT = "sale_credit", _("فروش قسطی/نسیه") + SERVICE_INCOME = "service_income", _("درآمد خدمات") + OTHER_INCOME = "other_income", _("سایر درآمدها") + # هزینه‌ها + PURCHASE_CASH = "purchase_cash", _("خرید نقدی") + PURCHASE_CREDIT = "purchase_credit", _("خرید نسیه") + RENT = "rent", _("اجاره") + SALARY = "salary", _("حقوق") + UTILITY = "utility", _("قبض و آب‌وبرق") + TRANSPORT = "transport", _("حمل‌ونقل") + OTHER_EXPENSE = "other_expense", _("سایر هزینه‌ها") + + # فقط این دو دسته «بدهی‌دار»ند — یعنی مبلغِ کل، طرفِ حساب و اقساط دارند. + # بقیه یک ثبتِ ساده‌ی نقدی‌اند. + DEBT_CATEGORIES = {Category.SALE_CREDIT, Category.PURCHASE_CREDIT} + + # نگاشتِ لایه‌ی «عادی» به حسابِ معینِ لایه‌ی «حرفه‌ای» — کلیدِ اتصالِ + # دسته‌بندیِ ساده‌ی کاربر به درختِ حساب‌ها. + CATEGORY_ACCOUNT_CODES = { + Category.SALE_CASH: "4001", + Category.SALE_CREDIT: "4001", + Category.SERVICE_INCOME: "4002", + Category.OTHER_INCOME: "4003", + Category.PURCHASE_CASH: "5001", + Category.PURCHASE_CREDIT: "5001", + Category.RENT: "5002", + Category.SALARY: "5003", + Category.UTILITY: "5004", + Category.TRANSPORT: "5005", + Category.OTHER_EXPENSE: "5006", + } + + id = models.CharField(max_length=36, primary_key=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ledger_entries") + # null مجاز است تا ثبت‌های قدیمیِ پیش از افزودنِ «فایل» بی‌صاحب نمانند؛ + # سرویس‌ها هنگام ساخت، همیشه فایلِ جاری را ست می‌کنند. + book = models.ForeignKey( + LedgerBook, null=True, blank=True, on_delete=models.CASCADE, + related_name="entries", verbose_name=_("فایل دفتر"), + ) + entry_type = models.CharField(max_length=10, choices=EntryType.choices, verbose_name=_("نوع")) + title = models.CharField(max_length=150, verbose_name=_("عنوان")) + amount = models.FloatField(verbose_name=_("مبلغ")) + # max_length=100 (نه تنگ‌تر) عمداً — این فیلد قبلا آزاد بود و داده‌ی + # واقعیِ قدیمی با متنِ فارسیِ دلخواه دارد؛ تنگ‌کردنش موقعِ مهاجرت روی + # پستگرس می‌تواند «value too long» بدهد. + category = models.CharField( + max_length=100, choices=Category.choices, blank=True, verbose_name=_("دسته") + ) + entry_date = models.DateField(verbose_name=_("تاریخ")) + note = models.TextField(blank=True, verbose_name=_("یادداشت")) + # فیلدهای بدهی‌دار — فقط وقتی category در DEBT_CATEGORIES باشد معنی دارند. + total_amount = models.FloatField(default=0, verbose_name=_("مبلغ کل تعهد")) + counterparty_name = models.CharField(max_length=150, blank=True, verbose_name=_("طرف حساب")) + counterparty_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره طرف حساب")) + linked_invoice = models.ForeignKey( + Invoice, null=True, blank=True, on_delete=models.SET_NULL, related_name="ledger_entries" + ) + account = models.ForeignKey( + Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="ledger_entries", + verbose_name=_("حساب معین"), + ) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _("ردیف دفتر درآمد/هزینه") + verbose_name_plural = _("دفتر درآمد و هزینه") + ordering = ["-entry_date"] + indexes = [models.Index(fields=["user", "entry_date"])] + + def __str__(self): + return f"{self.title} ({self.get_entry_type_display()})" + + @property + def is_debt(self) -> bool: + return self.category in self.DEBT_CATEGORIES + + @property + def professional_type(self) -> str: + """ترجمه‌ی دسته‌ی «عادی» به اصطلاحِ حسابداریِ حرفه‌ای.""" + if self.category == self.Category.SALE_CREDIT: + return "receivable" + if self.category == self.Category.PURCHASE_CREDIT: + return "payable" + return "simple" + + @property + def paid_amount(self) -> float: + if not self.is_debt: + return 0 + return sum(i.amount for i in self.installments.all()) + + @property + def remaining_amount(self) -> float: + if not self.is_debt: + return 0 + return (self.total_amount or 0) - self.paid_amount + + +class LedgerInstallment(models.Model): + """هر قسطِ پرداخت‌شده روی یک ردیفِ بدهی‌دار. مانده‌ی هر بدهی همیشه از + اینجا محاسبه می‌شود (نه ذخیره‌ی مستقیم) تا هیچ‌وقت با واقعیت فاصله نگیرد.""" + + id = models.CharField(max_length=36, primary_key=True) + ledger_entry = models.ForeignKey(LedgerEntry, on_delete=models.CASCADE, related_name="installments") + amount = models.FloatField(verbose_name=_("مبلغ قسط")) + paid_date = models.DateField(verbose_name=_("تاریخ پرداخت")) + reference = models.CharField(max_length=150, blank=True, verbose_name=_("شماره مرجع")) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = _("قسط") + verbose_name_plural = _("اقساط") + ordering = ["paid_date"] + + def __str__(self): + return f"{self.amount:,.0f} — {self.paid_date}" diff --git a/apps/bizapps/tests.py b/apps/bizapps/tests.py index a7a811b..7a6cee6 100644 --- a/apps/bizapps/tests.py +++ b/apps/bizapps/tests.py @@ -1,3 +1,4 @@ +import io import uuid from datetime import date @@ -7,8 +8,28 @@ from rest_framework.test import APIClient from rest_framework_simplejwt.tokens import RefreshToken from apps.account.models import User +from apps.bizapps.api.accounting import ( + ACCOUNT_CASH, + ACCOUNT_RECEIVABLE, + ACCOUNT_SALES, + DEFAULT_CHART, + ChartOfAccountsService, + CustomerStatementService, + ExcelExportService, + LedgerBookService, + SubsidiaryLedgerService, +) from apps.bizapps.api.services import InvoiceService, LedgerService -from apps.bizapps.models import Customer, LedgerEntry, LedgerInstallment +from apps.bizapps.models import ( + Account, + Customer, + Invoice, + LedgerBook, + LedgerEntry, + LedgerInstallment, + Product, + Transaction, +) # این ماژول لایه‌ی «هماهنگیِ عادی/حرفه‌ای»ِ دفتر درآمد و هزینه را پوشش می‌دهد — # دقیقا همان بخشی که کاربر صریحا خواسته «تست‌نویسی برایش خیلی مهم است»، چون @@ -290,7 +311,724 @@ class LedgerApiTests(TestCase): ) self.assertEqual(response.status_code, 400) - def test_export_returns_csv(self): + def test_export_defaults_to_xlsx(self): + """پیش‌فرضِ خروجی، اکسلِ چندبرگی است — چون همان چیزی است که مستقیم + به‌دردِ حسابدارِ کاربر می‌خورد، نه CSV خام.""" response = self.client.get(reverse("bizapps:ledger-export")) self.assertEqual(response.status_code, 200) + self.assertIn("spreadsheetml.sheet", response["Content-Type"]) + self.assertIn(".xlsx", response["Content-Disposition"]) + # امضای فایلِ zip — یک xlsx واقعی، نه متنِ خالی با هدرِ اشتباه. + self.assertEqual(response.content[:2], b"PK") + + def test_export_csv_still_available_via_query_param(self): + response = self.client.get(reverse("bizapps:ledger-export"), {"type": "csv"}) + self.assertEqual(response.status_code, 200) self.assertIn("text/csv", response["Content-Type"]) + + +# --------------------------------------------------------------------------- +# لایه‌ی حسابداریِ حرفه‌ای — درختِ حساب‌ها (کل/معین/تفصیلی)، ترازِ حساب‌ها، +# صورت‌حسابِ مشتری، و خروجیِ اکسلِ چندبرگی. +# --------------------------------------------------------------------------- + + +class ChartOfAccountsTests(TestCase): + def setUp(self): + self.user = _make_user() + + def test_ensure_chart_creates_default_tree(self): + ChartOfAccountsService.ensure_chart(self.user) + accounts = Account.objects.filter(user=self.user) + self.assertEqual(accounts.count(), len(DEFAULT_CHART)) + self.assertTrue(accounts.filter(level=Account.Level.KOL).exists()) + self.assertTrue(accounts.filter(level=Account.Level.MOIN).exists()) + + def test_ensure_chart_is_idempotent(self): + ChartOfAccountsService.ensure_chart(self.user) + first = Account.objects.filter(user=self.user).count() + ChartOfAccountsService.ensure_chart(self.user) + self.assertEqual(Account.objects.filter(user=self.user).count(), first) + + def test_moin_accounts_are_linked_to_their_kol_parent(self): + ChartOfAccountsService.ensure_chart(self.user) + cash = Account.objects.get(user=self.user, code=ACCOUNT_CASH) + self.assertEqual(cash.level, Account.Level.MOIN) + self.assertIsNotNone(cash.parent) + self.assertEqual(cash.parent.level, Account.Level.KOL) + self.assertIn("دارایی", cash.full_path) + + def test_chart_is_per_user_not_shared(self): + other = _make_user(phone="09129999999") + ChartOfAccountsService.ensure_chart(self.user) + ChartOfAccountsService.ensure_chart(other) + self.assertEqual(Account.objects.filter(user=self.user).count(), len(DEFAULT_CHART)) + self.assertEqual(Account.objects.filter(user=other).count(), len(DEFAULT_CHART)) + + def test_customer_gets_tafsili_account_under_receivables(self): + customer = Customer.objects.create( + id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" + ) + account = ChartOfAccountsService.ensure_customer_account(self.user, customer) + self.assertEqual(account.level, Account.Level.TAFSILI) + self.assertEqual(account.parent.code, ACCOUNT_RECEIVABLE) + self.assertTrue(account.code.startswith(f"{ACCOUNT_RECEIVABLE}-")) + customer.refresh_from_db() + self.assertEqual(customer.account_id, account.id) + + def test_customer_account_is_not_duplicated(self): + customer = Customer.objects.create( + id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" + ) + first = ChartOfAccountsService.ensure_customer_account(self.user, customer) + second = ChartOfAccountsService.ensure_customer_account(self.user, customer) + self.assertEqual(first.id, second.id) + + def test_debit_nature_matches_account_type(self): + ChartOfAccountsService.ensure_chart(self.user) + self.assertTrue(Account.objects.get(user=self.user, code=ACCOUNT_CASH).is_debit_nature) + self.assertFalse(Account.objects.get(user=self.user, code=ACCOUNT_SALES).is_debit_nature) + + +class LedgerAccountMappingTests(TestCase): + """هر دسته‌ی «عادی» باید به حسابِ معینِ درست نگاشت شود — این پلِ بینِ + لایه‌ی ساده و لایه‌ی حرفه‌ای است و اگر بشکند، ترازِ حساب‌ها بی‌معنی می‌شود.""" + + def setUp(self): + self.user = _make_user() + + def test_every_category_maps_to_an_existing_account(self): + ChartOfAccountsService.ensure_chart(self.user) + for category in LedgerEntry.Category.values: + account = LedgerService.account_for_category(self.user, category) + self.assertIsNotNone(account, f"دسته‌ی {category} به هیچ حسابی نگاشت نشد") + self.assertEqual(account.level, Account.Level.MOIN) + + def test_income_categories_map_to_income_accounts(self): + ChartOfAccountsService.ensure_chart(self.user) + account = LedgerService.account_for_category(self.user, LedgerEntry.Category.SALE_CASH) + self.assertEqual(account.account_type, Account.AccountType.INCOME) + + def test_expense_categories_map_to_expense_accounts(self): + ChartOfAccountsService.ensure_chart(self.user) + account = LedgerService.account_for_category(self.user, LedgerEntry.Category.RENT) + self.assertEqual(account.account_type, Account.AccountType.EXPENSE) + + +class TrialBalanceTests(TestCase): + def setUp(self): + self.user = _make_user() + self.customer = Customer.objects.create( + id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" + ) + self.product = Product.objects.create( + id=str(uuid.uuid4()), user=self.user, name="کالا", quantity=10, unit_price=100, purchase_price=60 + ) + + def test_cash_sale_posts_to_cash_and_sales(self): + InvoiceService.create_invoice( + self.user, + { + "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], + "customer": self.customer, + "payment_type": Invoice.PaymentType.CASH, + }, + ) + rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)} + self.assertIn(ACCOUNT_CASH, rows) + self.assertIn(ACCOUNT_SALES, rows) + self.assertEqual(rows[ACCOUNT_CASH]["debit"], 100) + self.assertEqual(rows[ACCOUNT_SALES]["credit"], 100) + + def test_credit_sale_posts_to_receivable_not_cash(self): + InvoiceService.create_invoice( + self.user, + { + "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], + "customer": self.customer, + "payment_type": Invoice.PaymentType.CREDIT, + }, + ) + rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)} + self.assertIn(ACCOUNT_RECEIVABLE, rows) + self.assertEqual(rows[ACCOUNT_RECEIVABLE]["debit"], 100) + self.assertNotIn(ACCOUNT_CASH, rows) + + def test_balance_respects_account_nature(self): + InvoiceService.create_invoice( + self.user, + { + "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], + "customer": self.customer, + "payment_type": Invoice.PaymentType.CASH, + }, + ) + rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)} + # صندوق (دارایی، ماهیت بدهکار) و فروش (درآمد، ماهیت بستانکار) هر دو + # باید مانده‌ی مثبت نشان دهند، نه یکی منفی. + self.assertEqual(rows[ACCOUNT_CASH]["balance"], 100) + self.assertEqual(rows[ACCOUNT_SALES]["balance"], 100) + + def test_empty_accounts_are_omitted(self): + rows = ChartOfAccountsService.trial_balance(self.user) + self.assertEqual(rows, []) + + +class CustomerStatementTests(TestCase): + def setUp(self): + self.user = _make_user() + self.customer = Customer.objects.create( + id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" + ) + self.product = Product.objects.create( + id=str(uuid.uuid4()), user=self.user, name="کالا", quantity=10, unit_price=100, purchase_price=60 + ) + + def test_statement_gathers_invoices_and_open_debts(self): + InvoiceService.create_invoice( + self.user, + { + "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], + "customer": self.customer, + "buyer_phone": "09121112222", + "payment_type": Invoice.PaymentType.CREDIT, + }, + ) + self.customer.refresh_from_db() + statement = CustomerStatementService.build(self.user, self.customer) + + self.assertEqual(len(statement["invoices"]), 1) + self.assertEqual(len(statement["debts"]), 1) + self.assertEqual(statement["debts"][0]["remaining_amount"], 100) + self.assertEqual(statement["customer"]["outstanding_balance"], 100) + # حسابِ تفصیلیِ مشتری هم باید در صورت‌حساب بیاید. + self.assertTrue(statement["customer"]["account_code"]) + + def test_statement_reflects_installment_payments(self): + InvoiceService.create_invoice( + self.user, + { + "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], + "customer": self.customer, + "buyer_phone": "09121112222", + "payment_type": Invoice.PaymentType.CREDIT, + }, + ) + entry = LedgerEntry.objects.get(user=self.user, category=LedgerEntry.Category.SALE_CREDIT) + LedgerInstallment.objects.create( + id=str(uuid.uuid4()), ledger_entry=entry, amount=40, paid_date=date.today() + ) + LedgerService.recompute_customer_balance(self.user, "09121112222") + self.customer.refresh_from_db() + + statement = CustomerStatementService.build(self.user, self.customer) + self.assertEqual(statement["debts"][0]["paid_amount"], 40) + self.assertEqual(statement["debts"][0]["remaining_amount"], 60) + self.assertEqual(len(statement["debts"][0]["installments"]), 1) + + +class ExcelExportTests(TestCase): + def setUp(self): + self.user = _make_user() + self.customer = Customer.objects.create( + id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" + ) + self.product = Product.objects.create( + id=str(uuid.uuid4()), + user=self.user, + name="کالا", + quantity=10, + unit_price=100, + purchase_price=60, + total_purchase_value=600, + ) + + def _workbook(self): + from openpyxl import load_workbook + + content = ExcelExportService.build(self.user) + return load_workbook(io.BytesIO(content)) + + def test_workbook_has_all_expected_sheets(self): + wb = self._workbook() + self.assertEqual( + wb.sheetnames, + ["خلاصه", "دفتر روزنامه", "تراز حساب‌ها", "بدهکاران", "فاکتورها", "انبار"], + ) + + def test_sheets_are_right_to_left(self): + wb = self._workbook() + for name in wb.sheetnames: + self.assertTrue(wb[name].sheet_view.rightToLeft, f"برگه‌ی {name} راست‌چین نیست") + + def test_inventory_sheet_lists_products(self): + wb = self._workbook() + rows = list(wb["انبار"].iter_rows(min_row=2, values_only=True)) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], "کالا") + + def test_summary_reports_inventory_value(self): + wb = self._workbook() + summary = {r[0]: r[1] for r in wb["خلاصه"].iter_rows(min_row=2, values_only=True)} + self.assertEqual(summary["ارزش موجودی انبار"], 600) + + def test_debtors_sheet_lists_only_customers_with_balance(self): + InvoiceService.create_invoice( + self.user, + { + "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], + "customer": self.customer, + "buyer_phone": "09121112222", + "payment_type": Invoice.PaymentType.CREDIT, + }, + ) + wb = self._workbook() + rows = list(wb["بدهکاران"].iter_rows(min_row=2, values_only=True)) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], "علی") + self.assertEqual(rows[0][3], 100) + + def test_invoice_sheet_lists_invoices(self): + InvoiceService.create_invoice( + self.user, + { + "items": [{"product": self.product, "quantity": 2, "unit_price": 100}], + "customer": self.customer, + "payment_type": Invoice.PaymentType.CASH, + }, + ) + wb = self._workbook() + rows = list(wb["فاکتورها"].iter_rows(min_row=2, values_only=True)) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][6], 200) # مبلغ نهایی + + +class InstallmentPostsTransactionTests(TestCase): + """هر قسط باید علاوه بر به‌روزکردنِ مانده، یک سندِ حسابداریِ واقعی هم + بسازد — وگرنه ترازِ حساب‌ها بعد از دریافتِ قسط غلط می‌ماند.""" + + def setUp(self): + self.user = _make_user() + self.client = APIClient() + self.client.force_authenticate(self.user) + self.entry = LedgerEntry.objects.create( + id=str(uuid.uuid4()), + user=self.user, + entry_type=LedgerEntry.EntryType.INCOME, + title="فروش قسطی", + amount=1000, + category=LedgerEntry.Category.SALE_CREDIT, + entry_date=date.today(), + total_amount=1000, + counterparty_phone="09121112222", + ) + + def test_installment_creates_balanced_transaction(self): + response = self.client.post( + reverse("bizapps:ledger-installment-list", kwargs={"entry_id": self.entry.id}), + {"amount": 400, "paid_date": date.today().isoformat()}, + format="json", + ) + self.assertEqual(response.status_code, 201) + + txn = Transaction.objects.get(user=self.user, transaction_type=Transaction.TransactionType.INSTALLMENT) + self.assertEqual(txn.amount, 400) + # دریافتِ قسط: صندوق بدهکار، حساب دریافتنی بستانکار. + self.assertEqual(txn.debit_account, ACCOUNT_CASH) + self.assertEqual(txn.credit_account, ACCOUNT_RECEIVABLE) + self.assertIsNotNone(txn.debit_account_ref) + self.assertIsNotNone(txn.credit_account_ref) + + def test_installment_shows_up_in_trial_balance(self): + self.client.post( + reverse("bizapps:ledger-installment-list", kwargs={"entry_id": self.entry.id}), + {"amount": 400, "paid_date": date.today().isoformat()}, + format="json", + ) + rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)} + self.assertEqual(rows[ACCOUNT_CASH]["debit"], 400) + + +class BusinessDashboardTests(TestCase): + """داشبورد، همان «اتصالِ بخش‌ها» را در یک پاسخ جمع می‌کند.""" + + def setUp(self): + self.user = _make_user() + self.client = APIClient() + self.client.force_authenticate(self.user) + self.customer = Customer.objects.create( + id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" + ) + self.product = Product.objects.create( + id=str(uuid.uuid4()), + user=self.user, + name="کالا", + quantity=2, + unit_price=100, + purchase_price=60, + total_purchase_value=120, + low_stock_threshold=5, + ) + + def test_dashboard_aggregates_across_modules(self): + InvoiceService.create_invoice( + self.user, + { + "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], + "customer": self.customer, + "buyer_phone": "09121112222", + "payment_type": Invoice.PaymentType.CREDIT, + }, + ) + response = self.client.get(reverse("bizapps:dashboard")) + self.assertEqual(response.status_code, 200) + data = response.data + + self.assertEqual(data["open_receivable"], 100) + self.assertEqual(data["invoice_count"], 1) + self.assertEqual(data["customer_count"], 1) + self.assertEqual(data["debtor_count"], 1) + # موجودی از ۲ به ۱ رسیده و زیرِ آستانه‌ی ۵ است. + self.assertEqual(data["low_stock_count"], 1) + + +class NegativeStockGuardTests(TestCase): + """سیاستِ صریحِ موجودی: فروشِ بیش از موجودی رد می‌شود و هیچ اثری + (نه فاکتور، نه سند، نه کسرِ موجودی) باقی نمی‌گذارد.""" + + def setUp(self): + self.user = _make_user() + self.client = APIClient() + self.client.force_authenticate(self.user) + self.product = Product.objects.create( + id=str(uuid.uuid4()), user=self.user, name="کالا", unit="عدد", + quantity=2, unit_price=100, purchase_price=60, + ) + + def test_selling_more_than_stock_is_rejected(self): + from apps.bizapps.api.services import InsufficientStockError + + with self.assertRaises(InsufficientStockError): + InvoiceService.create_invoice( + self.user, + {"items": [{"product": self.product, "quantity": 5, "unit_price": 100}]}, + ) + + def test_rejected_sale_leaves_no_partial_data(self): + from apps.bizapps.api.services import InsufficientStockError + + with self.assertRaises(InsufficientStockError): + InvoiceService.create_invoice( + self.user, + {"items": [{"product": self.product, "quantity": 5, "unit_price": 100}]}, + ) + self.product.refresh_from_db() + self.assertEqual(self.product.quantity, 2) + self.assertEqual(Invoice.objects.filter(user=self.user).count(), 0) + self.assertEqual(Transaction.objects.filter(user=self.user).count(), 0) + + def test_selling_exactly_the_remaining_stock_is_allowed(self): + InvoiceService.create_invoice( + self.user, + {"items": [{"product": self.product, "quantity": 2, "unit_price": 100}]}, + ) + self.product.refresh_from_db() + self.assertEqual(self.product.quantity, 0) + + def test_api_returns_readable_400_not_500(self): + response = self.client.post( + reverse("bizapps:invoice-list"), + {"items": [{"product": self.product.id, "quantity": 5, "unit_price": 100}]}, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("موجودی", str(response.data)) + + +class CustomerSocialFieldTests(TestCase): + """راهِ ارتباطیِ شبکه‌ی اجتماعی — برای مخاطبِ اصلیِ این اپ (کسب‌وکارهای + اینستاگرامی/تلگرامی) کاربردی‌تر از ایمیل است.""" + + def setUp(self): + self.user = _make_user() + self.client = APIClient() + self.client.force_authenticate(self.user) + + def test_customer_can_store_platform_and_handle(self): + response = self.client.post( + reverse("bizapps:customer-list"), + { + "id": str(uuid.uuid4()), + "name": "فروشگاه گلها", + "phone": "09121112222", + "social_platform": Customer.SocialPlatform.INSTAGRAM, + "social_handle": "@golha.shop", + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + customer = Customer.objects.get(user=self.user) + self.assertEqual(customer.social_platform, "instagram") + self.assertEqual(customer.social_handle, "@golha.shop") + + def test_social_fields_are_optional(self): + response = self.client.post( + reverse("bizapps:customer-list"), + {"id": str(uuid.uuid4()), "name": "بدون شبکه اجتماعی"}, + format="json", + ) + self.assertEqual(response.status_code, 201) + + def test_invalid_platform_is_rejected(self): + response = self.client.post( + reverse("bizapps:customer-list"), + { + "id": str(uuid.uuid4()), + "name": "تست", + "social_platform": "myspace", + }, + format="json", + ) + self.assertEqual(response.status_code, 400) + + +class LedgerBookTests(TestCase): + """«فایل» دفتر — باز کردن فایل تازه، بستن فایل قبلی، انتقال مانده.""" + + def setUp(self): + self.user = _make_user() + self.client = APIClient() + self.client.force_authenticate(self.user) + + def _entry(self, amount, entry_type=LedgerEntry.EntryType.INCOME, book=None): + return LedgerEntry.objects.create( + id=str(uuid.uuid4()), user=self.user, book=book, + entry_type=entry_type, title="ثبت", amount=amount, + category=LedgerEntry.Category.SALE_CASH, entry_date=date.today(), + ) + + def test_active_book_is_created_on_demand(self): + book = LedgerBookService.ensure_active_book(self.user) + self.assertTrue(book.is_active) + self.assertFalse(book.is_closed) + self.assertEqual(LedgerBook.objects.filter(user=self.user).count(), 1) + + def test_ensure_active_book_is_idempotent(self): + first = LedgerBookService.ensure_active_book(self.user) + second = LedgerBookService.ensure_active_book(self.user) + self.assertEqual(first.id, second.id) + + def test_orphan_entries_are_adopted_by_the_first_book(self): + orphan = self._entry(500) + self.assertIsNone(orphan.book_id) + book = LedgerBookService.ensure_active_book(self.user) + orphan.refresh_from_db() + self.assertEqual(orphan.book_id, book.id) + + def test_opening_a_new_book_closes_the_previous_one(self): + 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.assertTrue(new.is_active) + # همیشه دقیقا یک فایل جاری + self.assertEqual(LedgerBook.objects.filter(user=self.user, is_active=True).count(), 1) + + def test_balance_is_carried_into_the_new_book(self): + old = LedgerBookService.ensure_active_book(self.user) + self._entry(1000, book=old) + self._entry(300, entry_type=LedgerEntry.EntryType.EXPENSE, book=old) + + new = LedgerBookService.open_new_book( + self.user, name="دوره بعد", start_date=date(2027, 3, 21), carry_balance=True + ) + self.assertEqual(new.opening_balance, 700) + + def test_balance_is_not_carried_when_not_requested(self): + old = LedgerBookService.ensure_active_book(self.user) + self._entry(1000, book=old) + new = LedgerBookService.open_new_book( + self.user, name="دوره بعد", start_date=date(2027, 3, 21), carry_balance=False + ) + self.assertEqual(new.opening_balance, 0) + + def test_summary_includes_opening_and_closing(self): + book = LedgerBookService.ensure_active_book(self.user) + book.opening_balance = 500 + book.save() + self._entry(1000, book=book) + self._entry(200, entry_type=LedgerEntry.EntryType.EXPENSE, book=book) + + summary = LedgerBookService.summary(book) + self.assertEqual(summary["opening_balance"], 500) + self.assertEqual(summary["total_income"], 1000) + self.assertEqual(summary["total_expense"], 200) + self.assertEqual(summary["net"], 800) + self.assertEqual(summary["closing_balance"], 1300) + + def test_new_entries_go_into_the_active_book(self): + book = LedgerBookService.ensure_active_book(self.user) + response = self.client.post( + reverse("bizapps:ledger-list"), + { + "id": str(uuid.uuid4()), + "entry_type": LedgerEntry.EntryType.INCOME, + "title": "فروش", + "amount": 400, + "category": LedgerEntry.Category.SALE_CASH, + "entry_date": date.today().isoformat(), + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(LedgerEntry.objects.get(user=self.user).book_id, book.id) + + def test_entry_list_can_be_scoped_to_one_book(self): + """قراردادی که اپ به آن تکیه می‌کند: ?book= فقط ثبت‌های همان + دوره را برمی‌گرداند. + + بدون این فیلتر، صفحه‌ی دفتر ثبت‌های همه‌ی دوره‌ها را با هم نشان + می‌داد و جمعِ درآمد/هزینه‌ی بالای صفحه با مانده‌ی فایلِ جاری + نمی‌خواند.""" + old = LedgerBookService.ensure_active_book(self.user) + self._entry(1000, book=old) + new_book = LedgerBookService.open_new_book( + self.user, name="دوره بعد", start_date=date(2027, 3, 21) + ) + self._entry(250, book=new_book) + + url = reverse("bizapps:ledger-list") + scoped = self.client.get(url, {"book": new_book.id}) + self.assertEqual(scoped.status_code, 200) + self.assertEqual([e["amount"] for e in scoped.data], [250]) + + # بدون پارامتر، همه‌ی دوره‌ها — برای گزارش‌های چنددوره‌ای. + everything = self.client.get(url) + self.assertEqual(len(everything.data), 2) + + def test_scoped_summary_ignores_the_other_book(self): + old = LedgerBookService.ensure_active_book(self.user) + self._entry(1000, book=old) + new_book = LedgerBookService.open_new_book( + self.user, name="دوره بعد", start_date=date(2027, 3, 21) + ) + self._entry(250, book=new_book) + + response = self.client.get( + reverse("bizapps:ledger-book-summary", kwargs={"pk": new_book.id}) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["total_income"], 250) + # مانده‌ی ابتدا از فایل قبل منتقل شده، پس مانده‌ی پایانی ۱۲۵۰ است. + self.assertEqual(response.data["opening_balance"], 1000) + self.assertEqual(response.data["closing_balance"], 1250) + + def test_open_book_endpoint_rejects_end_before_start(self): + response = self.client.post( + reverse("bizapps:ledger-book-open"), + {"name": "بد", "start_date": "2027-03-21", "end_date": "2026-01-01"}, + format="json", + ) + self.assertEqual(response.status_code, 400) + + def test_book_list_always_returns_at_least_one(self): + response = self.client.get(reverse("bizapps:ledger-book-list")) + self.assertEqual(response.status_code, 200) + self.assertGreaterEqual(len(response.data), 1) + + +class SubsidiaryLedgerTests(TestCase): + """دفتر معین — گردشِ یک حساب با مانده‌ی در جریان، همان چیزی که حسابدار + انتظار دارد ببیند.""" + + def setUp(self): + self.user = _make_user() + self.client = APIClient() + self.client.force_authenticate(self.user) + self.customer = Customer.objects.create( + id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" + ) + self.product = Product.objects.create( + id=str(uuid.uuid4()), user=self.user, name="کالا", + quantity=100, unit_price=100, purchase_price=60, + ) + ChartOfAccountsService.ensure_chart(self.user) + self.cash = Account.objects.get(user=self.user, code=ACCOUNT_CASH) + self.sales = Account.objects.get(user=self.user, code=ACCOUNT_SALES) + + def _sale(self, qty=1, price=100, credit=False): + return InvoiceService.create_invoice( + self.user, + { + "items": [{"product": self.product, "quantity": qty, "unit_price": price}], + "customer": self.customer, + "buyer_phone": "09121112222", + "payment_type": Invoice.PaymentType.CREDIT if credit else Invoice.PaymentType.CASH, + }, + ) + + def test_statement_lists_rows_with_running_balance(self): + # قیمتِ واحد عمدا از خودِ کالا برداشته می‌شود (ضدِ دستکاری از سمت + # کلاینت)، پس هر فروشِ یک‌عددی ۱۰۰ است — مانده باید تجمعی جلو برود. + self._sale() + self._sale() + + statement = SubsidiaryLedgerService.statement(self.user, self.cash) + balances = [r["balance"] for r in statement["rows"]] + self.assertEqual(len(statement["rows"]), 2) + self.assertEqual(balances, [100, 200]) + self.assertEqual(statement["closing_balance"], 200) + + def test_debit_and_credit_columns_are_separated(self): + self._sale(price=100) + cash_stmt = SubsidiaryLedgerService.statement(self.user, self.cash) + sales_stmt = SubsidiaryLedgerService.statement(self.user, self.sales) + + self.assertEqual(cash_stmt["rows"][0]["debit"], 100) + self.assertEqual(cash_stmt["rows"][0]["credit"], 0) + self.assertEqual(sales_stmt["rows"][0]["credit"], 100) + self.assertEqual(sales_stmt["rows"][0]["debit"], 0) + + def test_credit_nature_account_balance_grows_on_credit(self): + self._sale(price=100) + sales_stmt = SubsidiaryLedgerService.statement(self.user, self.sales) + # فروش ماهیت بستانکار دارد؛ مانده باید مثبت باشد نه منفی. + self.assertEqual(sales_stmt["closing_balance"], 100) + self.assertFalse(sales_stmt["account"]["is_debit_nature"]) + + def test_totals_match_rows(self): + self._sale() + self._sale() + statement = SubsidiaryLedgerService.statement(self.user, self.cash) + self.assertEqual(statement["total_debit"], 200) + self.assertEqual(statement["total_credit"], 0) + self.assertEqual( + statement["total_debit"], sum(r["debit"] for r in statement["rows"]) + ) + + def test_empty_account_returns_empty_statement(self): + statement = SubsidiaryLedgerService.statement(self.user, self.cash) + self.assertEqual(statement["rows"], []) + self.assertEqual(statement["closing_balance"], 0) + + def test_api_endpoint_returns_statement(self): + self._sale(price=100) + response = self.client.get( + reverse("bizapps:account-subsidiary-ledger", kwargs={"pk": self.cash.id}) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["account"]["code"], ACCOUNT_CASH) + self.assertEqual(len(response.data["rows"]), 1) + + def test_other_users_account_is_not_reachable(self): + other = _make_user(phone="09129998888") + ChartOfAccountsService.ensure_chart(other) + foreign = Account.objects.get(user=other, code=ACCOUNT_CASH) + response = self.client.get( + reverse("bizapps:account-subsidiary-ledger", kwargs={"pk": foreign.id}) + ) + self.assertEqual(response.status_code, 404) diff --git a/apps/bizapps/urls.py b/apps/bizapps/urls.py index 38f717b..6ad1701 100644 --- a/apps/bizapps/urls.py +++ b/apps/bizapps/urls.py @@ -1,28 +1,75 @@ from django.urls import path from .api.views import ( - AppointmentDetailView, - AppointmentListCreateView, + AccountDetailView, + AccountListView, + BusinessDashboardView, CustomerDetailView, CustomerListCreateView, + CustomerStatementView, + InventorySummaryView, + InvoiceCancelView, + InvoiceDetailView, + InvoiceListCreateView, + InvoiceStatusView, LedgerEntryDetailView, LedgerEntryListCreateView, + LedgerBookListView, + LedgerBookSummaryView, + LedgerExportView, + LedgerInstallmentDetailView, + LedgerInstallmentListCreateView, + OpenLedgerBookView, ProductAdjustStockView, ProductDetailView, ProductListCreateView, ProductMovementsView, + SubsidiaryLedgerView, + TransactionListView, + TrialBalanceView, ) app_name = "bizapps" urlpatterns = [ + path("dashboard/", BusinessDashboardView.as_view(), name="dashboard"), + path("customers/", CustomerListCreateView.as_view(), name="customer-list"), + # قبل از نیاید، وگرنه "statement" به‌عنوان pk تفسیر می‌شود. + path("customers//statement/", CustomerStatementView.as_view(), name="customer-statement"), path("customers//", CustomerDetailView.as_view(), name="customer-detail"), + + # export قبل از ثبت شده تا مسیرها با هم تداخل نکنند. + 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/export/", LedgerExportView.as_view(), name="ledger-export"), path("ledger/", LedgerEntryListCreateView.as_view(), name="ledger-list"), + path( + "ledger//installments/", + LedgerInstallmentListCreateView.as_view(), + name="ledger-installment-list", + ), path("ledger//", LedgerEntryDetailView.as_view(), name="ledger-detail"), + path( + "ledger-installments//", + LedgerInstallmentDetailView.as_view(), + name="ledger-installment-detail", + ), + + path("accounts/", AccountListView.as_view(), name="account-list"), + path("accounts//ledger/", SubsidiaryLedgerView.as_view(), name="account-subsidiary-ledger"), + path("accounts//", AccountDetailView.as_view(), name="account-detail"), + path("trial-balance/", TrialBalanceView.as_view(), name="trial-balance"), + path("transactions/", TransactionListView.as_view(), name="transaction-list"), + + path("products/summary/", InventorySummaryView.as_view(), name="product-summary"), path("products/", ProductListCreateView.as_view(), name="product-list"), - path("products//", ProductDetailView.as_view(), name="product-detail"), path("products//adjust/", ProductAdjustStockView.as_view(), name="product-adjust"), path("products//movements/", ProductMovementsView.as_view(), name="product-movements"), - path("appointments/", AppointmentListCreateView.as_view(), name="appointment-list"), - path("appointments//", AppointmentDetailView.as_view(), name="appointment-detail"), + path("products//", ProductDetailView.as_view(), name="product-detail"), + + path("invoices/", InvoiceListCreateView.as_view(), name="invoice-list"), + path("invoices//status/", InvoiceStatusView.as_view(), name="invoice-status"), + path("invoices//cancel/", InvoiceCancelView.as_view(), name="invoice-cancel"), + path("invoices//", InvoiceDetailView.as_view(), name="invoice-detail"), ] diff --git a/apps/landing/migrations/0001_initial.py b/apps/landing/migrations/0001_initial.py new file mode 100644 index 0000000..b5fb15b --- /dev/null +++ b/apps/landing/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.15 on 2026-09-02 00:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Article', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(max_length=120, unique=True, verbose_name='نامک (slug)')), + ('title', models.CharField(max_length=200, verbose_name='عنوان')), + ('meta_description', models.CharField(blank=True, help_text='در تگ\u200cهای meta description و og:description استفاده می\u200cشود.', max_length=300, verbose_name='توضیح متا (SEO)')), + ('excerpt', models.CharField(help_text='همان متن کوتاهی که زیر عنوان در لیست مقالات نشان داده می\u200cشود.', max_length=300, verbose_name='خلاصه')), + ('content_html', models.TextField(help_text='محتوای مقاله با تگ\u200cهای HTML ساده مثل

    ،

    ، .', verbose_name='محتوا (HTML)')), + ('read_minutes', models.PositiveSmallIntegerField(default=3, verbose_name='زمان مطالعه (دقیقه)')), + ('is_published', models.BooleanField(default=True, verbose_name='منتشرشده')), + ('published_at', models.DateField(verbose_name='تاریخ انتشار')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'مقاله', + 'verbose_name_plural': 'مقالات', + 'ordering': ('-published_at', '-created_at'), + }, + ), + ] diff --git a/apps/landing/migrations/0002_articles_batch.py b/apps/landing/migrations/0002_articles_batch.py new file mode 100644 index 0000000..cba7561 --- /dev/null +++ b/apps/landing/migrations/0002_articles_batch.py @@ -0,0 +1,898 @@ +import datetime + +from django.db import migrations + +# سی مقاله‌ی تازه — جدا از هشت‌تای قبلی (چهارتای بازنویسی‌شده + چهارتای +# جدیدِ فصل قبل). هرکدام حداقل یکی‌دو لینکِ داخلیِ واقعی و مرتبط، توی خودِ +# متن (نه فقط بخشِ «مقالات مرتبط»ِ خودکارِ پایین صفحه) به مقاله‌های دیگر +# دارد — هم قدیمی‌ها هم بین خودشان — تا یک وبِ لینکِ داخلیِ واقعی شکل بگیرد +# (دقیقا همان چیزی که خواسته شده بود: «مقالات توش لینک داشته باشه و به هم +# لینک بشن»). انتشار هفتگی: یکی هر هفته، شروع از یک هفته بعد از آخرین +# مقاله‌ی فصل قبل — با همان مکانیزمِ published_at-به‌عنوان-gate که در +# apps.landing.views._visible_articles پیاده شده، نیازی به کرون نیست. + +LAST_PREVIOUS_DATE = datetime.date(2026, 9, 8) + +ARTICLES = [ + # --- خوشه‌ی دفتر درآمد و هزینه / حسابداری --- + { + "slug": "tafavot-daramad-naghdi-talab", + "title": "تفاوت درآمد نقدی و طلب چیست؟", + "meta_description": "چرا هر پولی که «قرار است» به تو برسد را نباید همان لحظه درآمد حساب کرد — فرق درآمد نقدی و طلب در دفترداریِ کسب‌وکار کوچک.", + "excerpt": "هر پولی که «قرار است» برسد، هنوز پول نیست — فرق درآمد و طلب همین‌جاست.", + "read_minutes": 3, + "content_html": """ +

    + یک سوال ساده که جواب دادنش سخت‌تر از چیزی است که فکر می‌کنی: «همین + الان چقدر پول نقد در دسترس داری؟» خیلی از کسب‌وکارهای کوچک این عدد را + با «جمعِ کل فروش» اشتباه می‌گیرند — در حالی که بخشی از آن فروش هنوز + طلب است، نه پول در جیب. +

    +

    درآمد یعنی چه، طلب یعنی چه

    +

    + درآمدِ نقدی یعنی پولی که همین الان دستت رسیده. طلب (receivable) یعنی + حقی که داری تا در آینده پول را بگیری — مثل وقتی جنسی را + قسطی می‌فروشی. هر دو + «فروش» حساب می‌شوند، اما فقط یکی‌شان همین الان قابل‌خرج‌کردن است. +

    +

    چرا این فرق مهم است

    +

    + اگر طلب را با درآمدِ نقدی یکی حساب کنی، فکر می‌کنی پول بیشتری داری + که واقعا نداری — و همین باعث می‌شود در پرداختِ هزینه‌های خودت (اجاره، + خرید جنسِ تازه) کم بیاوری. دفتر درآمد و هزینه‌ی شاپ شیپ این دو را از + هم جدا نگه می‌دارد تا همیشه بدانی چقدر واقعا در دسترس داری. +

    +""", + }, + { + "slug": "csv-daftar-baraye-hesabdar", + "title": "چطور خروجی CSV دفتر حساب را برای حسابدار بفرستم؟", + "meta_description": "دفتر درآمد و هزینه‌ی شاپ شیپ را برای یک بازه‌ی دلخواه به فایل CSV تبدیل و برای حسابدارت ارسال کن — سازگار با اکسل فارسی.", + "excerpt": "یک بازه‌ی تاریخ انتخاب کن، خروجی CSV بگیر، برای حسابدارت بفرست.", + "read_minutes": 2, + "content_html": """ +

    + خیلی از کسب‌وکارهای کوچک هر چند ماه یک‌بار باید دفترشان را برای یک + حسابدار بفرستند — برای اظهارنامه‌ی مالیاتی یا فقط یک مرور کلی. شاپ + شیپ این کار را به چند ثانیه رسانده. +

    +

    خروجی گرفتن

    +

    + از دفتر درآمد و هزینه، یک بازه‌ی تاریخِ دلخواه (مثلا یک ماه یا یک + فصل) انتخاب کن و «خروجی CSV» را بزن. فایلی می‌سازد که مستقیم در اکسل + یا گوگل‌شیت باز می‌شود — با حروف فارسیِ درست، بدون به‌هم‌ریختگی. +

    +

    چه چیزی در فایل هست

    +

    + هر ردیف یک ثبت است: تاریخ، دسته‌بندی (مثلا فروش نقدی یا + خرید نسیه)، مبلغ، + و یادداشتی که خودت نوشته بودی. برای ثبت‌های بدهی/طلب، مانده‌حساب هم + در فایل می‌آید — حسابدارت همان چیزی را می‌بیند که تو در اپ می‌بینی. +

    +""", + }, + { + "slug": "modiriyat-bedehi-moshtarian", + "title": "مدیریت بدهی مشتریان با موبایل", + "meta_description": "همه‌ی مشتریانی که به تو بدهکارند، مانده‌حساب و تاریخچه‌ی اقساطشان را در یک صفحه ببین — بدون دفترچه‌ی کاغذی.", + "excerpt": "مانده‌حساب هر مشتری، خودکار از فاکتورها و دفترت محاسبه می‌شود — نه با دفترچه‌ی کاغذی.", + "read_minutes": 3, + "content_html": """ +

    + خیلی از مغازه‌دارها یک دفترچه‌ی کاغذیِ جدا برای «کیا بهم بدهکارن» + دارند — که هم گم می‌شود، هم به‌روزرسانیش یادشان می‌رود. در شاپ شیپ، + این دفترچه دیگر لازم نیست. +

    +

    مانده‌حساب خودکار

    +

    + هر بار که یک فروش + قسطی برای یک مشتری ثبت می‌کنی یا یک قسط از او می‌گیری، مانده‌حساب + همان مشتری در پروفایلش خودکار به‌روز می‌شود — جمع تمام بدهی‌های بازش، + نه فقط آخرین ثبت. +

    +

    یک نگاه، همه‌ی بدهکارها

    +

    + از پرونده‌ی مشتری + می‌توانی ببینی چه کسی چقدر بدهکار است و آخرین قسطش کِی بوده — و اگر + لازم شد، از همان‌جا یک پیامکِ یادآوری برایش بفرستی. +

    +""", + }, + { + "slug": "sabt-hazine-rozane-maghaze", + "title": "ثبت هزینه‌های روزمره‌ی مغازه: از کجا شروع کنم؟", + "meta_description": "اجاره، قبض، حقوق، خرید جنس — راهنمای شروع ثبت هزینه‌های روزمره‌ی یک کسب‌وکار کوچک در دفتر درآمد و هزینه‌ی شاپ شیپ.", + "excerpt": "لازم نیست حسابدار باشی — فقط دسته‌ی درستِ نزدیک به هزینه‌ات را انتخاب کن.", + "read_minutes": 3, + "content_html": """ +

    + خیلی‌ها ثبتِ هزینه را به این دلیل کنار می‌گذارند که فکر می‌کنند باید + اصطلاحاتِ حسابداری بلد باشند. خبر خوب: نیستی، فقط چند دسته‌ی ساده + کافی است. +

    +

    دسته‌های آماده برای شروع

    +

    + اجاره، حقوق، قبض/آب‌وبرق، خرید نقدی جنس، و «سایر هزینه‌ها» — این پنج + دسته اکثرِ هزینه‌های روزمره‌ی یک مغازه‌ی کوچک را پوشش می‌دهند. برای + خرید جنسی که نقد نپرداختی، دسته‌ی + خرید نسیه جداست. +

    +

    عادتِ هر روز، بهتر از هر هفته

    +

    + ثبتِ هر هزینه همان لحظه که اتفاق می‌افتد، چند ثانیه بیشتر طول + نمی‌کشد؛ ولی اگر یک هفته عقب بیفتی، خیلی از ریزه‌کاری‌ها یادت + می‌رود. بعد از چند هفته می‌توانی از + نمودارِ درآمد و + هزینه ببینی بیشترین هزینه‌ات کجا می‌رود. +

    +""", + }, + { + "slug": "nemodar-daramad-hazine-tasmim", + "title": "نمودار درآمد و هزینه چطور به تصمیم‌گیری کمک می‌کند؟", + "meta_description": "نمودار دفتر درآمد و هزینه‌ی شاپ شیپ را برای یک بازه‌ی زمانی دلخواه ببین و بفهم درآمدت روند صعودی دارد یا نه.", + "excerpt": "یک نگاهِ بصری به روندِ درآمد و هزینه‌ات — نه فقط یک عدد خام.", + "read_minutes": 3, + "content_html": """ +

    + یک لیستِ بلند از اعداد، فهمیدنِ «آیا وضعیتم بهتر شده یا بدتر» را سخت + می‌کند. نمودار به همین درد می‌خورد — یک نگاه، و روند مشخص است. +

    +

    بازه‌ی زمانیِ دلخواه

    +

    + از صفحه‌ی دفترِ درآمد و هزینه، وارد نمودار شو و بازه‌ای که می‌خواهی + (یک ماه، یک فصل، یک سال) را انتخاب کن. درآمد و هزینه‌ی هر بازه کنار + هم رسم می‌شود — همان چیزی که بعدا می‌توانی به‌عنوان + خروجی CSV هم + داشته باشی. +

    +

    سوالی که نمودار جواب می‌دهد

    +

    + «این ماه بهتر از ماه پیش بودم؟» یا «بعد از فعال‌کردنِ پیام گروهی، + فروشم بالا رفت؟» — این‌ها سوالاتی هستند که یک نمودارِ ساده بهتر از + یک لیستِ عددی جوابشان را می‌دهد. +

    +""", + }, + { + "slug": "kharid-nasiye-dar-daftardari", + "title": "فرق خرید نسیه و خرید نقدی در دفترداری", + "meta_description": "خرید نسیه یک نوع بدهکاریِ تو به فروشنده است، نه یک هزینه‌ی ساده — چطور شاپ شیپ این تفاوت را در دفتر نشان می‌دهد.", + "excerpt": "خرید نسیه یعنی تو بدهکارِ فروشنده‌ای — دقیقا برعکسِ فروش قسطی.", + "read_minutes": 3, + "content_html": """ +

    + مقاله‌ی فروش قسطی چطور + باید ثبت شود؟ نشان داد وقتی تو جنسی را قسطی می‌فروشی، مشتری به تو + بدهکار می‌شود. حالا برعکسش: وقتی تو از یک تامین‌کننده جنس نسیه + می‌خری، این‌بار خودت بدهکاری. +

    +

    چرا این را جدا از هزینه‌ی نقدی ثبت کنیم

    +

    + اگر خرید نسیه را همان لحظه «هزینه» حساب کنی، انگار همان لحظه پول + دادی — در حالی که ندادی. این یعنی موجودیِ نقدیِ حساب‌شده‌ات از + واقعیت بیشتر نشان داده می‌شود، دقیقا برعکسِ اشتباهی که در مقاله‌ی + تفاوت درآمد نقدی و + طلب گفتیم. +

    +

    در شاپ شیپ

    +

    + دسته‌ی «خرید نسیه» را که انتخاب کنی، اپ مبلغِ کل و نام طرف‌حساب را + می‌پرسد و یک بدهیِ باز برایت می‌سازد — دقیقا هم‌خانواده‌ی همان مکانیزمِ + طلب، فقط جهتش برعکس است. +

    +""", + }, + { + "slug": "sabt-aghsat-bedehi", + "title": "چطور اقساط یک بدهی را ثبت و پیگیری کنم؟", + "meta_description": "برای هر بدهی/طلبِ باز در دفتر شاپ شیپ، هر قسطی که می‌گیری یا می‌دهی را جدا ثبت کن و مانده‌حساب خودکار کم شود.", + "excerpt": "هر قسط را جدا ثبت کن — مانده‌حساب خودش کم می‌شود، حساب‌وکتاب دستی لازم نیست.", + "read_minutes": 2, + "content_html": """ +

    + وقتی یک فروش قسطی + یا خرید نسیه در + دفترت ثبت می‌کنی، یک «مانده‌حساب» برایش ساخته می‌شود. حالا سوال این + است: وقتی هر قسط می‌رسد، چطور ثبتش کنم؟ +

    +

    ثبت هر قسط، جدا

    +

    + روی همان ثبتِ بدهی/طلب باز کن و یک قسطِ جدید اضافه کن — مبلغ، تاریخ، + و اگر خواستی یک شماره‌ی مرجع (مثلا شماره‌ی رسید). همین که ثبت شد، + مانده‌حساب خودکار همان مقدار کم می‌شود. +

    +

    وقتی مانده صفر شود

    +

    + وقتی مجموعِ اقساط به کل بدهی برسد، آن ثبت دیگر در لیستِ «بدهی‌های + باز» نشان داده نمی‌شود — اما تاریخچه‌ی کاملِ اقساط همیشه قابل‌مرور + می‌ماند، برای وقتی که لازم شد به آن رجوع کنی. +

    +""", + }, + # --- خوشه‌ی فاکتور --- + { + "slug": "fatoore-chand-ghalam-kala", + "title": "فاکتور با تخفیف و مالیات چند نوع کالا چطور صادر می‌شود؟", + "meta_description": "هر قلمِ کالا در فاکتورِ شاپ شیپ می‌تواند نرخ تخفیف و مالیاتِ جدا از بقیه داشته باشد — جمعِ نهایی خودکار محاسبه می‌شود.", + "excerpt": "هر قلم کالا، تخفیف و مالیاتِ خودش را دارد — جمعِ نهایی را اپ حساب می‌کند.", + "read_minutes": 3, + "content_html": """ +

    + فرض کن یک فاکتور داری با سه قلم کالا: یکی تخفیف دارد، یکی مالیاتِ + متفاوت، و سومی هیچ‌کدام. حساب‌کردنِ دستیِ این‌ها با ماشین‌حساب، هم + وقت‌گیر است هم پرخطا. +

    +

    هر قلم، تنظیماتِ خودش

    +

    + وقتی کالایی را (از همان لیستِ + انبارت) به فاکتور + اضافه می‌کنی، می‌توانی برایش یک درصد تخفیف و یک نرخ مالیات جدا از + بقیه‌ی قلم‌ها بگذاری. +

    +

    جمعِ نهایی، خودکار

    +

    + جمعِ کل، جمعِ مالیات، و مبلغِ تخفیف‌خورده‌ی نهایی خودش محاسبه + می‌شود — همان چیزی که در مقاله‌ی + فاکتور رسمی برای + مغازه هم به اتصالش به انبار اشاره شده بود. +

    +""", + }, + { + "slug": "vaziat-fatoore-pardakht", + "title": "فرق فاکتور پرداخت‌شده و در‌انتظار پرداخت", + "meta_description": "هر فاکتور در شاپ شیپ یک وضعیت روشن دارد — پرداخت‌شده یا در‌انتظار — و همین وضعیت تعیین می‌کند دفترت و انبارت چه تغییری می‌کنند.", + "excerpt": "وضعیتِ فاکتور فقط یک برچسب نیست — تعیین می‌کند انبار و دفترت چه اتفاقی برایشان می‌افتد.", + "read_minutes": 2, + "content_html": """ +

    + یک فاکتور صرفا «صادر شدن» کافی نیست — باید بدانی پولش رسیده یا نه. + شاپ شیپ برای همین یک وضعیتِ روشن به هر فاکتور می‌دهد. +

    +

    وقتی پرداخت‌شده می‌شود

    +

    + همین که فاکتور را «پرداخت‌شده» علامت بزنی، دو اتفاق خودکار می‌افتد: + موجودیِ کالاهای همان فاکتور از + انبار کم می‌شود، و اگر + نوعِ پرداخت «قسطی/نسیه» بود، یک ثبتِ طلب هم در + دفترت ساخته + می‌شود. +

    +

    وقتی هنوز در‌انتظار است

    +

    + تا وضعیت پرداخت‌شده نشود، هیچ‌کدام از این دو اتفاق نمی‌افتد — یعنی + می‌توانی فاکتور را از قبل آماده کنی، بدهی به مشتری نشان دهی، و بعدا + وقتی واقعا پول رسید، وضعیتش را عوض کنی. +

    +""", + }, + { + "slug": "chera-fatoore-rasmi-mohem-ast", + "title": "چرا باید فاکتور رسمی به مشتری بدهم حتی برای فروش کوچک؟", + "meta_description": "فاکتور رسمی فقط برای فروش‌های بزرگ نیست — اعتماد، پیگیریِ اختلاف، و تاریخچه‌ی خرید مشتری، سه دلیل برای صدورِ فاکتورِ هر فروش.", + "excerpt": "فاکتور فقط کاغذ نیست — سندِ اعتماد بین تو و مشتری است.", + "read_minutes": 3, + "content_html": """ +

    + خیلی از مغازه‌دارها فقط برای فروش‌های «بزرگ» فاکتور صادر می‌کنند — + برای بقیه، یک رسیدِ دست‌نویس یا هیچی. اما فاکتورِ رسمی برای هر فروش، + چند فایده دارد که با رسیدِ دست‌نویس به‌دست نمی‌آید. +

    +

    اعتماد و حرفه‌ای‌بودن

    +

    + یک فاکتورِ با لوگو، حس حرفه‌ای‌تری به مشتری می‌دهد — دقیقا همان چیزی + که در فاکتور رسمی + برای مغازه توضیح داده شد. +

    +

    پیگیریِ اختلاف

    +

    + اگر روزی مشتری بگوید «من این قیمت را ندیدم» یا «این کالا را نخریدم»، + یک فاکتورِ ثبت‌شده در اپ، فورا قابل‌نشان‌دادن است — بدون گشتن در + دفترچه‌ای که شاید گم شده باشد. +

    +

    تاریخچه‌ی خرید مشتری

    +

    + هر فاکتور خودکار به پرونده‌ی + همان مشتری وصل می‌شود — یعنی بعد از چند ماه می‌توانی ببینی هر + مشتری چقدر و چه چیزی خریده، بدون این‌که چیزی را دوباره تایپ کنی. +

    +""", + }, + # --- خوشه‌ی مشتریان --- + { + "slug": "parvande-moshtari-chist", + "title": "پرونده‌ی مشتری چیست و چه چیزهایی در آن ثبت می‌شود؟", + "meta_description": "پرونده‌ی هر مشتری در شاپ شیپ، تاریخچه‌ی فاکتورها و مانده‌حسابش را خودکار جمع می‌کند — بدون هیچ ثبتِ دستیِ اضافه.", + "excerpt": "تاریخچه‌ی خرید و مانده‌حساب هر مشتری، خودکار و در یک صفحه.", + "read_minutes": 2, + "content_html": """ +

    + وقتی چند ده یا چند صد مشتری داری، به‌خاطر سپردنِ این‌که هرکدام چه + خریده و چقدر بدهکار است، عملا غیرممکن می‌شود. پرونده‌ی مشتری همین + کار را برایت انجام می‌دهد. +

    +

    چیزی که خودکار ساخته می‌شود

    +

    + هر بار که برای یک مشتری فاکتور + صادر می‌کنی یا در دفترت یک بدهی/طلب برایش ثبت می‌کنی، همه‌ی این‌ها + خودکار در پرونده‌ی همان مشتری جمع می‌شود — تو چیزی جدا وارد نمی‌کنی. +

    +

    مانده‌حساب، همان‌جا

    +

    + اگر مشتری‌ای بدهیِ + باز داشته باشد، همان‌جا در پرونده‌اش می‌بینی — دیگر نیازی به گشتن + در دفتر یا فاکتورهای قدیمی نیست. +

    +""", + }, + { + "slug": "faal-sazi-mojadad-moshtarian", + "title": "چطور مشتریان قدیمی را دوباره فعال کنم؟", + "meta_description": "با پیام گروهی و لیستِ مشتریانی که مدتی خرید نکرده‌اند، یک یادآوریِ ساده بفرست و مشتریِ فراموش‌شده را برگردان.", + "excerpt": "یک پیامِ یادآوریِ ساده، بیشترِ وقت‌ها کافی است — نه تخفیفِ بزرگ.", + "read_minutes": 3, + "content_html": """ +

    + خیلی از کسب‌وکارهای کوچک انرژی‌شان را فقط صرفِ جذبِ مشتریِ تازه + می‌کنند و مشتریِ قدیمی که مدتی سراغش نرفته‌اند را فراموش می‌کنند — + در حالی که برگرداندنِ یک مشتریِ قدیمی معمولا ارزان‌تر از جذبِ تازه است. +

    +

    پیدا کردنِ مشتریانِ ساکت

    +

    + از پرونده‌های مشتری، + کسانی که مدتی فاکتور یا خریدی نداشته‌اند مشخص‌اند — همین لیست، شروعِ + خوبی برای یک یادآوری است. +

    +

    یک قالبِ ساده کافی است

    +

    + لازم نیست تخفیفِ بزرگ بدهی؛ گاهی یک + قالبِ پیام ساده مثل «دلمون + برات تنگ شده، سر بزن» با نامِ خودِ مشتری، از طریقِ + ارسالِ گروهی برایِ + همان لیست، کافی است. +

    +""", + }, + # --- خوشه‌ی انبار --- + { + "slug": "dastebandi-kala-anbar", + "title": "دسته‌بندی کالا در انبار: از کجا شروع کنم؟", + "meta_description": "برای انبارِ چند ده یا چند صد قلمی، دسته‌بندی کالا در شاپ شیپ پیدا کردن و مدیریت را چند برابر سریع‌تر می‌کند.", + "excerpt": "دسته‌های ساده مثل «نوشیدنی» یا «لبنیات» — پیدا کردن یک کالا چند برابر سریع‌تر می‌شود.", + "read_minutes": 2, + "content_html": """ +

    + اگر انبارت فقط چند قلم کالا دارد، دسته‌بندی شاید لازم نباشد. اما + همین که به چند ده قلم برسی، یک لیستِ بلندِ بی‌ترتیب پیدا کردنِ یک + کالا را کند می‌کند. +

    +

    دسته‌های دلخواهت

    +

    + دسته‌ها را خودت تعریف می‌کنی — چیزی که برای کسب‌وکارت معنی‌دار باشد، + نه یک قالبِ از‌قبل‌تعیین‌شده. یک سوپرمارکت شاید «نوشیدنی/لبنیات/خشکبار» + داشته باشد، یک لوازم‌یدکی‌فروشی چیزِ کاملا متفاوتی. +

    +

    اثرش روی فاکتور هم هست

    +

    + وقتی برای فاکتور + کالا اضافه می‌کنی، همین دسته‌بندی کمک می‌کند سریع‌تر کالای موردنظر را + پیدا کنی — به‌جای اسکرول‌کردنِ یک لیستِ طولانی. +

    +""", + }, + { + "slug": "gheymat-kharid-vs-arzesh-anbar", + "title": "فرق قیمت خرید و ارزش کل انبار", + "meta_description": "قیمت خرید هر کالا را ثبت کن تا شاپ شیپ خودش ارزشِ کلِ سرمایه‌ی خوابیده در انبارت را برایت حساب کند.", + "excerpt": "دو عدد ساده (قیمت خرید و موجودی)، یک جوابِ مهم: چقدر سرمایه در انبارت خوابیده؟", + "read_minutes": 2, + "content_html": """ +

    + «الان چقدر پول در قالبِ جنس، روی طاقچه‌های مغازه‌ام خوابیده؟» سوالی + است که خیلی از صاحبانِ مغازه دقیق جوابش را نمی‌دانند. +

    +

    یک ضرب ساده، محاسبه‌شده برایت

    +

    + برای هر کالا در انبار، + قیمتِ خریدش را ثبت می‌کنی. اپ همین عدد را در موجودیِ فعلی ضرب می‌کند و + «ارزشِ کلِ انبار» را نشان می‌دهد — بدون این‌که خودت دستی جمع بزنی. +

    +

    چرا این عدد مهم است

    +

    + این عدد به تو می‌گوید چقدر سرمایه در جنسِ فروخته‌نشده گیر افتاده — + اطلاعاتی که برای تصمیمِ «الان جنسِ تازه بخرم یا نه» مستقیم به کار + می‌آید. +

    +""", + }, + { + "slug": "hoshdar-kambood-mojoodi", + "title": "هشدار کمبود موجودی چطور کار می‌کند؟", + "meta_description": "برای هر کالا یک آستانه‌ی «کم شد» تعریف کن — قبل از این‌که مشتری بیاید و نداشته باشی، خودت متوجه می‌شوی.", + "excerpt": "یک آستانه تعریف کن — قبل از تمام‌شدنِ واقعیِ جنس، خودت باخبر می‌شوی.", + "read_minutes": 2, + "content_html": """ +

    + یکی از بدترین حس‌ها برای یک مغازه‌دار، این است که مشتری بیاید و جنسی + که فکر می‌کردی داری، تمام شده باشد. هشدارِ کمبود دقیقا برای جلوگیری + از همین لحظه است. +

    +

    آستانه‌ی دلخواهِ هر کالا

    +

    + برای هر کالا در انبار، + یک عدد به‌عنوان «کم شد» تعریف می‌کنی — مثلا وقتی موجودی به ۵ رسید. + این عدد برای هر کالا می‌تواند فرق کند. +

    +

    باخبر شدن زودتر

    +

    + وقتی موجودی به آن آستانه برسد، اپ نشانش می‌دهد — قبل از این‌که واقعا + صفر شود. یعنی وقت کافی برای سفارشِ جنسِ تازه داری، بدون فشارِ + لحظه‌ی‌آخری. +

    +""", + }, + # --- خوشه‌ی پیام گروهی --- + { + "slug": "ghaleb-payam-chist", + "title": "قالب پیام چیست و چطور بسازمش؟", + "meta_description": "قالب پیام یک متنِ آماده با جای‌خالیِ نام/شماره است که یک‌بار می‌سازی و بارها برای مخاطبین مختلف استفاده می‌کنی.", + "excerpt": "یک متن، با جای‌خالی برای نام — یک‌بار بساز، بارها استفاده کن.", + "read_minutes": 2, + "content_html": """ +

    + اگر هر بار که می‌خواهی پیام گروهی بفرستی، از صفر متن تایپ کنی، هم + وقت تلف می‌شود هم احتمالِ فراموش‌کردنِ چیزی بالا می‌رود. قالبِ پیام + همین مشکل را حل می‌کند. +

    +

    جای‌خالیِ نام و شماره

    +

    + در متنِ قالب، می‌توانی از جای‌خالی‌هایی مثل «نام» استفاده کنی — وقتی + برای هر مخاطب ارسال می‌شود، خودکار با اطلاعاتِ همان مخاطب پر می‌شود. +

    +

    فیلدِ اجرا، برای مقادیرِ متغیر

    +

    + اگر یک مقدار داری که از قبل معلوم نیست (مثل کد رهگیری)، قالب می‌تواند + یک فیلدِ اجرا + هم داشته باشد — همان لحظه‌ی ارسال از تو پرسیده می‌شود. +

    +""", + }, + { + "slug": "ersal-payam-be-goruh-khas", + "title": "ارسال پیام به گروه خاصی از مشتریان", + "meta_description": "به‌جای فرستادن یک پیام به همه، فقط برای بدهکارها، مشتریانِ ساکت، یا هر گروهِ دلخواه دیگری در شاپ شیپ پیام بفرست.", + "excerpt": "همه یک پیام یکسان نمی‌خواهند — پیامِ درست را برای گروهِ درست بفرست.", + "read_minutes": 2, + "content_html": """ +

    + فرستادنِ یک پیامِ یکسان به همه‌ی مخاطبین، همیشه بهترین راه نیست — + پیامِ یادآوریِ بدهی برای بدهکارها معنی دارد، نه برای همه. +

    +

    انتخاب گیرنده‌ها

    +

    + قبل از ارسال، مخاطبینی که پیام برایشان می‌رود را خودت انتخاب می‌کنی — + مثلا فقط کسانی که بدهیِ + باز دارند، یا فقط + مشتریانِ مدتی + ساکت. +

    +

    یک قالب، گیرنده‌های متفاوت

    +

    + همان قالبِ پیام که یک‌بار + ساختی، برای هر گروهی که بعدا انتخاب کنی دوباره قابل‌استفاده است — + فقط لیستِ گیرنده‌ها را عوض می‌کنی. +

    +""", + }, + { + "slug": "vaziat-tahvil-payamak", + "title": "چک کردن وضعیت تحویل پیامک‌های ارسالی", + "meta_description": "بعد از ارسال گروهی، بفهم کدام پیامک واقعا تحویل داده شده، کدام در صف مانده و کدام ناموفق بوده — همه در یک لیست.", + "excerpt": "بعد از ارسال، بفهم واقعا چند نفر پیامک را گرفتند.", + "read_minutes": 2, + "content_html": """ +

    + «آیا واقعا رسید؟» سوالی است که بعد از هر ارسالِ گروهی می‌آید — به‌خصوص + برای قالب‌های پیام مهم + مثل یادآوریِ پرداخت. +

    +

    سه وضعیتِ ممکن

    +

    + هر پیامکِ ارسالی یکی از این سه وضعیت را دارد: تحویل‌شده، در‌صف + (هنوز پردازش می‌شود)، یا ناموفق. لیستِ کاملِ همه‌ی ارسال‌ها، با + وضعیتِ هرکدام، در دسترس است. +

    +

    وقتی چیزی ناموفق بود

    +

    + اگر پیامکی به دلیلی (مثلا شماره‌ی اشتباه) ناموفق بود، می‌دانی دقیقا + چه کسی پیام را نگرفته — می‌توانی جداگانه دوباره برایش بفرستی، به‌جای + حدس‌زدن. +

    +""", + }, + { + "slug": "kodam-kanal-baraye-kodam-kasbokar", + "title": "کدام کانال برای کدام کسب‌وکار بهتر است؟", + "meta_description": "فروشگاه، آنلاین‌شاپ، یا کسب‌وکار خدماتی — هرکدام معمولا با یک ترکیبِ متفاوت از پیامک/واتساپ/تلگرام بهتر جواب می‌گیرند.", + "excerpt": "نوعِ کسب‌وکارت مشخص می‌کند کدام کانال بیشترین اثر را دارد.", + "read_minutes": 3, + "content_html": """ +

    + مقاله‌ی پیامک یا + واتساپ‌بیزینس؟ تفاوتِ فنیِ کانال‌ها را توضیح داد. این‌جا می‌خواهیم + ببینیم کدام کسب‌وکار معمولا کدام ترکیب را بیشتر لازم دارد. +

    +

    فروشگاه‌های محلی

    +

    + برای اعلامِ موجودیِ روزانه یا تخفیف، پیامک قابل‌اطمینان‌ترین است — + همه به آن دسترسی دارند، حتی بدون اینترنت. +

    +

    آنلاین‌شاپ‌های کوچک

    +

    + اطلاع‌رسانیِ مراحلِ + سفارش معمولا با پیامک بهتر جواب می‌دهد، چون مشتری منتظرِ خبرِ + «ارسال شد» است و نمی‌خواهد وابسته به بازکردنِ یک اپ خاص باشد. +

    +

    کسب‌وکارهای خدماتی

    +

    + برای ارتباطِ دوطرفه‌تر (سوال‌وجواب با مشتری)، واتساپ یا تلگرام معمولا + راحت‌تر است — به‌شرطی که مسیرِ رسمیِ هرکدام رعایت شود. +

    +""", + }, + # --- خوشه‌ی پیگیری سفارش --- + { + "slug": "tartib-marahel-sefaresh", + "title": "جابه‌جا کردن ترتیب مراحل سفارش با کشیدن و رها کردن", + "meta_description": "مراحلِ پیگیری سفارش در شاپ شیپ ثابت نیستند — با کشیدن و رها کردن، ترتیبشان را دقیقا مطابق جریان کاریِ واقعیت تنظیم کن.", + "excerpt": "ترتیبِ مراحل، دقیقا مطابقِ جریانِ کاریِ واقعیِ خودت — با یک کشیدن و رها کردن.", + "read_minutes": 2, + "content_html": """ +

    + همان‌طور که در مقاله‌ی + پیگیری سفارش + آنلاین‌شاپ گفتیم، مراحلِ کار را خودت تعریف می‌کنی. اما گاهی بعد از + مدتی می‌فهمی ترتیبِ اولیه دقیق نبوده. +

    +

    کشیدن و رها کردن

    +

    + از صفحه‌ی مراحلِ سفارش، هر مرحله را می‌توانی بگیری و به جای درست + بکشی — بدون نیاز به حذف و ساختِ دوباره. مثلا اگر «بسته‌بندی» را قبل + از «آماده‌سازی» گذاشته بودی، فقط جابه‌جایش کن. +

    +

    تغییر ترتیب، تاثیری روی سفارش‌های قبلی ندارد

    +

    + سفارش‌هایی که از قبل در یک مرحله بودند، همان‌جا می‌مانند — تغییرِ + ترتیب فقط روی مسیرِ سفارش‌های بعدی اثر می‌گذارد. +

    +""", + }, + { + "slug": "payam-mokhtalef-har-marhale", + "title": "چطور برای هر مرحله‌ی سفارش پیامک متفاوت بفرستم؟", + "meta_description": "هر مرحله‌ی پیگیری سفارش در شاپ شیپ به یک قالبِ پیام جداگانه وصل می‌شود — «آماده شد» یک متن، «ارسال شد» متنی دیگر.", + "excerpt": "هر مرحله، قالبِ پیامِ خودش را دارد — نه یک متنِ یکسان برای همه‌جا.", + "read_minutes": 2, + "content_html": """ +

    + یک پیامِ یکسان برای «سفارشت آماده شد» و «سفارشت تحویل داده شد» + منطقی نیست — هرکدام باید خبرِ متفاوتی بدهند. +

    +

    هر مرحله، یک قالب

    +

    + وقتی یک مرحله + می‌سازی، همان‌جا یک قالبِ + پیام برایش انتخاب می‌کنی — می‌تواند برای هر مرحله کاملا متفاوت + باشد. +

    +

    وقتی مرحله فیلدِ اجرا دارد

    +

    + برای مرحله‌ای مثل «ارسال شد» که یک کدِ رهگیری لازم دارد، قالبِ همان + مرحله می‌تواند فیلدِ + اجرا داشته باشد — همان لحظه از تو پرسیده و در پیامک جای می‌شود. +

    +""", + }, + # --- خوشه‌ی راهنمای عمومی/آشناسازی --- + { + "slug": "behtarin-estefade-az-hafte-raygan", + "title": "یک هفته‌ی رایگان شاپ شیپ را چطور بهترین استفاده را ببرم؟", + "meta_description": "راهنمای هفت‌روزه: چه کارهایی در هفته‌ی رایگانِ شاپ شیپ انجام بده تا قبل از هر تصمیمِ پرداختی، ارزشِ واقعیِ اپ را ببینی.", + "excerpt": "هفت روز، هفت قدمِ کوچک — تا قبل از هر تصمیم پرداختی، ارزشِ واقعی را ببینی.", + "read_minutes": 3, + "content_html": """ +

    + یک هفته زمانِ کمی است، ولی برای فهمیدنِ این‌که شاپ شیپ واقعا به‌دردت + می‌خورد یا نه، کافی است — اگر درست ازش استفاده کنی. +

    +

    روزهای اول: داده‌های واقعی

    +

    + به‌جای امتحان‌کردنِ با داده‌ی الکی، از همین روزِ اول + چند کالای واقعیِ انبارت + و چند مخاطبِ + واقعی را وارد کن. +

    +

    وسط هفته: یک فاکتور و یک پیام واقعی

    +

    + یک فاکتورِ واقعی + برای یک فروشِ واقعی بزن، و یک قالبِ پیام بساز و برایِ چند مشتریِ + واقعی بفرست. +

    +

    آخر هفته: مرور

    +

    + به دفترِ درآمد و هزینه‌ات نگاه کن — همان چیزی که در این هفته ثبت شده، + تصویرِ کوچکی از این است که یک ماهِ کامل با شاپ شیپ چه شکلی خواهد بود. +

    +""", + }, + { + "slug": "amniat-etelaat-kasbokar", + "title": "امنیت اطلاعات کسب‌وکارت در شاپ شیپ", + "meta_description": "قفل اثرانگشت، دیتای اختصاصی هر کاربر، و این‌که چرا اطلاعاتِ مالی و مشتریانِ تو فقط مالِ خودت است.", + "excerpt": "قفل اثرانگشت روی اطلاعاتِ حساس، و دیتایی که فقط مالِ خودت است.", + "read_minutes": 2, + "content_html": """ +

    + اطلاعاتِ مالی و لیستِ مشتریانِ یک کسب‌وکار، از حساس‌ترین چیزهایی است + که روی گوشیِ کسی می‌تواند باشد — به همین دلیل امنیت اینجا جدی گرفته + شده. +

    +

    قفل اثرانگشت

    +

    + می‌توانی ورود به بخش‌های حساس (مثل + دفتر درآمد و + هزینه) را پشتِ قفلِ اثرانگشتِ گوشی‌ات بگذاری — حتی اگر کسی + گوشی‌ات را باز کند، به این بخش‌ها بدون اثرانگشتِ خودت دسترسی ندارد. +

    +

    دیتای تو، مالِ خودت

    +

    + اطلاعاتِ کسب‌وکارِ هر کاربر کاملا از بقیه جدا است — چیزی بینِ + حساب‌های مختلف به اشتراک گذاشته نمی‌شود. +

    +""", + }, + { + "slug": "taghvim-shamsi-dar-narmafzar", + "title": "تقویم شمسی در نرم‌افزارهای کسب‌وکار چرا مهم است؟", + "meta_description": "تاریخِ میلادی در نرم‌افزارهای مالی، محاسبه‌ی سررسیدها و مرورِ گزارش‌ها را برای کاربر فارسی‌زبان سخت‌تر می‌کند — شاپ شیپ همه‌جا شمسی است.", + "excerpt": "وقتی سررسیدِ قسط یا تاریخِ فاکتور را می‌بینی، باید همان لحظه معنی‌اش را بفهمی — نه تبدیل کنی.", + "read_minutes": 2, + "content_html": """ +

    + خیلی از نرم‌افزارهای مالی و حسابداری با تاریخِ میلادی کار می‌کنند — + و کاربرِ فارسی‌زبان باید در ذهنش تبدیل کند. این تبدیلِ ذهنی، هرچقدر + هم سریع، یک لایه‌ی اضافه‌ی خستگی است. +

    +

    همه‌جا شمسی

    +

    + از تاریخِ فاکتور تا + سررسیدِ اقساط تا + نمودارِ دفتر، + همه‌جا با تقویمِ شمسی نمایش داده می‌شود. +

    +

    یعنی چه در عمل

    +

    + وقتی می‌بینی «سررسید ۱۵ مهر»، همان لحظه معنایش را می‌فهمی — بدون + این‌که مجبور باشی محاسبه کنی امروز چندمِ میلادی است و چند روز مانده. +

    +""", + }, + { + "slug": "jam-chist-chetor-kar-mikonad", + "title": "جم چیست و چطور برای فعال‌سازی ابزارها استفاده می‌شود؟", + "meta_description": "شاپ شیپ به‌جای اشتراکِ همه‌یا‌هیچ، هر بخش را جدا با «جم» فعال می‌کند — جم چطور خریداری و خرج می‌شود.", + "excerpt": "به‌جای اشتراکِ همه‌یا‌هیچ، هر بخش را جدا، فقط با جم، فعال می‌کنی.", + "read_minutes": 2, + "content_html": """ +

    + خیلی از نرم‌افزارها یک اشتراکِ ثابت می‌فروشند که شاملِ همه‌ی + امکانات است — حتی آن‌هایی که لازم نداری. شاپ شیپ این مدل را عوض + کرده. +

    +

    جم چیست

    +

    + جم واحدِ داخلیِ شاپ شیپ برای فعال‌سازیِ بخش‌هاست — فاکتور، پیام + گروهی، دفتر، انبار، هرکدام قیمتِ جداگانه‌ای به جم دارند. +

    +

    از کجا جم بگیرم

    +

    + جم را از فروشگاهِ داخلِ اپ می‌خری، یا از راهِ + دعوتِ دوستان به‌عنوانِ + پاداش دریافت می‌کنی. هر بخش را فقط برایِ مدتی که واقعا لازمش داری + فعال می‌کنی — تمدیدِ اجباری وجود ندارد. +

    +""", + }, + { + "slug": "davat-doostan-refferal", + "title": "دعوت دوستان و پاداش معرفی در شاپ شیپ", + "meta_description": "کد معرفِ خودت را با یک صاحب کسب‌وکار دیگر به اشتراک بگذار — هر دو نفر جم پاداش می‌گیرید که می‌شود برای فعال‌سازیِ ابزارها خرج کرد.", + "excerpt": "کدِ معرفت را با یک صاحب کسب‌وکارِ دیگر به اشتراک بگذار — هر دو برنده می‌شوید.", + "read_minutes": 2, + "content_html": """ +

    + اگر یک صاحب کسب‌وکارِ دیگر را می‌شناسی که ممکن است شاپ شیپ به دردش + بخورد، معرفیِ او هم به نفعِ او هم به نفعِ خودت است. +

    +

    چطور کار می‌کند

    +

    + کدِ معرفِ خودت را (از پروفایلِ خودت در اپ) با او به اشتراک بگذار. + وقتی او با همان کد ثبت‌نام و فعالیتِ واقعی را شروع کند، هر دوی شما + جمِ پاداش + می‌گیرید. +

    +

    خرجِ جمِ پاداش

    +

    + جمی که از این راه می‌گیری، دقیقا مثلِ جمِ خریداری‌شده است — می‌توانی + با آن هر بخشی از اپ (فاکتور، پیام گروهی، دفتر) را برای مدتی فعال + کنی، بدون پرداختِ پول. +

    +""", + }, + { + "slug": "kasbokar-khord-chist", + "title": "کسب‌وکار خرد چیست و چه نرم‌افزارهایی واقعا به دردش می‌خورد؟", + "meta_description": "یک مغازه‌ی یک‌نفره یا یک آنلاین‌شاپِ کوچک، نیازِ نرم‌افزاریِ متفاوتی از یک شرکتِ بزرگ دارد — چرا سادگی مهم‌تر از قابلیت‌های زیاد است.", + "excerpt": "کسب‌وکارِ یک یا دو‌نفره، نیازِ نرم‌افزاریِ متفاوتی از یک شرکتِ بزرگ دارد.", + "read_minutes": 3, + "content_html": """ +

    + «کسب‌وکار خرد» یعنی معمولا یک یا چند نفر، بدونِ بخشِ IT جدا، بدونِ + وقتِ زیاد برای یادگیریِ نرم‌افزارِ پیچیده. نیازهایش با یک شرکتِ + بزرگ خیلی فرق دارد. +

    +

    چرا نرم‌افزارِ شرکت‌های بزرگ جواب نمی‌دهد

    +

    + نرم‌افزارهایی که برای تیمِ حسابداریِ چندنفره طراحی شده‌اند، پر از + تنظیماتی هستند که یک مغازه‌دارِ تنها هیچ‌وقت لازمشان ندارد — و همین + پیچیدگی باعث می‌شود اصلا استفاده نشود. +

    +

    چه چیزی واقعا لازم است

    +

    + چیزهایی مثل فاکتورِ + ساده، ثبتِ + هزینه‌ی روزمره، و پیامِ + گروهیِ ساده — نه گزارش‌های پیچیده‌ای که هیچ‌وقت باز نمی‌شوند. +

    +""", + }, + { + "slug": "checklist-rahandazi-maghaze", + "title": "چک‌لیست راه‌اندازی یک مغازه‌ی کوچک با موبایل", + "meta_description": "از ثبتِ اولین کالا تا صدور اولین فاکتور — چک‌لیستِ قدم‌به‌قدمِ راه‌اندازیِ مدیریتِ یک مغازه‌ی کوچک با شاپ شیپ.", + "excerpt": "از اولین کالا تا اولین فاکتور — یک چک‌لیستِ قدم‌به‌قدم.", + "read_minutes": 3, + "content_html": """ +

    + شروعِ مدیریتِ یک مغازه با یک اپِ جدید، اگر قدم‌به‌قدم نباشد، گیج‌کننده + می‌شود. این چک‌لیست، ترتیبِ پیشنهادی است. +

    +

    قدم اول: انبار

    +

    + چند کالای پرتکرارت را با + دسته‌بندی و قیمتِ خرید + وارد کن — نیازی نیست همه را یک‌جا وارد کنی، می‌توانی کم‌کم اضافه کنی. +

    +

    قدم دوم: چند مشتریِ ثابت

    +

    + اگر مشتریانِ ثابت داری، آن‌ها را با + وارد‌کردن از + مخاطبینِ گوشی اضافه کن. +

    +

    قدم سوم: اولین فاکتور

    +

    + برای یک فروشِ واقعی، + اولین فاکتورت را + بزن — از همین‌جا می‌بینی چطور فاکتور، انبار، و دفترت به هم وصل + می‌شوند. +

    +

    قدم چهارم: اولین پیامِ گروهی

    +

    + یک قالبِ پیام ساده بساز و + برای همان چند مشتری بفرست — تمام. +

    +""", + }, + { + "slug": "shopship-vs-excel", + "title": "تفاوت شاپ شیپ با اکسل برای مدیریت کسب‌وکار", + "meta_description": "اکسل رایگان است ولی محاسبه، یادآوری، و اتصالِ خودکارِ بین فاکتور/انبار/دفتر را ندارد — مقایسه‌ای صادق بین دو رویکرد.", + "excerpt": "اکسل رایگان است، ولی چیزهایی را خودکار نمی‌کند که هر روز وقتت را می‌گیرد.", + "read_minutes": 3, + "content_html": """ +

    + اکسل ابزارِ قدرتمندی است و خیلی از کسب‌وکارهای کوچک سال‌ها با آن کار + کرده‌اند. سوال این نیست که اکسل بد است — سوال این است که چه چیزی + اکسل به‌طورِ پیش‌فرض برایت انجام نمی‌دهد. +

    +

    اتصالِ خودکار بین بخش‌ها

    +

    + در اکسل، وقتی فاکتور می‌زنی باید جداگانه هم + موجودیِ انبار را کم + کنی، هم دفترت + را به‌روز کنی — سه کار برای یک فروش. در شاپ شیپ این‌ها خودکار به هم + وصل‌اند. +

    +

    یادآوری و پیام، بدونِ ابزارِ جدا

    +

    + اکسل نمی‌تواند برایت پیامک بفرستد. برای + یادآوریِ مشتری + یا هشدارِ کمبودِ موجودی، باز باید ابزارِ جدا داشته باشی. +

    +

    وقتی اکسل هنوز کافی است

    +

    + اگر فقط چند فروش در هفته داری و هیچ‌وقت نیاز به پیام گروهی یا + پیگیریِ بدهی نداری، شاید اکسل هنوز جواب بدهد. اما همین که کسب‌وکارت + رشد کند، این محاسبات و یادآوری‌های دستی، وقتِ زیادی می‌گیرند. +

    +""", + }, + { + "slug": "yek-app-bejaye-chand-narmafzar", + "title": "چطور از یک اپ واحد به‌جای چند نرم‌افزار جدا استفاده کنم؟", + "meta_description": "به‌جای یک اپ برای فاکتور، یک اپ برای پیامک، و یک دفترچه برای حساب‌وکتاب، همه را در شاپ شیپ یک‌جا داشته باش.", + "excerpt": "فاکتور، پیام، انبار، دفتر — همه یک‌جا، و به‌هم وصل.", + "read_minutes": 3, + "content_html": """ +

    + خیلی از کسب‌وکارهای کوچک بدونِ این‌که تصمیمِ آگاهانه‌ای گرفته باشند، + سرِ آخر با پنج ابزارِ جدا کار می‌کنند: یک اپ برای فاکتور، یکی برای + پیامک، یک دفترچه برای حساب، و اکسلِ انبار. +

    +

    مشکلِ اصلیِ ابزارهای جدا

    +

    + هیچ‌کدام از این ابزارها با هم حرف نمی‌زنند — یعنی هر فروش را باید سه + یا چهار بار جدا ثبت کنی. همان‌طور که در مقاله‌ی + تفاوتِ شاپ شیپ با اکسل + گفتیم، همین دوباره‌کاری‌هاست که وقتِ زیادی می‌گیرد. +

    +

    وقتی همه‌چیز یک‌جاست

    +

    + یک فاکتور که + پرداخت می‌شود، خودش انبار و دفتر را به‌روز می‌کند. یک + مشتری که فاکتور + می‌گیرد، خودش پرونده‌اش ساخته می‌شود. کارِ کمتر، خطای کمتر. +

    +

    شروعِ آسان

    +

    + نیازی نیست همه‌چیز را یک‌جا شروع کنی — می‌توانی از + چک‌لیستِ + راه‌اندازی شروع کنی و کم‌کم بخش‌های دیگر را اضافه کنی. +

    +""", + }, +] + + +def add_articles(apps, schema_editor): + Article = apps.get_model("landing", "Article") + for index, data in enumerate(ARTICLES): + Article.objects.update_or_create( + slug=data["slug"], + defaults={ + "title": data["title"], + "meta_description": data["meta_description"], + "excerpt": data["excerpt"], + "content_html": data["content_html"], + "read_minutes": data["read_minutes"], + "is_published": True, + "published_at": LAST_PREVIOUS_DATE + datetime.timedelta(weeks=index + 1), + }, + ) + + +def remove_articles(apps, schema_editor): + Article = apps.get_model("landing", "Article") + Article.objects.filter(slug__in=[a["slug"] for a in ARTICLES]).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("landing", "0001_initial"), + ] + + operations = [ + migrations.RunPython(add_articles, remove_articles), + ] diff --git a/apps/landing/migrations/0003_restore_earlier_articles.py b/apps/landing/migrations/0003_restore_earlier_articles.py new file mode 100644 index 0000000..97c7abd --- /dev/null +++ b/apps/landing/migrations/0003_restore_earlier_articles.py @@ -0,0 +1,343 @@ +import datetime + +from django.db import migrations + +# هشت مقاله‌ی دوره‌ی قبل — این‌ها قبلا در مهاجرت‌های 0002/0003 قدیمی seed +# شده بودند که همراهِ بقیه‌ی فایل‌های مهاجرت از دست رفتند. چون مقاله‌های +# دسته‌ی جدید (0002_articles_batch) مستقیما به این slugها لینک می‌دهند، +# بدونِ این‌ها شش لینکِ داخلی ۴۰۴ می‌شد. تاریخ انتشارشان عمدا در گذشته است +# تا بلافاصله دیده شوند (برخلافِ دسته‌ی جدید که پلکانیِ هفتگی است). + +PUBLISHED_AT = datetime.date(2026, 8, 23) + +ARTICLES = [ + { + "slug": "peyam-goruhi-ba-shomare", + "title": "پیام گروهی با شماره چیست و چطور بدون مسدود شدن ارسال کنیم؟", + "meta_description": "راهنمای ارسال پیام گروهی با شماره در شاپ شیپ: فرق روش رسمی (Business API/Bot) با روش‌های غیررسمی، و چرا روش دوم دیر یا زود منجر به مسدودشدن اکانت می‌شود.", + "excerpt": "فرق ارسال گروهی رسمی با روش‌های غیررسمی، و چرا مسیر Business/Bot API تنها راه پایدار است.", + "read_minutes": 4, + "content_html": """ +

    + خیلی از کسب‌وکارهای کوچک اولین باری که «پیام گروهی» را امتحان می‌کنند، + یا از یک نرم‌افزار مشکوک استفاده می‌کنند که چند شماره را همزمان روی + چند گوشی می‌چرخاند، یا دستی و طاقت‌فرسا یکی‌یکی کپی‌پیست می‌کنند. هر دو + راه یک مشکل مشترک دارند: یکی امن نیست، یکی زمان‌بر. شاپ شیپ یک راه سوم + را انتخاب کرده — دقیقا همان مسیری که خودِ پلتفرم‌ها رسمی اعلام کرده‌اند. +

    +

    چرا اکانت‌ها مسدود می‌شوند

    +

    + واتساپ، تلگرام، بله و ایتا همه یک سیستم ضدهرزنامه دارند که رفتار + غیرطبیعی (ارسال حجم بالا از یک شماره‌ی معمولی، چرخاندن چند دستگاه/سشن، + شبیه‌سازی رفتار انسانی برای فرار از تشخیص) را شناسایی و مسدود می‌کند. + این محدودیت تصادفی نیست — دقیقا همان مکانیزمی است که جلوی اسپم واقعی + را می‌گیرد، و هیچ «ترفند» پایداری برای دور زدنش وجود ندارد. +

    +

    راه پایدار: مسیر رسمی هر پلتفرم

    +

    + پیامک از طریق درگاه‌های بالک اپراتور، واتساپ + از طریق WhatsApp Business Platform (با Template از‌پیش‌تاییدشده)، و + تلگرام/بله/ایتا از طریق Bot API — با یک محدودیت مهم: + بات فقط می‌تواند به کسانی پیام بدهد که خودشان قبلا چت را با بات شروع + کرده‌اند (opt-in). جزئیاتِ انتخاب بین این کانال‌ها در مقاله‌ی + پیامک یا واتساپ‌بیزینس؟ + آمده است. +

    +

    شاپ شیپ چطور این را حل می‌کند

    +

    + برای کانال‌هایی که opt-in لازم دارند و مخاطب هنوز چت را شروع نکرده، + شاپ شیپ به‌جای اتوماسیونِ مخفی و پرخطر، یک صفِ ارسال دستیِ یک‌لمسی نشان + می‌دهد: قالب پیام را از قبل آماده کرده‌ای، اپ مقصد را با متنِ آماده باز + می‌کند، خودت فقط دکمه‌ی ارسال را می‌زنی. +

    +""", + }, + { + "slug": "field-sefareshi-dar-template", + "title": "فیلد اجرا در قالب پیام یعنی چه؟", + "meta_description": "قالب پیام در شاپ شیپ می‌تواند یک «فیلد اجرا» داشته باشد — مقداری که هر بار لحظه‌ی ارسال پرسیده می‌شود، نه از قبل ثبت شده.", + "excerpt": "یک فیلد در قالب پیام که مقدارش از قبل معلوم نیست و هر بار لحظه‌ی ارسال پرسیده می‌شود — مثلا کد رهگیری پستی.", + "read_minutes": 3, + "content_html": """ +

    + قالب پیام معمولی یک متن ثابت با چند جای‌خالیِ از‌قبل‌معلوم است، مثلا + «سلام {نام} عزیز». اما بعضی وقت‌ها مقداری که باید در پیام بیاید اصلا + از قبل معلوم نیست — مثلا کد رهگیری پستیِ یک مرسوله که همان لحظه‌ی + تحویل به پست تولید می‌شود. این‌جاست که «فیلد اجرا» به کمک می‌آید. +

    +

    فرقش با جای‌خالی‌های معمولی چیست

    +

    + جای‌خالی‌های معمولیِ قالب (مثل نام یا شماره) از اطلاعاتِ همان مخاطب پر + می‌شوند — از قبل در اپ ثبت شده‌اند. فیلد اجرا برعکس است: هیچ مقدار + از‌پیش‌ذخیره‌شده‌ای ندارد؛ دقیقا همان لحظه که پیام دارد ارسال می‌شود، + اپ یک‌بار از تو می‌پرسدش. +

    +

    کاربرد اول: پیگیری سفارش

    +

    + وقتی برای یک مرحله از + پیگیری سفارش + قالبی انتخاب می‌کنی که فیلد اجرا دارد، همین که سفارشی به آن مرحله + می‌رسد، اپ همان لحظه مقدار را می‌پرسد — مثلا کد رهگیری — و بعد پیامک + را با همان مقدار برای مشتری می‌فرستد. +

    +

    کاربرد دوم: ارسال گروهی

    +

    + در ارسال گروهی هم اگر قالب انتخابی فیلد اجرا داشته باشد، اپ مخاطبین + را یکی‌یکی نشان می‌دهد و مقدار را برای هرکدام می‌پرسد — و برای + راحتی، مقداری که برای نفرِ قبلی وارد کردی به‌عنوان پیش‌فرضِ نفرِ بعدی + هم پیشنهاد می‌شود. +

    +

    هر قالب فقط یک فیلد اجرا دارد

    +

    + ساده نگه داشتنش عمدی است: هر قالب پیام حداکثر یک فیلد اجرا دارد، با + یک برچسبِ دلخواه (مثلا «کد رهگیری»). اگر قالبی این فیلد را نداشته + باشد، مثل قبل مستقیم ارسال می‌شود، بدون هیچ پرسش اضافه‌ای. +

    +""", + }, + { + "slug": "merge-mokhatebin-ganoshi", + "title": "وارد کردن مخاطبین گوشی به شاپ شیپ", + "meta_description": "سریع‌ترین راه برای شروع: مخاطبین گوشی‌ات را با یک تیک انتخاب‌همه، بدون تعیین گروه و بدون تایپ دستی، به مخاطبین یا مشتریانِ شاپ شیپ اضافه کن.", + "excerpt": "مخاطبین گوشی‌ات را با یک تیک انتخاب‌همه وارد کن — بدون گروه‌بندی اضافه، بدون تایپ دستی.", + "read_minutes": 2, + "content_html": """ +

    + اگر لیست مشتری یا مخاطبت همین حالا در دفترچه‌تلفنِ گوشی‌ات است، نیازی + به تایپ دوباره‌ی تک‌تک شماره‌ها نیست. شاپ شیپ می‌تواند مستقیم به + مخاطبین گوشی‌ات دسترسی بگیرد (فقط با اجازه‌ی خودت) و هرکدام را که + خواستی یک‌جا وارد اپ کند. +

    +

    چطور کار می‌کند

    +

    + از صفحه‌ی «مخاطبین» یا «مشتریان»، گزینه‌ی «وارد کردن از مخاطبین گوشی» + را بزن. اپ لیست مخاطبین گوشی‌ات را نشان می‌دهد — با یک تیکِ + «انتخاب همه» بالای لیست برای وقتی که می‌خواهی همه را یک‌جا اضافه کنی، + یا تیک تک‌تک برای انتخاب دستی. +

    +

    بدون مرحله‌ی گروه‌بندی

    +

    + برخلاف نسخه‌های قدیمی‌تر، این‌جا دیگر مجبور نیستی قبل از وارد کردن + یک «گروه مقصد» انتخاب کنی — همین یک مرحله‌ی کمتر یعنی وارد کردن ده‌ها + مخاطب چند ثانیه بیشتر طول نمی‌کشد. +

    +

    دوباره‌کاری نمی‌شود

    +

    + مخاطبینی که شماره‌شان از قبل در اپ ثبت شده، خودشان از لیست وارد‌کردن + کنار گذاشته می‌شوند — تطبیق بر اساس شماره‌ی نرمال‌شده انجام می‌شود، نه + اسم، پس اختلاف املایی اسم مشکلی ایجاد نمی‌کند. +

    +""", + }, + { + "slug": "sms-ya-whatsapp-business", + "title": "پیامک یا واتساپ‌بیزینس؟ کدام برای ارسال گروهی بهتر است", + "meta_description": "مقایسه‌ی پیامک و واتساپ‌بیزینس برای ارسال پیام گروهی در شاپ شیپ: هزینه، نرخ باز شدن، محدودیت Template، و کدام برای چه کسب‌وکاری مناسب‌تر است.", + "excerpt": "مقایسه‌ی هزینه، نرخ باز شدن، و محدودیت‌های هرکدام برای پیام گروهی کسب‌وکارها.", + "read_minutes": 5, + "content_html": """ +

    + هر دو کانال جای خودشان را دارند و در شاپ شیپ می‌توانی همزمان از هردو + (به‌علاوه‌ی تلگرام، بله و ایتا) استفاده کنی — یک + قالب پیام بنویسی، + برای هرکدام از کانال‌ها بفرستی. +

    +

    پیامک

    +

    + به هر شماره‌ای می‌رسد، حتی بدون اینترنت یا اپ نصب‌شده — قابل‌اطمینان‌ترین + کانال از نظر تحویل. هزینه‌ی هر پیام مشخص و قابل‌پیش‌بینی است. محدودیت: + طول پیام (پیامک‌های بلند چند‌قسمتی حساب می‌شوند) و نداشتن تصویر/دکمه. +

    +

    واتساپ‌بیزینس

    +

    + نرخ باز شدن پیام معمولا بالاتر از پیامک است و امکاناتی مثل دکمه و + رسانه دارد. اما برای ارسال گروهی (business-initiated) باید از یک + Template از‌پیش‌تاییدشده در Meta Business Manager استفاده کنی — + یعنی نمی‌توانی هر متن دلخواهی را بی‌محدودیت به هرکسی بفرستی. +

    +

    تلگرام، بله، ایتا

    +

    + این‌ها فقط به کسانی می‌رسند که قبلا با بات کسب‌وکارت چت را شروع + کرده‌اند. برای مخاطبینی که این کار را نکرده‌اند، شاپ شیپ یک صف + ارسال دستیِ یک‌لمسی نشان می‌دهد. +

    +

    جمع‌بندی

    +

    + اگر تحویل تضمینی برایت مهم‌تر است، پیامک را انتخاب کن. اگر مخاطبینت + بیشتر واتساپی هستند و Template تاییدشده داری، واتساپ‌بیزینس گزینه‌ی + بهتری است. بهترین نتیجه معمولا ترکیب چند کانال است. +

    +""", + }, + { + "slug": "forosh-ghesti-hesabdari", + "title": "فروش قسطی چطور باید در دفترداری ثبت شود؟", + "meta_description": "فروش قسطی از نظر حسابداری یک نوع بدهکاری/طلب است، نه فقط یک عدد در لیست درآمد. دفتر درآمد و هزینه‌ی شاپ شیپ این تفاوت را خودش تشخیص می‌دهد.", + "excerpt": "فروش قسطی فقط «درآمد» نیست — یک نوع طلب از مشتری است که باید جدا پیگیری شود.", + "read_minutes": 4, + "content_html": """ +

    + فرض کن یک کالا را ۵ میلیون تومان قسطی می‌فروشی: امروز ۲ میلیون + نقد می‌گیری، ۳ میلیون باقی‌مانده قرار است طی سه قسط پرداخت شود. سوال + این‌جاست: همین امروز چقدر «درآمد» داشته‌ای؟ +

    +

    چرا فروش قسطی «فقط درآمد» نیست

    +

    + از نظر حسابداریِ حرفه‌ای، فروش قسطی یک نوع بدهکاریِ مشتری به + تو (طلب/receivable) ایجاد می‌کند، نه یک عدد ساده در لیست + درآمد. اگر همان ۵ میلیون را یک‌جا «درآمد» ثبت کنی، ترازِ نقدینگی‌ات + اشتباه می‌شود — چون آن ۳ میلیونِ باقی‌مانده هنوز دستت نیامده. +

    +

    دفتر درآمد و هزینه‌ی شاپ شیپ چطور حلش می‌کند

    +

    + وقتی دسته‌بندیِ «فروش قسطی» را برای یک ثبت انتخاب می‌کنی، اپ خودش + می‌فهمد این یک نوع بدهکاری است — و سه فیلد اضافه از تو می‌پرسد: مبلغِ + کل، نام و شماره‌ی طرفِ حساب. از همان لحظه، این ثبت یک «مانده‌حساب» + دارد که هر قسطِ پرداختی از آن کم می‌شود. +

    +

    ثبتِ ساده برای تو، حسابداریِ درست پشتِ صحنه

    +

    + لازم نیست اصطلاحاتِ حسابداری بلد باشی. کافی است دسته‌ی درستی که به + زبانِ خودت نزدیک‌تر است انتخاب کنی — بقیه‌اش (تشخیص طلب/بدهی، محاسبه‌ی + مانده، تفکیک از درآمد نقدی) را اپ خودش پشتِ صحنه انجام می‌دهد. +

    +

    اتصال به فاکتور و مشتری

    +

    + اگر فاکتوری را با + نوعِ پرداختِ «قسطی/نسیه» صادر کنی، شاپ شیپ همان لحظه یک ثبتِ طلب در + دفترت می‌سازد و آن را به پرونده‌ی همان مشتری هم وصل می‌کند. +

    +""", + }, + { + "slug": "fatoore-rasmi-baraye-maghaze", + "title": "فاکتور رسمی برای مغازه: چرا لازم است و چطور با انبار وصل می‌شود", + "meta_description": "فاکتور رسمی با لوگو، محاسبه‌ی خودکار مالیات و تخفیف، و اتصال مستقیم به انبار — همین که پرداخت شد، موجودی خودش کم می‌شود.", + "excerpt": "فاکتوری که وقتی پرداخت می‌شود، موجودیِ انبار را هم خودش کم می‌کند — بدون ثبت دوباره.", + "read_minutes": 4, + "content_html": """ +

    + خیلی از مغازه‌دارها فاکتور را یا اصلا صادر نمی‌کنند، یا دستی روی + یک برگه می‌نویسند — و بعد جداگانه همان کالا را از موجودیِ انبار کم + می‌کنند. این یعنی هر فروش، دوبار ثبت می‌شود. +

    +

    یک فاکتور، هم برای مشتری هم برای انبار

    +

    + در شاپ شیپ، وقتی برای یک فاکتور کالا اضافه می‌کنی، همان کالا را از + لیستِ محصولاتِ + انبارت انتخاب + می‌کنی — نه این‌که اسمش را دوباره تایپ کنی. همین که فاکتور ثبت شود، + تعداد همان کالا خودش از موجودیِ انبار کم می‌شود. +

    +

    مالیات و تخفیف، خودکار

    +

    + هر قلمِ کالا می‌تواند نرخِ مالیاتِ خودش و تخفیفِ خودش را داشته باشد — + جمعِ نهایی، مالیات، و مبلغِ تخفیف‌خورده خودکار محاسبه می‌شود. +

    +

    پرداخت‌شده یا نشده، همیشه مشخص

    +

    + هر فاکتور یک وضعیتِ روشن دارد. برای فاکتورهای نقدی، دفترِ درآمد و + هزینه‌ات را دستکاری نمی‌کند؛ اما اگر نوعِ پرداخت را «قسطی/نسیه» + بگذاری، همان لحظه یک ثبتِ طلب هم در دفترت ساخته می‌شود — جزئیاتش در + فروش قسطی چطور ثبت + می‌شود؟ آمده. +

    +""", + }, + { + "slug": "kontorol-mojoodi-anbar-ba-mobile", + "title": "کنترل موجودی انبار با موبایل، بدون اکسل", + "meta_description": "دسته‌بندی کالا، کم‌وزیاد کردن موجودی با یک ضربه، دیدنِ ارزش کلِ انبار، و هشدار وقتی چیزی کم می‌شود — همه از گوشی.", + "excerpt": "موجودی را با یک ضربه کم‌وزیاد کن، ارزش کلِ انبارت را ببین، وقتی چیزی کم شد زودتر از مشتری بفهم.", + "read_minutes": 3, + "content_html": """ +

    + خیلی از کسب‌وکارهای کوچک موجودیِ انبار را در یک فایلِ اکسل نگه + می‌دارند که همیشه یک نسخه‌ی «قدیمی‌تر از واقعیت» است. +

    +

    هر کالا با واحد و قیمتِ خرید

    +

    + برای هر کالا، واحدِ شمارشش (عدد، کیلوگرم، بسته و…) و قیمتِ خریدش را + ثبت می‌کنی. با همین دو عدد، اپ خودش ارزشِ کلِ انبارت + را محاسبه می‌کند — یعنی می‌دانی چقدر سرمایه روی طاقچه‌هایت خوابیده. +

    +

    کم‌وزیاد با یک ضربه

    +

    + موجودیِ هر کالا را می‌شود با یک ضربه کم یا زیاد کرد. اگر هم + فاکتور برای همان + کالا صادر کنی، موجودی خودش کم می‌شود، بدون ثبتِ دستیِ دوباره. +

    +

    هشدارِ کمبود

    +

    + برای هر کالا می‌شود یک آستانه‌ی «کم شد» تعریف کرد؛ وقتی موجودی به آن + آستانه برسد، پیش از این‌که مشتری بیاید و نداشته باشی، خودت متوجه + می‌شوی. +

    +""", + }, + { + "slug": "peygiri-sefaresh-online-shop-etemad", + "title": "پیگیری سفارش آنلاین‌شاپ: اعتماد مشتری با یک پیامک خودکار", + "meta_description": "برای آنلاین‌شاپ‌های کوچک، شاپ شیپ مراحل سفارش را قابل‌تعریف می‌کند و با رسیدنِ سفارش به هر مرحله، خودکار از روی قالبِ دلخواهت به مشتری پیامک می‌رود.", + "excerpt": "مراحل کارت را تعریف کن — با رسیدنِ سفارش به هر مرحله، پیامکِ خودکار برای مشتری می‌رود.", + "read_minutes": 4, + "content_html": """ +

    + یکی از بزرگ‌ترین دلایلِ نگرانیِ مشتریِ آنلاین‌شاپ‌های کوچک همین است: + «سفارشم الان کجاست؟» وقتی جوابِ این سوال را با تاخیر یا اصلا نمی‌دهی، + اعتماد از دست می‌رود. +

    +

    مراحلِ کارِ خودت را تعریف کن

    +

    + در شاپ شیپ، مراحلِ سفارش را خودت تعریف می‌کنی — مثلا «آماده‌سازی»، + «بسته‌بندی»، «ارسال»، «تحویل‌شد» — دقیقا همان جریانِ کاریِ واقعیِ + کسب‌وکارت. ترتیبِ مراحل را هم می‌شود با کشیدن‌و‌رهاکردن جابه‌جا کرد. +

    +

    هر مرحله، یک قالبِ پیام

    +

    + هر مرحله را به یک قالبِ پیام وصل می‌کنی. همین که سفارشی به آن مرحله + می‌رسد، پیامک از روی همان قالب — خودکار — برای مشتری می‌رود. +

    +

    وقتی یک مقدار متغیر لازم است

    +

    + برای مرحله‌ی «ارسال» معمولا یک کد رهگیریِ پستی لازم است که از قبل + معلوم نیست. برای همین، قالبِ همین مرحله می‌تواند یک + فیلدِ اجرا + داشته باشد. +

    +""", + }, +] + + +def add_articles(apps, schema_editor): + Article = apps.get_model("landing", "Article") + for data in ARTICLES: + Article.objects.update_or_create( + slug=data["slug"], + defaults={ + "title": data["title"], + "meta_description": data["meta_description"], + "excerpt": data["excerpt"], + "content_html": data["content_html"], + "read_minutes": data["read_minutes"], + "is_published": True, + "published_at": PUBLISHED_AT, + }, + ) + + +def remove_articles(apps, schema_editor): + Article = apps.get_model("landing", "Article") + Article.objects.filter(slug__in=[a["slug"] for a in ARTICLES]).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("landing", "0002_articles_batch"), + ] + + operations = [ + migrations.RunPython(add_articles, remove_articles), + ] diff --git a/apps/messaging/api/serializers.py b/apps/messaging/api/serializers.py index fa436e6..b77c2ed 100644 --- a/apps/messaging/api/serializers.py +++ b/apps/messaging/api/serializers.py @@ -45,3 +45,18 @@ class BulkMessageSerializer(serializers.ModelSerializer): def get_needs_manual(self, obj: BulkMessage): pending = obj.recipients.filter(status=MessageRecipient.Status.NEEDS_MANUAL) return MessageRecipientSerializer(pending, many=True).data + + +class ContactMessageHistorySerializer(serializers.ModelSerializer): + """یک ردیفِ تاریخچه — متنِ واقعیِ فرستاده‌شده، کانال، وضعیت و زمان.""" + + sent_at = serializers.DateTimeField(source="bulk_message.created_at", read_only=True) + channel_display = serializers.CharField(source="get_channel_display", read_only=True) + status_display = serializers.CharField(source="get_status_display", read_only=True) + + class Meta: + model = MessageRecipient + fields = [ + "id", "channel", "channel_display", "status", "status_display", + "rendered_body", "error", "sent_at", + ] diff --git a/apps/messaging/api/views.py b/apps/messaging/api/views.py index f590f5f..f814cca 100644 --- a/apps/messaging/api/views.py +++ b/apps/messaging/api/views.py @@ -10,7 +10,13 @@ from apps.banker.models import ContactGroup from apps.billing.permissions import HasActiveFeature from apps.messaging.models import BulkMessage, MessageRecipient -from .serializers import BulkMessageSerializer, SendBulkMessageSerializer +from apps.billing.forms import normalize_iran_phone + +from .serializers import ( + BulkMessageSerializer, + ContactMessageHistorySerializer, + SendBulkMessageSerializer, +) from .services import BulkMessageService @@ -90,3 +96,34 @@ class ConfirmManualSendView(APIView): except MessageRecipient.DoesNotExist: raise NotFound("گیرنده یافت نشد.") return Response({"success": True, "status": recipient.status}) + + +class ContactMessageHistoryView(APIView): + """تاریخچه‌ی پیام‌های فرستاده‌شده به یک شماره. + + چرا بر اساس *شماره* و نه شناسه‌ی مخاطب: صفحه‌ی «مشتریان» و «مخاطبین» + دو موجودیتِ جدا هستند و کاربر انتظار دارد روی پروفایلِ مشتری هم ببیند + چه پیامی برایش رفته. شماره تنها چیزی است که این دو را به هم وصل + می‌کند، پس با شماره‌ی نرمال‌شده جستجو می‌کنیم. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + raw = request.query_params.get("phone", "") or "" + phone = normalize_iran_phone(raw) + if not phone: + raise ValidationError({"phone": "شماره لازم است."}) + + # ارقامِ ذخیره‌شده ممکن است با ۰ یا +۹۸ باشند؛ روی چند شکل تطبیق + # می‌دهیم تا تاریخچه به‌خاطر تفاوتِ قالبِ شماره گم نشود. + variants = {phone, phone.lstrip("0"), "+98" + phone.lstrip("0"), "98" + phone.lstrip("0")} + rows = ( + MessageRecipient.objects.filter( + bulk_message__user=request.user, + contact__phone__in=variants, + ) + .select_related("bulk_message") + .order_by("-bulk_message__created_at", "-id")[:100] + ) + return Response(ContactMessageHistorySerializer(rows, many=True).data) diff --git a/apps/messaging/migrations/0001_initial.py b/apps/messaging/migrations/0001_initial.py new file mode 100644 index 0000000..db04df4 --- /dev/null +++ b/apps/messaging/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 5.1.15 on 2026-09-02 00:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('banker', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BulkMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('body_override', models.TextField(blank=True, verbose_name='متن آزاد (اگر قالب انتخاب نشده)')), + ('channels', models.JSONField(default=list, verbose_name='کانال\u200cهای انتخاب\u200cشده')), + ('status', models.CharField(choices=[('dispatched', 'ارسال\u200cشده'), ('partially_manual', 'بخشی نیاز به تایید دستی')], default='dispatched', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='banker.contactgroup')), + ('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='banker.messagetemplate')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_messages', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'ارسال گروهی', + 'verbose_name_plural': 'ارسال\u200cهای گروهی', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='MessageRecipient', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('channel', models.CharField(choices=[('sms', 'پیامک'), ('whatsapp', 'واتساپ'), ('telegram', 'تلگرام'), ('bale', 'بله'), ('eitaa', 'ایتا')], max_length=10)), + ('status', models.CharField(choices=[('sent', 'ارسال\u200cشده'), ('failed', 'ناموفق'), ('needs_manual', 'نیاز به تایید دستی'), ('manual_confirmed', 'تایید دستی شد')], max_length=20)), + ('rendered_body', models.TextField()), + ('error', models.CharField(blank=True, max_length=255)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('bulk_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recipients', to='messaging.bulkmessage')), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='banker.contact')), + ], + options={ + 'verbose_name': 'گیرنده\u200cی پیام', + 'verbose_name_plural': 'گیرنده\u200cهای پیام', + 'indexes': [models.Index(fields=['bulk_message', 'status'], name='messaging_m_bulk_me_a7a1eb_idx')], + }, + ), + ] diff --git a/apps/messaging/tests.py b/apps/messaging/tests.py new file mode 100644 index 0000000..6286409 --- /dev/null +++ b/apps/messaging/tests.py @@ -0,0 +1,58 @@ +import uuid + +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.account.models import User +from apps.banker.models import Contact +from apps.messaging.models import BulkMessage, MessageRecipient + + +class ContactMessageHistoryTests(TestCase): + """تاریخچه‌ی پیام‌های یک شماره — چیزی که پروفایلِ مشتری/مخاطب نشان می‌دهد.""" + + def setUp(self): + self.user = User.objects.create(phone="09120000010") + self.other = User.objects.create(phone="09120000011") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.url = reverse("messaging:contact-history") + + def _recipient(self, user, phone, body="سلام", status=MessageRecipient.Status.SENT): + contact = Contact.objects.create( + id=str(uuid.uuid4()), user=user, name="رضایی", phone=phone + ) + bulk = BulkMessage.objects.create(user=user, channels=["sms"]) + return MessageRecipient.objects.create( + bulk_message=bulk, contact=contact, channel="sms", + status=status, rendered_body=body, + ) + + def test_returns_messages_sent_to_that_number(self): + self._recipient(self.user, "09121112233", body="سفارشت آماده است") + response = self.client.get(self.url, {"phone": "09121112233"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["rendered_body"], "سفارشت آماده است") + self.assertEqual(response.data[0]["channel_display"], "پیامک") + + def test_matches_regardless_of_how_the_number_was_stored(self): + """شماره ممکن است +۹۸… ذخیره شده باشد ولی کاربر ۰۹… جستجو کند.""" + self._recipient(self.user, "+989121112233") + response = self.client.get(self.url, {"phone": "09121112233"}) + self.assertEqual(len(response.data), 1) + + def test_never_leaks_another_users_messages(self): + self._recipient(self.other, "09121112233", body="مالِ کاربر دیگر") + response = self.client.get(self.url, {"phone": "09121112233"}) + self.assertEqual(response.data, []) + + def test_missing_phone_is_a_400_not_a_dump_of_everything(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 400) + + def test_requires_authentication(self): + self.client.force_authenticate(None) + response = self.client.get(self.url, {"phone": "09121112233"}) + self.assertIn(response.status_code, (401, 403)) diff --git a/apps/messaging/urls.py b/apps/messaging/urls.py index b0a4d18..e7d4859 100644 --- a/apps/messaging/urls.py +++ b/apps/messaging/urls.py @@ -3,6 +3,7 @@ from django.urls import path from .api.views import ( BulkMessageDetailView, ConfirmManualSendView, + ContactMessageHistoryView, MessagingStatsView, SendBulkMessageView, ) @@ -11,6 +12,7 @@ app_name = "messaging" urlpatterns = [ path("send/", SendBulkMessageView.as_view(), name="send"), path("stats/", MessagingStatsView.as_view(), name="stats"), + path("history/", ContactMessageHistoryView.as_view(), name="contact-history"), path("/", BulkMessageDetailView.as_view(), name="detail"), path("/confirm//", ConfirmManualSendView.as_view(), name="confirm"), ] diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..6909abe --- /dev/null +++ b/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.15 on 2026-09-02 00:19 + +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='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=150, verbose_name='عنوان')), + ('body', models.TextField(blank=True, verbose_name='متن')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='کاربر (خالی = عمومی برای همه)')), + ], + options={ + 'verbose_name': 'اعلان', + 'verbose_name_plural': 'اعلان\u200cها', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='NotificationRead', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('read_at', models.DateTimeField(auto_now_add=True)), + ('notification', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reads', to='notifications.notification')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_reads', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'خوانده\u200cشدنِ اعلان', + 'verbose_name_plural': 'خوانده\u200cشدنِ اعلان\u200cها', + 'constraints': [models.UniqueConstraint(fields=('user', 'notification'), name='unique_user_notification_read')], + }, + ), + ] diff --git a/apps/orders/migrations/0001_initial.py b/apps/orders/migrations/0001_initial.py new file mode 100644 index 0000000..bfde1dc --- /dev/null +++ b/apps/orders/migrations/0001_initial.py @@ -0,0 +1,72 @@ +# Generated by Django 5.1.15 on 2026-09-02 00:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('banker', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OrderStage', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('label', models.CharField(max_length=150, verbose_name='عنوان مرحله')), + ('sort_order', models.PositiveIntegerField(default=0, verbose_name='ترتیب')), + ('requires_extra_input', models.BooleanField(default=False, verbose_name='این مرحله به یک ورودی اضافه نیاز دارد')), + ('extra_input_label', models.CharField(blank=True, help_text='مثلا: کد تسویه، کد محصول — وقتی سفارش به این مرحله می\u200cرسد از کاربر پرسیده می\u200cشود.', max_length=100, verbose_name='عنوان ورودی اضافه')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_stages', to='banker.messagetemplate', verbose_name='قالب پیامک این مرحله')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='order_stages', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'مرحله\u200cی پیگیری سفارش', + 'verbose_name_plural': 'مراحل پیگیری سفارش', + 'ordering': ['sort_order'], + }, + ), + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('customer_name', models.CharField(max_length=150, verbose_name='نام مشتری')), + ('customer_phone', models.CharField(max_length=20, verbose_name='شماره مشتری')), + ('tracking_code', models.CharField(blank=True, max_length=100, verbose_name='کد پیگیری پستی')), + ('is_completed', models.BooleanField(default=False, verbose_name='تکمیل\u200cشده')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to=settings.AUTH_USER_MODEL)), + ('current_stage', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.orderstage')), + ], + options={ + 'verbose_name': 'سفارش', + 'verbose_name_plural': 'سفارش\u200cها', + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='OrderStageLog', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('stage_label', models.CharField(max_length=150, verbose_name='عنوان مرحله')), + ('message_body', models.TextField(blank=True, verbose_name='متن پیامک ارسالی')), + ('extra_input_value', models.CharField(blank=True, max_length=255, verbose_name='ورودی اضافه\u200cی این مرحله')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stage_logs', to='orders.order')), + ('stage', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='orders.orderstage')), + ], + options={ + 'verbose_name': 'تاریخچه\u200cی مرحله\u200cی سفارش', + 'verbose_name_plural': 'تاریخچه\u200cی مراحل سفارش', + 'ordering': ['created_at'], + }, + ), + ] diff --git a/apps/telemetry/migrations/0001_initial.py b/apps/telemetry/migrations/0001_initial.py new file mode 100644 index 0000000..86f28d3 --- /dev/null +++ b/apps/telemetry/migrations/0001_initial.py @@ -0,0 +1,114 @@ +# Generated by Django 5.1.15 on 2026-09-02 00:25 + +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='AppSession', + fields=[ + ('id', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('platform', models.CharField(blank=True, max_length=30, verbose_name='پلتفرم')), + ('app_version', models.CharField(blank=True, max_length=30, verbose_name='نسخه\u200cی اپ')), + ('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='آی\u200cپی')), + ('started_at', models.DateTimeField(verbose_name='شروع')), + ('ended_at', models.DateTimeField(blank=True, null=True, verbose_name='پایان')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='app_sessions', to=settings.AUTH_USER_MODEL, verbose_name='کاربر')), + ], + options={ + 'verbose_name': 'جلسه\u200cی استفاده از اپ', + 'verbose_name_plural': 'جلسه\u200cهای استفاده از اپ', + 'ordering': ['-started_at'], + }, + ), + migrations.CreateModel( + name='ClickEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('screen_name', models.CharField(blank=True, max_length=150, verbose_name='نام صفحه')), + ('element', models.CharField(blank=True, max_length=150, verbose_name='برچسبِ عنصر (اختیاری)')), + ('x', models.FloatField(blank=True, null=True)), + ('y', models.FloatField(blank=True, null=True)), + ('created_at', models.DateTimeField(verbose_name='زمان')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clicks', to='telemetry.appsession')), + ], + options={ + 'verbose_name': 'رویداد کلیک', + 'verbose_name_plural': 'رویدادهای کلیک', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ClientErrorLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('screen_name', models.CharField(blank=True, max_length=150, verbose_name='نام صفحه')), + ('message', models.TextField(verbose_name='پیام خطا')), + ('stack_trace', models.TextField(blank=True, verbose_name='Stack trace')), + ('created_at', models.DateTimeField(verbose_name='زمان')), + ('session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='errors', to='telemetry.appsession')), + ], + options={ + 'verbose_name': 'خطای سمت کاربر', + 'verbose_name_plural': 'خطاهای سمت کاربر', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PageView', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('screen_name', models.CharField(max_length=150, verbose_name='نام صفحه')), + ('entered_at', models.DateTimeField(verbose_name='زمان ورود')), + ('duration_seconds', models.FloatField(blank=True, null=True, verbose_name='مدت ماندن (ثانیه)')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_views', to='telemetry.appsession')), + ], + options={ + 'verbose_name': 'بازدید صفحه', + 'verbose_name_plural': 'بازدیدهای صفحه', + 'ordering': ['entered_at'], + }, + ), + migrations.CreateModel( + name='ApiCallLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('method', models.CharField(max_length=10, verbose_name='متد')), + ('path', models.CharField(max_length=255, verbose_name='مسیر')), + ('status_code', models.PositiveSmallIntegerField(verbose_name='کد وضعیت')), + ('duration_ms', models.FloatField(verbose_name='زمان پاسخ (میلی\u200cثانیه)')), + ('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='آی\u200cپی')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='زمان')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='api_calls', to=settings.AUTH_USER_MODEL, verbose_name='کاربر')), + ], + options={ + 'verbose_name': 'لاگ فراخوانی API', + 'verbose_name_plural': 'لاگ\u200cهای فراخوانی API', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['user', 'created_at'], name='telemetry_a_user_id_7a7591_idx'), models.Index(fields=['path', 'created_at'], name='telemetry_a_path_267c85_idx')], + }, + ), + migrations.AddIndex( + model_name='appsession', + index=models.Index(fields=['user', 'started_at'], name='telemetry_a_user_id_142b1f_idx'), + ), + migrations.AddIndex( + model_name='clickevent', + index=models.Index(fields=['screen_name'], name='telemetry_c_screen__d3940b_idx'), + ), + migrations.AddIndex( + model_name='pageview', + index=models.Index(fields=['screen_name'], name='telemetry_p_screen__242757_idx'), + ), + ] diff --git a/config/settings.py b/config/settings.py index d96554f..fa399dc 100644 --- a/config/settings.py +++ b/config/settings.py @@ -57,6 +57,7 @@ LOCAL_APPS = [ "apps.notifications", "apps.landing", "apps.orders", + "apps.telemetry", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -74,6 +75,8 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + # آخر از همه: زمانِ پاسخ را وقتی همه‌ی میدل‌ورهای دیگر کارشان تمام شده اندازه می‌گیرد. + "apps.telemetry.middleware.ApiCallLoggingMiddleware", ] ROOT_URLCONF = "config.urls" diff --git a/config/urls.py b/config/urls.py index 0bc6afe..0fea798 100644 --- a/config/urls.py +++ b/config/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path("api/billing/", include("apps.billing.urls")), path("api/notifications/", include("apps.notifications.urls")), path("api/orders/", include("apps.orders.urls")), + path("api/telemetry/", include("apps.telemetry.urls")), # فایل‌های استاتیک قبلی landing/articles/*.html حذف شده‌اند — چون # WhiteNoise میدل‌ور همیشه قبل از urls.py بررسی می‌شود و اگر آن فایل‌ها # هنوز وجود داشتند، هیچ‌وقت به این view دینامیک نمی‌رسیدیم. diff --git a/kangelo_db b/kangelo_db new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index 52df1ca..5660b58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,5 +6,6 @@ psycopg2-binary>=2.9,<3.0 python-decouple>=3.8,<4.0 django-cors-headers>=4.4,<5.0 requests>=2.32,<3.0 +openpyxl>=3.1,<4.0 gunicorn>=22.0,<23.0 whitenoise>=6.7,<7.0 diff --git a/sad b/sad new file mode 100644 index 0000000..e69de29