This commit is contained in:
2026-08-24 15:54:08 +03:30
commit 236002a87c
133 changed files with 5946 additions and 0 deletions
View File
+40
View File
@@ -0,0 +1,40 @@
from django.contrib import admin
from .models import Appointment, Customer, LedgerEntry, Product, StockMovement
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
list_display = ("name", "phone", "user", "updated_at")
search_fields = ("name", "phone", "email", "user__phone")
@admin.register(LedgerEntry)
class LedgerEntryAdmin(admin.ModelAdmin):
list_display = ("title", "entry_type", "amount", "entry_date", "user")
list_filter = ("entry_type", "category")
search_fields = ("title", "user__phone")
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "sku", "category", "quantity", "unit_price", "user")
list_filter = ("category",)
search_fields = ("name", "sku", "user__phone")
@admin.register(StockMovement)
class StockMovementAdmin(admin.ModelAdmin):
list_display = ("product", "delta", "quantity_after", "created_at")
search_fields = ("product__name", "product__user__phone")
readonly_fields = ("product", "delta", "quantity_after", "note", "created_at")
def has_add_permission(self, request):
return False
@admin.register(Appointment)
class AppointmentAdmin(admin.ModelAdmin):
list_display = ("customer_name", "title", "start_at", "status", "user")
list_filter = ("status",)
search_fields = ("customer_name", "customer_phone", "user__phone")
View File
+58
View File
@@ -0,0 +1,58 @@
from rest_framework import serializers
from apps.bizapps.models import Appointment, Customer, LedgerEntry, Product, StockMovement
class CustomerSerializer(serializers.ModelSerializer):
class Meta:
model = Customer
fields = ["id", "name", "phone", "email", "address", "notes", "updated_at"]
read_only_fields = ["updated_at"]
class LedgerEntrySerializer(serializers.ModelSerializer):
class Meta:
model = LedgerEntry
fields = ["id", "entry_type", "title", "amount", "category", "entry_date", "note", "updated_at"]
read_only_fields = ["updated_at"]
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = [
"id",
"name",
"sku",
"category",
"quantity",
"unit_price",
"low_stock_threshold",
"notes",
"updated_at",
]
read_only_fields = ["updated_at"]
class StockMovementSerializer(serializers.ModelSerializer):
class Meta:
model = StockMovement
fields = ["id", "delta", "quantity_after", "note", "created_at"]
read_only_fields = fields
class AdjustStockSerializer(serializers.Serializer):
delta = serializers.FloatField()
note = serializers.CharField(max_length=255, required=False, allow_blank=True, default="")
def validate_delta(self, value):
if value == 0:
raise serializers.ValidationError("مقدار تغییر نمی‌تواند صفر باشد.")
return value
class AppointmentSerializer(serializers.ModelSerializer):
class Meta:
model = Appointment
fields = ["id", "customer_name", "customer_phone", "title", "start_at", "status", "notes", "updated_at"]
read_only_fields = ["updated_at"]
+130
View File
@@ -0,0 +1,130 @@
from django.db import transaction
from rest_framework import generics, status
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.billing.permissions import HasActiveFeature
from apps.bizapps.models import Appointment, Customer, LedgerEntry, Product, StockMovement
from .serializers import (
AdjustStockSerializer,
AppointmentSerializer,
CustomerSerializer,
LedgerEntrySerializer,
ProductSerializer,
StockMovementSerializer,
)
# همه‌ی این ریزاپ‌ها با جم فعال می‌شوند (یا دوره‌ی آزمایشی) — `feature_key`
# روی هر ویو مشخص می‌کند HasActiveFeature باید چه چیزی را چک کند.
# permission_classes اینجا Base است تا زیرکلاس‌ها فراموش نکنند.
class _UserScopedListCreateView(generics.ListCreateAPIView):
permission_classes = [IsAuthenticated, HasActiveFeature]
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class _UserScopedDetailView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [IsAuthenticated, HasActiveFeature]
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
class CustomerListCreateView(_UserScopedListCreateView):
model = Customer
serializer_class = CustomerSerializer
feature_key = "customers"
class CustomerDetailView(_UserScopedDetailView):
model = Customer
serializer_class = CustomerSerializer
feature_key = "customers"
class LedgerEntryListCreateView(_UserScopedListCreateView):
model = LedgerEntry
serializer_class = LedgerEntrySerializer
feature_key = "ledger"
class LedgerEntryDetailView(_UserScopedDetailView):
model = LedgerEntry
serializer_class = LedgerEntrySerializer
feature_key = "ledger"
class ProductListCreateView(_UserScopedListCreateView):
model = Product
serializer_class = ProductSerializer
feature_key = "inventory"
class ProductDetailView(_UserScopedDetailView):
model = Product
serializer_class = ProductSerializer
feature_key = "inventory"
class ProductAdjustStockView(APIView):
"""تعدیل اتمیک موجودی (+/-) + یک ردیف تاریخچه — به‌جای این‌که کلاینت
مستقیم quantity جدید را PATCH کند (که هیچ تاریخچه‌ای نمی‌سازد و اگر دو
درخواست هم‌زمان بیایند می‌تواند یکی را گم کند)."""
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "inventory"
@transaction.atomic
def post(self, request, pk):
try:
product = Product.objects.select_for_update().get(pk=pk, user=request.user)
except Product.DoesNotExist:
raise NotFound("کالا یافت نشد.")
serializer = AdjustStockSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
delta = serializer.validated_data["delta"]
product.quantity += delta
product.save(update_fields=["quantity", "updated_at"])
StockMovement.objects.create(
product=product,
delta=delta,
quantity_after=product.quantity,
note=serializer.validated_data.get("note", ""),
)
return Response(ProductSerializer(product).data, status=status.HTTP_200_OK)
class ProductMovementsView(generics.ListAPIView):
"""تاریخچه‌ی تغییرات موجودی یک کالا — جدیدترین اول."""
serializer_class = StockMovementSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "inventory"
def get_queryset(self):
return StockMovement.objects.filter(
product_id=self.kwargs["pk"], product__user=self.request.user
)
class AppointmentListCreateView(_UserScopedListCreateView):
model = Appointment
serializer_class = AppointmentSerializer
feature_key = "booking"
class AppointmentDetailView(_UserScopedDetailView):
model = Appointment
serializer_class = AppointmentSerializer
feature_key = "booking"
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BizappsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.bizapps"
verbose_name = "ابزارهای کسب‌وکار"
+92
View File
@@ -0,0 +1,92 @@
# Generated by Django 5.1.15 on 2026-08-23 11: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='Appointment',
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(blank=True, max_length=20, verbose_name='شماره مشتری')),
('title', models.CharField(max_length=150, verbose_name='عنوان/خدمت')),
('start_at', models.DateTimeField(verbose_name='زمان نوبت')),
('status', models.CharField(choices=[('scheduled', 'زمان\u200cبندی\u200cشده'), ('done', 'انجام\u200cشده'), ('cancelled', 'لغوشده')], default='scheduled', max_length=10)),
('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='appointments', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'نوبت',
'verbose_name_plural': 'نوبت\u200cدهی',
'ordering': ['start_at'],
},
),
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='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('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='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, max_length=100, verbose_name='دسته')),
('entry_date', models.DateField(verbose_name='تاریخ')),
('note', 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='ledger_entries', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'ردیف دفتر درآمد/هزینه',
'verbose_name_plural': 'دفتر درآمد و هزینه',
'ordering': ['-entry_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='کد کالا')),
('quantity', models.FloatField(default=0, verbose_name='موجودی')),
('unit_price', 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'],
},
),
]
@@ -0,0 +1,38 @@
# Generated by Django 5.1.15 on 2026-08-23 12:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BrokerageTransaction',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('client_name', models.CharField(max_length=150, verbose_name='نام مشتری')),
('client_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره مشتری')),
('symbol', models.CharField(max_length=50, verbose_name='نماد')),
('side', models.CharField(choices=[('buy', 'خرید'), ('sell', 'فروش')], max_length=4, verbose_name='نوع معامله')),
('quantity', models.FloatField(verbose_name='تعداد')),
('price', models.FloatField(verbose_name='قیمت واحد')),
('commission_percent', models.FloatField(default=0, verbose_name='درصد کارمزد')),
('transaction_date', models.DateField(verbose_name='تاریخ')),
('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='brokerage_transactions', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'معامله\u200cی بورسی',
'verbose_name_plural': 'کارگزاری',
'ordering': ['-transaction_date'],
},
),
]
@@ -0,0 +1,16 @@
# Generated by Django 5.1.15 on 2026-08-23 14:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0002_brokeragetransaction'),
]
operations = [
migrations.DeleteModel(
name='BrokerageTransaction',
),
]
@@ -0,0 +1,35 @@
# Generated by Django 5.1.15 on 2026-08-23 20:24
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0003_delete_brokeragetransaction'),
]
operations = [
migrations.AddField(
model_name='product',
name='category',
field=models.CharField(blank=True, max_length=100, verbose_name='دسته'),
),
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'],
},
),
]
View File
+116
View File
@@ -0,0 +1,116 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# ریزاپ‌های ساده‌ی کسب‌وکار کوچک — مشتریان، دفتر درآمد/هزینه، انبار،
# نوبت‌دهی. دقیقا هم‌الگو با apps.banker: id همان UUID تولیدشده در خودِ اپ
# فلاتر است (نه auto-increment)، هر رکورد به user صاحبش محدود می‌شود.
class Customer(models.Model):
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="customers")
name = models.CharField(max_length=150, verbose_name=_("نام"))
phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره تماس"))
email = models.CharField(max_length=150, blank=True, verbose_name=_("ایمیل"))
address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس"))
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("مشتری")
verbose_name_plural = _("مشتریان")
ordering = ["name"]
def __str__(self):
return self.name
class LedgerEntry(models.Model):
class EntryType(models.TextChoices):
INCOME = "income", _("درآمد")
EXPENSE = "expense", _("هزینه")
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ledger_entries")
entry_type = models.CharField(max_length=10, choices=EntryType.choices, verbose_name=_("نوع"))
title = models.CharField(max_length=150, verbose_name=_("عنوان"))
amount = models.FloatField(verbose_name=_("مبلغ"))
category = models.CharField(max_length=100, blank=True, verbose_name=_("دسته"))
entry_date = models.DateField(verbose_name=_("تاریخ"))
note = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("ردیف دفتر درآمد/هزینه")
verbose_name_plural = _("دفتر درآمد و هزینه")
ordering = ["-entry_date"]
def __str__(self):
return f"{self.title} ({self.get_entry_type_display()})"
class Product(models.Model):
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="products")
name = models.CharField(max_length=150, verbose_name=_("نام کالا"))
sku = models.CharField(max_length=100, blank=True, verbose_name=_("کد کالا"))
category = models.CharField(max_length=100, blank=True, verbose_name=_("دسته"))
quantity = models.FloatField(default=0, verbose_name=_("موجودی"))
unit_price = models.FloatField(default=0, verbose_name=_("قیمت واحد"))
low_stock_threshold = models.FloatField(default=0, verbose_name=_("آستانه‌ی هشدار موجودی کم"))
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("کالا")
verbose_name_plural = _("انبار")
ordering = ["name"]
def __str__(self):
return self.name
class StockMovement(models.Model):
"""تاریخچه‌ی تغییرات موجودی — هر تعدیل (دستیِ +/- یا ویرایش مستقیم فرم)
یک ردیف اینجا ثبت می‌کند تا معلوم باشد موجودی کِی و چقدر تغییر کرده."""
id = models.AutoField(primary_key=True)
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="movements")
delta = models.FloatField(verbose_name=_("تغییر"))
quantity_after = models.FloatField(verbose_name=_("موجودی بعد از تغییر"))
note = 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 = ["-created_at"]
def __str__(self):
return f"{self.product.name} — {self.delta:+g} — {self.created_at:%Y-%m-%d %H:%M}"
class Appointment(models.Model):
class Status(models.TextChoices):
SCHEDULED = "scheduled", _("زمان‌بندی‌شده")
DONE = "done", _("انجام‌شده")
CANCELLED = "cancelled", _("لغوشده")
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="appointments")
customer_name = models.CharField(max_length=150, verbose_name=_("نام مشتری"))
customer_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره مشتری"))
title = models.CharField(max_length=150, verbose_name=_("عنوان/خدمت"))
start_at = models.DateTimeField(verbose_name=_("زمان نوبت"))
status = models.CharField(max_length=10, choices=Status.choices, default=Status.SCHEDULED)
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("نوبت")
verbose_name_plural = _("نوبت‌دهی")
ordering = ["start_at"]
def __str__(self):
return f"{self.customer_name} — {self.title}"
+28
View File
@@ -0,0 +1,28 @@
from django.urls import path
from .api.views import (
AppointmentDetailView,
AppointmentListCreateView,
CustomerDetailView,
CustomerListCreateView,
LedgerEntryDetailView,
LedgerEntryListCreateView,
ProductAdjustStockView,
ProductDetailView,
ProductListCreateView,
ProductMovementsView,
)
app_name = "bizapps"
urlpatterns = [
path("customers/", CustomerListCreateView.as_view(), name="customer-list"),
path("customers/<str:pk>/", CustomerDetailView.as_view(), name="customer-detail"),
path("ledger/", LedgerEntryListCreateView.as_view(), name="ledger-list"),
path("ledger/<str:pk>/", LedgerEntryDetailView.as_view(), name="ledger-detail"),
path("products/", ProductListCreateView.as_view(), name="product-list"),
path("products/<str:pk>/", ProductDetailView.as_view(), name="product-detail"),
path("products/<str:pk>/adjust/", ProductAdjustStockView.as_view(), name="product-adjust"),
path("products/<str:pk>/movements/", ProductMovementsView.as_view(), name="product-movements"),
path("appointments/", AppointmentListCreateView.as_view(), name="appointment-list"),
path("appointments/<str:pk>/", AppointmentDetailView.as_view(), name="appointment-detail"),
]