diff --git a/admin_panel/admin.py b/admin_panel/admin.py index 669e9b4..ef162ea 100644 --- a/admin_panel/admin.py +++ b/admin_panel/admin.py @@ -18,4 +18,35 @@ admin.site.register(Article) admin.site.register(Sarresid) admin.site.register(Action) admin.site.register(CacheTable) -admin.site.register(ProductStockHistory) \ No newline at end of file +admin.site.register(ProductStockHistory) + + +@admin.register(StoreTransferRequest) +class StoreTransferRequestAdmin(admin.ModelAdmin): + # ۱. حل مشکل لودینگ و هنگ کردن فرم (تبدیل منوی کشویی غولآسا به باکس شناسه عددی) + # این دستور فیلدهای پرجمعیت را به شدت بهینه و سبک میکند + raw_id_fields = ['out_transaction', 'in_transaction', 'product', 'requester', 'approver'] + + # ۲. ستونهایی که میخواهید در صفحه جدول (Changelist) نمایش داده شوند + list_display = [ + 'id', + 'source_store', + 'target_store', + 'product', + 'quantity', + 'status', + 'created_at', + 'action_date' + ] + + # ۳. فیکس قطعی باگ N+1 (لود کردن روابط در یک کوئری واحد به جای صدها کوئری تودرتو برای __str__) + list_select_related = ['source_store', 'target_store', 'product', 'requester', 'approver'] + + # ۴. فیلترهای سمت راست صفحه (فقط روی فیلدهای کمجمعیت مثل وضعیت و انبار فیلتر بگذارید) + list_filter = ['status', 'source_store', 'target_store', 'created_at'] + + # ۵. باکس جستجوی بهینه شده + search_fields = ['id', 'desc', 'product__product_name', 'requester__username'] + + # ۶. غیرقابل ادیت کردن فیلدهای حساس مالی در پنل ادمین (اختیاری اما برای Enterprise الزامی است) + readonly_fields = ['created_at', 'updated_at'] \ No newline at end of file diff --git a/admin_panel/migrations/0048_alter_productinstore_options.py b/admin_panel/migrations/0048_alter_productinstore_options.py new file mode 100644 index 0000000..ad22af1 --- /dev/null +++ b/admin_panel/migrations/0048_alter_productinstore_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.25 on 2026-07-03 10:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0047_alter_productinstore_movement_type'), + ] + + operations = [ + migrations.AlterModelOptions( + name='productinstore', + options={'permissions': [('ViewProductInStore', 'ViewProductInStore'), ('EditProductInStore', 'EditProductInStore')]}, + ), + ] diff --git a/admin_panel/migrations/0049_alter_productinstore_options.py b/admin_panel/migrations/0049_alter_productinstore_options.py new file mode 100644 index 0000000..42d4691 --- /dev/null +++ b/admin_panel/migrations/0049_alter_productinstore_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.25 on 2026-07-03 10:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0048_alter_productinstore_options'), + ] + + operations = [ + migrations.AlterModelOptions( + name='productinstore', + options={'permissions': [('ViewProductInStore', 'ViewProductInStore'), ('EditProductInStore', 'EditProductInStore'), ('OnlyAnbar', 'OnlyAnbar')]}, + ), + ] diff --git a/admin_panel/migrations/0050_storetransferrequest.py b/admin_panel/migrations/0050_storetransferrequest.py new file mode 100644 index 0000000..6520e0c --- /dev/null +++ b/admin_panel/migrations/0050_storetransferrequest.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.25 on 2026-07-03 12:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('admin_panel', '0049_alter_productinstore_options'), + ] + + operations = [ + migrations.CreateModel( + name='StoreTransferRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(verbose_name='تعداد انتقال')), + ('status', models.CharField(choices=[('PENDING', 'در انتظار تایید انبار مقصد'), ('APPROVED', 'تایید شده و منتقل شده'), ('REJECTED', 'رد شده توسط انبار مقصد'), ('CANCELED', 'انصراف توسط مبدا')], db_index=True, default='PENDING', max_length=15, verbose_name='وضعیت')), + ('desc', models.CharField(blank=True, max_length=500, null=True, verbose_name='توضیحات درخواست')), + ('rejection_reason', models.CharField(blank=True, max_length=500, null=True, verbose_name='دلیل رد درخواست')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='زمان ثبت درخواست')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='زمان آخرین به\u200cروزرسانی')), + ('action_date', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='زمان تعیین تکلیف')), + ('approver', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='processed_transfers', to=settings.AUTH_USER_MODEL, verbose_name='کاربر تعیین\u200cتکلیف\u200cکننده')), + ('in_transaction', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_in_ref', to='admin_panel.productinstore', verbose_name='سند ورود انبار مقصد')), + ('out_transaction', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_out_ref', to='admin_panel.productinstore', verbose_name='سند خروج انبار مبدا')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='admin_panel.product', verbose_name='کالا')), + ('requester', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='requested_transfers', to=settings.AUTH_USER_MODEL, verbose_name='کاربر درخواست\u200cدهنده')), + ('source_store', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing_transfers', to='admin_panel.storeroom', verbose_name='انبار مبدا')), + ('target_store', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_transfers', to='admin_panel.storeroom', verbose_name='انبار مقصد')), + ], + options={ + 'verbose_name': 'درخواست انتقال بین انبار', + 'verbose_name_plural': 'درخواست\u200cهای انتقال بین انبار', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/admin_panel/models.py b/admin_panel/models.py index 526dd49..3194cec 100644 --- a/admin_panel/models.py +++ b/admin_panel/models.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.db import models from django.db.models import Sum # from django.db.models.signals import post_save, post_delete @@ -146,7 +147,12 @@ class Factor(models.Model): self.save() class ProductInStore(models.Model): - + class Meta: + permissions = [ + ("ViewProductInStore", "ViewProductInStore"), + ("EditProductInStore", "EditProductInStore"), + ("OnlyAnbar", "OnlyAnbar"), + ] class MovementType(models.TextChoices): SALE = 'SALE', 'فروش عادی' WARRANTY = 'WARRANTY', 'گارانتی' @@ -273,6 +279,92 @@ class ProductStockHistory(models.Model): def __str__(self): return f"{self.product.product_name} in {self.store_room.store_name} - Stock: {self.current_stock}" + +class StoreTransferRequest(models.Model): + class Status(models.TextChoices): + PENDING = 'PENDING', 'در انتظار تایید انبار مقصد' + APPROVED = 'APPROVED', 'تایید شده و منتقل شده' + REJECTED = 'REJECTED', 'رد شده توسط انبار مقصد' + CANCELED = 'CANCELED', 'انصراف توسط مبدا' + + source_store = models.ForeignKey( + 'StoreRoom', + on_delete=models.PROTECT, + related_name='outgoing_transfers', + verbose_name="انبار مبدا" + ) + target_store = models.ForeignKey( + 'StoreRoom', + on_delete=models.PROTECT, + related_name='incoming_transfers', + verbose_name="انبار مقصد" + ) + product = models.ForeignKey( + 'Product', + on_delete=models.PROTECT, + verbose_name="کالا" + ) + quantity = models.PositiveIntegerField(verbose_name="تعداد انتقال") + + status = models.CharField( + max_length=15, + choices=Status.choices, + default=Status.PENDING, + db_index=True, + verbose_name="وضعیت" + ) + desc = models.CharField(max_length=500, blank=True, null=True, verbose_name="توضیحات درخواست") + rejection_reason = models.CharField(max_length=500, blank=True, null=True, verbose_name="دلیل رد درخواست") + + requester = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name='requested_transfers', + verbose_name="کاربر درخواستدهنده" + ) + approver = models.ForeignKey( + User, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name='processed_transfers', + verbose_name="کاربر تعیینتکلیفکننده" + ) + + created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name="زمان ثبت درخواست") + updated_at = models.DateTimeField(auto_now=True, verbose_name="زمان آخرین بهروزرسانی") + action_date = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name="زمان تعیین تکلیف") + + out_transaction = models.ForeignKey( + 'ProductInStore', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='transfer_out_ref', + verbose_name="سند خروج انبار مبدا" + ) + in_transaction = models.ForeignKey( + 'ProductInStore', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='transfer_in_ref', + verbose_name="سند ورود انبار مقصد" + ) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"[{self.get_status_display()}] {self.quantity} عدد {self.product.product_name} از {self.source_store.store_name} به {self.target_store.store_name}" + + def clean(self): + super().clean() + if self.source_store_id == self.target_store_id: + raise ValidationError("انبار مبدا و مقصد نمیتوانند یکسان باشند.") + if self.quantity and self.quantity <= 0: + raise ValidationError("تعداد انتقال باید بیشتر از صفر باشد.") + class FactorIteam(models.Model): # پیشنهاد: در آینده املای نام این کلاس را به FactorItem اصلاح کنید factor = models.ForeignKey(Factor, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE, null=True, blank=True) diff --git a/admin_panel/services.py b/admin_panel/services.py new file mode 100644 index 0000000..0c177bb --- /dev/null +++ b/admin_panel/services.py @@ -0,0 +1,207 @@ +# services/transfer_services.py +import logging +from typing import Optional +from django.db import transaction +from django.core.exceptions import ValidationError, PermissionDenied +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.db.models import Sum, QuerySet +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractBaseUser + +from .models import StoreTransferRequest, ProductInStore, StoreRoom, Product + +# تنظیم لابر استاندارد برای مانیتورینگ سازمانی +logger = logging.getLogger(__name__) +User = get_user_model() + + +def _check_target_store_permission(store: StoreRoom, user: AbstractBaseUser) -> None: + """بررسی دسترسی کاربر به انبار مقصد""" + if not (user.is_superuser or store.user.filter(id=user.id).exists()): + logger.warning( + "Unauthorized transfer approval attempt", + extra={"user_id": user.id, "store_id": store.id} + ) + raise PermissionDenied(_("شما دسترسی و مالکیت لازم برای مدیریت ورود کالا به این انبار را ندارید.")) + + +def _check_source_store_permission(store: StoreRoom, user: AbstractBaseUser) -> None: + """بررسی دسترسی کاربر به انبار مبدا""" + if not (user.is_superuser or store.user.filter(id=user.id).exists()): + logger.warning( + "Unauthorized transfer request attempt", + extra={"user_id": user.id, "store_id": store.id} + ) + raise PermissionDenied(_("شما دسترسی لازم برای مدیریت خروج کالا از این انبار را ندارید.")) + + +def get_product_current_stock(store: StoreRoom, product: Product, lock_for_update: bool = False) -> int: + """ + محاسبه موجودی لحظهای یک کالا در انبار مشخص. + پارامتر lock_for_update در زمان تایید تراکنش فعال میشود تا از Race Condition در موجودی جلوگیری کند. + """ + qs = ProductInStore.objects.filter(StoreRoom=store, Product=product) + + # قفل کردن رکوردهای موجودی انبار برای جلوگیری از کسر همزمان توسط چند تراکنش + if lock_for_update: + qs = qs.select_for_update() + + totals = qs.aggregate(stock=Sum('s_in') - Sum('s_out')) + return totals['stock'] or 0 + + +@transaction.atomic +def create_transfer_request( + *, + source_store: StoreRoom, + target_store: StoreRoom, + product: Product, + quantity: int, + requester: AbstractBaseUser, + desc: Optional[str] = None +) -> StoreTransferRequest: + """ + سرویس ثبت درخواست انتقال جدید + """ + _check_source_store_permission(source_store, requester) + + if source_store.id == target_store.id: + raise ValidationError(_("انبار مبدا و مقصد نمیتوانند یکسان باشند.")) + + if quantity <= 0: + raise ValidationError(_("تعداد انتقال باید بیشتر از صفر باشد.")) + + # در زمان ثبت درخواست، نیازی به قفل دیتابیس نیست (فقط چک اولیه) + current_stock = get_product_current_stock(source_store, product, lock_for_update=False) + if current_stock < quantity: + raise ValidationError(_("موجودی انبار مبدا کافی نیست. موجودی فعلی: %(stock)s") % {'stock': current_stock}) + + transfer_request = StoreTransferRequest.objects.create( + source_store=source_store, + target_store=target_store, + product=product, + quantity=quantity, + requester=requester, + desc=desc, + status=StoreTransferRequest.Status.PENDING + ) + + logger.info( + f"Transfer request #{transfer_request.id} created by user #{requester.id}", + extra={"transfer_id": transfer_request.id, "quantity": quantity} + ) + return transfer_request + + +@transaction.atomic +def approve_transfer_request(*, request_id: int, approver: AbstractBaseUser) -> StoreTransferRequest: + """ + سرویس تایید انتقال: قفل رکورد درخواست و رکوردهای انبار، کسر از مبدا، ورود به مقصد و تغییر وضعیت + """ + try: + # ۱. قفل کردن رکورد درخواست برای جلوگیری از Double Approval + transfer = StoreTransferRequest.objects.select_for_update().get(pk=request_id) + except StoreTransferRequest.DoesNotExist: + raise ValidationError(_("درخواست انتقال مورد نظر یافت نشد.")) + + if transfer.status != StoreTransferRequest.Status.PENDING: + raise ValidationError(_("این درخواست قبلاً تعیین تکلیف شده است و قابل تایید نیست.")) + + # ۲. بررسی سطح دسترسی تاییدکننده + _check_target_store_permission(transfer.target_store, approver) + + # ۳. بررسی موجودی با فعالسازی قفل (lock_for_update=True) برای جلوگیری از Race Condition موجودی انبار + current_stock = get_product_current_stock(transfer.source_store, transfer.product, lock_for_update=True) + if current_stock < transfer.quantity: + raise ValidationError( + _("موجودی انبار مبدا برای تایید این انتقال کافی نیست. موجودی فعلی: %(stock)s") % {'stock': current_stock} + ) + + now = timezone.now() + + # ۴. صدور سند خروج قطعی از انبار مبدا + out_tx = ProductInStore.objects.create( + StoreRoom=transfer.source_store, + Product=transfer.product, + s_in=0, + s_out=transfer.quantity, + movement_type=ProductInStore.MovementType.TRANSFER, + desc=f"انتقال به {transfer.target_store.store_name} | درخواست #{transfer.id} | تایید توسط {approver.username}" + ) + + # ۵. صدور سند ورود قطعی به انبار مقصد + in_tx = ProductInStore.objects.create( + StoreRoom=transfer.target_store, + Product=transfer.product, + s_in=transfer.quantity, + s_out=0, + movement_type=ProductInStore.MovementType.TRANSFER, + desc=f"انتقال از {transfer.source_store.store_name} | درخواست #{transfer.id} | تایید توسط {approver.username}" + ) + + # ۶. بهروزرسانی نهایی درخواست + transfer.status = StoreTransferRequest.Status.APPROVED + transfer.approver = approver + transfer.action_date = now + transfer.out_transaction = out_tx + transfer.in_transaction = in_tx + transfer.save(update_fields=['status', 'approver', 'action_date', 'out_transaction', 'in_transaction', 'updated_at']) + + logger.info( + f"Transfer request #{transfer.id} approved successfully by user #{approver.id}", + extra={"transfer_id": transfer.id, "out_tx": out_tx.id, "in_tx": in_tx.id} + ) + return transfer + + +@transaction.atomic +def reject_transfer_request(*, request_id: int, rejector: AbstractBaseUser, reason: str = "") -> StoreTransferRequest: + """ + سرویس رد درخواست توسط انبار مقصد + """ + try: + transfer = StoreTransferRequest.objects.select_for_update().get(pk=request_id) + except StoreTransferRequest.DoesNotExist: + raise ValidationError(_("درخواست انتقال مورد نظر یافت نشد.")) + + if transfer.status != StoreTransferRequest.Status.PENDING: + raise ValidationError(_("این درخواست قبلاً تعیین تکلیف شده است.")) + + _check_target_store_permission(transfer.target_store, rejector) + + transfer.status = StoreTransferRequest.Status.REJECTED + transfer.approver = rejector + transfer.action_date = timezone.now() + if reason: + transfer.rejection_reason = reason + + transfer.save(update_fields=['status', 'approver', 'action_date', 'rejection_reason', 'updated_at']) + + logger.info(f"Transfer request #{transfer.id} rejected by user #{rejector.id}. Reason: {reason}") + return transfer + + +@transaction.atomic +def cancel_transfer_request(*, request_id: int, user: AbstractBaseUser) -> StoreTransferRequest: + """ + سرویس انصراف از انتقال توسط ایجادکننده یا مسئول انبار مبدا + """ + try: + transfer = StoreTransferRequest.objects.select_for_update().get(pk=request_id) + except StoreTransferRequest.DoesNotExist: + raise ValidationError(_("درخواست انتقال مورد نظر یافت نشد.")) + + if transfer.status != StoreTransferRequest.Status.PENDING: + raise ValidationError(_("فقط درخواستهای در انتظار تایید قابل انصراف هستند.")) + + if not (user.is_superuser or transfer.requester_id == user.id or transfer.source_store.user.filter(id=user.id).exists()): + raise PermissionDenied(_("شما اجازه انصراف از این درخواست را ندارید.")) + + transfer.status = StoreTransferRequest.Status.CANCELED + transfer.approver = user + transfer.action_date = timezone.now() + transfer.save(update_fields=['status', 'approver', 'action_date', 'updated_at']) + + logger.info(f"Transfer request #{transfer.id} canceled by user #{user.id}") + return transfer \ No newline at end of file diff --git a/admin_panel/templates/admin_panel/base.html b/admin_panel/templates/admin_panel/base.html index 1e8ee02..2b5f305 100644 --- a/admin_panel/templates/admin_panel/base.html +++ b/admin_panel/templates/admin_panel/base.html @@ -47,7 +47,7 @@ {% endif %} - {% if not perms.admin_panel.ViewStore %} + {% if not perms.admin_panel.ViewStore %} اصلی @@ -63,7 +63,6 @@ {% endif %} - انبار diff --git a/admin_panel/templates/admin_panel/baseStore.html b/admin_panel/templates/admin_panel/baseStore.html index df59f9f..4331e7a 100644 --- a/admin_panel/templates/admin_panel/baseStore.html +++ b/admin_panel/templates/admin_panel/baseStore.html @@ -96,16 +96,28 @@
{{ storeO.store_name }}
+{{ storeO.store_name }}
+