adding
This commit is contained in:
@@ -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': 'کاربران',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
@@ -1,8 +1,13 @@
|
|||||||
from django.contrib import admin, messages
|
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.urls import path, reverse
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
from apps.account.models import User
|
||||||
|
|
||||||
|
from .api.services import GemWalletService
|
||||||
|
from .forms import GemGiftForm
|
||||||
from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction
|
from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction
|
||||||
from .providers.bazaar import BazaarAuthError, build_authorize_url, exchange_code_for_tokens
|
from .providers.bazaar import BazaarAuthError, build_authorize_url, exchange_code_for_tokens
|
||||||
|
|
||||||
@@ -115,7 +120,9 @@ class FeaturePlanTierInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(FeaturePlan)
|
@admin.register(FeaturePlan)
|
||||||
class FeaturePlanAdmin(admin.ModelAdmin):
|
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")
|
search_fields = ("name", "key")
|
||||||
inlines = [FeaturePlanTierInline]
|
inlines = [FeaturePlanTierInline]
|
||||||
|
|
||||||
@@ -137,16 +144,84 @@ class FeatureActivationAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(GemTransaction)
|
@admin.register(GemTransaction)
|
||||||
class GemTransactionAdmin(admin.ModelAdmin):
|
class GemTransactionAdmin(admin.ModelAdmin):
|
||||||
|
"""تاریخچهی جم — فقطخواندنی، بهعلاوهی ابزارِ «هدیهی جم».
|
||||||
|
|
||||||
|
هدیه از دکمهی بالای همین صفحه انجام میشود، نه با ساختنِ دستیِ ردیفِ
|
||||||
|
تراکنش: اگر ادمین ردیف را دستی بسازد، User.gem_balance بهروز نمیشود و
|
||||||
|
موجودی با تاریخچه واگرا میشود. مسیرِ درست از GemWalletService رد
|
||||||
|
میشود که هر دو را با هم و داخلِ یک تراکنش تغییر میدهد.
|
||||||
|
"""
|
||||||
|
|
||||||
list_display = ("user", "amount", "reason", "created_at")
|
list_display = ("user", "amount", "reason", "created_at")
|
||||||
list_filter = ("reason",)
|
list_filter = ("reason",)
|
||||||
search_fields = ("user__phone", "note")
|
search_fields = ("user__phone", "note")
|
||||||
readonly_fields = ("user", "amount", "reason", "note", "created_at")
|
readonly_fields = ("user", "amount", "reason", "note", "created_at")
|
||||||
|
change_list_template = "admin/billing/gemtransaction/change_list.html"
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
def has_add_permission(self, request):
|
||||||
# تراکنش جم فقط از سرویسهای داخلی ساخته میشود، نه دستی از ادمین —
|
|
||||||
# برای اصلاح دستی از GemTransaction.Reason.ADMIN_ADJUSTMENT در شل/سرویس استفاده کن.
|
|
||||||
return False
|
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)
|
@admin.register(BazaarPurchase)
|
||||||
class BazaarPurchaseAdmin(admin.ModelAdmin):
|
class BazaarPurchaseAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -33,13 +33,21 @@ class FeaturePlanSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FeaturePlan
|
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:
|
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):
|
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):
|
class GemPackSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ class ReferralService:
|
|||||||
class FeatureAccessService:
|
class FeatureAccessService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_active(user: User, feature_key: str) -> bool:
|
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:
|
if user.is_in_trial:
|
||||||
return True
|
return True
|
||||||
return FeatureActivation.objects.filter(
|
return FeatureActivation.objects.filter(
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import generics, status
|
from rest_framework import generics, status
|
||||||
from rest_framework.exceptions import NotFound
|
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.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
@@ -38,9 +39,18 @@ class WalletView(APIView):
|
|||||||
|
|
||||||
|
|
||||||
class FeatureListView(generics.ListAPIView):
|
class FeatureListView(generics.ListAPIView):
|
||||||
|
"""فهرستِ ابزارها — عمدا AllowAny: کاربرِ مهمان (بدون حساب) هم باید
|
||||||
|
بتواند بفهمد کدام ابزار رایگان است. این پاسخ هیچ دادهی شخصی ندارد؛
|
||||||
|
فقط کاتالوگِ ابزارها و قیمتهاست."""
|
||||||
|
|
||||||
serializer_class = FeaturePlanSerializer
|
serializer_class = FeaturePlanSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [AllowAny]
|
||||||
queryset = FeaturePlan.objects.filter(is_purchasable=True).prefetch_related("tiers")
|
# ابزارهای رایگان is_purchasable=False دارند (فروختنی نیستند) ولی باید
|
||||||
|
# حتما در لیست بیایند — وگرنه اپ آنها را نمیشناسد و قفل نشانشان میدهد.
|
||||||
|
queryset = (
|
||||||
|
FeaturePlan.objects.filter(Q(is_purchasable=True) | Q(is_free=True))
|
||||||
|
.prefetch_related("tiers")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ActivateFeatureView(APIView):
|
class ActivateFeatureView(APIView):
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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)]
|
||||||
@@ -110,6 +110,14 @@ class FeaturePlan(models.Model):
|
|||||||
key = models.SlugField(max_length=50, unique=True, verbose_name=_("کلید ریزاپ"))
|
key = models.SlugField(max_length=50, unique=True, verbose_name=_("کلید ریزاپ"))
|
||||||
name = models.CharField(max_length=150, verbose_name=_("نام نمایشی"))
|
name = models.CharField(max_length=150, verbose_name=_("نام نمایشی"))
|
||||||
is_purchasable = models.BooleanField(default=True, verbose_name=_("قابلفروش"))
|
is_purchasable = models.BooleanField(default=True, verbose_name=_("قابلفروش"))
|
||||||
|
# ابزارِ رایگان همیشه در دسترس است و هیچوقت جم نمیخواهد.
|
||||||
|
# قوانین انتشار کافهبازار: بخشی از امکاناتِ برنامه باید رایگان باشد و
|
||||||
|
# کاربر بتواند پیش از هر خریدی واقعا با اپ کار کند.
|
||||||
|
is_free = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("رایگان"),
|
||||||
|
help_text=_("اگر روشن باشد، این ابزار بدون جم و بدون دورهی آزمایشی در دسترس است."),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("ریزاپ قابلفعالسازی")
|
verbose_name = _("ریزاپ قابلفعالسازی")
|
||||||
|
|||||||
+180
-107
@@ -1,35 +1,28 @@
|
|||||||
# apps/billing/providers/bazaar.py
|
|
||||||
"""
|
"""
|
||||||
تایید خرید کافهبازار — سمت سرور، طبق مستندات رسمی کافهبازار
|
اتصال به API «پرداخت درونبرنامهای» کافهبازار (devapi v2) — گرفتن/تمدید
|
||||||
(developers.cafebazaar.ir/fa/guidelines/in-app-billing). این تنها API ای
|
access_token و تایید خریدها.
|
||||||
است که اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»،
|
|
||||||
تا این تابع True برنگرداند هیچ جمی اضافه نمیشود.
|
|
||||||
|
|
||||||
GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}
|
دو روشِ کاملا متفاوت برای احراز هویت با همین API وجود دارد — قاطیکردنشان
|
||||||
/inapp/{product_id}/purchases/{purchase_token}/
|
دقیقا همان چیزی است که باعث خطا میشود، پس اینجا صریح از هم جدا شدهاند:
|
||||||
|
|
||||||
پاسخ موفق شامل 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)، در هدر:
|
مستندات رسمی: https://developers.cafebazaar.ir/fa/guidelines/in-app-billing
|
||||||
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 — چون باید بدون
|
|
||||||
ریدیپلوی قابلتعویض باشد.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -38,130 +31,210 @@ from apps.billing.models import BazaarSettings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
REQUEST_TIMEOUT_SECONDS = 15
|
AUTHORIZE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth/authorize/"
|
||||||
DEVAPI_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api"
|
TOKEN_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth/token/"
|
||||||
OAUTH_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth"
|
VALIDATE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}/inapp/{sku}/purchases/{token}/"
|
||||||
PISHKHAN_TOKEN_HEADER = "CAFEBAZAAR-PISHKHAN-API-SECRET"
|
|
||||||
|
|
||||||
# پیش از این فاصله تا انقضا، توکن OAuth را تمدید میکنیم تا وسط یک درخواست
|
REQUEST_TIMEOUT = 15 # ثانیه
|
||||||
# واقعی منقضی نشود (access_token های کافهبازار معمولا ۳۶۰۰ ثانیه عمر دارند).
|
|
||||||
REFRESH_SAFETY_MARGIN_SECONDS = 120
|
# اگر کمتر از این مدت تا انقضای access_token مانده باشد، پیش از استفاده
|
||||||
|
# با refresh_token تمدید میشود (بهجای اینکه صبر کنیم واقعا 401 بگیریم).
|
||||||
|
REFRESH_MARGIN = timedelta(minutes=5)
|
||||||
|
|
||||||
|
# purchaseState طبق مستندات کافهبازار (همشکل با Google Play):
|
||||||
|
# 0 = خریداریشده، 1 = لغوشده، 2 = در انتظار (pending)
|
||||||
|
PURCHASE_STATE_PURCHASED = 0
|
||||||
|
|
||||||
|
|
||||||
class BazaarAuthError(Exception):
|
class BazaarAuthError(Exception):
|
||||||
"""احراز هویت با کافهبازار ممکن نیست (تنظیمات ناقص یا رفرش ناموفق)."""
|
"""خطای قابلنمایش مستقیم به ادمین (پیاماش با messages.error در admin.py نشان داده میشود)."""
|
||||||
|
|
||||||
|
|
||||||
def build_authorize_url(redirect_uri: str) -> str:
|
def _force_https(url: str) -> str:
|
||||||
"""لینک شروع فلوی OAuth (روش قدیمی) — کاربر باید یکبار در مرورگر باز و
|
"""redirect_uri باید دقیقا با چیزی که موقع ساخت Client در پیشخان ثبت
|
||||||
تایید کند. 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()
|
settings_obj = BazaarSettings.get_solo()
|
||||||
if not settings_obj.client_id:
|
if not settings_obj.client_id:
|
||||||
raise BazaarAuthError("Client ID در تنظیمات کافهبازار خالی است.")
|
raise BazaarAuthError("ابتدا Client ID را در تنظیمات کافهبازار پر و ذخیره کن.")
|
||||||
return (
|
|
||||||
f"{OAUTH_BASE_URL}/authorize/"
|
params = {
|
||||||
f"?response_type=code&access_type=offline"
|
"response_type": "code",
|
||||||
f"&redirect_uri={redirect_uri}&client_id={settings_obj.client_id}"
|
"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:
|
def exchange_code_for_tokens(code: str, callback_url: str) -> None:
|
||||||
"""گام آخر فلوی OAuth — code گرفتهشده از ریدایرکت (یا دستی کپیشده) را
|
"""بعد از برگشت کاربر با ?code=... از پیشخان صدا زده میشود (oauth_callback_view)."""
|
||||||
به access_token/refresh_token تبدیل و در BazaarSettings ذخیره میکند."""
|
|
||||||
settings_obj = BazaarSettings.get_solo()
|
settings_obj = BazaarSettings.get_solo()
|
||||||
if not settings_obj.client_id or not settings_obj.client_secret:
|
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(
|
response = _post_token_request(
|
||||||
f"{OAUTH_BASE_URL}/token/",
|
{
|
||||||
data={
|
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
"code": code,
|
"code": code,
|
||||||
"client_id": settings_obj.client_id,
|
"client_id": settings_obj.client_id,
|
||||||
"client_secret": settings_obj.client_secret,
|
"client_secret": settings_obj.client_secret,
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": _force_https(callback_url),
|
||||||
},
|
}
|
||||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
||||||
)
|
)
|
||||||
if not response.ok:
|
_save_token_response(settings_obj, response)
|
||||||
raise BazaarAuthError(f"تبادل کد ناموفق بود: {response.status_code} {response.text}")
|
|
||||||
|
|
||||||
_save_token_response(settings_obj, response.json())
|
|
||||||
|
|
||||||
|
|
||||||
def _refresh_access_token(settings_obj: BazaarSettings) -> None:
|
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(
|
raise BazaarAuthError(
|
||||||
"برای تمدید خودکار access_token باید client_id/client_secret/refresh_token هر سه ست باشند."
|
"access_token منقضی شده و refresh_token موجود نیست — از پنل ادمین دوباره «شروع اتصال به کافهبازار» را بزن."
|
||||||
)
|
)
|
||||||
|
response = _post_token_request(
|
||||||
response = requests.post(
|
{
|
||||||
f"{OAUTH_BASE_URL}/token/",
|
|
||||||
data={
|
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
"client_id": settings_obj.client_id,
|
"client_id": settings_obj.client_id,
|
||||||
"client_secret": settings_obj.client_secret,
|
"client_secret": settings_obj.client_secret,
|
||||||
"refresh_token": settings_obj.refresh_token,
|
"refresh_token": settings_obj.refresh_token,
|
||||||
},
|
}
|
||||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
||||||
)
|
)
|
||||||
if not response.ok:
|
# پاسخِ refresh_token معمولا خودِ refresh_token جدید را برنمیگرداند —
|
||||||
raise BazaarAuthError(f"تمدید access_token ناموفق بود: {response.status_code} {response.text}")
|
# یعنی همان قبلی همچنان معتبر میماند؛ _save_token_response این را رعایت میکند.
|
||||||
|
_save_token_response(settings_obj, response)
|
||||||
_save_token_response(settings_obj, response.json())
|
|
||||||
|
|
||||||
|
|
||||||
def _save_token_response(settings_obj: BazaarSettings, body: dict) -> None:
|
def _post_token_request(data: dict) -> requests.Response:
|
||||||
settings_obj.access_token = body["access_token"]
|
try:
|
||||||
# رفرشگرنت گاهی refresh_token جدید برنمیگرداند — قبلی را نگه میداریم.
|
return requests.post(TOKEN_URL, data=data, timeout=REQUEST_TIMEOUT)
|
||||||
if body.get("refresh_token"):
|
except requests.RequestException as exc:
|
||||||
settings_obj.refresh_token = body["refresh_token"]
|
logger.exception("درخواست توکن کافهبازار با خطای شبکه مواجه شد")
|
||||||
expires_in = body.get("expires_in", 3600)
|
raise BazaarAuthError(f"اتصال به سرور کافهبازار برقرار نشد: {exc}") from exc
|
||||||
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 _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:
|
settings_obj.access_token = data["access_token"]
|
||||||
"""توکنِ سالم برای یک درخواست devapi را برمیگرداند — هدر یا پارامتر URL،
|
settings_obj.access_token_expires_at = timezone.now() + timedelta(seconds=data.get("expires_in", 3600))
|
||||||
بسته به اینکه توکن ثابتِ پیشخان است یا OAuth (و اگر OAuth و نزدیک
|
if data.get("refresh_token"):
|
||||||
انقضاست، همینجا اول تمدیدش میکند)."""
|
settings_obj.refresh_token = data["refresh_token"]
|
||||||
is_oauth_managed = settings_obj.access_token_expires_at is not None
|
settings_obj.save(update_fields=["access_token", "access_token_expires_at", "refresh_token"])
|
||||||
|
|
||||||
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}}
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
settings_obj = BazaarSettings.get_solo()
|
||||||
if not settings_obj.access_token or not settings_obj.package_name:
|
if not settings_obj.package_name:
|
||||||
logger.error("BazaarSettings.access_token یا 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
|
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:
|
try:
|
||||||
auth_kwargs = _auth_kwargs_for_request(settings_obj)
|
response = _call(settings_obj)
|
||||||
except BazaarAuthError as exc:
|
except requests.RequestException:
|
||||||
logger.error(f"❌ احراز هویت کافهبازار ناموفق: {exc}")
|
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
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS, **auth_kwargs)
|
data = response.json()
|
||||||
response.raise_for_status()
|
except ValueError:
|
||||||
body = response.json()
|
logger.warning("پاسخ تایید خرید کافهبازار JSON نبود: %s", response.text)
|
||||||
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}")
|
|
||||||
return False
|
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
@@ -1,13 +1,20 @@
|
|||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from django.test import TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.account.models import User
|
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 (
|
from apps.billing.providers.bazaar import (
|
||||||
BazaarAuthError,
|
BazaarAuthError,
|
||||||
build_authorize_url,
|
build_authorize_url,
|
||||||
@@ -90,7 +97,9 @@ class BazaarOAuthTests(TestCase):
|
|||||||
def test_build_authorize_url_shape(self):
|
def test_build_authorize_url_shape(self):
|
||||||
url = build_authorize_url("https://example.com/callback/")
|
url = build_authorize_url("https://example.com/callback/")
|
||||||
self.assertIn("client_id=client-123", url)
|
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)
|
self.assertIn("response_type=code", url)
|
||||||
|
|
||||||
@patch("apps.billing.providers.bazaar.requests.post")
|
@patch("apps.billing.providers.bazaar.requests.post")
|
||||||
@@ -299,3 +308,183 @@ class PurchaseFlowIntegrationTests(TestCase):
|
|||||||
second_id = self._create_purchase(token="same-token")
|
second_id = self._create_purchase(token="same-token")
|
||||||
self.assertEqual(first_id, second_id)
|
self.assertEqual(first_id, second_id)
|
||||||
self.assertEqual(BazaarPurchase.objects.filter(purchase_token="same-token").count(), 1)
|
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
@@ -1,40 +1,82 @@
|
|||||||
from django.contrib import admin
|
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)
|
@admin.register(Customer)
|
||||||
class CustomerAdmin(admin.ModelAdmin):
|
class CustomerAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "phone", "user", "updated_at")
|
list_display = ("name", "phone", "outstanding_balance", "total_purchases_count", "last_purchase_date")
|
||||||
search_fields = ("name", "phone", "email", "user__phone")
|
search_fields = ("name", "phone", "user__phone")
|
||||||
|
readonly_fields = (
|
||||||
|
"total_purchases_count", "total_purchase_amount", "last_purchase_date", "outstanding_balance",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LedgerEntry)
|
@admin.register(LedgerEntry)
|
||||||
class LedgerEntryAdmin(admin.ModelAdmin):
|
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")
|
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)
|
@admin.register(Product)
|
||||||
class ProductAdmin(admin.ModelAdmin):
|
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",)
|
list_filter = ("category",)
|
||||||
search_fields = ("name", "sku", "user__phone")
|
search_fields = ("name", "sku", "user__phone")
|
||||||
|
readonly_fields = ("total_purchase_value",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(StockMovement)
|
@admin.register(StockMovement)
|
||||||
class StockMovementAdmin(admin.ModelAdmin):
|
class StockMovementAdmin(admin.ModelAdmin):
|
||||||
list_display = ("product", "delta", "quantity_after", "created_at")
|
list_display = ("product", "delta", "quantity_after", "note", "created_at")
|
||||||
search_fields = ("product__name", "product__user__phone")
|
search_fields = ("product__name",)
|
||||||
readonly_fields = ("product", "delta", "quantity_after", "note", "created_at")
|
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Appointment)
|
class InvoiceItemInline(admin.TabularInline):
|
||||||
class AppointmentAdmin(admin.ModelAdmin):
|
model = InvoiceItem
|
||||||
list_display = ("customer_name", "title", "start_at", "status", "user")
|
extra = 0
|
||||||
list_filter = ("status",)
|
|
||||||
search_fields = ("customer_name", "customer_phone", "user__phone")
|
|
||||||
|
@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")
|
||||||
|
|||||||
@@ -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
@@ -1,37 +1,120 @@
|
|||||||
from rest_framework import serializers
|
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):
|
class CustomerSerializer(serializers.ModelSerializer):
|
||||||
|
account_code = serializers.CharField(source="account.code", read_only=True, default="")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Customer
|
model = Customer
|
||||||
fields = ["id", "name", "phone", "email", "address", "notes", "updated_at"]
|
fields = [
|
||||||
read_only_fields = ["updated_at"]
|
"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):
|
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:
|
class Meta:
|
||||||
model = LedgerEntry
|
model = LedgerEntry
|
||||||
fields = ["id", "entry_type", "title", "amount", "category", "entry_date", "note", "updated_at"]
|
fields = [
|
||||||
read_only_fields = ["updated_at"]
|
"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):
|
class ProductSerializer(serializers.ModelSerializer):
|
||||||
|
is_low_stock = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id", "name", "sku", "category", "unit", "quantity",
|
||||||
"name",
|
"unit_price", "purchase_price", "total_purchase_value",
|
||||||
"sku",
|
"low_stock_threshold", "is_low_stock", "notes", "updated_at",
|
||||||
"category",
|
|
||||||
"quantity",
|
|
||||||
"unit_price",
|
|
||||||
"low_stock_threshold",
|
|
||||||
"notes",
|
|
||||||
"updated_at",
|
|
||||||
]
|
]
|
||||||
read_only_fields = ["updated_at"]
|
read_only_fields = ["total_purchase_value", "is_low_stock", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
class StockMovementSerializer(serializers.ModelSerializer):
|
class StockMovementSerializer(serializers.ModelSerializer):
|
||||||
@@ -51,8 +134,122 @@ class AdjustStockSerializer(serializers.Serializer):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class AppointmentSerializer(serializers.ModelSerializer):
|
class InvoiceItemSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Appointment
|
model = InvoiceItem
|
||||||
fields = ["id", "customer_name", "customer_phone", "title", "start_at", "status", "notes", "updated_at"]
|
fields = ["id", "product", "description", "quantity", "unit_price", "discount_percent", "total_price"]
|
||||||
read_only_fields = ["updated_at"]
|
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
|
||||||
|
|||||||
@@ -19,13 +19,21 @@ from apps.bizapps.models import (
|
|||||||
Transaction,
|
Transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from apps.bizapps.api.accounting import (
|
||||||
|
ACCOUNT_CASH,
|
||||||
|
ACCOUNT_PAYABLE,
|
||||||
|
ACCOUNT_RECEIVABLE,
|
||||||
|
ACCOUNT_SALES,
|
||||||
|
ChartOfAccountsService,
|
||||||
|
ExcelExportService,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# کدهای حساب — دفترکل ساده طبق سند فنی (بدون نیاز به جدول Chart of Accounts
|
|
||||||
# جدا، چون این اپ برای کسبوکارهای خرد است نه حسابداری شرکتی کامل).
|
class InsufficientStockError(Exception):
|
||||||
ACCOUNT_CASH = "1000" # صندوق
|
"""فروشِ بیش از موجودیِ انبار — عمدا یک استثنای جدا (نه ValueError عمومی)
|
||||||
ACCOUNT_RECEIVABLE = "1100" # مطالبات (فروش اعتباری — نسخهی آتی)
|
تا ویو بتواند دقیقا همین حالت را به پیامِ ۴۰۰ قابلفهم تبدیل کند."""
|
||||||
ACCOUNT_SALES = "4000" # فروش
|
|
||||||
|
|
||||||
|
|
||||||
class InvoiceService:
|
class InvoiceService:
|
||||||
@@ -118,6 +126,16 @@ class InvoiceService:
|
|||||||
|
|
||||||
if product is not None:
|
if product is not None:
|
||||||
product = Product.objects.select_for_update().get(pk=product.pk)
|
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.quantity -= quantity
|
||||||
product.recompute_purchase_value()
|
product.recompute_purchase_value()
|
||||||
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
|
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")
|
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
|
||||||
is_credit = payment_type == Invoice.PaymentType.CREDIT
|
is_credit = payment_type == Invoice.PaymentType.CREDIT
|
||||||
|
debit_code = ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH
|
||||||
Transaction.objects.create(
|
Transaction.objects.create(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
user=user,
|
user=user,
|
||||||
@@ -144,8 +163,10 @@ class InvoiceService:
|
|||||||
transaction_type=(
|
transaction_type=(
|
||||||
Transaction.TransactionType.CREDIT_SALE if is_credit else Transaction.TransactionType.CASH_SALE
|
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,
|
credit_account=ACCOUNT_SALES,
|
||||||
|
debit_account_ref=ChartOfAccountsService.get(user, debit_code),
|
||||||
|
credit_account_ref=ChartOfAccountsService.get(user, ACCOUNT_SALES),
|
||||||
amount=invoice.final_amount,
|
amount=invoice.final_amount,
|
||||||
description=f"فروش فاکتور {invoice_number}",
|
description=f"فروش فاکتور {invoice_number}",
|
||||||
)
|
)
|
||||||
@@ -158,6 +179,9 @@ class InvoiceService:
|
|||||||
customer.save(
|
customer.save(
|
||||||
update_fields=["total_purchases_count", "total_purchase_amount", "last_purchase_date", "updated_at"]
|
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_name=invoice.buyer_name,
|
||||||
counterparty_phone=invoice.buyer_phone,
|
counterparty_phone=invoice.buyer_phone,
|
||||||
linked_invoice=invoice,
|
linked_invoice=invoice,
|
||||||
|
account=LedgerService.account_for_category(user, LedgerEntry.Category.SALE_CREDIT),
|
||||||
)
|
)
|
||||||
if invoice.buyer_phone:
|
if invoice.buyer_phone:
|
||||||
LedgerService.recompute_customer_balance(user, 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,
|
transaction_type=original.transaction_type if original else Transaction.TransactionType.CASH_SALE,
|
||||||
debit_account=original.credit_account if original else ACCOUNT_SALES,
|
debit_account=original.credit_account if original else ACCOUNT_SALES,
|
||||||
credit_account=original.debit_account if original else ACCOUNT_CASH,
|
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),
|
amount=-(original.amount if original else invoice.final_amount),
|
||||||
description=f"لغو فاکتور {invoice.invoice_number}",
|
description=f"لغو فاکتور {invoice.invoice_number}",
|
||||||
)
|
)
|
||||||
@@ -350,6 +378,51 @@ class LedgerService:
|
|||||||
این سرویس تضمین میکند ماندهی بدهیِ هر مشتری (Customer.outstanding_balance)
|
این سرویس تضمین میکند ماندهی بدهیِ هر مشتری (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
|
@staticmethod
|
||||||
def recompute_customer_balance(user: User, phone: str) -> None:
|
def recompute_customer_balance(user: User, phone: str) -> None:
|
||||||
phone = (phone or "").strip()
|
phone = (phone or "").strip()
|
||||||
@@ -366,9 +439,17 @@ class LedgerService:
|
|||||||
customer.outstanding_balance = balance
|
customer.outstanding_balance = balance
|
||||||
customer.save(update_fields=["outstanding_balance", "updated_at"])
|
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
|
@staticmethod
|
||||||
def export_csv(user: User, start=None, end=None) -> str:
|
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")
|
qs = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related("installments")
|
||||||
if start:
|
if start:
|
||||||
qs = qs.filter(entry_date__gte=start)
|
qs = qs.filter(entry_date__gte=start)
|
||||||
|
|||||||
+430
-71
@@ -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 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.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from apps.billing.permissions import HasActiveFeature
|
from apps.bizapps.models import (
|
||||||
from apps.bizapps.models import Appointment, Customer, LedgerEntry, Product, StockMovement
|
Account,
|
||||||
|
Customer,
|
||||||
from .serializers import (
|
Invoice,
|
||||||
AdjustStockSerializer,
|
LedgerBook,
|
||||||
AppointmentSerializer,
|
LedgerEntry,
|
||||||
CustomerSerializer,
|
LedgerInstallment,
|
||||||
LedgerEntrySerializer,
|
Product,
|
||||||
ProductSerializer,
|
StockMovement,
|
||||||
StockMovementSerializer,
|
Transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
# همهی این ریزاپها با جم فعال میشوند (یا دورهی آزمایشی) — `feature_key`
|
from .accounting import (
|
||||||
# روی هر ویو مشخص میکند HasActiveFeature باید چه چیزی را چک کند.
|
ChartOfAccountsService,
|
||||||
# permission_classes اینجا Base است تا زیرکلاسها فراموش نکنند.
|
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):
|
class _OwnedMixin:
|
||||||
permission_classes = [IsAuthenticated, HasActiveFeature]
|
"""همهی منابع این اپ فقط متعلق به کاربرِ درخواستدهندهاند."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.model.objects.filter(user=self.request.user)
|
return self.model.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- مشتریان ----------------
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerListCreateView(_OwnedMixin, generics.ListCreateAPIView):
|
||||||
|
model = Customer
|
||||||
|
serializer_class = CustomerSerializer
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
serializer.save(user=self.request.user)
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
class _UserScopedDetailView(generics.RetrieveUpdateDestroyAPIView):
|
class CustomerDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||||
permission_classes = [IsAuthenticated, HasActiveFeature]
|
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):
|
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):
|
class LedgerEntryDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||||
model = Customer
|
|
||||||
serializer_class = CustomerSerializer
|
|
||||||
feature_key = "customers"
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerDetailView(_UserScopedDetailView):
|
|
||||||
model = Customer
|
|
||||||
serializer_class = CustomerSerializer
|
|
||||||
feature_key = "customers"
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerEntryListCreateView(_UserScopedListCreateView):
|
|
||||||
model = LedgerEntry
|
model = LedgerEntry
|
||||||
serializer_class = LedgerEntrySerializer
|
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):
|
class LedgerInstallmentListCreateView(generics.ListCreateAPIView):
|
||||||
model = LedgerEntry
|
serializer_class = LedgerInstallmentSerializer
|
||||||
serializer_class = LedgerEntrySerializer
|
permission_classes = [IsAuthenticated]
|
||||||
feature_key = "ledger"
|
|
||||||
|
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
|
model = Product
|
||||||
serializer_class = ProductSerializer
|
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
|
model = Product
|
||||||
serializer_class = ProductSerializer
|
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):
|
class ProductAdjustStockView(APIView):
|
||||||
"""تعدیل اتمیک موجودی (+/-) + یک ردیف تاریخچه — بهجای اینکه کلاینت
|
permission_classes = [IsAuthenticated]
|
||||||
مستقیم quantity جدید را PATCH کند (که هیچ تاریخچهای نمیسازد و اگر دو
|
|
||||||
درخواست همزمان بیایند میتواند یکی را گم کند)."""
|
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated, HasActiveFeature]
|
|
||||||
feature_key = "inventory"
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
try:
|
product = Product.objects.filter(user=request.user, pk=pk).first()
|
||||||
product = Product.objects.select_for_update().get(pk=pk, user=request.user)
|
if product is None:
|
||||||
except Product.DoesNotExist:
|
|
||||||
raise NotFound("کالا یافت نشد.")
|
raise NotFound("کالا یافت نشد.")
|
||||||
|
|
||||||
serializer = AdjustStockSerializer(data=request.data)
|
serializer = AdjustStockSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
delta = serializer.validated_data["delta"]
|
|
||||||
|
|
||||||
product.quantity += delta
|
product.quantity += serializer.validated_data["delta"]
|
||||||
product.save(update_fields=["quantity", "updated_at"])
|
product.recompute_purchase_value()
|
||||||
|
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
|
||||||
StockMovement.objects.create(
|
StockMovement.objects.create(
|
||||||
product=product,
|
product=product,
|
||||||
delta=delta,
|
delta=serializer.validated_data["delta"],
|
||||||
quantity_after=product.quantity,
|
quantity_after=product.quantity,
|
||||||
note=serializer.validated_data.get("note", ""),
|
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):
|
class ProductMovementsView(generics.ListAPIView):
|
||||||
"""تاریخچهی تغییرات موجودی یک کالا — جدیدترین اول."""
|
|
||||||
|
|
||||||
serializer_class = StockMovementSerializer
|
serializer_class = StockMovementSerializer
|
||||||
permission_classes = [IsAuthenticated, HasActiveFeature]
|
permission_classes = [IsAuthenticated]
|
||||||
feature_key = "inventory"
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return StockMovement.objects.filter(
|
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):
|
class InventorySummaryView(APIView):
|
||||||
model = Appointment
|
"""خلاصهی انبار — ارزشِ کل، تعداد اقلام، و کالاهای روی آستانهی کمبود."""
|
||||||
serializer_class = AppointmentSerializer
|
|
||||||
feature_key = "booking"
|
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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
@@ -2,62 +2,184 @@ from django.conf import settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
# ریزاپهای سادهی کسبوکار کوچک — مشتریان، دفتر درآمد/هزینه، انبار،
|
# ریزاپهای کسبوکار کوچک — مشتریان، دفتر درآمد/هزینه، انبار، فاکتور.
|
||||||
# نوبتدهی. دقیقا همالگو با apps.banker: id همان UUID تولیدشده در خودِ اپ
|
# دقیقا همالگو با apps.banker: id همان UUID تولیدشده در خودِ اپ فلاتر است
|
||||||
# فلاتر است (نه auto-increment)، هر رکورد به user صاحبش محدود میشود.
|
# (نه 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 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)
|
id = models.CharField(max_length=36, primary_key=True)
|
||||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="customers")
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="customers")
|
||||||
name = models.CharField(max_length=150, verbose_name=_("نام"))
|
name = models.CharField(max_length=150, verbose_name=_("نام"))
|
||||||
phone = models.CharField(max_length=20, blank=True, 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=_("ایمیل"))
|
email = models.CharField(max_length=150, blank=True, verbose_name=_("ایمیل"))
|
||||||
address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس"))
|
address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس"))
|
||||||
notes = models.TextField(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)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("مشتری")
|
verbose_name = _("مشتری")
|
||||||
verbose_name_plural = _("مشتریان")
|
verbose_name_plural = _("مشتریان")
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
indexes = [models.Index(fields=["user", "phone"])]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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):
|
class Product(models.Model):
|
||||||
id = models.CharField(max_length=36, primary_key=True)
|
id = models.CharField(max_length=36, primary_key=True)
|
||||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="products")
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="products")
|
||||||
name = models.CharField(max_length=150, verbose_name=_("نام کالا"))
|
name = models.CharField(max_length=150, verbose_name=_("نام کالا"))
|
||||||
sku = models.CharField(max_length=100, blank=True, verbose_name=_("کد کالا"))
|
sku = models.CharField(max_length=100, blank=True, verbose_name=_("کد کالا"))
|
||||||
category = 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=_("موجودی"))
|
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=_("آستانهی هشدار موجودی کم"))
|
low_stock_threshold = models.FloatField(default=0, verbose_name=_("آستانهی هشدار موجودی کم"))
|
||||||
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
|
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
@@ -66,14 +188,23 @@ class Product(models.Model):
|
|||||||
verbose_name = _("کالا")
|
verbose_name = _("کالا")
|
||||||
verbose_name_plural = _("انبار")
|
verbose_name_plural = _("انبار")
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
indexes = [models.Index(fields=["user", "category"])]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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):
|
class StockMovement(models.Model):
|
||||||
"""تاریخچهی تغییرات موجودی — هر تعدیل (دستیِ +/- یا ویرایش مستقیم فرم)
|
"""تاریخچهی تغییرات موجودی — هر تعدیل (دستیِ +/-، ویرایش مستقیم فرم، یا
|
||||||
یک ردیف اینجا ثبت میکند تا معلوم باشد موجودی کِی و چقدر تغییر کرده."""
|
فروش/لغوِ فاکتور) یک ردیف اینجا ثبت میکند تا معلوم باشد موجودی کِی،
|
||||||
|
چقدر و به چه دلیل تغییر کرده."""
|
||||||
|
|
||||||
id = models.AutoField(primary_key=True)
|
id = models.AutoField(primary_key=True)
|
||||||
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="movements")
|
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}"
|
return f"{self.product.name} — {self.delta:+g} — {self.created_at:%Y-%m-%d %H:%M}"
|
||||||
|
|
||||||
|
|
||||||
class Appointment(models.Model):
|
class Invoice(models.Model):
|
||||||
class Status(models.TextChoices):
|
class PaymentType(models.TextChoices):
|
||||||
SCHEDULED = "scheduled", _("زمانبندیشده")
|
CASH = "cash", _("نقدی")
|
||||||
DONE = "done", _("انجامشده")
|
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", _("لغوشده")
|
CANCELLED = "cancelled", _("لغوشده")
|
||||||
|
|
||||||
|
class ShippingMethod(models.TextChoices):
|
||||||
|
PICKUP = "pickup", _("تحویل حضوری")
|
||||||
|
POST = "post", _("پست")
|
||||||
|
COURIER = "courier", _("پیک")
|
||||||
|
|
||||||
id = models.CharField(max_length=36, primary_key=True)
|
id = models.CharField(max_length=36, primary_key=True)
|
||||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="appointments")
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="invoices")
|
||||||
customer_name = models.CharField(max_length=150, verbose_name=_("نام مشتری"))
|
invoice_number = models.CharField(max_length=30, verbose_name=_("شماره فاکتور"))
|
||||||
customer_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره مشتری"))
|
invoice_date = models.DateTimeField(verbose_name=_("تاریخ فاکتور"))
|
||||||
title = models.CharField(max_length=150, verbose_name=_("عنوان/خدمت"))
|
customer = models.ForeignKey(
|
||||||
start_at = models.DateTimeField(verbose_name=_("زمان نوبت"))
|
Customer, null=True, blank=True, on_delete=models.SET_NULL, related_name="invoices"
|
||||||
status = models.CharField(max_length=10, choices=Status.choices, default=Status.SCHEDULED)
|
)
|
||||||
|
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=_("یادداشت"))
|
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)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("نوبت")
|
verbose_name = _("فاکتور")
|
||||||
verbose_name_plural = _("نوبتدهی")
|
verbose_name_plural = _("فاکتورها")
|
||||||
ordering = ["start_at"]
|
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):
|
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
@@ -1,3 +1,4 @@
|
|||||||
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
@@ -7,8 +8,28 @@ from rest_framework.test import APIClient
|
|||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
from apps.account.models import User
|
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.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)
|
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"))
|
response = self.client.get(reverse("bizapps:ledger-export"))
|
||||||
self.assertEqual(response.status_code, 200)
|
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"])
|
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
@@ -1,28 +1,75 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .api.views import (
|
from .api.views import (
|
||||||
AppointmentDetailView,
|
AccountDetailView,
|
||||||
AppointmentListCreateView,
|
AccountListView,
|
||||||
|
BusinessDashboardView,
|
||||||
CustomerDetailView,
|
CustomerDetailView,
|
||||||
CustomerListCreateView,
|
CustomerListCreateView,
|
||||||
|
CustomerStatementView,
|
||||||
|
InventorySummaryView,
|
||||||
|
InvoiceCancelView,
|
||||||
|
InvoiceDetailView,
|
||||||
|
InvoiceListCreateView,
|
||||||
|
InvoiceStatusView,
|
||||||
LedgerEntryDetailView,
|
LedgerEntryDetailView,
|
||||||
LedgerEntryListCreateView,
|
LedgerEntryListCreateView,
|
||||||
|
LedgerBookListView,
|
||||||
|
LedgerBookSummaryView,
|
||||||
|
LedgerExportView,
|
||||||
|
LedgerInstallmentDetailView,
|
||||||
|
LedgerInstallmentListCreateView,
|
||||||
|
OpenLedgerBookView,
|
||||||
ProductAdjustStockView,
|
ProductAdjustStockView,
|
||||||
ProductDetailView,
|
ProductDetailView,
|
||||||
ProductListCreateView,
|
ProductListCreateView,
|
||||||
ProductMovementsView,
|
ProductMovementsView,
|
||||||
|
SubsidiaryLedgerView,
|
||||||
|
TransactionListView,
|
||||||
|
TrialBalanceView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "bizapps"
|
app_name = "bizapps"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("dashboard/", BusinessDashboardView.as_view(), name="dashboard"),
|
||||||
|
|
||||||
path("customers/", CustomerListCreateView.as_view(), name="customer-list"),
|
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"),
|
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/", 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/<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/", 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>/adjust/", ProductAdjustStockView.as_view(), name="product-adjust"),
|
||||||
path("products/<str:pk>/movements/", ProductMovementsView.as_view(), name="product-movements"),
|
path("products/<str:pk>/movements/", ProductMovementsView.as_view(), name="product-movements"),
|
||||||
path("appointments/", AppointmentListCreateView.as_view(), name="appointment-list"),
|
path("products/<str:pk>/", ProductDetailView.as_view(), name="product-detail"),
|
||||||
path("appointments/<str:pk>/", AppointmentDetailView.as_view(), name="appointment-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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -45,3 +45,18 @@ class BulkMessageSerializer(serializers.ModelSerializer):
|
|||||||
def get_needs_manual(self, obj: BulkMessage):
|
def get_needs_manual(self, obj: BulkMessage):
|
||||||
pending = obj.recipients.filter(status=MessageRecipient.Status.NEEDS_MANUAL)
|
pending = obj.recipients.filter(status=MessageRecipient.Status.NEEDS_MANUAL)
|
||||||
return MessageRecipientSerializer(pending, many=True).data
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ from apps.banker.models import ContactGroup
|
|||||||
from apps.billing.permissions import HasActiveFeature
|
from apps.billing.permissions import HasActiveFeature
|
||||||
from apps.messaging.models import BulkMessage, MessageRecipient
|
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
|
from .services import BulkMessageService
|
||||||
|
|
||||||
|
|
||||||
@@ -90,3 +96,34 @@ class ConfirmManualSendView(APIView):
|
|||||||
except MessageRecipient.DoesNotExist:
|
except MessageRecipient.DoesNotExist:
|
||||||
raise NotFound("گیرنده یافت نشد.")
|
raise NotFound("گیرنده یافت نشد.")
|
||||||
return Response({"success": True, "status": recipient.status})
|
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)
|
||||||
|
|||||||
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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))
|
||||||
@@ -3,6 +3,7 @@ from django.urls import path
|
|||||||
from .api.views import (
|
from .api.views import (
|
||||||
BulkMessageDetailView,
|
BulkMessageDetailView,
|
||||||
ConfirmManualSendView,
|
ConfirmManualSendView,
|
||||||
|
ContactMessageHistoryView,
|
||||||
MessagingStatsView,
|
MessagingStatsView,
|
||||||
SendBulkMessageView,
|
SendBulkMessageView,
|
||||||
)
|
)
|
||||||
@@ -11,6 +12,7 @@ app_name = "messaging"
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("send/", SendBulkMessageView.as_view(), name="send"),
|
path("send/", SendBulkMessageView.as_view(), name="send"),
|
||||||
path("stats/", MessagingStatsView.as_view(), name="stats"),
|
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:pk>/", BulkMessageDetailView.as_view(), name="detail"),
|
||||||
path("<int:bulk_id>/confirm/<int:recipient_id>/", ConfirmManualSendView.as_view(), name="confirm"),
|
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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -57,6 +57,7 @@ LOCAL_APPS = [
|
|||||||
"apps.notifications",
|
"apps.notifications",
|
||||||
"apps.landing",
|
"apps.landing",
|
||||||
"apps.orders",
|
"apps.orders",
|
||||||
|
"apps.telemetry",
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
@@ -74,6 +75,8 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
# آخر از همه: زمانِ پاسخ را وقتی همهی میدلورهای دیگر کارشان تمام شده اندازه میگیرد.
|
||||||
|
"apps.telemetry.middleware.ApiCallLoggingMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "config.urls"
|
ROOT_URLCONF = "config.urls"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ urlpatterns = [
|
|||||||
path("api/billing/", include("apps.billing.urls")),
|
path("api/billing/", include("apps.billing.urls")),
|
||||||
path("api/notifications/", include("apps.notifications.urls")),
|
path("api/notifications/", include("apps.notifications.urls")),
|
||||||
path("api/orders/", include("apps.orders.urls")),
|
path("api/orders/", include("apps.orders.urls")),
|
||||||
|
path("api/telemetry/", include("apps.telemetry.urls")),
|
||||||
# فایلهای استاتیک قبلی landing/articles/*.html حذف شدهاند — چون
|
# فایلهای استاتیک قبلی landing/articles/*.html حذف شدهاند — چون
|
||||||
# WhiteNoise میدلور همیشه قبل از urls.py بررسی میشود و اگر آن فایلها
|
# WhiteNoise میدلور همیشه قبل از urls.py بررسی میشود و اگر آن فایلها
|
||||||
# هنوز وجود داشتند، هیچوقت به این view دینامیک نمیرسیدیم.
|
# هنوز وجود داشتند، هیچوقت به این view دینامیک نمیرسیدیم.
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ psycopg2-binary>=2.9,<3.0
|
|||||||
python-decouple>=3.8,<4.0
|
python-decouple>=3.8,<4.0
|
||||||
django-cors-headers>=4.4,<5.0
|
django-cors-headers>=4.4,<5.0
|
||||||
requests>=2.32,<3.0
|
requests>=2.32,<3.0
|
||||||
|
openpyxl>=3.1,<4.0
|
||||||
gunicorn>=22.0,<23.0
|
gunicorn>=22.0,<23.0
|
||||||
whitenoise>=6.7,<7.0
|
whitenoise>=6.7,<7.0
|
||||||
|
|||||||
Reference in New Issue
Block a user