This commit is contained in:
2026-09-04 13:05:54 +03:30
parent 463b812e3e
commit 17082a5ea9
43 changed files with 5663 additions and 285 deletions
+60
View File
@@ -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': 'کاربران',
},
),
]
+117
View File
@@ -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'),
),
]
+79 -4
View File
@@ -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):
+11 -3
View File
@@ -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):
+3
View File
@@ -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(
+13 -3
View File
@@ -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):
+92
View File
@@ -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]
+128
View File
@@ -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')],
},
),
]
@@ -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='رایگان'),
),
]
@@ -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)]
@@ -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)]
+8
View File
@@ -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 = _("ریزاپ قابل‌فعال‌سازی")
+180 -107
View File
@@ -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: <token>
این توکن API رفرش ندارد (اگر باطل شود باید دستی از پیشخان توکن تازه گرفت).
«روش قدیمی» — OAuth2 (.../api/setting-up)، در پارامتر URL:
?access_token=<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 _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 اشتباه است."
)
try:
data = response.json()
except ValueError as exc:
raise BazaarAuthError("پاسخ کافه‌بازار قابل‌خواندن (JSON) نبود.") 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
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}}
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
@@ -0,0 +1,17 @@
{% extends "admin/base_site.html" %}
{% block content %}
<div style="max-width:720px">
<p style="margin-bottom:1rem">
به هر شماره‌ای که این‌جا وارد کنی، همان تعداد جم اضافه می‌شود و یک ردیف در
«تراکنش‌های جم» با علتِ «اصلاح دستی ادمین» ثبت می‌گردد.
</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="submit-row">
<input type="submit" value="هدیه بده" class="default">
<a href="{% url 'admin:billing_gemtransaction_changelist' %}" class="button cancel-link">انصراف</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,7 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="{% url 'admin:billing_gemtransaction_gift' %}" class="addlink">هدیه‌ی جم به شماره‌ها</a>
</li>
{{ block.super }}
{% endblock %}
+192 -3
View File
@@ -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)
+59 -17
View File
@@ -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")
+627
View File
@@ -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)
+216 -19
View File
@@ -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
+88 -7
View File
@@ -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)
+430 -71
View File
@@ -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=<id> صریح، وگرنه همه؛ تا
# گزارش‌های چنددوره‌ای هم ممکن بماند.
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)
)
+246
View File
@@ -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'),
),
]
@@ -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='پلتفرم ارتباطی'),
),
]
@@ -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'),
),
]
+408 -44
View File
@@ -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}"
+740 -2
View File
@@ -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=<id> فقط ثبت‌های همان
دوره را برمی‌گرداند.
بدون این فیلتر، صفحه‌ی دفتر ثبت‌های همه‌ی دوره‌ها را با هم نشان
می‌داد و جمعِ درآمد/هزینه‌ی بالای صفحه با مانده‌ی فایلِ جاری
نمی‌خواند."""
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)
+52 -5
View File
@@ -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"),
# قبل از <str:pk> نیاید، وگرنه "statement" به‌عنوان pk تفسیر می‌شود.
path("customers/<str:pk>/statement/", CustomerStatementView.as_view(), name="customer-statement"),
path("customers/<str:pk>/", CustomerDetailView.as_view(), name="customer-detail"),
# export قبل از <str:pk> ثبت شده تا مسیرها با هم تداخل نکنند.
path("ledger/books/", LedgerBookListView.as_view(), name="ledger-book-list"),
path("ledger/books/open/", OpenLedgerBookView.as_view(), name="ledger-book-open"),
path("ledger/books/<str:pk>/summary/", LedgerBookSummaryView.as_view(), name="ledger-book-summary"),
path("ledger/export/", LedgerExportView.as_view(), name="ledger-export"),
path("ledger/", LedgerEntryListCreateView.as_view(), name="ledger-list"),
path(
"ledger/<str:entry_id>/installments/",
LedgerInstallmentListCreateView.as_view(),
name="ledger-installment-list",
),
path("ledger/<str:pk>/", LedgerEntryDetailView.as_view(), name="ledger-detail"),
path(
"ledger-installments/<str:pk>/",
LedgerInstallmentDetailView.as_view(),
name="ledger-installment-detail",
),
path("accounts/", AccountListView.as_view(), name="account-list"),
path("accounts/<str:pk>/ledger/", SubsidiaryLedgerView.as_view(), name="account-subsidiary-ledger"),
path("accounts/<str:pk>/", 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/<str:pk>/", ProductDetailView.as_view(), name="product-detail"),
path("products/<str:pk>/adjust/", ProductAdjustStockView.as_view(), name="product-adjust"),
path("products/<str:pk>/movements/", ProductMovementsView.as_view(), name="product-movements"),
path("appointments/", AppointmentListCreateView.as_view(), name="appointment-list"),
path("appointments/<str:pk>/", AppointmentDetailView.as_view(), name="appointment-detail"),
path("products/<str:pk>/", ProductDetailView.as_view(), name="product-detail"),
path("invoices/", InvoiceListCreateView.as_view(), name="invoice-list"),
path("invoices/<str:pk>/status/", InvoiceStatusView.as_view(), name="invoice-status"),
path("invoices/<str:pk>/cancel/", InvoiceCancelView.as_view(), name="invoice-cancel"),
path("invoices/<str:pk>/", InvoiceDetailView.as_view(), name="invoice-detail"),
]
+35
View File
@@ -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 ساده مثل <p>، <h2>، <strong>.', 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'),
},
),
]
@@ -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": """
<p>
یک سوال ساده که جواب دادنش سخت‌تر از چیزی است که فکر می‌کنی: «همین
الان چقدر پول نقد در دسترس داری؟» خیلی از کسب‌وکارهای کوچک این عدد را
با «جمعِ کل فروش» اشتباه می‌گیرند — در حالی که بخشی از آن فروش هنوز
طلب است، نه پول در جیب.
</p>
<h2>درآمد یعنی چه، طلب یعنی چه</h2>
<p>
درآمدِ نقدی یعنی پولی که همین الان دستت رسیده. طلب (receivable) یعنی
حقی که داری تا در آینده پول را بگیری — مثل وقتی جنسی را
<a href="/articles/forosh-ghesti-hesabdari/">قسطی می‌فروشی</a>. هر دو
«فروش» حساب می‌شوند، اما فقط یکی‌شان همین الان قابل‌خرج‌کردن است.
</p>
<h2>چرا این فرق مهم است</h2>
<p>
اگر طلب را با درآمدِ نقدی یکی حساب کنی، فکر می‌کنی پول بیشتری داری
که واقعا نداری — و همین باعث می‌شود در پرداختِ هزینه‌های خودت (اجاره،
خرید جنسِ تازه) کم بیاوری. دفتر درآمد و هزینه‌ی شاپ شیپ این دو را از
هم جدا نگه می‌دارد تا همیشه بدانی چقدر واقعا در دسترس داری.
</p>
""",
},
{
"slug": "csv-daftar-baraye-hesabdar",
"title": "چطور خروجی CSV دفتر حساب را برای حسابدار بفرستم؟",
"meta_description": "دفتر درآمد و هزینه‌ی شاپ شیپ را برای یک بازه‌ی دلخواه به فایل CSV تبدیل و برای حسابدارت ارسال کن — سازگار با اکسل فارسی.",
"excerpt": "یک بازه‌ی تاریخ انتخاب کن، خروجی CSV بگیر، برای حسابدارت بفرست.",
"read_minutes": 2,
"content_html": """
<p>
خیلی از کسب‌وکارهای کوچک هر چند ماه یک‌بار باید دفترشان را برای یک
حسابدار بفرستند — برای اظهارنامه‌ی مالیاتی یا فقط یک مرور کلی. شاپ
شیپ این کار را به چند ثانیه رسانده.
</p>
<h2>خروجی گرفتن</h2>
<p>
از دفتر درآمد و هزینه، یک بازه‌ی تاریخِ دلخواه (مثلا یک ماه یا یک
فصل) انتخاب کن و «خروجی CSV» را بزن. فایلی می‌سازد که مستقیم در اکسل
یا گوگل‌شیت باز می‌شود — با حروف فارسیِ درست، بدون به‌هم‌ریختگی.
</p>
<h2>چه چیزی در فایل هست</h2>
<p>
هر ردیف یک ثبت است: تاریخ، دسته‌بندی (مثلا فروش نقدی یا
<a href="/articles/kharid-nasiye-dar-daftardari/">خرید نسیه</a>)، مبلغ،
و یادداشتی که خودت نوشته بودی. برای ثبت‌های بدهی/طلب، مانده‌حساب هم
در فایل می‌آید — حسابدارت همان چیزی را می‌بیند که تو در اپ می‌بینی.
</p>
""",
},
{
"slug": "modiriyat-bedehi-moshtarian",
"title": "مدیریت بدهی مشتریان با موبایل",
"meta_description": "همه‌ی مشتریانی که به تو بدهکارند، مانده‌حساب و تاریخچه‌ی اقساطشان را در یک صفحه ببین — بدون دفترچه‌ی کاغذی.",
"excerpt": "مانده‌حساب هر مشتری، خودکار از فاکتورها و دفترت محاسبه می‌شود — نه با دفترچه‌ی کاغذی.",
"read_minutes": 3,
"content_html": """
<p>
خیلی از مغازه‌دارها یک دفترچه‌ی کاغذیِ جدا برای «کیا بهم بدهکارن»
دارند — که هم گم می‌شود، هم به‌روزرسانیش یادشان می‌رود. در شاپ شیپ،
این دفترچه دیگر لازم نیست.
</p>
<h2>مانده‌حساب خودکار</h2>
<p>
هر بار که یک <a href="/articles/forosh-ghesti-hesabdari/">فروش
قسطی</a> برای یک مشتری ثبت می‌کنی یا یک قسط از او می‌گیری، مانده‌حساب
همان مشتری در پروفایلش خودکار به‌روز می‌شود — جمع تمام بدهی‌های بازش،
نه فقط آخرین ثبت.
</p>
<h2>یک نگاه، همه‌ی بدهکارها</h2>
<p>
از <a href="/articles/parvande-moshtari-chist/">پرونده‌ی مشتری</a>
می‌توانی ببینی چه کسی چقدر بدهکار است و آخرین قسطش کِی بوده — و اگر
لازم شد، از همان‌جا یک پیامکِ یادآوری برایش بفرستی.
</p>
""",
},
{
"slug": "sabt-hazine-rozane-maghaze",
"title": "ثبت هزینه‌های روزمره‌ی مغازه: از کجا شروع کنم؟",
"meta_description": "اجاره، قبض، حقوق، خرید جنس — راهنمای شروع ثبت هزینه‌های روزمره‌ی یک کسب‌وکار کوچک در دفتر درآمد و هزینه‌ی شاپ شیپ.",
"excerpt": "لازم نیست حسابدار باشی — فقط دسته‌ی درستِ نزدیک به هزینه‌ات را انتخاب کن.",
"read_minutes": 3,
"content_html": """
<p>
خیلی‌ها ثبتِ هزینه را به این دلیل کنار می‌گذارند که فکر می‌کنند باید
اصطلاحاتِ حسابداری بلد باشند. خبر خوب: نیستی، فقط چند دسته‌ی ساده
کافی است.
</p>
<h2>دسته‌های آماده برای شروع</h2>
<p>
اجاره، حقوق، قبض/آب‌وبرق، خرید نقدی جنس، و «سایر هزینه‌ها» — این پنج
دسته اکثرِ هزینه‌های روزمره‌ی یک مغازه‌ی کوچک را پوشش می‌دهند. برای
خرید جنسی که نقد نپرداختی، دسته‌ی
<a href="/articles/kharid-nasiye-dar-daftardari/">خرید نسیه</a> جداست.
</p>
<h2>عادتِ هر روز، بهتر از هر هفته</h2>
<p>
ثبتِ هر هزینه همان لحظه که اتفاق می‌افتد، چند ثانیه بیشتر طول
نمی‌کشد؛ ولی اگر یک هفته عقب بیفتی، خیلی از ریزه‌کاری‌ها یادت
می‌رود. بعد از چند هفته می‌توانی از
<a href="/articles/nemodar-daramad-hazine-tasmim/">نمودارِ درآمد و
هزینه</a> ببینی بیشترین هزینه‌ات کجا می‌رود.
</p>
""",
},
{
"slug": "nemodar-daramad-hazine-tasmim",
"title": "نمودار درآمد و هزینه چطور به تصمیم‌گیری کمک می‌کند؟",
"meta_description": "نمودار دفتر درآمد و هزینه‌ی شاپ شیپ را برای یک بازه‌ی زمانی دلخواه ببین و بفهم درآمدت روند صعودی دارد یا نه.",
"excerpt": "یک نگاهِ بصری به روندِ درآمد و هزینه‌ات — نه فقط یک عدد خام.",
"read_minutes": 3,
"content_html": """
<p>
یک لیستِ بلند از اعداد، فهمیدنِ «آیا وضعیتم بهتر شده یا بدتر» را سخت
می‌کند. نمودار به همین درد می‌خورد — یک نگاه، و روند مشخص است.
</p>
<h2>بازه‌ی زمانیِ دلخواه</h2>
<p>
از صفحه‌ی دفترِ درآمد و هزینه، وارد نمودار شو و بازه‌ای که می‌خواهی
(یک ماه، یک فصل، یک سال) را انتخاب کن. درآمد و هزینه‌ی هر بازه کنار
هم رسم می‌شود — همان چیزی که بعدا می‌توانی به‌عنوان
<a href="/articles/csv-daftar-baraye-hesabdar/">خروجی CSV</a> هم
داشته باشی.
</p>
<h2>سوالی که نمودار جواب می‌دهد</h2>
<p>
«این ماه بهتر از ماه پیش بودم؟» یا «بعد از فعال‌کردنِ پیام گروهی،
فروشم بالا رفت؟» — این‌ها سوالاتی هستند که یک نمودارِ ساده بهتر از
یک لیستِ عددی جوابشان را می‌دهد.
</p>
""",
},
{
"slug": "kharid-nasiye-dar-daftardari",
"title": "فرق خرید نسیه و خرید نقدی در دفترداری",
"meta_description": "خرید نسیه یک نوع بدهکاریِ تو به فروشنده است، نه یک هزینه‌ی ساده — چطور شاپ شیپ این تفاوت را در دفتر نشان می‌دهد.",
"excerpt": "خرید نسیه یعنی تو بدهکارِ فروشنده‌ای — دقیقا برعکسِ فروش قسطی.",
"read_minutes": 3,
"content_html": """
<p>
مقاله‌ی <a href="/articles/forosh-ghesti-hesabdari/">فروش قسطی چطور
باید ثبت شود؟</a> نشان داد وقتی تو جنسی را قسطی می‌فروشی، مشتری به تو
بدهکار می‌شود. حالا برعکسش: وقتی تو از یک تامین‌کننده جنس نسیه
می‌خری، این‌بار خودت بدهکاری.
</p>
<h2>چرا این را جدا از هزینه‌ی نقدی ثبت کنیم</h2>
<p>
اگر خرید نسیه را همان لحظه «هزینه» حساب کنی، انگار همان لحظه پول
دادی — در حالی که ندادی. این یعنی موجودیِ نقدیِ حساب‌شده‌ات از
واقعیت بیشتر نشان داده می‌شود، دقیقا برعکسِ اشتباهی که در مقاله‌ی
<a href="/articles/tafavot-daramad-naghdi-talab/">تفاوت درآمد نقدی و
طلب</a> گفتیم.
</p>
<h2>در شاپ شیپ</h2>
<p>
دسته‌ی «خرید نسیه» را که انتخاب کنی، اپ مبلغِ کل و نام طرف‌حساب را
می‌پرسد و یک بدهیِ باز برایت می‌سازد — دقیقا هم‌خانواده‌ی همان مکانیزمِ
طلب، فقط جهتش برعکس است.
</p>
""",
},
{
"slug": "sabt-aghsat-bedehi",
"title": "چطور اقساط یک بدهی را ثبت و پیگیری کنم؟",
"meta_description": "برای هر بدهی/طلبِ باز در دفتر شاپ شیپ، هر قسطی که می‌گیری یا می‌دهی را جدا ثبت کن و مانده‌حساب خودکار کم شود.",
"excerpt": "هر قسط را جدا ثبت کن — مانده‌حساب خودش کم می‌شود، حساب‌وکتاب دستی لازم نیست.",
"read_minutes": 2,
"content_html": """
<p>
وقتی یک <a href="/articles/forosh-ghesti-hesabdari/">فروش قسطی</a>
یا <a href="/articles/kharid-nasiye-dar-daftardari/">خرید نسیه</a> در
دفترت ثبت می‌کنی، یک «مانده‌حساب» برایش ساخته می‌شود. حالا سوال این
است: وقتی هر قسط می‌رسد، چطور ثبتش کنم؟
</p>
<h2>ثبت هر قسط، جدا</h2>
<p>
روی همان ثبتِ بدهی/طلب باز کن و یک قسطِ جدید اضافه کن — مبلغ، تاریخ،
و اگر خواستی یک شماره‌ی مرجع (مثلا شماره‌ی رسید). همین که ثبت شد،
مانده‌حساب خودکار همان مقدار کم می‌شود.
</p>
<h2>وقتی مانده صفر شود</h2>
<p>
وقتی مجموعِ اقساط به کل بدهی برسد، آن ثبت دیگر در لیستِ «بدهی‌های
باز» نشان داده نمی‌شود — اما تاریخچه‌ی کاملِ اقساط همیشه قابل‌مرور
می‌ماند، برای وقتی که لازم شد به آن رجوع کنی.
</p>
""",
},
# --- خوشه‌ی فاکتور ---
{
"slug": "fatoore-chand-ghalam-kala",
"title": "فاکتور با تخفیف و مالیات چند نوع کالا چطور صادر می‌شود؟",
"meta_description": "هر قلمِ کالا در فاکتورِ شاپ شیپ می‌تواند نرخ تخفیف و مالیاتِ جدا از بقیه داشته باشد — جمعِ نهایی خودکار محاسبه می‌شود.",
"excerpt": "هر قلم کالا، تخفیف و مالیاتِ خودش را دارد — جمعِ نهایی را اپ حساب می‌کند.",
"read_minutes": 3,
"content_html": """
<p>
فرض کن یک فاکتور داری با سه قلم کالا: یکی تخفیف دارد، یکی مالیاتِ
متفاوت، و سومی هیچ‌کدام. حساب‌کردنِ دستیِ این‌ها با ماشین‌حساب، هم
وقت‌گیر است هم پرخطا.
</p>
<h2>هر قلم، تنظیماتِ خودش</h2>
<p>
وقتی کالایی را (از همان لیستِ
<a href="/articles/dastebandi-kala-anbar/">انبارت</a>) به فاکتور
اضافه می‌کنی، می‌توانی برایش یک درصد تخفیف و یک نرخ مالیات جدا از
بقیه‌ی قلم‌ها بگذاری.
</p>
<h2>جمعِ نهایی، خودکار</h2>
<p>
جمعِ کل، جمعِ مالیات، و مبلغِ تخفیف‌خورده‌ی نهایی خودش محاسبه
می‌شود — همان چیزی که در مقاله‌ی
<a href="/articles/fatoore-rasmi-baraye-maghaze/">فاکتور رسمی برای
مغازه</a> هم به اتصالش به انبار اشاره شده بود.
</p>
""",
},
{
"slug": "vaziat-fatoore-pardakht",
"title": "فرق فاکتور پرداخت‌شده و در‌انتظار پرداخت",
"meta_description": "هر فاکتور در شاپ شیپ یک وضعیت روشن دارد — پرداخت‌شده یا در‌انتظار — و همین وضعیت تعیین می‌کند دفترت و انبارت چه تغییری می‌کنند.",
"excerpt": "وضعیتِ فاکتور فقط یک برچسب نیست — تعیین می‌کند انبار و دفترت چه اتفاقی برایشان می‌افتد.",
"read_minutes": 2,
"content_html": """
<p>
یک فاکتور صرفا «صادر شدن» کافی نیست — باید بدانی پولش رسیده یا نه.
شاپ شیپ برای همین یک وضعیتِ روشن به هر فاکتور می‌دهد.
</p>
<h2>وقتی پرداخت‌شده می‌شود</h2>
<p>
همین که فاکتور را «پرداخت‌شده» علامت بزنی، دو اتفاق خودکار می‌افتد:
موجودیِ کالاهای همان فاکتور از
<a href="/articles/dastebandi-kala-anbar/">انبار</a> کم می‌شود، و اگر
نوعِ پرداخت «قسطی/نسیه» بود، یک ثبتِ طلب هم در
<a href="/articles/tafavot-daramad-naghdi-talab/">دفترت</a> ساخته
می‌شود.
</p>
<h2>وقتی هنوز در‌انتظار است</h2>
<p>
تا وضعیت پرداخت‌شده نشود، هیچ‌کدام از این دو اتفاق نمی‌افتد — یعنی
می‌توانی فاکتور را از قبل آماده کنی، بدهی به مشتری نشان دهی، و بعدا
وقتی واقعا پول رسید، وضعیتش را عوض کنی.
</p>
""",
},
{
"slug": "chera-fatoore-rasmi-mohem-ast",
"title": "چرا باید فاکتور رسمی به مشتری بدهم حتی برای فروش کوچک؟",
"meta_description": "فاکتور رسمی فقط برای فروش‌های بزرگ نیست — اعتماد، پیگیریِ اختلاف، و تاریخچه‌ی خرید مشتری، سه دلیل برای صدورِ فاکتورِ هر فروش.",
"excerpt": "فاکتور فقط کاغذ نیست — سندِ اعتماد بین تو و مشتری است.",
"read_minutes": 3,
"content_html": """
<p>
خیلی از مغازه‌دارها فقط برای فروش‌های «بزرگ» فاکتور صادر می‌کنند —
برای بقیه، یک رسیدِ دست‌نویس یا هیچی. اما فاکتورِ رسمی برای هر فروش،
چند فایده دارد که با رسیدِ دست‌نویس به‌دست نمی‌آید.
</p>
<h2>اعتماد و حرفه‌ای‌بودن</h2>
<p>
یک فاکتورِ با لوگو، حس حرفه‌ای‌تری به مشتری می‌دهد — دقیقا همان چیزی
که در <a href="/articles/fatoore-rasmi-baraye-maghaze/">فاکتور رسمی
برای مغازه</a> توضیح داده شد.
</p>
<h2>پیگیریِ اختلاف</h2>
<p>
اگر روزی مشتری بگوید «من این قیمت را ندیدم» یا «این کالا را نخریدم»،
یک فاکتورِ ثبت‌شده در اپ، فورا قابل‌نشان‌دادن است — بدون گشتن در
دفترچه‌ای که شاید گم شده باشد.
</p>
<h2>تاریخچه‌ی خرید مشتری</h2>
<p>
هر فاکتور خودکار به <a href="/articles/parvande-moshtari-chist/">پرونده‌ی
همان مشتری</a> وصل می‌شود — یعنی بعد از چند ماه می‌توانی ببینی هر
مشتری چقدر و چه چیزی خریده، بدون این‌که چیزی را دوباره تایپ کنی.
</p>
""",
},
# --- خوشه‌ی مشتریان ---
{
"slug": "parvande-moshtari-chist",
"title": "پرونده‌ی مشتری چیست و چه چیزهایی در آن ثبت می‌شود؟",
"meta_description": "پرونده‌ی هر مشتری در شاپ شیپ، تاریخچه‌ی فاکتورها و مانده‌حسابش را خودکار جمع می‌کند — بدون هیچ ثبتِ دستیِ اضافه.",
"excerpt": "تاریخچه‌ی خرید و مانده‌حساب هر مشتری، خودکار و در یک صفحه.",
"read_minutes": 2,
"content_html": """
<p>
وقتی چند ده یا چند صد مشتری داری، به‌خاطر سپردنِ این‌که هرکدام چه
خریده و چقدر بدهکار است، عملا غیرممکن می‌شود. پرونده‌ی مشتری همین
کار را برایت انجام می‌دهد.
</p>
<h2>چیزی که خودکار ساخته می‌شود</h2>
<p>
هر بار که برای یک مشتری <a href="/articles/vaziat-fatoore-pardakht/">فاکتور</a>
صادر می‌کنی یا در دفترت یک بدهی/طلب برایش ثبت می‌کنی، همه‌ی این‌ها
خودکار در پرونده‌ی همان مشتری جمع می‌شود — تو چیزی جدا وارد نمی‌کنی.
</p>
<h2>مانده‌حساب، همان‌جا</h2>
<p>
اگر مشتری‌ای <a href="/articles/modiriyat-bedehi-moshtarian/">بدهیِ
باز</a> داشته باشد، همان‌جا در پرونده‌اش می‌بینی — دیگر نیازی به گشتن
در دفتر یا فاکتورهای قدیمی نیست.
</p>
""",
},
{
"slug": "faal-sazi-mojadad-moshtarian",
"title": "چطور مشتریان قدیمی را دوباره فعال کنم؟",
"meta_description": "با پیام گروهی و لیستِ مشتریانی که مدتی خرید نکرده‌اند، یک یادآوریِ ساده بفرست و مشتریِ فراموش‌شده را برگردان.",
"excerpt": "یک پیامِ یادآوریِ ساده، بیشترِ وقت‌ها کافی است — نه تخفیفِ بزرگ.",
"read_minutes": 3,
"content_html": """
<p>
خیلی از کسب‌وکارهای کوچک انرژی‌شان را فقط صرفِ جذبِ مشتریِ تازه
می‌کنند و مشتریِ قدیمی که مدتی سراغش نرفته‌اند را فراموش می‌کنند —
در حالی که برگرداندنِ یک مشتریِ قدیمی معمولا ارزان‌تر از جذبِ تازه است.
</p>
<h2>پیدا کردنِ مشتریانِ ساکت</h2>
<p>
از <a href="/articles/parvande-moshtari-chist/">پرونده‌های مشتری</a>،
کسانی که مدتی فاکتور یا خریدی نداشته‌اند مشخص‌اند — همین لیست، شروعِ
خوبی برای یک یادآوری است.
</p>
<h2>یک قالبِ ساده کافی است</h2>
<p>
لازم نیست تخفیفِ بزرگ بدهی؛ گاهی یک
<a href="/articles/ghaleb-payam-chist/">قالبِ پیام</a> ساده مثل «دلمون
برات تنگ شده، سر بزن» با نامِ خودِ مشتری، از طریقِ
<a href="/articles/ersal-payam-be-goruh-khas/">ارسالِ گروهی</a> برایِ
همان لیست، کافی است.
</p>
""",
},
# --- خوشه‌ی انبار ---
{
"slug": "dastebandi-kala-anbar",
"title": "دسته‌بندی کالا در انبار: از کجا شروع کنم؟",
"meta_description": "برای انبارِ چند ده یا چند صد قلمی، دسته‌بندی کالا در شاپ شیپ پیدا کردن و مدیریت را چند برابر سریع‌تر می‌کند.",
"excerpt": "دسته‌های ساده مثل «نوشیدنی» یا «لبنیات» — پیدا کردن یک کالا چند برابر سریع‌تر می‌شود.",
"read_minutes": 2,
"content_html": """
<p>
اگر انبارت فقط چند قلم کالا دارد، دسته‌بندی شاید لازم نباشد. اما
همین که به چند ده قلم برسی، یک لیستِ بلندِ بی‌ترتیب پیدا کردنِ یک
کالا را کند می‌کند.
</p>
<h2>دسته‌های دلخواهت</h2>
<p>
دسته‌ها را خودت تعریف می‌کنی — چیزی که برای کسب‌وکارت معنی‌دار باشد،
نه یک قالبِ از‌قبل‌تعیین‌شده. یک سوپرمارکت شاید «نوشیدنی/لبنیات/خشکبار»
داشته باشد، یک لوازم‌یدکی‌فروشی چیزِ کاملا متفاوتی.
</p>
<h2>اثرش روی فاکتور هم هست</h2>
<p>
وقتی برای <a href="/articles/fatoore-chand-ghalam-kala/">فاکتور</a>
کالا اضافه می‌کنی، همین دسته‌بندی کمک می‌کند سریع‌تر کالای موردنظر را
پیدا کنی — به‌جای اسکرول‌کردنِ یک لیستِ طولانی.
</p>
""",
},
{
"slug": "gheymat-kharid-vs-arzesh-anbar",
"title": "فرق قیمت خرید و ارزش کل انبار",
"meta_description": "قیمت خرید هر کالا را ثبت کن تا شاپ شیپ خودش ارزشِ کلِ سرمایه‌ی خوابیده در انبارت را برایت حساب کند.",
"excerpt": "دو عدد ساده (قیمت خرید و موجودی)، یک جوابِ مهم: چقدر سرمایه در انبارت خوابیده؟",
"read_minutes": 2,
"content_html": """
<p>
«الان چقدر پول در قالبِ جنس، روی طاقچه‌های مغازه‌ام خوابیده؟» سوالی
است که خیلی از صاحبانِ مغازه دقیق جوابش را نمی‌دانند.
</p>
<h2>یک ضرب ساده، محاسبه‌شده برایت</h2>
<p>
برای هر کالا در <a href="/articles/dastebandi-kala-anbar/">انبار</a>،
قیمتِ خریدش را ثبت می‌کنی. اپ همین عدد را در موجودیِ فعلی ضرب می‌کند و
«ارزشِ کلِ انبار» را نشان می‌دهد — بدون این‌که خودت دستی جمع بزنی.
</p>
<h2>چرا این عدد مهم است</h2>
<p>
این عدد به تو می‌گوید چقدر سرمایه در جنسِ فروخته‌نشده گیر افتاده —
اطلاعاتی که برای تصمیمِ «الان جنسِ تازه بخرم یا نه» مستقیم به کار
می‌آید.
</p>
""",
},
{
"slug": "hoshdar-kambood-mojoodi",
"title": "هشدار کمبود موجودی چطور کار می‌کند؟",
"meta_description": "برای هر کالا یک آستانه‌ی «کم شد» تعریف کن — قبل از این‌که مشتری بیاید و نداشته باشی، خودت متوجه می‌شوی.",
"excerpt": "یک آستانه تعریف کن — قبل از تمام‌شدنِ واقعیِ جنس، خودت باخبر می‌شوی.",
"read_minutes": 2,
"content_html": """
<p>
یکی از بدترین حس‌ها برای یک مغازه‌دار، این است که مشتری بیاید و جنسی
که فکر می‌کردی داری، تمام شده باشد. هشدارِ کمبود دقیقا برای جلوگیری
از همین لحظه است.
</p>
<h2>آستانه‌ی دلخواهِ هر کالا</h2>
<p>
برای هر کالا در <a href="/articles/dastebandi-kala-anbar/">انبار</a>،
یک عدد به‌عنوان «کم شد» تعریف می‌کنی — مثلا وقتی موجودی به ۵ رسید.
این عدد برای هر کالا می‌تواند فرق کند.
</p>
<h2>باخبر شدن زودتر</h2>
<p>
وقتی موجودی به آن آستانه برسد، اپ نشانش می‌دهد — قبل از این‌که واقعا
صفر شود. یعنی وقت کافی برای سفارشِ جنسِ تازه داری، بدون فشارِ
لحظه‌ی‌آخری.
</p>
""",
},
# --- خوشه‌ی پیام گروهی ---
{
"slug": "ghaleb-payam-chist",
"title": "قالب پیام چیست و چطور بسازمش؟",
"meta_description": "قالب پیام یک متنِ آماده با جای‌خالیِ نام/شماره است که یک‌بار می‌سازی و بارها برای مخاطبین مختلف استفاده می‌کنی.",
"excerpt": "یک متن، با جای‌خالی برای نام — یک‌بار بساز، بارها استفاده کن.",
"read_minutes": 2,
"content_html": """
<p>
اگر هر بار که می‌خواهی پیام گروهی بفرستی، از صفر متن تایپ کنی، هم
وقت تلف می‌شود هم احتمالِ فراموش‌کردنِ چیزی بالا می‌رود. قالبِ پیام
همین مشکل را حل می‌کند.
</p>
<h2>جای‌خالیِ نام و شماره</h2>
<p>
در متنِ قالب، می‌توانی از جای‌خالی‌هایی مثل «نام» استفاده کنی — وقتی
برای هر مخاطب ارسال می‌شود، خودکار با اطلاعاتِ همان مخاطب پر می‌شود.
</p>
<h2>فیلدِ اجرا، برای مقادیرِ متغیر</h2>
<p>
اگر یک مقدار داری که از قبل معلوم نیست (مثل کد رهگیری)، قالب می‌تواند
یک <a href="/articles/field-sefareshi-dar-template/">فیلدِ اجرا</a>
هم داشته باشد — همان لحظه‌ی ارسال از تو پرسیده می‌شود.
</p>
""",
},
{
"slug": "ersal-payam-be-goruh-khas",
"title": "ارسال پیام به گروه خاصی از مشتریان",
"meta_description": "به‌جای فرستادن یک پیام به همه، فقط برای بدهکارها، مشتریانِ ساکت، یا هر گروهِ دلخواه دیگری در شاپ شیپ پیام بفرست.",
"excerpt": "همه یک پیام یکسان نمی‌خواهند — پیامِ درست را برای گروهِ درست بفرست.",
"read_minutes": 2,
"content_html": """
<p>
فرستادنِ یک پیامِ یکسان به همه‌ی مخاطبین، همیشه بهترین راه نیست —
پیامِ یادآوریِ بدهی برای بدهکارها معنی دارد، نه برای همه.
</p>
<h2>انتخاب گیرنده‌ها</h2>
<p>
قبل از ارسال، مخاطبینی که پیام برایشان می‌رود را خودت انتخاب می‌کنی —
مثلا فقط کسانی که <a href="/articles/modiriyat-bedehi-moshtarian/">بدهیِ
باز</a> دارند، یا فقط
<a href="/articles/faal-sazi-mojadad-moshtarian/">مشتریانِ مدتی
ساکت</a>.
</p>
<h2>یک قالب، گیرنده‌های متفاوت</h2>
<p>
همان <a href="/articles/ghaleb-payam-chist/">قالبِ پیام</a> که یک‌بار
ساختی، برای هر گروهی که بعدا انتخاب کنی دوباره قابل‌استفاده است —
فقط لیستِ گیرنده‌ها را عوض می‌کنی.
</p>
""",
},
{
"slug": "vaziat-tahvil-payamak",
"title": "چک کردن وضعیت تحویل پیامک‌های ارسالی",
"meta_description": "بعد از ارسال گروهی، بفهم کدام پیامک واقعا تحویل داده شده، کدام در صف مانده و کدام ناموفق بوده — همه در یک لیست.",
"excerpt": "بعد از ارسال، بفهم واقعا چند نفر پیامک را گرفتند.",
"read_minutes": 2,
"content_html": """
<p>
«آیا واقعا رسید؟» سوالی است که بعد از هر ارسالِ گروهی می‌آید — به‌خصوص
برای <a href="/articles/ghaleb-payam-chist/">قالب‌های پیام</a> مهم
مثل یادآوریِ پرداخت.
</p>
<h2>سه وضعیتِ ممکن</h2>
<p>
هر پیامکِ ارسالی یکی از این سه وضعیت را دارد: تحویل‌شده، در‌صف
(هنوز پردازش می‌شود)، یا ناموفق. لیستِ کاملِ همه‌ی ارسال‌ها، با
وضعیتِ هرکدام، در دسترس است.
</p>
<h2>وقتی چیزی ناموفق بود</h2>
<p>
اگر پیامکی به دلیلی (مثلا شماره‌ی اشتباه) ناموفق بود، می‌دانی دقیقا
چه کسی پیام را نگرفته — می‌توانی جداگانه دوباره برایش بفرستی، به‌جای
حدس‌زدن.
</p>
""",
},
{
"slug": "kodam-kanal-baraye-kodam-kasbokar",
"title": "کدام کانال برای کدام کسب‌وکار بهتر است؟",
"meta_description": "فروشگاه، آنلاین‌شاپ، یا کسب‌وکار خدماتی — هرکدام معمولا با یک ترکیبِ متفاوت از پیامک/واتساپ/تلگرام بهتر جواب می‌گیرند.",
"excerpt": "نوعِ کسب‌وکارت مشخص می‌کند کدام کانال بیشترین اثر را دارد.",
"read_minutes": 3,
"content_html": """
<p>
مقاله‌ی <a href="/articles/sms-ya-whatsapp-business/">پیامک یا
واتساپ‌بیزینس؟</a> تفاوتِ فنیِ کانال‌ها را توضیح داد. این‌جا می‌خواهیم
ببینیم کدام کسب‌وکار معمولا کدام ترکیب را بیشتر لازم دارد.
</p>
<h2>فروشگاه‌های محلی</h2>
<p>
برای اعلامِ موجودیِ روزانه یا تخفیف، پیامک قابل‌اطمینان‌ترین است —
همه به آن دسترسی دارند، حتی بدون اینترنت.
</p>
<h2>آنلاین‌شاپ‌های کوچک</h2>
<p>
اطلاع‌رسانیِ <a href="/articles/tartib-marahel-sefaresh/">مراحلِ
سفارش</a> معمولا با پیامک بهتر جواب می‌دهد، چون مشتری منتظرِ خبرِ
«ارسال شد» است و نمی‌خواهد وابسته به بازکردنِ یک اپ خاص باشد.
</p>
<h2>کسب‌وکارهای خدماتی</h2>
<p>
برای ارتباطِ دوطرفه‌تر (سوال‌وجواب با مشتری)، واتساپ یا تلگرام معمولا
راحت‌تر است — به‌شرطی که مسیرِ رسمیِ هرکدام رعایت شود.
</p>
""",
},
# --- خوشه‌ی پیگیری سفارش ---
{
"slug": "tartib-marahel-sefaresh",
"title": "جابه‌جا کردن ترتیب مراحل سفارش با کشیدن و رها کردن",
"meta_description": "مراحلِ پیگیری سفارش در شاپ شیپ ثابت نیستند — با کشیدن و رها کردن، ترتیبشان را دقیقا مطابق جریان کاریِ واقعیت تنظیم کن.",
"excerpt": "ترتیبِ مراحل، دقیقا مطابقِ جریانِ کاریِ واقعیِ خودت — با یک کشیدن و رها کردن.",
"read_minutes": 2,
"content_html": """
<p>
همان‌طور که در مقاله‌ی
<a href="/articles/peygiri-sefaresh-online-shop-etemad/">پیگیری سفارش
آنلاین‌شاپ</a> گفتیم، مراحلِ کار را خودت تعریف می‌کنی. اما گاهی بعد از
مدتی می‌فهمی ترتیبِ اولیه دقیق نبوده.
</p>
<h2>کشیدن و رها کردن</h2>
<p>
از صفحه‌ی مراحلِ سفارش، هر مرحله را می‌توانی بگیری و به جای درست
بکشی — بدون نیاز به حذف و ساختِ دوباره. مثلا اگر «بسته‌بندی» را قبل
از «آماده‌سازی» گذاشته بودی، فقط جابه‌جایش کن.
</p>
<h2>تغییر ترتیب، تاثیری روی سفارش‌های قبلی ندارد</h2>
<p>
سفارش‌هایی که از قبل در یک مرحله بودند، همان‌جا می‌مانند — تغییرِ
ترتیب فقط روی مسیرِ سفارش‌های بعدی اثر می‌گذارد.
</p>
""",
},
{
"slug": "payam-mokhtalef-har-marhale",
"title": "چطور برای هر مرحله‌ی سفارش پیامک متفاوت بفرستم؟",
"meta_description": "هر مرحله‌ی پیگیری سفارش در شاپ شیپ به یک قالبِ پیام جداگانه وصل می‌شود — «آماده شد» یک متن، «ارسال شد» متنی دیگر.",
"excerpt": "هر مرحله، قالبِ پیامِ خودش را دارد — نه یک متنِ یکسان برای همه‌جا.",
"read_minutes": 2,
"content_html": """
<p>
یک پیامِ یکسان برای «سفارشت آماده شد» و «سفارشت تحویل داده شد»
منطقی نیست — هرکدام باید خبرِ متفاوتی بدهند.
</p>
<h2>هر مرحله، یک قالب</h2>
<p>
وقتی <a href="/articles/tartib-marahel-sefaresh/">یک مرحله</a>
می‌سازی، همان‌جا یک <a href="/articles/ghaleb-payam-chist/">قالبِ
پیام</a> برایش انتخاب می‌کنی — می‌تواند برای هر مرحله کاملا متفاوت
باشد.
</p>
<h2>وقتی مرحله فیلدِ اجرا دارد</h2>
<p>
برای مرحله‌ای مثل «ارسال شد» که یک کدِ رهگیری لازم دارد، قالبِ همان
مرحله می‌تواند <a href="/articles/field-sefareshi-dar-template/">فیلدِ
اجرا</a> داشته باشد — همان لحظه از تو پرسیده و در پیامک جای می‌شود.
</p>
""",
},
# --- خوشه‌ی راهنمای عمومی/آشناسازی ---
{
"slug": "behtarin-estefade-az-hafte-raygan",
"title": "یک هفته‌ی رایگان شاپ شیپ را چطور بهترین استفاده را ببرم؟",
"meta_description": "راهنمای هفت‌روزه: چه کارهایی در هفته‌ی رایگانِ شاپ شیپ انجام بده تا قبل از هر تصمیمِ پرداختی، ارزشِ واقعیِ اپ را ببینی.",
"excerpt": "هفت روز، هفت قدمِ کوچک — تا قبل از هر تصمیم پرداختی، ارزشِ واقعی را ببینی.",
"read_minutes": 3,
"content_html": """
<p>
یک هفته زمانِ کمی است، ولی برای فهمیدنِ این‌که شاپ شیپ واقعا به‌دردت
می‌خورد یا نه، کافی است — اگر درست ازش استفاده کنی.
</p>
<h2>روزهای اول: داده‌های واقعی</h2>
<p>
به‌جای امتحان‌کردنِ با داده‌ی الکی، از همین روزِ اول
<a href="/articles/dastebandi-kala-anbar/">چند کالای واقعیِ انبارت</a>
و <a href="/articles/merge-mokhatebin-ganoshi/">چند مخاطبِ
واقعی</a> را وارد کن.
</p>
<h2>وسط هفته: یک فاکتور و یک پیام واقعی</h2>
<p>
یک <a href="/articles/chera-fatoore-rasmi-mohem-ast/">فاکتورِ واقعی</a>
برای یک فروشِ واقعی بزن، و یک قالبِ پیام بساز و برایِ چند مشتریِ
واقعی بفرست.
</p>
<h2>آخر هفته: مرور</h2>
<p>
به دفترِ درآمد و هزینه‌ات نگاه کن — همان چیزی که در این هفته ثبت شده،
تصویرِ کوچکی از این است که یک ماهِ کامل با شاپ شیپ چه شکلی خواهد بود.
</p>
""",
},
{
"slug": "amniat-etelaat-kasbokar",
"title": "امنیت اطلاعات کسب‌وکارت در شاپ شیپ",
"meta_description": "قفل اثرانگشت، دیتای اختصاصی هر کاربر، و این‌که چرا اطلاعاتِ مالی و مشتریانِ تو فقط مالِ خودت است.",
"excerpt": "قفل اثرانگشت روی اطلاعاتِ حساس، و دیتایی که فقط مالِ خودت است.",
"read_minutes": 2,
"content_html": """
<p>
اطلاعاتِ مالی و لیستِ مشتریانِ یک کسب‌وکار، از حساس‌ترین چیزهایی است
که روی گوشیِ کسی می‌تواند باشد — به همین دلیل امنیت اینجا جدی گرفته
شده.
</p>
<h2>قفل اثرانگشت</h2>
<p>
می‌توانی ورود به بخش‌های حساس (مثل
<a href="/articles/tafavot-daramad-naghdi-talab/">دفتر درآمد و
هزینه</a>) را پشتِ قفلِ اثرانگشتِ گوشی‌ات بگذاری — حتی اگر کسی
گوشی‌ات را باز کند، به این بخش‌ها بدون اثرانگشتِ خودت دسترسی ندارد.
</p>
<h2>دیتای تو، مالِ خودت</h2>
<p>
اطلاعاتِ کسب‌وکارِ هر کاربر کاملا از بقیه جدا است — چیزی بینِ
حساب‌های مختلف به اشتراک گذاشته نمی‌شود.
</p>
""",
},
{
"slug": "taghvim-shamsi-dar-narmafzar",
"title": "تقویم شمسی در نرم‌افزارهای کسب‌وکار چرا مهم است؟",
"meta_description": "تاریخِ میلادی در نرم‌افزارهای مالی، محاسبه‌ی سررسیدها و مرورِ گزارش‌ها را برای کاربر فارسی‌زبان سخت‌تر می‌کند — شاپ شیپ همه‌جا شمسی است.",
"excerpt": "وقتی سررسیدِ قسط یا تاریخِ فاکتور را می‌بینی، باید همان لحظه معنی‌اش را بفهمی — نه تبدیل کنی.",
"read_minutes": 2,
"content_html": """
<p>
خیلی از نرم‌افزارهای مالی و حسابداری با تاریخِ میلادی کار می‌کنند —
و کاربرِ فارسی‌زبان باید در ذهنش تبدیل کند. این تبدیلِ ذهنی، هرچقدر
هم سریع، یک لایه‌ی اضافه‌ی خستگی است.
</p>
<h2>همه‌جا شمسی</h2>
<p>
از تاریخِ <a href="/articles/vaziat-fatoore-pardakht/">فاکتور</a> تا
سررسیدِ <a href="/articles/sabt-aghsat-bedehi/">اقساط</a> تا
<a href="/articles/nemodar-daramad-hazine-tasmim/">نمودارِ دفتر</a>،
همه‌جا با تقویمِ شمسی نمایش داده می‌شود.
</p>
<h2>یعنی چه در عمل</h2>
<p>
وقتی می‌بینی «سررسید ۱۵ مهر»، همان لحظه معنایش را می‌فهمی — بدون
این‌که مجبور باشی محاسبه کنی امروز چندمِ میلادی است و چند روز مانده.
</p>
""",
},
{
"slug": "jam-chist-chetor-kar-mikonad",
"title": "جم چیست و چطور برای فعال‌سازی ابزارها استفاده می‌شود؟",
"meta_description": "شاپ شیپ به‌جای اشتراکِ همه‌یا‌هیچ، هر بخش را جدا با «جم» فعال می‌کند — جم چطور خریداری و خرج می‌شود.",
"excerpt": "به‌جای اشتراکِ همه‌یا‌هیچ، هر بخش را جدا، فقط با جم، فعال می‌کنی.",
"read_minutes": 2,
"content_html": """
<p>
خیلی از نرم‌افزارها یک اشتراکِ ثابت می‌فروشند که شاملِ همه‌ی
امکانات است — حتی آن‌هایی که لازم نداری. شاپ شیپ این مدل را عوض
کرده.
</p>
<h2>جم چیست</h2>
<p>
جم واحدِ داخلیِ شاپ شیپ برای فعال‌سازیِ بخش‌هاست — فاکتور، پیام
گروهی، دفتر، انبار، هرکدام قیمتِ جداگانه‌ای به جم دارند.
</p>
<h2>از کجا جم بگیرم</h2>
<p>
جم را از فروشگاهِ داخلِ اپ می‌خری، یا از راهِ
<a href="/articles/davat-doostan-refferal/">دعوتِ دوستان</a> به‌عنوانِ
پاداش دریافت می‌کنی. هر بخش را فقط برایِ مدتی که واقعا لازمش داری
فعال می‌کنی — تمدیدِ اجباری وجود ندارد.
</p>
""",
},
{
"slug": "davat-doostan-refferal",
"title": "دعوت دوستان و پاداش معرفی در شاپ شیپ",
"meta_description": "کد معرفِ خودت را با یک صاحب کسب‌وکار دیگر به اشتراک بگذار — هر دو نفر جم پاداش می‌گیرید که می‌شود برای فعال‌سازیِ ابزارها خرج کرد.",
"excerpt": "کدِ معرفت را با یک صاحب کسب‌وکارِ دیگر به اشتراک بگذار — هر دو برنده می‌شوید.",
"read_minutes": 2,
"content_html": """
<p>
اگر یک صاحب کسب‌وکارِ دیگر را می‌شناسی که ممکن است شاپ شیپ به دردش
بخورد، معرفیِ او هم به نفعِ او هم به نفعِ خودت است.
</p>
<h2>چطور کار می‌کند</h2>
<p>
کدِ معرفِ خودت را (از پروفایلِ خودت در اپ) با او به اشتراک بگذار.
وقتی او با همان کد ثبت‌نام و فعالیتِ واقعی را شروع کند، هر دوی شما
<a href="/articles/jam-chist-chetor-kar-mikonad/">جمِ پاداش</a>
می‌گیرید.
</p>
<h2>خرجِ جمِ پاداش</h2>
<p>
جمی که از این راه می‌گیری، دقیقا مثلِ جمِ خریداری‌شده است — می‌توانی
با آن هر بخشی از اپ (فاکتور، پیام گروهی، دفتر) را برای مدتی فعال
کنی، بدون پرداختِ پول.
</p>
""",
},
{
"slug": "kasbokar-khord-chist",
"title": "کسب‌وکار خرد چیست و چه نرم‌افزارهایی واقعا به دردش می‌خورد؟",
"meta_description": "یک مغازه‌ی یک‌نفره یا یک آنلاین‌شاپِ کوچک، نیازِ نرم‌افزاریِ متفاوتی از یک شرکتِ بزرگ دارد — چرا سادگی مهم‌تر از قابلیت‌های زیاد است.",
"excerpt": "کسب‌وکارِ یک یا دو‌نفره، نیازِ نرم‌افزاریِ متفاوتی از یک شرکتِ بزرگ دارد.",
"read_minutes": 3,
"content_html": """
<p>
«کسب‌وکار خرد» یعنی معمولا یک یا چند نفر، بدونِ بخشِ IT جدا، بدونِ
وقتِ زیاد برای یادگیریِ نرم‌افزارِ پیچیده. نیازهایش با یک شرکتِ
بزرگ خیلی فرق دارد.
</p>
<h2>چرا نرم‌افزارِ شرکت‌های بزرگ جواب نمی‌دهد</h2>
<p>
نرم‌افزارهایی که برای تیمِ حسابداریِ چندنفره طراحی شده‌اند، پر از
تنظیماتی هستند که یک مغازه‌دارِ تنها هیچ‌وقت لازمشان ندارد — و همین
پیچیدگی باعث می‌شود اصلا استفاده نشود.
</p>
<h2>چه چیزی واقعا لازم است</h2>
<p>
چیزهایی مثل <a href="/articles/chera-fatoore-rasmi-mohem-ast/">فاکتورِ
ساده</a>، <a href="/articles/sabt-hazine-rozane-maghaze/">ثبتِ
هزینه‌ی روزمره</a>، و <a href="/articles/ghaleb-payam-chist/">پیامِ
گروهیِ ساده</a> — نه گزارش‌های پیچیده‌ای که هیچ‌وقت باز نمی‌شوند.
</p>
""",
},
{
"slug": "checklist-rahandazi-maghaze",
"title": "چک‌لیست راه‌اندازی یک مغازه‌ی کوچک با موبایل",
"meta_description": "از ثبتِ اولین کالا تا صدور اولین فاکتور — چک‌لیستِ قدم‌به‌قدمِ راه‌اندازیِ مدیریتِ یک مغازه‌ی کوچک با شاپ شیپ.",
"excerpt": "از اولین کالا تا اولین فاکتور — یک چک‌لیستِ قدم‌به‌قدم.",
"read_minutes": 3,
"content_html": """
<p>
شروعِ مدیریتِ یک مغازه با یک اپِ جدید، اگر قدم‌به‌قدم نباشد، گیج‌کننده
می‌شود. این چک‌لیست، ترتیبِ پیشنهادی است.
</p>
<h2>قدم اول: انبار</h2>
<p>
چند کالای پرتکرارت را با
<a href="/articles/dastebandi-kala-anbar/">دسته‌بندی</a> و قیمتِ خرید
وارد کن — نیازی نیست همه را یک‌جا وارد کنی، می‌توانی کم‌کم اضافه کنی.
</p>
<h2>قدم دوم: چند مشتریِ ثابت</h2>
<p>
اگر مشتریانِ ثابت داری، آن‌ها را با
<a href="/articles/merge-mokhatebin-ganoshi/">وارد‌کردن از
مخاطبینِ گوشی</a> اضافه کن.
</p>
<h2>قدم سوم: اولین فاکتور</h2>
<p>
برای یک فروشِ واقعی،
<a href="/articles/fatoore-chand-ghalam-kala/">اولین فاکتورت</a> را
بزن — از همین‌جا می‌بینی چطور فاکتور، انبار، و دفترت به هم وصل
می‌شوند.
</p>
<h2>قدم چهارم: اولین پیامِ گروهی</h2>
<p>
یک <a href="/articles/ghaleb-payam-chist/">قالبِ پیام</a> ساده بساز و
برای همان چند مشتری بفرست — تمام.
</p>
""",
},
{
"slug": "shopship-vs-excel",
"title": "تفاوت شاپ شیپ با اکسل برای مدیریت کسب‌وکار",
"meta_description": "اکسل رایگان است ولی محاسبه، یادآوری، و اتصالِ خودکارِ بین فاکتور/انبار/دفتر را ندارد — مقایسه‌ای صادق بین دو رویکرد.",
"excerpt": "اکسل رایگان است، ولی چیزهایی را خودکار نمی‌کند که هر روز وقتت را می‌گیرد.",
"read_minutes": 3,
"content_html": """
<p>
اکسل ابزارِ قدرتمندی است و خیلی از کسب‌وکارهای کوچک سال‌ها با آن کار
کرده‌اند. سوال این نیست که اکسل بد است — سوال این است که چه چیزی
اکسل به‌طورِ پیش‌فرض برایت انجام نمی‌دهد.
</p>
<h2>اتصالِ خودکار بین بخش‌ها</h2>
<p>
در اکسل، وقتی فاکتور می‌زنی باید جداگانه هم
<a href="/articles/hoshdar-kambood-mojoodi/">موجودیِ انبار</a> را کم
کنی، هم <a href="/articles/tafavot-daramad-naghdi-talab/">دفترت</a>
را به‌روز کنی — سه کار برای یک فروش. در شاپ شیپ این‌ها خودکار به هم
وصل‌اند.
</p>
<h2>یادآوری و پیام، بدونِ ابزارِ جدا</h2>
<p>
اکسل نمی‌تواند برایت پیامک بفرستد. برای
<a href="/articles/faal-sazi-mojadad-moshtarian/">یادآوریِ مشتری</a>
یا هشدارِ کمبودِ موجودی، باز باید ابزارِ جدا داشته باشی.
</p>
<h2>وقتی اکسل هنوز کافی است</h2>
<p>
اگر فقط چند فروش در هفته داری و هیچ‌وقت نیاز به پیام گروهی یا
پیگیریِ بدهی نداری، شاید اکسل هنوز جواب بدهد. اما همین که کسب‌وکارت
رشد کند، این محاسبات و یادآوری‌های دستی، وقتِ زیادی می‌گیرند.
</p>
""",
},
{
"slug": "yek-app-bejaye-chand-narmafzar",
"title": "چطور از یک اپ واحد به‌جای چند نرم‌افزار جدا استفاده کنم؟",
"meta_description": "به‌جای یک اپ برای فاکتور، یک اپ برای پیامک، و یک دفترچه برای حساب‌وکتاب، همه را در شاپ شیپ یک‌جا داشته باش.",
"excerpt": "فاکتور، پیام، انبار، دفتر — همه یک‌جا، و به‌هم وصل.",
"read_minutes": 3,
"content_html": """
<p>
خیلی از کسب‌وکارهای کوچک بدونِ این‌که تصمیمِ آگاهانه‌ای گرفته باشند،
سرِ آخر با پنج ابزارِ جدا کار می‌کنند: یک اپ برای فاکتور، یکی برای
پیامک، یک دفترچه برای حساب، و اکسلِ انبار.
</p>
<h2>مشکلِ اصلیِ ابزارهای جدا</h2>
<p>
هیچ‌کدام از این ابزارها با هم حرف نمی‌زنند — یعنی هر فروش را باید سه
یا چهار بار جدا ثبت کنی. همان‌طور که در مقاله‌ی
<a href="/articles/shopship-vs-excel/">تفاوتِ شاپ شیپ با اکسل</a>
گفتیم، همین دوباره‌کاری‌هاست که وقتِ زیادی می‌گیرد.
</p>
<h2>وقتی همه‌چیز یک‌جاست</h2>
<p>
یک <a href="/articles/vaziat-fatoore-pardakht/">فاکتور</a> که
پرداخت می‌شود، خودش انبار و دفتر را به‌روز می‌کند. یک
<a href="/articles/parvande-moshtari-chist/">مشتری</a> که فاکتور
می‌گیرد، خودش پرونده‌اش ساخته می‌شود. کارِ کمتر، خطای کمتر.
</p>
<h2>شروعِ آسان</h2>
<p>
نیازی نیست همه‌چیز را یک‌جا شروع کنی — می‌توانی از
<a href="/articles/checklist-rahandazi-maghaze/">چک‌لیستِ
راه‌اندازی</a> شروع کنی و کم‌کم بخش‌های دیگر را اضافه کنی.
</p>
""",
},
]
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),
]
@@ -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": """
<p>
خیلی از کسب‌وکارهای کوچک اولین باری که «پیام گروهی» را امتحان می‌کنند،
یا از یک نرم‌افزار مشکوک استفاده می‌کنند که چند شماره را همزمان روی
چند گوشی می‌چرخاند، یا دستی و طاقت‌فرسا یکی‌یکی کپی‌پیست می‌کنند. هر دو
راه یک مشکل مشترک دارند: یکی امن نیست، یکی زمان‌بر. شاپ شیپ یک راه سوم
را انتخاب کرده — دقیقا همان مسیری که خودِ پلتفرم‌ها رسمی اعلام کرده‌اند.
</p>
<h2>چرا اکانت‌ها مسدود می‌شوند</h2>
<p>
واتساپ، تلگرام، بله و ایتا همه یک سیستم ضدهرزنامه دارند که رفتار
غیرطبیعی (ارسال حجم بالا از یک شماره‌ی معمولی، چرخاندن چند دستگاه/سشن،
شبیه‌سازی رفتار انسانی برای فرار از تشخیص) را شناسایی و مسدود می‌کند.
این محدودیت تصادفی نیست — دقیقا همان مکانیزمی است که جلوی اسپم واقعی
را می‌گیرد، و هیچ «ترفند» پایداری برای دور زدنش وجود ندارد.
</p>
<h2>راه پایدار: مسیر رسمی هر پلتفرم</h2>
<p>
<strong>پیامک</strong> از طریق درگاه‌های بالک اپراتور، <strong>واتساپ</strong>
از طریق WhatsApp Business Platform (با Template از‌پیش‌تاییدشده)، و
<strong>تلگرام/بله/ایتا</strong> از طریق Bot API — با یک محدودیت مهم:
بات فقط می‌تواند به کسانی پیام بدهد که خودشان قبلا چت را با بات شروع
کرده‌اند (opt-in). جزئیاتِ انتخاب بین این کانال‌ها در مقاله‌ی
<a href="/articles/sms-ya-whatsapp-business/">پیامک یا واتساپ‌بیزینس؟</a>
آمده است.
</p>
<h2>شاپ شیپ چطور این را حل می‌کند</h2>
<p>
برای کانال‌هایی که opt-in لازم دارند و مخاطب هنوز چت را شروع نکرده،
شاپ شیپ به‌جای اتوماسیونِ مخفی و پرخطر، یک صفِ ارسال دستیِ یک‌لمسی نشان
می‌دهد: قالب پیام را از قبل آماده کرده‌ای، اپ مقصد را با متنِ آماده باز
می‌کند، خودت فقط دکمه‌ی ارسال را می‌زنی.
</p>
""",
},
{
"slug": "field-sefareshi-dar-template",
"title": "فیلد اجرا در قالب پیام یعنی چه؟",
"meta_description": "قالب پیام در شاپ شیپ می‌تواند یک «فیلد اجرا» داشته باشد — مقداری که هر بار لحظه‌ی ارسال پرسیده می‌شود، نه از قبل ثبت شده.",
"excerpt": "یک فیلد در قالب پیام که مقدارش از قبل معلوم نیست و هر بار لحظه‌ی ارسال پرسیده می‌شود — مثلا کد رهگیری پستی.",
"read_minutes": 3,
"content_html": """
<p>
قالب پیام معمولی یک متن ثابت با چند جای‌خالیِ از‌قبل‌معلوم است، مثلا
«سلام {نام} عزیز». اما بعضی وقت‌ها مقداری که باید در پیام بیاید اصلا
از قبل معلوم نیست — مثلا کد رهگیری پستیِ یک مرسوله که همان لحظه‌ی
تحویل به پست تولید می‌شود. این‌جاست که «فیلد اجرا» به کمک می‌آید.
</p>
<h2>فرقش با جای‌خالی‌های معمولی چیست</h2>
<p>
جای‌خالی‌های معمولیِ قالب (مثل نام یا شماره) از اطلاعاتِ همان مخاطب پر
می‌شوند — از قبل در اپ ثبت شده‌اند. فیلد اجرا برعکس است: هیچ مقدار
از‌پیش‌ذخیره‌شده‌ای ندارد؛ دقیقا همان لحظه که پیام دارد ارسال می‌شود،
اپ یک‌بار از تو می‌پرسدش.
</p>
<h2>کاربرد اول: پیگیری سفارش</h2>
<p>
وقتی برای یک مرحله از
<a href="/articles/peygiri-sefaresh-online-shop-etemad/">پیگیری سفارش</a>
قالبی انتخاب می‌کنی که فیلد اجرا دارد، همین که سفارشی به آن مرحله
می‌رسد، اپ همان لحظه مقدار را می‌پرسد — مثلا کد رهگیری — و بعد پیامک
را با همان مقدار برای مشتری می‌فرستد.
</p>
<h2>کاربرد دوم: ارسال گروهی</h2>
<p>
در ارسال گروهی هم اگر قالب انتخابی فیلد اجرا داشته باشد، اپ مخاطبین
را یکی‌یکی نشان می‌دهد و مقدار را برای هرکدام می‌پرسد — و برای
راحتی، مقداری که برای نفرِ قبلی وارد کردی به‌عنوان پیش‌فرضِ نفرِ بعدی
هم پیشنهاد می‌شود.
</p>
<h2>هر قالب فقط یک فیلد اجرا دارد</h2>
<p>
ساده نگه داشتنش عمدی است: هر قالب پیام حداکثر یک فیلد اجرا دارد، با
یک برچسبِ دلخواه (مثلا «کد رهگیری»). اگر قالبی این فیلد را نداشته
باشد، مثل قبل مستقیم ارسال می‌شود، بدون هیچ پرسش اضافه‌ای.
</p>
""",
},
{
"slug": "merge-mokhatebin-ganoshi",
"title": "وارد کردن مخاطبین گوشی به شاپ شیپ",
"meta_description": "سریع‌ترین راه برای شروع: مخاطبین گوشی‌ات را با یک تیک انتخاب‌همه، بدون تعیین گروه و بدون تایپ دستی، به مخاطبین یا مشتریانِ شاپ شیپ اضافه کن.",
"excerpt": "مخاطبین گوشی‌ات را با یک تیک انتخاب‌همه وارد کن — بدون گروه‌بندی اضافه، بدون تایپ دستی.",
"read_minutes": 2,
"content_html": """
<p>
اگر لیست مشتری یا مخاطبت همین حالا در دفترچه‌تلفنِ گوشی‌ات است، نیازی
به تایپ دوباره‌ی تک‌تک شماره‌ها نیست. شاپ شیپ می‌تواند مستقیم به
مخاطبین گوشی‌ات دسترسی بگیرد (فقط با اجازه‌ی خودت) و هرکدام را که
خواستی یک‌جا وارد اپ کند.
</p>
<h2>چطور کار می‌کند</h2>
<p>
از صفحه‌ی «مخاطبین» یا «مشتریان»، گزینه‌ی «وارد کردن از مخاطبین گوشی»
را بزن. اپ لیست مخاطبین گوشی‌ات را نشان می‌دهد — با یک تیکِ
«انتخاب همه» بالای لیست برای وقتی که می‌خواهی همه را یک‌جا اضافه کنی،
یا تیک تک‌تک برای انتخاب دستی.
</p>
<h2>بدون مرحله‌ی گروه‌بندی</h2>
<p>
برخلاف نسخه‌های قدیمی‌تر، این‌جا دیگر مجبور نیستی قبل از وارد کردن
یک «گروه مقصد» انتخاب کنی — همین یک مرحله‌ی کمتر یعنی وارد کردن ده‌ها
مخاطب چند ثانیه بیشتر طول نمی‌کشد.
</p>
<h2>دوباره‌کاری نمی‌شود</h2>
<p>
مخاطبینی که شماره‌شان از قبل در اپ ثبت شده، خودشان از لیست وارد‌کردن
کنار گذاشته می‌شوند — تطبیق بر اساس شماره‌ی نرمال‌شده انجام می‌شود، نه
اسم، پس اختلاف املایی اسم مشکلی ایجاد نمی‌کند.
</p>
""",
},
{
"slug": "sms-ya-whatsapp-business",
"title": "پیامک یا واتساپ‌بیزینس؟ کدام برای ارسال گروهی بهتر است",
"meta_description": "مقایسه‌ی پیامک و واتساپ‌بیزینس برای ارسال پیام گروهی در شاپ شیپ: هزینه، نرخ باز شدن، محدودیت Template، و کدام برای چه کسب‌وکاری مناسب‌تر است.",
"excerpt": "مقایسه‌ی هزینه، نرخ باز شدن، و محدودیت‌های هرکدام برای پیام گروهی کسب‌وکارها.",
"read_minutes": 5,
"content_html": """
<p>
هر دو کانال جای خودشان را دارند و در شاپ شیپ می‌توانی همزمان از هردو
(به‌علاوه‌ی تلگرام، بله و ایتا) استفاده کنی — یک
<a href="/articles/peyam-goruhi-ba-shomare/">قالب پیام</a> بنویسی،
برای هرکدام از کانال‌ها بفرستی.
</p>
<h2>پیامک</h2>
<p>
به هر شماره‌ای می‌رسد، حتی بدون اینترنت یا اپ نصب‌شده — قابل‌اطمینان‌ترین
کانال از نظر تحویل. هزینه‌ی هر پیام مشخص و قابل‌پیش‌بینی است. محدودیت:
طول پیام (پیامک‌های بلند چند‌قسمتی حساب می‌شوند) و نداشتن تصویر/دکمه.
</p>
<h2>واتساپ‌بیزینس</h2>
<p>
نرخ باز شدن پیام معمولا بالاتر از پیامک است و امکاناتی مثل دکمه و
رسانه دارد. اما برای ارسال گروهی (business-initiated) باید از یک
Template از‌پیش‌تاییدشده در Meta Business Manager استفاده کنی —
یعنی نمی‌توانی هر متن دلخواهی را بی‌محدودیت به هرکسی بفرستی.
</p>
<h2>تلگرام، بله، ایتا</h2>
<p>
این‌ها فقط به کسانی می‌رسند که قبلا با بات کسب‌وکارت چت را شروع
کرده‌اند. برای مخاطبینی که این کار را نکرده‌اند، شاپ شیپ یک صف
ارسال دستیِ یک‌لمسی نشان می‌دهد.
</p>
<h2>جمع‌بندی</h2>
<p>
اگر تحویل تضمینی برایت مهم‌تر است، پیامک را انتخاب کن. اگر مخاطبینت
بیشتر واتساپی هستند و Template تاییدشده داری، واتساپ‌بیزینس گزینه‌ی
بهتری است. بهترین نتیجه معمولا ترکیب چند کانال است.
</p>
""",
},
{
"slug": "forosh-ghesti-hesabdari",
"title": "فروش قسطی چطور باید در دفترداری ثبت شود؟",
"meta_description": "فروش قسطی از نظر حسابداری یک نوع بدهکاری/طلب است، نه فقط یک عدد در لیست درآمد. دفتر درآمد و هزینه‌ی شاپ شیپ این تفاوت را خودش تشخیص می‌دهد.",
"excerpt": "فروش قسطی فقط «درآمد» نیست — یک نوع طلب از مشتری است که باید جدا پیگیری شود.",
"read_minutes": 4,
"content_html": """
<p>
فرض کن یک کالا را ۵ میلیون تومان قسطی می‌فروشی: امروز ۲ میلیون
نقد می‌گیری، ۳ میلیون باقی‌مانده قرار است طی سه قسط پرداخت شود. سوال
این‌جاست: همین امروز چقدر «درآمد» داشته‌ای؟
</p>
<h2>چرا فروش قسطی «فقط درآمد» نیست</h2>
<p>
از نظر حسابداریِ حرفه‌ای، فروش قسطی یک نوع <strong>بدهکاریِ مشتری به
تو</strong> (طلب/receivable) ایجاد می‌کند، نه یک عدد ساده در لیست
درآمد. اگر همان ۵ میلیون را یک‌جا «درآمد» ثبت کنی، ترازِ نقدینگی‌ات
اشتباه می‌شود — چون آن ۳ میلیونِ باقی‌مانده هنوز دستت نیامده.
</p>
<h2>دفتر درآمد و هزینه‌ی شاپ شیپ چطور حلش می‌کند</h2>
<p>
وقتی دسته‌بندیِ «فروش قسطی» را برای یک ثبت انتخاب می‌کنی، اپ خودش
می‌فهمد این یک نوع بدهکاری است — و سه فیلد اضافه از تو می‌پرسد: مبلغِ
کل، نام و شماره‌ی طرفِ حساب. از همان لحظه، این ثبت یک «مانده‌حساب»
دارد که هر قسطِ پرداختی از آن کم می‌شود.
</p>
<h2>ثبتِ ساده برای تو، حسابداریِ درست پشتِ صحنه</h2>
<p>
لازم نیست اصطلاحاتِ حسابداری بلد باشی. کافی است دسته‌ی درستی که به
زبانِ خودت نزدیک‌تر است انتخاب کنی — بقیه‌اش (تشخیص طلب/بدهی، محاسبه‌ی
مانده، تفکیک از درآمد نقدی) را اپ خودش پشتِ صحنه انجام می‌دهد.
</p>
<h2>اتصال به فاکتور و مشتری</h2>
<p>
اگر <a href="/articles/fatoore-rasmi-baraye-maghaze/">فاکتوری</a> را با
نوعِ پرداختِ «قسطی/نسیه» صادر کنی، شاپ شیپ همان لحظه یک ثبتِ طلب در
دفترت می‌سازد و آن را به پرونده‌ی همان مشتری هم وصل می‌کند.
</p>
""",
},
{
"slug": "fatoore-rasmi-baraye-maghaze",
"title": "فاکتور رسمی برای مغازه: چرا لازم است و چطور با انبار وصل می‌شود",
"meta_description": "فاکتور رسمی با لوگو، محاسبه‌ی خودکار مالیات و تخفیف، و اتصال مستقیم به انبار — همین که پرداخت شد، موجودی خودش کم می‌شود.",
"excerpt": "فاکتوری که وقتی پرداخت می‌شود، موجودیِ انبار را هم خودش کم می‌کند — بدون ثبت دوباره.",
"read_minutes": 4,
"content_html": """
<p>
خیلی از مغازه‌دارها فاکتور را یا اصلا صادر نمی‌کنند، یا دستی روی
یک برگه می‌نویسند — و بعد جداگانه همان کالا را از موجودیِ انبار کم
می‌کنند. این یعنی هر فروش، دوبار ثبت می‌شود.
</p>
<h2>یک فاکتور، هم برای مشتری هم برای انبار</h2>
<p>
در شاپ شیپ، وقتی برای یک فاکتور کالا اضافه می‌کنی، همان کالا را از
لیستِ محصولاتِ
<a href="/articles/kontorol-mojoodi-anbar-ba-mobile/">انبارت</a> انتخاب
می‌کنی — نه این‌که اسمش را دوباره تایپ کنی. همین که فاکتور ثبت شود،
تعداد همان کالا خودش از موجودیِ انبار کم می‌شود.
</p>
<h2>مالیات و تخفیف، خودکار</h2>
<p>
هر قلمِ کالا می‌تواند نرخِ مالیاتِ خودش و تخفیفِ خودش را داشته باشد —
جمعِ نهایی، مالیات، و مبلغِ تخفیف‌خورده خودکار محاسبه می‌شود.
</p>
<h2>پرداخت‌شده یا نشده، همیشه مشخص</h2>
<p>
هر فاکتور یک وضعیتِ روشن دارد. برای فاکتورهای نقدی، دفترِ درآمد و
هزینه‌ات را دستکاری نمی‌کند؛ اما اگر نوعِ پرداخت را «قسطی/نسیه»
بگذاری، همان لحظه یک ثبتِ طلب هم در دفترت ساخته می‌شود — جزئیاتش در
<a href="/articles/forosh-ghesti-hesabdari/">فروش قسطی چطور ثبت
می‌شود؟</a> آمده.
</p>
""",
},
{
"slug": "kontorol-mojoodi-anbar-ba-mobile",
"title": "کنترل موجودی انبار با موبایل، بدون اکسل",
"meta_description": "دسته‌بندی کالا، کم‌وزیاد کردن موجودی با یک ضربه، دیدنِ ارزش کلِ انبار، و هشدار وقتی چیزی کم می‌شود — همه از گوشی.",
"excerpt": "موجودی را با یک ضربه کم‌وزیاد کن، ارزش کلِ انبارت را ببین، وقتی چیزی کم شد زودتر از مشتری بفهم.",
"read_minutes": 3,
"content_html": """
<p>
خیلی از کسب‌وکارهای کوچک موجودیِ انبار را در یک فایلِ اکسل نگه
می‌دارند که همیشه یک نسخه‌ی «قدیمی‌تر از واقعیت» است.
</p>
<h2>هر کالا با واحد و قیمتِ خرید</h2>
<p>
برای هر کالا، واحدِ شمارشش (عدد، کیلوگرم، بسته و…) و قیمتِ خریدش را
ثبت می‌کنی. با همین دو عدد، اپ خودش <strong>ارزشِ کلِ انبارت</strong>
را محاسبه می‌کند — یعنی می‌دانی چقدر سرمایه روی طاقچه‌هایت خوابیده.
</p>
<h2>کم‌وزیاد با یک ضربه</h2>
<p>
موجودیِ هر کالا را می‌شود با یک ضربه کم یا زیاد کرد. اگر هم
<a href="/articles/fatoore-rasmi-baraye-maghaze/">فاکتور</a> برای همان
کالا صادر کنی، موجودی خودش کم می‌شود، بدون ثبتِ دستیِ دوباره.
</p>
<h2>هشدارِ کمبود</h2>
<p>
برای هر کالا می‌شود یک آستانه‌ی «کم شد» تعریف کرد؛ وقتی موجودی به آن
آستانه برسد، پیش از این‌که مشتری بیاید و نداشته باشی، خودت متوجه
می‌شوی.
</p>
""",
},
{
"slug": "peygiri-sefaresh-online-shop-etemad",
"title": "پیگیری سفارش آنلاین‌شاپ: اعتماد مشتری با یک پیامک خودکار",
"meta_description": "برای آنلاین‌شاپ‌های کوچک، شاپ شیپ مراحل سفارش را قابل‌تعریف می‌کند و با رسیدنِ سفارش به هر مرحله، خودکار از روی قالبِ دلخواهت به مشتری پیامک می‌رود.",
"excerpt": "مراحل کارت را تعریف کن — با رسیدنِ سفارش به هر مرحله، پیامکِ خودکار برای مشتری می‌رود.",
"read_minutes": 4,
"content_html": """
<p>
یکی از بزرگ‌ترین دلایلِ نگرانیِ مشتریِ آنلاین‌شاپ‌های کوچک همین است:
«سفارشم الان کجاست؟» وقتی جوابِ این سوال را با تاخیر یا اصلا نمی‌دهی،
اعتماد از دست می‌رود.
</p>
<h2>مراحلِ کارِ خودت را تعریف کن</h2>
<p>
در شاپ شیپ، مراحلِ سفارش را خودت تعریف می‌کنی — مثلا «آماده‌سازی»،
«بسته‌بندی»، «ارسال»، «تحویل‌شد» — دقیقا همان جریانِ کاریِ واقعیِ
کسب‌وکارت. ترتیبِ مراحل را هم می‌شود با کشیدن‌و‌رهاکردن جابه‌جا کرد.
</p>
<h2>هر مرحله، یک قالبِ پیام</h2>
<p>
هر مرحله را به یک قالبِ پیام وصل می‌کنی. همین که سفارشی به آن مرحله
می‌رسد، پیامک از روی همان قالب — خودکار — برای مشتری می‌رود.
</p>
<h2>وقتی یک مقدار متغیر لازم است</h2>
<p>
برای مرحله‌ی «ارسال» معمولا یک کد رهگیریِ پستی لازم است که از قبل
معلوم نیست. برای همین، قالبِ همین مرحله می‌تواند یک
<a href="/articles/field-sefareshi-dar-template/">فیلدِ اجرا</a>
داشته باشد.
</p>
""",
},
]
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),
]
+15
View File
@@ -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",
]
+38 -1
View File
@@ -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)
+54
View File
@@ -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')],
},
),
]
+58
View File
@@ -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))
+2
View File
@@ -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("<int:pk>/", BulkMessageDetailView.as_view(), name="detail"),
path("<int:bulk_id>/confirm/<int:recipient_id>/", ConfirmManualSendView.as_view(), name="confirm"),
]
@@ -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')],
},
),
]
+72
View File
@@ -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'],
},
),
]
+114
View File
@@ -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'),
),
]
+3
View File
@@ -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"
+1
View File
@@ -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 دینامیک نمی‌رسیدیم.
View File
+1
View File
@@ -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
View File