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 @@
-
- -
+
+ +
{% block storeBody %} diff --git a/admin_panel/templates/admin_panel/store.html b/admin_panel/templates/admin_panel/store.html index c280e23..08787f8 100644 --- a/admin_panel/templates/admin_panel/store.html +++ b/admin_panel/templates/admin_panel/store.html @@ -3,86 +3,103 @@ {% load static %} {% block storeBody %} -

{{ storeO.store_name }}

+

{{ storeO.store_name }}

+
{% csrf_token %} -
-
- -
-
- -
-
-
- +
+
+
-
-
-
-
- +
+
+
+ +
+
-
- +
+
+ +
+
+ - + لغو جستجو و مشاهده همه
- +
- - + + + + - + + + {% for prd in productSum %} - - - - + + - {% if perms.admin_panel.ViewStore %} - - - - + - + + + - + {% endif %} + {% endfor %} @@ -97,10 +114,8 @@ - +
+ + +
- {% endblock %} \ No newline at end of file diff --git a/admin_panel/templates/admin_panel/store_transfers.html b/admin_panel/templates/admin_panel/store_transfers.html new file mode 100644 index 0000000..8b2e597 --- /dev/null +++ b/admin_panel/templates/admin_panel/store_transfers.html @@ -0,0 +1,224 @@ +{% extends "./baseStore.html" %} +{% load custom_tags %} +{% load static %} + +{% block storeBody %} +
+ + {% if messages %} +
+ {% for message in messages %} +
+ + {{ message }} +
+ {% endfor %} +
+ {% endif %} + +
+

+ 📥 بارهای ورودی در انتظار تایید شما + {{ incoming_pending.count }} +

+ + {% if incoming_pending %} +
+
نام محصول قیمتموجودیعملیاتموجودی فیزیکیدر انتظار خروج (رزرو)در انتظار ورود (در راه)عملیات
نام محصول قیمتموجودیموجودی فیزیکیدر انتظار خروج (رزرو)در انتظار ورود (در راه) عملیات
- - {{ prd.product_name }} - + + + {{ prd.product_name }} + {{ prd.product_price }} {{ prd.mojodi }} + {{ prd.product_price }} + {{ prd.mojodi }} + + {% if prd.pending_out > 0 %} + + {{ prd.pending_out }} - + + {% else %} + 0 + {% endif %} + + {% if prd.pending_in > 0 %} + + {{ prd.pending_in }} + + + {% else %} + 0 + {% endif %} + + {% if perms.admin_panel.ViewStore or perms.admin_panel.OnlyAnbar %} + + - {% endif %} - - - - -
+ + + + + + + + + + + + + {% for item in incoming_pending %} + + + + + + + + + + {% endfor %} + +
کد درخواستکالاتعدادارسال کننده (انبار مبدا)کاربر ثبت‌کنندهزمان ارسالاقدام انباردار
#{{ item.id }}{{ item.quantity }}{{ item.source_store.store_name }}{{ item.requester.first_name }} {{ item.requester.last_name }}{{ item.created_at|toJalali }} +
+
+ {% csrf_token %} + + +
+ + +
+
+
+ {% else %} +

هیچ بار ورودی جدیدی در انتظار تایید شما نیست.

+ {% endif %} +
+ +
+

+ 📤 بارهای ارسالی شما (در انتظار تایید انبار مقصد) + {{ outgoing_pending.count }} +

+ + {% if outgoing_pending %} +
+ + + + + + + + + + + + + + + {% for item in outgoing_pending %} + + + + + + + + + + + {% endfor %} + +
کد درخواستکالاتعدادانبار مقصدزمان ارسالتوضیحات شماوضعیتعملیات
#{{ item.id }}{{ item.product.product_name }}{{ item.quantity }}{{ item.target_store.store_name }}{{ item.created_at|toJalali }}{{ item.desc|default:"-" }}منتظر تحویل +
+ {% csrf_token %} + + +
+
+
+ {% else %} +

هیچ بار ارسالی معلقی ندارید.

+ {% endif %} +
+ +
+

🕒 تاریخچه ۵۰ انتقال اخیر انبار

+
+ + + + + + + + + + + + + + + + {% for item in history_list %} + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
کدکالاتعدادمبدامقصدوضعیتتعیین‌تکلیف کنندهزمان اقدامتوضیحات / دلیل رد
#{{ item.id }}{{ item.product.product_name }}{{ item.quantity }}{% if item.source_store_id == storeO.id %}انبار شما{% else %}{{ item.source_store.store_name }}{% endif %}{% if item.target_store_id == storeO.id %}انبار شما{% else %}{{ item.target_store.store_name }}{% endif %} + {% if item.status == 'APPROVED' %} + ✓ تایید و تحویل شد + {% elif item.status == 'REJECTED' %} + ✕ رد شد + {% elif item.status == 'CANCELED' %} + انصراف مبدا + {% endif %} + {{ item.approver.first_name }} {{ item.approver.last_name }}{{ item.action_date|toJalali|default:"-" }}{{ item.rejection_reason|default:item.desc|default:"-" }}
هیچ تاریخچه‌ای یافت نشد.
+
+
+ +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/admin_panel/urls.py b/admin_panel/urls.py index 7aa5f04..8848e3e 100644 --- a/admin_panel/urls.py +++ b/admin_panel/urls.py @@ -53,6 +53,9 @@ urlpatterns = [ path('store//operations/', views.operationProduct, name='operationProduct'), path('store//operation//new', views.newOperation, name='newOperation'), path('store/new', views.newStore, name='newStore'), + # === مسیرهای مدیریت انتقال بین انبارها === + path('store//transfers/', views.store_transfers, name='store_transfers'), + path('transfer//action/', views.process_transfer_action, name='process_transfer_action'), path('product/add', views.productAdd, name='productAdd'), path('product/edit', views.productEdit, name='productEdit'), path('operation', views.operation, name='operation'), diff --git a/admin_panel/views - Copy.py b/admin_panel/views - Copy.py new file mode 100644 index 0000000..bbc4a72 --- /dev/null +++ b/admin_panel/views - Copy.py @@ -0,0 +1,1970 @@ +from django.shortcuts import redirect, render, get_object_or_404 +from django.urls.base import reverse +from django.urls import reverse # تکراری در کد اصلی شما بود، اما جهت عدم تغییر حفظ شد +from persiantools.jdatetime import JalaliDate +from persiantools import characters, digits +import datetime +from django.utils import timezone # جدید +from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage +from django.core.exceptions import ValidationError # جدید +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest # بخشBadRequest جدید +from django.db import transaction, connection +from django.db.models import Count, Sum, Q +from django.contrib.auth.models import Permission, User +from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.decorators import login_required, permission_required +import os +import json # جدید +import logging # جدید + +from .models import * + +logger = logging.getLogger(__name__) + +class InventoryException(Exception): + """خطای اختصاصی برای کنترل موجودی انبار""" + pass + +def _safe_int(value, default=0): + """تابع امن برای تبدیل مقادیر دریافتی از جاوااسکریپت به عدد""" + if value is None: + return default + try: + clean_val = str(value).strip() + return int(clean_val) if clean_val else default + except (ValueError, TypeError): + return default + +def _parse_jalali_date(date_str): + """تبدیل امن تاریخ ارسالی از pDatepicker به میلادی""" + if not date_str or not str(date_str).strip(): + return None + try: + clean_date = digits.fa_to_en(digits.ar_to_fa(str(date_str).strip())) + y, m, d = map(int, clean_date.split("/")) + return JalaliDate(y, m, d).to_gregorian() + except Exception as e: + logger.warning(f"Error parsing Jalali date '{date_str}': {e}") + return None + +def _parse_timestamp(ts_value): + """تبدیل یونیکس تایم‌استمپ‌های ارسالی از جاوااسکریپت""" + if not ts_value: + return None + try: + # تبدیل میلی‌ثانیه جاوااسکریپت به ثانیه پایتون + return datetime.datetime.fromtimestamp(int(ts_value) / 1000.0, tz=timezone.get_current_timezone()) + except (ValueError, TypeError): + return None + +def p_login(request): + username = request.POST.get('username', None ) + password = request.POST.get('password', None ) + if username != None and password != None: + if not request.user.is_authenticated: + user = authenticate(request, username=username, password=password) + if user is not None: + login(request, user) + return redirect("/panel/ActionList/"+str(request.user.id)+"/today") + else: + data = {'message': 'یوزنیم یا پسورد اشتباه است'} + return render(request, 'admin_panel/login.html', data) + else: + return redirect("/panel/") + else: + if request.user.is_authenticated: + return redirect("/panel/") + else: + return render(request, 'admin_panel/login.html') + #return logout(request) + +def p_logout(request): + logout(request) + return redirect("/panel/p_login/") + + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewReportMali', raise_exception=True) +def report(request): + + nahvePardakhat = [ + {'fa': 'تخفیف', 'en': 'تخفیف'}, + {'fa': 'نسیه', 'en': 'نسیه'}, + {'fa': 'چک', 'en': 'چک'}, + {'fa': 'کارتخوان', 'en': 'کارت خوان'}, + {'fa': 'کارت به کارت', 'en': 'کارت به کارت'}, + {'fa': 'نقدی', 'en': 'نقد'}, + ] + + userHa = User.objects.filter() + + if request.method == "GET": + nahvePardakht = request.GET.get("nahvepardakht", None) + user = request.GET.get("user", None) + azTarikh = request.GET.get("azTarikh", None) + taTarikh = request.GET.get("taTarikh", None) + pagenumber = request.GET.get("page", None) + + + if azTarikh and nahvePardakht: + + query_data = { + 'nahvePardakht': nahvePardakht, + 'user': user, + 'azTarikh': azTarikh, + 'taTarikh': taTarikh, + } + + azDateTime = datetime.datetime.fromtimestamp(int(azTarikh) / 1e3) + azDateTime = azDateTime.strftime('%Y-%m-%d 00:00:00') + + taDateTime = datetime.datetime.fromtimestamp(int(taTarikh) / 1e3) + taDateTime = taDateTime.strftime('%Y-%m-%d 23:59:59') + print("b") + user_a = User.objects.get(username=user) + print(user_a) + + if nahvePardakht == "نسیه" or nahvePardakht == "چک": + if nahvePardakht == 'نسیه': + typeSarresid = 'nesiye' + elif nahvePardakht == 'چک': + typeSarresid = 'check' + + + sumSarresidha = Sarresid.objects.filter( + type=typeSarresid, + user=user_a, + createdDT__lte=taDateTime, + createdDT__gte=azDateTime, + payStatus='false' + ).aggregate(Sum('price')) + + sarresidha = Sarresid.objects.filter( + type=typeSarresid, + user=user_a, + createdDT__lte=taDateTime, + createdDT__gte=azDateTime, + payStatus='false' + ) + + paginator = Paginator(sarresidha, 25) # Show 25 contacts per page. + page_number = request.GET.get('page', None) + all_sarresidha = paginator.get_page(pagenumber) + data = {'sarresidha': all_sarresidha ,'sumSarresidha': sumSarresidha, 'nahvePardakhat': nahvePardakhat, 'userHa':userHa, 'query_data': query_data} + return render(request, 'admin_panel/reportCheckNesiye.html', data) + + else: + + sumAricles = Article.objects.filter( + nahvePardakht=nahvePardakht, + user=user_a, + createdDT__lte=taDateTime, + createdDT__gte=azDateTime, + ).aggregate(Sum('credit')) + + aricles1 = Article.objects.filter( + nahvePardakht=nahvePardakht, + user=user_a, + createdDT__lte=taDateTime, + createdDT__gte=azDateTime, + ) + + paginator = Paginator(aricles1, 25) # Show 25 contacts per page. + page_number = request.GET.get('page', None) + all_aricles = paginator.get_page(page_number) + data = {'aricles': all_aricles,'sum_credit': sumAricles, 'nahvePardakhat': nahvePardakhat, 'userHa':userHa, 'query_data': query_data} + return render(request, 'admin_panel/report.html', data) + else: + data = {'nahvePardakhat': nahvePardakhat, 'userHa':userHa} + return render(request, 'admin_panel/report.html', data) + + + data = {'nahvePardakhat': nahvePardakhat, 'userHa':userHa} + return render(request, 'admin_panel/report.html', data) + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewReportMali', raise_exception=True) +def inventory_movements_report(request): + """ + گزارش جامع گردش و خروج کالا از انبار به همراه فیلتر متنی نام کالا، + نوع تراکنش، کاربر، انبار و بازه زمانی شمسی. + """ + all_users = User.objects.all().order_by('first_name') + all_stores = StoreRoom.objects.all() + movement_choices = ProductInStore.MovementType.choices + + # دریافت پارامترهای فیلتر از فرانت‌اند + selected_type = request.GET.get('movement_type', '') + selected_user = request.GET.get('user_id', '') + selected_store = request.GET.get('store_id', '') + selected_product_name = request.GET.get('product_name', '').strip() # فیلد جدید جستجوی نام کالا + az_tarikh = request.GET.get('azTarikh', '') + ta_tarikh = request.GET.get('taTarikh', '') + + # ۱. کوئری پایه بهینه + movements_list = ProductInStore.objects.select_related( + 'Product', 'StoreRoom', 'factor__customer', 'factor__user' + ).filter(year=1400).order_by('-createdDT') + + # ۲. اعمال فیلترهای پویا + if selected_type: + movements_list = movements_list.filter(movement_type=selected_type) + + if selected_user: + movements_list = movements_list.filter(factor__user__id=_safe_int(selected_user)) + + if selected_store: + movements_list = movements_list.filter(StoreRoom__id=_safe_int(selected_store)) + + if selected_product_name: + # فیلتر متنی حساس نبودن به حروف بزرگ و کوچک (Case-Insensitive Contains) + movements_list = movements_list.filter(Product__product_name__icontains=selected_product_name) + + # ۳. پردازش بازه زمانی + if az_tarikh: + try: + az_dt = datetime.datetime.fromtimestamp(int(az_tarikh) / 1000.0) + movements_list = movements_list.filter(createdDT__gte=az_dt.strftime('%Y-%m-%d 00:00:00')) + except (ValueError, TypeError): + pass + + if ta_tarikh: + try: + ta_dt = datetime.datetime.fromtimestamp(int(ta_tarikh) / 1000.0) + movements_list = movements_list.filter(createdDT__lte=ta_dt.strftime('%Y-%m-%d 23:59:59')) + except (ValueError, TypeError): + pass + + # ۴. محاسبه آماری سریع دیتابیس روی نتایج فیلتر شده + summary_stats = movements_list.aggregate( + total_out_qty=Sum('s_out'), + total_in_qty=Sum('s_in') + ) + + # ۵. صفحه‌بندی نتایج + paginator = Paginator(movements_list, 50) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = { + 'page_obj': page_obj, + 'all_users': all_users, + 'all_stores': all_stores, + 'movement_choices': movement_choices, + 'summary_stats': summary_stats, + 'filters': { + 'movement_type': selected_type, + 'user_id': _safe_int(selected_user) if selected_user else '', + 'store_id': _safe_int(selected_store) if selected_store else '', + 'product_name': selected_product_name, # ارسال فیلد جدید به قالب برای حفظ مقدار ورودی + 'azTarikh': az_tarikh, + 'taTarikh': ta_tarikh, + } + } + + return render(request, 'admin_panel/report_inventory_movements.html', context) + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewReportMali', raise_exception=True) +def financial_dashboard(request): + """ + داشبورد هوش مالی و مدیریت نقدینگی سطح Enterprise (BI Dashboard) + همراه با روند ۱۲ ماهه جمع کل بدهی مشتریان (تفاضل debit و credit). + """ + # دریافت تایم‌استمپ‌های ارسالی از تقویم شمسی فرانت‌اند + az_tarikh = request.GET.get('azTarikh', '') + ta_tarikh = request.GET.get('taTarikh', '') + + az_dt = None + ta_dt = None + + if az_tarikh: + try: + az_dt = datetime.datetime.fromtimestamp(int(az_tarikh) / 1000.0) + az_dt = az_dt.replace(hour=0, minute=0, second=0) + except (ValueError, TypeError): + pass + + if ta_tarikh: + try: + ta_dt = datetime.datetime.fromtimestamp(int(ta_tarikh) / 1000.0) + ta_dt = ta_dt.replace(hour=23, minute=59, second=59) + except (ValueError, TypeError): + pass + + # نقطه اتکا (لنگر) محاسبات: زمان انتخابی کاربر یا زمان حال + anchor_dt = ta_dt if ta_dt else datetime.datetime.now() + + # ========================================================================= + # محاسبه روند ۱۲ ماهه غلتان (TTM) - نسخه اصلاح‌شده و امن + # ========================================================================= + jalali_months = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] + trend_labels = [] + trend_data = [] + + # روش استاندارد و بدون باگ برای تبدیل تاریخ میلادی به شمسی در persiantools + # با این کار مطمئن می‌شویم مقادیر حتماً عدد صحیح و مجاز هستند + anchor_jd = JalaliDate(anchor_dt.date()) + curr_y = int(anchor_jd.year) + curr_m = int(anchor_jd.month) + + # حلقه استخراج داده برای ۱۲ ماه گذشته + for _ in range(12): + if curr_m < 1: + curr_m = 12 + curr_y -= 1 + + label = f"{jalali_months[curr_m - 1]} {curr_y}" + trend_labels.insert(0, label) + + # ❗️ اصلاح اساسی: ترتیب آرگومان‌ها در این تابع باید (month, year) باشد + last_day = JalaliDate.days_in_month(curr_m, curr_y) + + # اما ترتیب سازنده کلاس اصلی همچنان (year, month, day) است + end_of_month_jd = JalaliDate(curr_y, curr_m, last_day) + end_of_month_gd = end_of_month_jd.to_gregorian() + + # ساخت رشته زمان برای پایان آن ماه (تا ساعت 23:59:59) + end_dt_str = end_of_month_gd.strftime('%Y-%m-%d 23:59:59') + + # محاسبه مانده حساب کل تا پایان این ماه + totals = Article.objects.filter(createdDT__lte=end_dt_str).aggregate( + t_deb=Sum('debit'), + t_cred=Sum('credit') + ) + t_deb = totals['t_deb'] or 0 + t_cred = totals['t_cred'] or 0 + month_total_debt = t_deb - t_cred + + trend_data.insert(0, month_total_debt) + + # شیفت به ماه قبل برای دور بعدی حلقه + curr_m -= 1 + + # ========================================================================= + # قرنطینه داده‌های سایر بخش‌های داشبورد بر اساس سال مالی ۱۴۰۰ + # ========================================================================= + + sarresid_qs = Sarresid.objects.filter(factor__productinstore__year=1400).distinct() + factor_qs = Factor.objects.filter(productinstore__year=1400, is_active=True).distinct() + article_qs = Article.objects.filter(factor__productinstore__year=1400).distinct() + store_qs = ProductInStore.objects.filter(year=1400) + + if az_dt: + sarresid_qs = sarresid_qs.filter(createdDT__gte=az_dt) + factor_qs = factor_qs.filter(create_date__gte=az_dt) + article_qs = article_qs.filter(createdDT__gte=az_dt) + store_qs = store_qs.filter(createdDT__gte=az_dt) + + if ta_dt: + sarresid_qs = sarresid_qs.filter(createdDT__lte=ta_dt) + factor_qs = factor_qs.filter(create_date__lte=ta_dt) + article_qs = article_qs.filter(createdDT__lte=ta_dt) + store_qs = store_qs.filter(createdDT__lte=ta_dt) + + # ۱. محاسبات کارت‌های شاخص عملکرد (KPI Cards) + total_unpaid_debts = sarresid_qs.filter(payStatus="false").aggregate(Sum('price'))['price__sum'] or 0 + total_sales_period = factor_qs.aggregate(Sum('amount'))['amount__sum'] or 0 + overdue_checks_count = sarresid_qs.filter(payStatus="false", type="check", sarresidDT__lte=anchor_dt).count() + + # ۲. نمودار اول: تحلیل پویای سن مطالبات + debt_future = sarresid_qs.filter(payStatus="false", sarresidDT__gt=anchor_dt).aggregate(Sum('price'))['price__sum'] or 0 + dt_30_days_ago = anchor_dt - datetime.timedelta(days=30) + debt_0_30 = sarresid_qs.filter(payStatus="false", sarresidDT__lte=anchor_dt, sarresidDT__gte=dt_30_days_ago).aggregate(Sum('price'))['price__sum'] or 0 + dt_90_days_ago = anchor_dt - datetime.timedelta(days=90) + debt_30_90 = sarresid_qs.filter(payStatus="false", sarresidDT__lt=dt_30_days_ago, sarresidDT__gte=dt_90_days_ago).aggregate(Sum('price'))['price__sum'] or 0 + debt_above_90 = sarresid_qs.filter(payStatus="false", sarresidDT__lt=dt_90_days_ago).aggregate(Sum('price'))['price__sum'] or 0 + + # ۳. نمودار دوم: ماتریس کیفیت ورودی پول + payment_articles = article_qs.filter(credit__gt=0).values('nahvePardakht').annotate(total_amount=Sum('credit')).order_by('-total_amount') + pay_labels = [] + pay_data = [] + for item in payment_articles: + label = item['nahvePardakht'] or 'سایر/نامشخص' + pay_labels.append(label) + pay_data.append(item['total_amount']) + + # ۴. نمودار سوم: مانیتورینگ نشت انبار + store_leaks = store_qs.filter( + movement_type__in=[ProductInStore.MovementType.WARRANTY, ProductInStore.MovementType.PROMOTION] + ).values('movement_type').annotate(total_qty=Sum('s_out')) + + leak_data = {'WARRANTY': 0, 'PROMOTION': 0} + for leak in store_leaks: + leak_data[leak['movement_type']] = leak['total_qty'] + + # ارسال ساختار داده به تمپلیت + context = { + 'kpi_total_unpaid': total_unpaid_debts, + 'kpi_total_sales': total_sales_period, + 'kpi_overdue_checks': overdue_checks_count, + + 'trend_labels': trend_labels, # نام ماه‌ها برای محور X + 'trend_data': trend_data, # جمع کل بدهی‌ها برای محور Y + + 'aging_data': [debt_future, debt_0_30, debt_30_90, debt_above_90], + 'pay_labels': pay_labels, + 'pay_data': pay_data, + 'warranty_qty': leak_data['WARRANTY'], + 'promotion_qty': leak_data['PROMOTION'], + + 'filters': { + 'azTarikh': az_tarikh, + 'taTarikh': ta_tarikh, + } + } + + return render(request, 'admin_panel/financial_dashboard.html', context) + + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewReportMali', raise_exception=True) +def bedehi(request): + try: + debit = Article.objects.filter().aggregate(Sum('debit'))['debit__sum'] + credit = Article.objects.filter().aggregate(Sum('credit'))['credit__sum'] + mandehesabCs = debit - credit + articls = Article.objects.filter().order_by('-createdDT') + articlsPaginator = Paginator(articls, 10) + pageNumber = request.GET.get("page", 1) + articl_objs = articlsPaginator.get_page(pageNumber) + + except: + articls =None + mandehesabCs = 0 + + data = {'articls': articl_objs, 'mandehesabKP': mandehesabCs} + return render(request, 'admin_panel/bedehi.html', data) + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewReportMali', raise_exception=True) +def hesabdari(request): + page_name = 'sarresidShodeha' + anchor_dt = datetime.datetime.now() + + # تعریف محدوده‌های زمانی ماتریس ریسک (سن مطالبات) + dt_30_days_ago = anchor_dt - datetime.timedelta(days=30) + dt_90_days_ago = anchor_dt - datetime.timedelta(days=90) + + # کوئری پایه: تمام سررسیدهای پرداخت‌نشده که از موعد آن‌ها گذشته است + base_qs = Sarresid.objects.filter(payStatus="false", sarresidDT__lte=anchor_dt) + + # محاسبه پرسرعت مجموع مبالغ در هر دسته (توسط موتور دیتابیس) + matrix_totals = base_qs.aggregate( + minor_delay=Sum('price', filter=Q(sarresidDT__gte=dt_30_days_ago)), + high_risk=Sum('price', filter=Q(sarresidDT__lt=dt_30_days_ago, sarresidDT__gte=dt_90_days_ago)), + critical=Sum('price', filter=Q(sarresidDT__lt=dt_90_days_ago)), + total_sum=Sum('price') + ) + + # دریافت فیلتر انتخابی کاربر از URL + risk_filter = request.GET.get('risk_level', 'all') + + # اعمال فیلتر روی لیست نمایش داده شده + if risk_filter == 'minor': + filtered_qs = base_qs.filter(sarresidDT__gte=dt_30_days_ago) + elif risk_filter == 'high': + filtered_qs = base_qs.filter(sarresidDT__lt=dt_30_days_ago, sarresidDT__gte=dt_90_days_ago) + elif risk_filter == 'critical': + filtered_qs = base_qs.filter(sarresidDT__lt=dt_90_days_ago) + else: + filtered_qs = base_qs + + # مرتب‌سازی: قدیمی‌ترین‌ها (خطرناک‌ترین‌ها) در صدر جدول قرار می‌گیرند + filtered_qs = filtered_qs.order_by('sarresidDT') + + page_number = request.GET.get('page', 1) + paginator = Paginator(filtered_qs, per_page=50) + all_sarresid_pgi = paginator.get_page(page_number) + + data = { + 'page_name': page_name, + 'dateNow': anchor_dt.strftime('%Y-%m-%d %H:%M:%S'), + 'all_sarresid': all_sarresid_pgi, + 'current_filter': risk_filter, + # متغیرهای ماتریس ریسک + 'sum_sarresid': matrix_totals['total_sum'] or 0, + 'minor_sum': matrix_totals['minor_delay'] or 0, + 'high_sum': matrix_totals['high_risk'] or 0, + 'critical_sum': matrix_totals['critical'] or 0, + } + + return render(request, 'admin_panel/hesabdari.html', data) + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewReportMali', raise_exception=True) +def hesabdari_checkPass(request): + + page_name = 'checkPass' + + dateNow = datetime.datetime.today().strftime('%Y-%m-%d 23:59:59') + all_sarresid = Sarresid.objects.filter(payStatus="true", type='check').order_by('-sarresidDT') + + page_number = request.GET.get('page', None) + paginator = Paginator(all_sarresid, per_page=50) + + all_sarresid_pgi = paginator.get_page(page_number) + + #paginator = Paginator(all_sarresid, per_page=200) + + data = {'all_sarresid': all_sarresid_pgi, "dateNow":dateNow, "page_name":page_name} + return render(request, 'admin_panel/hesabdari.html', data) + + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewReportMali', raise_exception=True) +def hesabdari_checkPassFail(request): + + page_name = 'checkPassFaild' + + dateNow = datetime.datetime.today().strftime('%Y-%m-%d 23:59:59') + all_sarresid = Sarresid.objects.filter(payStatus="false", type='check').order_by('sarresidDT') + + count_check_passfail = all_sarresid.count() + + sum_check_passfail = all_sarresid.aggregate(Sum('price'))['price__sum'] + + page_number = request.GET.get('page', None) + paginator = Paginator(all_sarresid, per_page=50) + + all_sarresid_pgi = paginator.get_page(page_number) + + #paginator = Paginator(all_sarresid, per_page=200) + + data = {'all_sarresid': all_sarresid_pgi, 'sum_check_passfail':sum_check_passfail, 'count_check_passfail': count_check_passfail, "dateNow":dateNow, "page_name":page_name} + return render(request, 'admin_panel/hesabdari.html', data) + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewReportMali', raise_exception=True) +def hesabdari_residnashone(request): + + page_name = 'sarresidNaShodeha' + + dateNow = datetime.datetime.today().strftime('%Y-%m-%d 23:59:59') + all_sarresid = Sarresid.objects.filter(payStatus="false", sarresidDT__gte=dateNow).order_by('sarresidDT') + + sarresidNashode = all_sarresid.aggregate(Sum('price'))['price__sum'] + page_number = request.GET.get('page', None) + paginator = Paginator(all_sarresid, per_page=50) + + all_sarresid_pgi = paginator.get_page(page_number) + + #paginator = Paginator(all_sarresid, per_page=200) + + data = {'sarresidNashode': sarresidNashode,'all_sarresid': all_sarresid_pgi, "dateNow":dateNow, "page_name":page_name} + return render(request, 'admin_panel/hesabdari.html', data) + + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewReportMali', raise_exception=True) +def bedehiKhadamat(request): + #bedehiKhadamat + try: + debit = Article.objects.filter(typePy='khadamat').aggregate(Sum('debit'))['debit__sum'] + credit = Article.objects.filter(typePy='khadamat').aggregate(Sum('credit'))['credit__sum'] + mandehesabCs = debit - credit + articls = Article.objects.filter(typePy='khadamat').order_by('-createdDT')[:100] + except: + articls =None + mandehesabCs = 0 + + data = {'articls': articls, 'mandehesabKP': mandehesabCs} + return render(request, 'admin_panel/bedehi.html', data) + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewReportMali', raise_exception=True) +def bedehiNasb(request): + try: + debit = Article.objects.filter(typePy='nasb').aggregate(Sum('debit'))['debit__sum'] + credit = Article.objects.filter(typePy='nasb').aggregate(Sum('credit'))['credit__sum'] + mandehesabCs = debit - credit + articls = Article.objects.filter(typePy='nasb').order_by('-createdDT')[:100] + except: + articls =None + mandehesabCs = 0 + + data = {'articls': articls, 'mandehesabKP': mandehesabCs} + return render(request, 'admin_panel/bedehi.html', data) + +@csrf_exempt +@login_required(login_url='/panel/p_login/') +def customers(request): + searchVal = request.GET.get('searchVal', '') + searchOn = request.GET.get('searchOn', '') + searchStatus = True + full_path = request.get_full_path() + + if searchOn == 'code': + all_customers = Customer.objects.filter(idCode__contains=searchVal).order_by('-create_date') + elif searchOn == 'phone': + all_customers = Customer.objects.filter(Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal) | Q(phone3__contains=searchVal) ).order_by('-create_date') + elif searchOn == 'name': + all_customers = Customer.objects.filter(name_family__contains=searchVal).order_by('-create_date') + elif searchOn == 'address': + all_customers = Customer.objects.filter(address__contains=searchVal).order_by('-create_date') + elif searchOn == 'laghv': + all_customers = Customer.objects.filter(laghv_bazdid=True).order_by('-create_date') + else: + searchStatus = False + all_customers = Customer.objects.all().order_by('-create_date') + + paginator = Paginator(all_customers, 25) # Show 25 contacts per page. + page_number = request.GET.get('page', None) + all_customers = paginator.get_page(page_number) + + + data = {'customersD': all_customers, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn':searchOn} + return render(request, 'admin_panel/customers.html', data) + + +@login_required(login_url='/panel/p_login/') +def customersSearch(request, searchVal,searchOn): + searchStatus = True + + if searchOn == 'code': + all_customers = Customer.objects.filter(idCode__contains=searchVal).order_by('-create_date') + elif searchOn == 'phone': + all_customers = Customer.objects.filter(phone1__contains=searchVal).order_by('-create_date') + elif searchOn == 'name': + all_customers = Customer.objects.filter(name_family__contains=searchVal).order_by('-create_date') + elif searchOn == 'address': + all_customers = Customer.objects.filter(address__contains=searchVal).order_by('-create_date') + else: + searchStatus = False + all_customers = Customer.objects.all().order_by('-create_date') + + paginator = Paginator(all_customers, 25) # Show 25 contacts per page. + page_number = request.GET.get('page', None) + all_customers = paginator.get_page(page_number) + + + data = {'customersD': all_customers, 'searchStatus': searchStatus} + return render(request, 'admin_panel/customers.html', data) + +@login_required(login_url='/panel/p_login/') +def customer(request ,c_id): + customer = Customer.objects.get(id=c_id) + categorys = ProductCategory.objects.all() + factorha = Factor.objects.filter(customer__id = c_id) + #all_prd = serializers.serialize("json", all_prda) + #return JsonResponse(all_prd,safe = False) + dateNow = datetime.datetime.now().date + data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha} + return render(request, 'admin_panel/customerID.html', data) + +@login_required(login_url='/panel/p_login/') +def customerNew(request ,c_id): + customer = Customer.objects.get(id=c_id) + categorys = ProductCategory.objects.all() + factorha = Factor.objects.filter(customer__id = c_id) + #all_prd = serializers.serialize("json", all_prda) + #return JsonResponse(all_prd,safe = False) + dateNow = datetime.datetime.now().date + data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha} + return render(request, 'admin_panel/customerNew.html', data) + + +@login_required(login_url='/panel/p_login/') +def customerNew2(request ,c_id): + customer = Customer.objects.get(id=c_id) + categorys = ProductCategory.objects.all() + factorha = Factor.objects.filter(customer__id = c_id) + + debit = Article.objects.filter(customer=customer).aggregate(Sum('debit'))['debit__sum'] + credit = Article.objects.filter(customer=customer).aggregate(Sum('credit'))['credit__sum'] + mandehesabCs = 0 + try: + if (debit - credit) < 0: + mandehesabCs = abs(debit - credit) + except: + pass + + + #all_prd = serializers.serialize("json", all_prda) + #return JsonResponse(all_prd,safe = False) + dateNow = datetime.datetime.now().date + data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha, 'mandehesabCs':mandehesabCs} + return render(request, 'admin_panel/customerNew2.html', data) + + +@login_required(login_url='/panel/p_login/') +def customerFactors(request ,c_id): + customer = Customer.objects.get(id=c_id) + categorys = ProductCategory.objects.all() + factorha = Factor.objects.filter(customer__id = c_id).order_by('-create_date') + #all_prd = serializers.serialize("json", all_prda) + #return JsonResponse(all_prd,safe = False) + dateNow = datetime.datetime.now().date + data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha} + return render(request, 'admin_panel/customerIDFactor.html', data) + +@login_required(login_url='/panel/p_login/') +def customerFactor(request ,c_id, f_id): + customer = Customer.objects.get(id=c_id) + categorys = ProductCategory.objects.all() + factorO = Factor.objects.get(customer=customer, id=f_id) + fiteam = FactorIteam.objects.filter(factor=factorO) + #all_prd = serializers.serialize("json", all_prda) + #return JsonResponse(all_prd,safe = False) + dateNow = datetime.datetime.now().date + data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorO': factorO, 'fiteam': fiteam} + return render(request, 'admin_panel/customerFactor.html', data) + + +def productsJson(request): + from django.core import serializers + from django.http import JsonResponse + all_prd = Product.objects.all() + all_prd = serializers.serialize("json", all_prd) + return JsonResponse(all_prd,safe = False) + +def ActionJson(request, action_id): + from django.core import serializers + from django.http import JsonResponse + AcJson = Action.objects.filter(id=action_id) + AcJson = serializers.serialize("json", AcJson) + return JsonResponse(AcJson,safe = False) + +def CustomerJson(request, c_id): + from django.core import serializers + from django.http import JsonResponse + customerJson = Customer.objects.filter(id=c_id) + customerJson = serializers.serialize("json", customerJson) + return JsonResponse(customerJson,safe = False) + +@login_required(login_url='/panel/p_login/') +def productMojodi(request, p_id): + storeUser = StoreRoom.objects.get(user__id=request.user.id) + + # ۱. تجمیع ورودی و خروجی در یک کوئری بهینه برای افزایش سرعت دیتابیس + totals = ProductInStore.objects.filter( + Product__id=p_id, StoreRoom=storeUser, year=1400 + ).aggregate(total_in=Sum('s_in'), total_out=Sum('s_out')) + + # ۲. رفع باگ قطعی NoneType: اگر رکوردی نباشد، مقدار None را به 0 تبدیل می‌کند + total_in = totals['total_in'] or 0 + total_out = totals['total_out'] or 0 + + # ۳. محاسبه دقیق موجودی بدون ریسک بروز خطای ریاضی پایتون + mojodi_prd = total_in - total_out + + # ۴. بازگشت خروجی دقیقاً متناسب با ساختار متنی فرانت‌اَند شما + return HttpResponse(str(mojodi_prd)) + +@login_required(login_url='/panel/p_login/') +def customersAdd(request): + an_name = request.POST.get('an_name', '') + an_address = request.POST.get('an_address', '') + phone1 = request.POST.get('phone1', '') + phone2 = request.POST.get('phone2', '') + phone3 = request.POST.get('phone3', '') + installDate = request.POST.get('installDate', '') + idnCode = request.POST.get('idnCode', '') + an_name = request.POST.get('an_name', '') + + year = int(installDate.split('/')[0]) + month = int(installDate.split('/')[1]) + day = int(installDate.split('/')[2]) + + #return HttpResponse(year) + installDate = JalaliDate(year, month, day).to_gregorian() + + newCs = Customer() + newCs.name_family = an_name + newCs.address = an_address + newCs.phone1 = phone1 + newCs.phone2 = phone2 + newCs.phone3 = phone3 + newCs.installDate = installDate + newCs.idCode = idnCode + newCs.save() + + #return HttpResponse(installDate) + return redirect('/panel/customers/') + #return render(request, 'admin_panel/customersAdd.html') + +@login_required(login_url='/panel/p_login/') +def customerEdit(request, c_id): + an_name = request.POST.get('an_name', '') + an_address = request.POST.get('an_address', '') + phone1 = request.POST.get('phone1', '') + phone2 = request.POST.get('phone2', '') + phone3 = request.POST.get('phone3', '') + installDate = request.POST.get('installDate', '') + idnCode = request.POST.get('idnCode', '') + laghv_bazdid = request.POST.get('laghv_bazdid', False) + an_name = request.POST.get('an_name', '') + + year = int(installDate.split('/')[0]) + month = int(installDate.split('/')[1]) + day = int(installDate.split('/')[2]) + + #return HttpResponse(year) + installDate = JalaliDate(year, month, day).to_gregorian() + + newCs = Customer.objects.get(id=c_id) + newCs.name_family = an_name + newCs.address = an_address + newCs.phone1 = phone1 + newCs.phone2 = phone2 + newCs.phone3 = phone3 + newCs.installDate = installDate + newCs.idCode = idnCode + if laghv_bazdid == 'true': + newCs.laghv_bazdid = True + else: + newCs.laghv_bazdid = False + newCs.save() + + #return HttpResponse(installDate) + return redirect('/panel/customer/'+str(c_id)) + + +@login_required(login_url='/panel/p_login/') +def entezar(request): + a = datetime.datetime.now() + + searchVal = request.GET.get('searchVal', '') + searchOn = request.GET.get('searchOn', '') + searchStatus = True + full_path = request.get_full_path() + + from django.db.models import Q + + bazdid = os.environ.get('bazdid', 9) + + bazdid = int ( int(bazdid) * 4.3 ) + + dateTime6MonthAgo = a - datetime.timedelta(weeks=bazdid) + ''' customers = Customer.objects.filter(name_family__isnull=False) + m6mbazdid = [] + for customerObj in customers: + try: + actionAa = Action.objects.filter(customer=customerObj, actionType='bazdid', status='finish').last() + if actionAa.status == 'finish': + customerObj.last_bazdid_dt = actionAa.actionDateTime + customerObj.save() + if actionAa.actionDateTime.date() < dateTime6MonthAgo.date(): + m6mbazdid.append(actionAa) + except: + pass ''' + + if searchOn == 'address': + print("addressaddressaddressaddressaddress") + customers_objj = Customer.objects.filter(Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True), + installDate__lte=dateTime6MonthAgo, + laghv_bazdid=False, name_family__isnull=False, address__contains=searchVal).order_by('last_bazdid_dt') + else: + searchStatus = False + customers_objj = Customer.objects.filter(Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True), + installDate__lte=dateTime6MonthAgo, + laghv_bazdid=False, name_family__isnull=False).order_by('last_bazdid_dt') + + #customers_objj = Customer.objects.filter(name_family__isnull=False, last_bazdid_dt__lte=dateTime6MonthAgo) + + try: + entezar_bazdid = CacheTable.objects.get(key='9mahbazdid') + + entezar_bazdid.value = Customer.objects.filter( + Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True), + installDate__lte=dateTime6MonthAgo, + laghv_bazdid=False, name_family__isnull=False).count() + + entezar_bazdid.save() + except: + pass + + paginator = Paginator(customers_objj, 20) # Show 25 contacts per page. + page_number = request.GET.get('page', None) + customerHa = paginator.get_page(page_number) + + return render(request, 'admin_panel/entezar.html', {'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn':searchOn}) + +@login_required(login_url='/panel/p_login/') +def stores(request): + if request.user.username not in ('admin', 'office1', 'office2', 'office3', 'khatibi'): + all_store = StoreRoom.objects.filter(user__id=request.user.id).order_by('id')[:20] + else: + all_store = StoreRoom.objects.all().order_by('id')[:20] + data = {'all_store': all_store} + return render(request, 'admin_panel/stores.html', data) + +@login_required(login_url='/panel/p_login/') +def store(request, s_id): + productCategory = ProductCategory.objects.all() + storeO = StoreRoom.objects.get(id=s_id) + searchStatus = False + searchQS = request.POST.get('searchQS', '') + mojodi = request.POST.get('mojodi', '') + #return HttpResponse(mojodi) + if searchQS !='': + productSum = Product.objects.filter(productinstore__StoreRoom=storeO ,productinstore__year=1400,product_name__contains=searchQS).annotate(mojodi=( Sum('productinstore__s_in') - Sum('productinstore__s_out') )) + if mojodi == "0": + productSum = productSum.filter(mojodi__lte=0) + searchStatus = True + else: + productSum = Product.objects.filter(productinstore__StoreRoom=storeO ,productinstore__year=1400).annotate(mojodi=( Sum('productinstore__s_in') - Sum('productinstore__s_out') )) + if mojodi == "0": + searchStatus = True + productSum = productSum.filter(mojodi__lte=0) + data = {'storeO': storeO,'productSum': productSum, 's_id': s_id, 'searchStatus': searchStatus, "mojodi": mojodi, 'djsearchQS':searchQS, 'productCategory': productCategory} + return render(request, 'admin_panel/store.html', data) + +@login_required(login_url='/panel/p_login/') +#@permission_required('admin_panel.ViewStore', raise_exception=True) +def operations(request, s_id): + page = request.GET.get('page', 1) + productCategory = ProductCategory.objects.all() + storeO = get_object_or_404(StoreRoom, id=s_id) + + # بهینه‌سازی کلیدی: استفاده از select_related برای جلوگیری از کندی و پر شدن RAM دیتابیس + operationsList = ProductInStore.objects.filter( + StoreRoom=storeO, year=1400 + ).select_related('Product').order_by('-createdDT') + + paginator = Paginator(operationsList, 25) + try: + data_list = paginator.page(page) + except PageNotAnInteger: + data_list = paginator.page(1) + except EmptyPage: + data_list = paginator.page(paginator.num_pages) + + data = { + 'storeO': storeO, + 'operationsList': data_list, + 's_id': s_id, + 'productCategory': productCategory + } + return render(request, 'admin_panel/operations.html', data) + + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewStore', raise_exception=True) +def operationProduct(request, s_id, p_id): + productCategory = ProductCategory.objects.all() + storeO = get_object_or_404(StoreRoom, id=s_id) + + # بهینه‌سازی کلیدی در فیلتر اختصاصی محصول + operationsList = ProductInStore.objects.filter( + StoreRoom=storeO, year=1400, Product__id=p_id + ).select_related('Product').order_by('-createdDT') + + data = { + 'storeO': storeO, + 'operationsList': operationsList, + 's_id': s_id, + 'productCategory': productCategory + } + return render(request, 'admin_panel/operations.html', data) + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewStore', raise_exception=True) +def newOperation(request, s_id, p_id): + productCategory = ProductCategory.objects.all() + storeO = StoreRoom.objects.get(id=s_id) + allStores = StoreRoom.objects.exclude(id=s_id) + product = Product.objects.get(id=p_id) + data = {'s_id': s_id,'p_id': p_id, 'allStores': allStores, 'storeO': storeO, 'product':product, 'productCategory': productCategory} + return render(request, 'admin_panel/newOperation.html', data ) + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewStore', raise_exception=True) +def newStore(request): + storeName = request.POST.get('storeName', '') + newStore = StoreRoom() + newStore.store_name = storeName + newStore.save() + + return redirect('/panel/store/') + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewStore', raise_exception=True) +def productAdd(request): + namePrd = request.POST.get('namePrd', '') + category = request.POST.get('category', '') + price = request.POST.get('price', '') + mojodiAval = request.POST.get('mojodiAval', '') + storeroom = request.POST.get('storeroom', '') + + newProduct = Product() + newProduct.product_name = namePrd + newProduct.ProductCategory = ProductCategory.objects.get(id=category) + newProduct.product_price = price + newProduct.save() + + prd_in_store = ProductInStore() + prd_in_store.Product = newProduct + prd_in_store.StoreRoom = StoreRoom.objects.get(id=storeroom) + prd_in_store.s_in = mojodiAval + prd_in_store.desc = "موجودی اولیه: "+str(mojodiAval) + prd_in_store.movement_type = ProductInStore.MovementType.INITIAL + prd_in_store.save() + + #return HttpResponse(installDate) + return redirect('/panel/store/1') + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewStore', raise_exception=True) +def productEdit(request): + prd_code = request.POST.get('prd_code', '') + namePrd = request.POST.get('namePrd', '') + category = request.POST.get('category', '') + price = request.POST.get('price', '') + + #return HttpResponse(prd_code) + + editProduct = Product.objects.get(id=prd_code) + editProduct.product_name = namePrd + editProduct.ProductCategory = ProductCategory.objects.get(id=category) + editProduct.product_price = price + editProduct.save() + + return redirect('/panel/store/1') + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewStore', raise_exception=True) +def productDel(request, p_id, s_id): + Product.objects.get(id=p_id).delete() + return redirect('/panel/store/'+str(s_id)) + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewStore', raise_exception=True) +def operation(request): + if request.method != 'POST': + return HttpResponseBadRequest("متد ارسالی نامعتبر است.") + + # استفاده از توابع امن اختصاصی پروژه شما برای جداسازی مقادیر عددی + prd_id = _safe_int(request.POST.get('prd_id')) + actionType = request.POST.get('actionType', '') + tedad = _safe_int(request.POST.get('tedad')) + azanbar = _safe_int(request.POST.get('azanbar')) + beanbar = _safe_int(request.POST.get('beanbar')) + desc = request.POST.get('desc', '') + + if tedad <= 0: + return HttpResponseBadRequest("تعداد وارد شده باید بیشتر از صفر باشد.") + + sRoom = get_object_or_404(StoreRoom, id=azanbar) + productO = get_object_or_404(Product, id=prd_id) + + try: + with transaction.atomic(): + + # ساخت کوئری پایه برای دریافت تراکنش‌های سال جاری کالا + qs = ProductInStore.objects.filter( + Product=productO, StoreRoom=sRoom, year=1400 + ) + + # بررسی هوشمند پشتیبانی دیتابیس از قابلیت قفل ردیف (سازگار با SQLite و PostgreSQL) + if connection.features.has_select_for_update: + qs = qs.select_for_update() + + # بارگذاری امن تراکنش‌ها در حافظه (RAM) جهت جلوگیری از خطای توابع تجمیعی همزمان با قفل دیتابیس + locked_records = list(qs) + + # --- عملیات ورود دستی --- + if actionType == 'vorod': + pInStore = ProductInStore( + StoreRoom=sRoom, + Product=productO, + year=1400 + ) + pInStore.s_in = tedad + pInStore.desc = "ورود: "+str(tedad)+" به انبار " + str(sRoom.store_name) + "#" + str(desc) + pInStore.movement_type = ProductInStore.MovementType.DIRECT_IN + pInStore.save() + + # --- عملیات خروج دستی یا انتقال بین انبارها --- + elif actionType in ['khoroj', 'transfer']: + + # محاسبات دقیق و سریع موجودی کالا در سطح پایتون بدون درگیر کردن دیسک + total_in = sum(item.s_in for item in locked_records if item.s_in) + total_out = sum(item.s_out for item in locked_records if item.s_out) + mojodi_prd = total_in - total_out + + if mojodi_prd < tedad: + # تطابق ۱۰۰ درصدی پاسخ کدهای قدیمی با فرانت‌اند شما + return HttpResponse("موجودی کافی نیست، موجودی فعلی: "+ str(mojodi_prd)) + + # الف) خروج دستی ساده + if actionType == 'khoroj': + pInStore = ProductInStore( + StoreRoom=sRoom, + Product=productO, + year=1400 + ) + pInStore.s_out = tedad + pInStore.desc = "خروج: "+str(tedad)+" از انبار " + str(sRoom.store_name) + "#" + str(desc) + pInStore.movement_type = ProductInStore.MovementType.DIRECT_OUT + pInStore.save() + + # ب) انتقال بین دو انبار مجزا + elif actionType == 'transfer': + if not beanbar: + return HttpResponse("انبار مقصد مشخص نشده است.") + + beanbarOjb = get_object_or_404(StoreRoom, id=_safe_int(beanbar)) + + # ردیف اول: ثبت تراکنش خروج از انبار مبدأ + pInStore = ProductInStore( + StoreRoom=sRoom, + Product=productO, + year=1400 + ) + pInStore.s_out = tedad + pInStore.desc = "خروج: "+str(tedad)+" از انبار " + str(sRoom.store_name) + " جهت انتقال به انبار " + str(beanbarOjb.store_name) + "#" + str(desc) + pInStore.movement_type = ProductInStore.MovementType.TRANSFER + pInStore.save() + + # ردیف دوم: ثبت تراکنش ورود به انبار مقصد (محافظت شده در بلاک اتمیک) + ProductInStore.objects.create( + StoreRoom=beanbarOjb, + Product=productO, + s_in=tedad, + year=1400, + desc="ورود: "+str(tedad)+" از انبار " + str(sRoom.store_name) + "#" + str(desc), + movement_type=ProductInStore.MovementType.TRANSFER + ) + + except Exception as e: + logger.error(f"Error in store operation view: {str(e)}", exc_info=True) + return HttpResponse("خطای سیستمی رخ داد. تراکنش لغو شد.") + + return redirect('/panel/store/'+str(sRoom.id)) + +@login_required(login_url='/panel/p_login/') +def newFactor(request, c_id): + prdsList = request.POST.get('prdsList', '') + sarResidDate = request.POST.get('sarResidDate', None) + pardakhti = request.POST.get('pardakhti', None) + #return HttpResponse(pardakhti) + if pardakhti: + pardakhti=int(pardakhti) + if pardakhti>=1: + pass + else: + pardakhti = None + #return HttpResponse(sarResidDate) + try: + sarResidDate = digits.fa_to_en(digits.ar_to_fa(sarResidDate)) + #return HttpResponse(sarResidDate) + sarResidDate = sarResidDate.split("/") + #return HttpResponse(sarResidDate[2]) + sarResidDate = JalaliDate(int(sarResidDate[0]), int(sarResidDate[1]), int(sarResidDate[2])).to_gregorian() + #return HttpResponse(sarResidDate) + except: + sarResidDate = None + + #return HttpResponse(sarResidDate[1]) + #sarResidDate = JalaliDate(sarResidDate[0], sarResidDate[1], sarResidDate[2]).to_gregorian() + + payType = request.POST.get('payType', '') + FactorDate = request.POST.get('FactorDate', '') + FactorType = request.POST.get('FactorType', '') + FactorDesc = request.POST.get('FactorDesc', '') + Factorfail = False + customerObj=Customer.objects.get(id=c_id) + storeUser = StoreRoom.objects.get(user__id=request.user.id) + import json + prdsList = json.loads(prdsList) + sum_price = 0 + for prd in prdsList: + if prd['garanty_Esh'] == ' ' or prd['garanty_Esh'] == '' or prd['garanty_Esh'] == None: + sum_price += prd['prd_count'] * prd['price'] + + #return HttpResponse(prd_code) + try: + newFactor = Factor() + newFactor.customer= customerObj + newFactor.amount = sum_price + if payType in ('naghd', 'kartKhan', 'cardToCard'): + newFactor.pay_status = 'paid' + else: + newFactor.pay_status = 'unpaid' + newFactor.FactorType = FactorType + newFactor.PayType = payType + newFactor.desc = FactorDesc + if sarResidDate != None: + newFactor.deadline = sarResidDate + newFactor.user = User.objects.get(id=request.user.id) + newFactor.save() + except: + try: + Factorfail=True + newFactor.delete() + except: + pass + + if Factorfail != True: + #accFactor To article + is_naghd = False + articleHesabDari = Article() + articleHesabDari.debit = newFactor.amount + articleHesabDari.credit = 0 + articleHesabDari.customer = customerObj + articleHesabDari.factor = newFactor + + if payType in ('naghd', 'kartKhan', 'cardToCard'): + is_naghd = True + + articleHesabDari.type = payType + articleHesabDari.desc = str(FactorType) + " " + str(customerObj.name_family) + " طی فاکتور شماره: " + str(newFactor.id) + articleHesabDari.save() + + if is_naghd: + articleHesabDari2 = Article() + articleHesabDari2.debit = 0 + articleHesabDari2.credit = newFactor.amount + articleHesabDari2.customer = customerObj + articleHesabDari2.type = payType + articleHesabDari2.factor = newFactor + articleHesabDari2.desc = str(FactorType) + " " + str(customerObj.name_family) + " تسویه نقدی فاکتور شماره: " + str(newFactor.id) + articleHesabDari2.save() + elif pardakhti != None and pardakhti!='' and pardakhti != ' ': + articleHesabDari2 = Article() + articleHesabDari2.debit = 0 + articleHesabDari2.credit = pardakhti + articleHesabDari2.customer = customerObj + articleHesabDari2.type = payType + articleHesabDari2.factor = newFactor + articleHesabDari2.desc = str(customerObj.name_family) + " پرداخت نقد در محل برا فاکتور " + str(newFactor.id) + articleHesabDari2.save() + + for prd in prdsList: + try: + fIteams = FactorIteam() + fIteams.factor = newFactor + if prd['prd_id'] == 9999: + Gproduct = None + fIteams.product = Gproduct + fIteams.product_name = prd['product_name'] + fIteams.num = prd['prd_count'] + fIteams.amount = int(prd['priceKol']) + else: + Gproduct = Product.objects.get(id=prd['prd_id']) + fIteams.product = Gproduct + fIteams.product_name = Gproduct.product_name + fIteams.num = prd['prd_count'] + fIteams.amount = prd['prd_count'] * Gproduct.product_price + fIteams.garantyEsh = prd['garanty_Esh'] + fIteams.save() + except: + Factorfail = True + newFactor.delete() + break + + if Factorfail != True: + for prd in prdsList: + if prd['prd_id'] != 9999: + pInStore = ProductInStore() + sRoom = storeUser + productO = Product.objects.get(id=prd['prd_id']) + pInStore.StoreRoom = sRoom + pInStore.Product = productO + pInStore.factor = newFactor + productS_in = ProductInStore.objects.filter(Product=productO, StoreRoom=sRoom, year=1400).aggregate(Sum('s_in'))['s_in__sum'] + productS_out = ProductInStore.objects.filter(Product=productO, StoreRoom=sRoom, year=1400).aggregate(Sum('s_out'))['s_out__sum'] + mojodi_prd = productS_in - productS_out + if mojodi_prd >= prd['prd_count']: + pInStore.s_out = prd['prd_count'] + if prd['garanty_Esh'] != ' ' and prd['garanty_Esh'] != '' and prd['garanty_Esh'] != None: + pInStore.desc = "خروج: " + str(prd['prd_count']) + "#"+str(prd['garanty_Esh'])+ "#" + str(customerObj.name_family) + " * " + str(request.user.first_name) + " " + str(request.user.last_name) + else: + pInStore.desc = "خروج: " + str(prd['prd_count']) + "#فروش" + "#" + str(customerObj.name_family) + " * " + str(request.user.first_name) + " " + str(request.user.last_name) + pInStore.save() + else: + return HttpResponse("موجودی کافی نیست، موجودی فعلی: "+ str(mojodi_prd)) + + return redirect('/panel/customer/'+str(c_id)+'/factors/') + +@login_required(login_url='/panel/p_login/') +def newFactor2(request, c_id): + if request.method != 'POST': + return HttpResponseBadRequest("متد ارسالی باید POST باشد.") + + user_op = request.user + + try: + prds_list = json.loads(request.POST.get('prdsList') or '[]') + json_pardakht_ha = json.loads(request.POST.get('PardakhtHa') or '{}') + except json.JSONDecodeError: + return HttpResponseBadRequest("فرمت داده‌های ارسالی JSON نامعتبر است.") + + factor_type = request.POST.get('FactorType', '') + factor_desc = request.POST.get('FactorDesc', '') + sar_resid_date = _parse_jalali_date(request.POST.get('sarResidDate')) + + customer_obj = get_object_or_404(Customer, id=c_id) + store_room = get_object_or_404(StoreRoom, user=user_op) + + # رفع مشکل متنی/عددی در استخراج شناسه کالاها + product_ids = [] + for item in prds_list: + p_id = item.get('prd_id') + if p_id and _safe_int(p_id) != 9999: + product_ids.append(_safe_int(p_id)) + + products_by_id = {p.id: p for p in Product.objects.filter(id__in=product_ids)} + + sum_price = sum( + _safe_int(item.get('prd_count')) * _safe_int(item.get('price')) + for item in prds_list + if not str(item.get('garanty_Esh', '')).strip() + ) + + try: + with transaction.atomic(): + + new_factor = Factor.objects.create( + customer=customer_obj, + amount=sum_price, + FactorType=factor_type, + desc=factor_desc, + deadline=sar_resid_date, + user=user_op + ) + + Article.objects.create( + debit=sum_price, + credit=0, + customer=customer_obj, + factor=new_factor, + desc=f"{customer_obj.name_family} فاکتور شماره: {new_factor.id}", + user=user_op + ) + + payment_map = { + 'l_naghd': 'نقد', + 'l_kartbekart': 'کارت به کارت', + 'l_kartKhan': 'کارت خوان', + 'l_takhfif': 'تخفیف' + } + + for key, amount_raw in json_pardakht_ha.items(): + amount = _safe_int(amount_raw) + if amount <= 0: + continue + + if key in payment_map: + nahve_pardakht = payment_map[key] + Article.objects.create( + debit=0, + credit=amount, + customer=customer_obj, + factor=new_factor, + desc=f"{nahve_pardakht} {customer_obj.name_family} فاکتور شماره: {new_factor.id}", + nahvePardakht=nahve_pardakht, + payStatus="true", + user=user_op + ) + + elif key in ['l_nesiye', 'l_checkPrice']: + is_nesiye = (key == 'l_nesiye') + type_str = 'nesiye' if is_nesiye else 'check' + ts_key = 'l_nesiyeSarResid' if is_nesiye else 'l_checkPriceSarResid' + + sar_resid_dt = _parse_timestamp(json_pardakht_ha.get(ts_key)) + + Sarresid.objects.create( + customer=customer_obj, + price=amount, + payStatus="false", + type=type_str, + user=user_op, + factor=new_factor, + sarresidDT=sar_resid_dt + ) + + factor_items_pool = [] + user_fullname = f"{user_op.first_name} {user_op.last_name}".strip() or user_op.username + + for item in prds_list: + # تبدیل امن شناسه به عدد + prd_id = _safe_int(item.get('prd_id')) + prd_count = _safe_int(item.get('prd_count')) + garanty_esh = item.get('garanty_Esh', '') + + if prd_id == 9999: + factor_items_pool.append(FactorIteam( + factor=new_factor, + product=None, + product_name=item.get('product_name', ''), + num=prd_count, + amount=_safe_int(item.get('priceKol')), + garantyEsh=garanty_esh + )) + else: + product_obj = products_by_id.get(prd_id) + + if not product_obj: + raise InventoryException(f"خطا در دریافت محصول: محصولی با شناسه {prd_id} در سیستم وجود ندارد.") + + factor_items_pool.append(FactorIteam( + factor=new_factor, + product=product_obj, + product_name=product_obj.product_name, + num=prd_count, + amount=prd_count * _safe_int(product_obj.product_price), + garantyEsh=garanty_esh + )) + + FactorIteam.objects.bulk_create(factor_items_pool, batch_size=500) + + # فرآیند کسر از انبار (Enterprise Level) + for item in prds_list: + prd_id = _safe_int(item.get('prd_id')) + if prd_id == 9999: + continue + + prd_count = _safe_int(item.get('prd_count')) + garanty_esh = item.get('garanty_Esh', '') + product_obj = products_by_id.get(prd_id) + + if not product_obj: + continue + + # ۱. کوئری پایه + qs = ProductInStore.objects.filter( + Product=product_obj, + StoreRoom=store_room, + year=1400 + ) + + # ۲. قفل دیتابیس هوشمند و سازگار با تمام پایگاه‌های داده + if connection.features.has_select_for_update: + qs = qs.select_for_update() + + # ۳. بارگذاری امن در رم برای جلوگیری از خطای Aggregate در دیتابیس قفل‌شده + locked_records = list(qs) + + # ۴. محاسبه دقیق و پرسرعت در پایتون + total_in = sum(rec.s_in for rec in locked_records if rec.s_in) + total_out = sum(rec.s_out for rec in locked_records if rec.s_out) + mojodi_prd = total_in - total_out + + if mojodi_prd < prd_count: + # استفاده از متن دقیق خطای قبلی جهت حفظ سازگاری با فرانت‌اند + raise InventoryException(f"موجودی کافی نیست، موجودی فعلی {product_obj.product_name}: {mojodi_prd}") + + # ۵. تشخیص نوع تراکنش برای سیستم گزارش‌گیری جدید انبار + if garanty_esh and str(garanty_esh).strip(): + desc_text = f"خروج: {prd_count}#{garanty_esh}#{customer_obj.name_family} * {user_fullname}" + + if "اشانتیون" in str(garanty_esh): + m_type = ProductInStore.MovementType.PROMOTION + else: + m_type = ProductInStore.MovementType.WARRANTY + else: + desc_text = f"خروج: {prd_count}#فروش#{customer_obj.name_family} * {user_fullname}" + m_type = ProductInStore.MovementType.SALE + + # ۶. ثبت تراکنش خروجی انبار + ProductInStore.objects.create( + StoreRoom=store_room, + Product=product_obj, + factor=new_factor, + s_out=prd_count, + desc=desc_text, + movement_type=m_type, # ثبت ساختاریافته نوع خروج برای سرعت در گزارش‌گیری + year=1400 + ) + + except InventoryException as e: + return HttpResponse(str(e), status=400) + except Exception as e: + logger.error(f"Error creating factor for customer {c_id}: {str(e)}", exc_info=True) + return HttpResponse("خطایی غیرمنتظره در سرور رخ داد. اطلاعات ذخیره نشد.", status=500) + + return redirect(f'/panel/customer/{c_id}/factors/') + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.DelFactor', raise_exception=True) +@require_POST +def FactorDel(request, c_id, f_id): + customer_obj = get_object_or_404(Customer, id=c_id) + factor_initial = get_object_or_404(Factor, id=f_id, customer=customer_obj) + + if factor_initial.pay_status == 'cancell' or not factor_initial.is_active: + return HttpResponseBadRequest("این فاکتور قبلاً باطل یا برگشت داده شده است.") + + user_fullname = f"{request.user.first_name} {request.user.last_name}".strip() or request.user.username + cancel_reason = request.POST.get('cancel_reason', 'error') + payment_action = request.POST.get('payment_action', 'keep') + + if cancel_reason == 'return': + reason_text = "برگشت از فروش (مرجوعی مشتری)" + inv_movement = ProductInStore.MovementType.RETURN + else: + reason_text = "اصلاح خطای کاربری" + inv_movement = ProductInStore.MovementType.ERROR_CORRECTION + + try: + with transaction.atomic(): + if connection.features.has_select_for_update: + factor = Factor.objects.select_for_update().get(pk=factor_initial.pk) + else: + factor = Factor.objects.get(pk=factor_initial.pk) + + if factor.pay_status == 'cancell' or not factor.is_active: + return HttpResponseBadRequest("خطای همزمانی: این فاکتور در لحظه فرستادن درخواست باطل شده بود.") + + factor.is_active = False + factor.pay_status = 'cancell' + factor.save(update_fields=['is_active', 'pay_status']) + + # واکشی اسناد پرداخت قبل از ایجاد سند جدید (جلوگیری از باگ تکرار سند) + payments_to_reverse = [] + if payment_action == 'reverse' or cancel_reason == 'error': + payments_to_reverse = list(Article.objects.filter( + Q(debit=0) | Q(debit__isnull=True), + factor=factor, + credit__gt=0 + )) + + # ۱. صدور سند معکوس مالی برای کل مبلغ فاکتور + factor_amount = int(factor.amount or 0) + if factor_amount > 0: + Article.objects.create( + debit=0, + credit=factor_amount, + customer=customer_obj, + factor=factor, + type=factor.PayType, + desc=f"برگشت از فروش و ابطال فاکتور شماره {factor.id} (مرجوعی کالا توسط مشتری) - کاربر: {user_fullname}" if cancel_reason == 'return' else f"اصلاح خطای سیستم: ابطال فاکتور اشتباه شماره {factor.id} (بدون جابجایی واقعی کالا) - کاربر: {user_fullname}", + user=request.user, + payStatus="true" + ) + + # ۲. خنثی کردن پرداخت‌های اولیه فاکتور + for p in payments_to_reverse: + Article.objects.create( + debit=p.credit, + credit=0, + customer=customer_obj, + factor=factor, + type=p.type, + nahvePardakht=p.nahvePardakht, + desc=f"عودت وجه پرداخت شده / ابطال رسید فاکتور {factor.id} (به دلیل مرجوعی کالا) - کاربر: {user_fullname}" if cancel_reason == 'return' else f"اصلاح خطای سیستم: ابطال رسید پرداخت فاکتور اشتباه شماره {factor.id} - کاربر: {user_fullname}", + user=request.user, + payStatus="true" + ) + + # ۳. 🛠️ اصلاح انبار (حل باگ عدم تغییر موجودی سیستم) + stock_movements = list(ProductInStore.objects.filter(factor=factor, s_out__gt=0)) + + for movement in stock_movements: + pInStore = ProductInStore( + StoreRoom=movement.StoreRoom, + Product=movement.Product, + factor=factor, + s_in=movement.s_out, # برگرداندن تعداد کسر شده به ستون ورودی + s_out=0, + year=movement.year, # 💡 فیکس قطعی: استفاده دقیق از سال مالی تراکنش اصلی (۱۴۰۰) جهت همخوانی با توابع سیستم + desc=f"ورود به انبار بابت برگشت از فروش کالا (فاکتور {factor.id}) - کاربر: {user_fullname}" if cancel_reason == 'return' else f"ورود به انبار بابت اصلاح خطای ثبت فاکتور {factor.id} - کاربر: {user_fullname}", + movement_type=inv_movement + ) + pInStore.save() # فراخوانی متد save مدل شما که محاسبات تاریخچه و موجودی لحظه‌ای را انجام می‌دهد + + # ۴. ابطال اقساط و سررسیدهای پاس نشده + Sarresid.objects.filter(factor=factor, payStatus="false").update( + payStatus="cancell", + desc=f"ابطال بابت {reason_text} فاکتور {factor.id}" + ) + + logger.info(f"Factor {factor.id} successfully cancelled. Reason: {cancel_reason}, Payment: {payment_action}") + + except Exception as e: + logger.error(f"Critical error in FactorDel for factor {f_id}: {str(e)}", exc_info=True) + return HttpResponse("خطای سیستمی در فرآیند ابطال و صدور اسناد اصلاحی رخ داد.", status=500) + + return redirect('admin_panel:customerFactors', c_id=c_id) + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.DelCustomer', raise_exception=True) +def customerDel(request, c_id): + try: + Customer.objects.get(id=c_id).delete() + return redirect('/panel/customers/') + except: + return HttpResponse("خطا در حذف مشتری") + + +@login_required(login_url='/panel/p_login/') +def customerHesabdari(request ,c_id): + customer = Customer.objects.get(id=c_id) + try: + debit = Article.objects.filter(customer=customer).aggregate(Sum('debit'))['debit__sum'] + credit = Article.objects.filter(customer=customer).aggregate(Sum('credit'))['credit__sum'] + mandehesabCs = debit - credit + articls = Article.objects.filter(customer=customer).order_by('-createdDT') + except: + articls =None + mandehesabCs = 0 + + dateNow = datetime.datetime.now().date + data = {'customer': customer, 'mandehesabCs': mandehesabCs, 'articls': articls} + return render(request, 'admin_panel/customerHesabDari.html', data) + +@login_required(login_url='/panel/p_login/') +def sarresidh(request ,c_id): + customer = Customer.objects.get(id=c_id) + try: + all_sarresid = Sarresid.objects.filter(customer=customer).order_by('-sarresidDT') + except: + all_sarresid = None + + data = {'customer': customer, 'all_sarresid': all_sarresid} + return render(request, 'admin_panel/customerSarresid.html', data) + +@login_required(login_url='/panel/p_login/') +def customerHesabdariAdd(request ,c_id): + user_op = request.user + price = request.POST.get('price', None) + type = request.POST.get('type', None) + typePy = request.POST.get('typePy', None) + desc = request.POST.get('desc', None) + nahvePardakhat = request.POST.get('nahvePardakhat', None) + + newArticl = Article() + newArticl.customer = Customer.objects.get(id=c_id) + newArticl.desc = desc + newArticl.typePy = typePy + if type == 'credit': + newArticl.credit = price + newArticl.debit = 0 + elif type == 'debit': + newArticl.debit = price + newArticl.credit = 0 + newArticl.user = user_op + newArticl.nahvePardakht = nahvePardakhat + newArticl.save() + return redirect('/panel/customer/'+str(c_id)+'/hesabdari') + + +@login_required(login_url='/panel/p_login/') +def customerAction(request ,c_id): + customer = Customer.objects.get(id=c_id) + actions = Action.objects.filter(customer=customer).order_by("-createdDT") + allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') + data = {'customer': customer, 'actions': actions, 'allUsers': allUsers} + return render(request, 'admin_panel/customerAction.html', data) + +@login_required(login_url='/panel/p_login/') +def customerNazar(request ,c_id): + customer = Customer.objects.get(id=c_id) + actions = Action.objects.filter(customer=customer, nazarDT__isnull=False).order_by("-createdDT") + allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') + data = {'customer': customer, 'actions': actions, 'allUsers': allUsers} + return render(request, 'admin_panel/customer/nazar.html', data) + +@login_required(login_url='/panel/p_login/') +def customerNewAction(request ,c_id): + actionType = request.POST.get('actionType', None) + dateTimeAnjam = request.POST.get('dateTimeAnjam', None) + #return HttpResponse(float(dateTimeAnjam+".0")) + try: + dateTimeAnjam = datetime.datetime.fromtimestamp(int(dateTimeAnjam)/1e3) + except: + pass + desc = request.POST.get('desc', None) + userID = request.POST.get('userID', None) + is_done = request.POST.get('is_done', None) + result = request.POST.get('result', None) + FinishDateTime = request.POST.get('FinishDateTime', None) + try: + FinishDateTime = datetime.datetime.fromtimestamp(int(FinishDateTime)/1e3) + except: + pass + + customer_obj = Customer.objects.get(id=int(c_id)) + newAction = Action() + newAction.actionType = actionType + newAction.actionDateTime = dateTimeAnjam + newAction.desc = desc + newAction.forUser = User.objects.get(id=int(userID)) + newAction.customer = customer_obj + if is_done: + newAction.status = 'finish' + newAction.finishDateTime = FinishDateTime + newAction.result = result + else: + newAction.status = 'waite' + newAction.save() + + if newAction.status == 'finish' and newAction.actionType == 'bazdid': + customer_obj.last_bazdid_dt = newAction.finishDateTime + customer_obj.save() + + return redirect('/panel/customer/'+str(c_id)+'/action') + + +@login_required(login_url='/panel/p_login/') +def ActionList(request): + actions = Action.objects.filter(forUser__id=request.user.id, status='waite').order_by("-createdDT") + allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') + data = {'actions': actions, 'allUsers': allUsers} + return render(request, 'admin_panel/ActionList.html', data) + +@login_required(login_url='/panel/p_login/') +def ActionListUser(request, u_id): + actions = Action.objects.filter(forUser__id=u_id, status='waite').order_by("-createdDT") + allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') + data = {'actions': actions, 'allUsers': allUsers} + return render(request, 'admin_panel/ActionList.html', data) + +@login_required(login_url='/panel/p_login/') +def customerEditAction(request ,c_id, action_id): + + actionType = request.POST.get('actionType', None) + dateTimeAnjam = request.POST.get('dateTimeAnjam', None) + print("ok") + #return HttpResponse(float(dateTimeAnjam+".0")) + try: + dateTimeAnjam = datetime.datetime.fromtimestamp(int(dateTimeAnjam)/1e3) + except: + pass + desc = request.POST.get('desc', None) + userID = request.POST.get('userID', None) + is_done = request.POST.get('is_done', None) + result = request.POST.get('result', None) + FinishDateTime = request.POST.get('FinishDateTime', None) + try: + FinishDateTime = datetime.datetime.fromtimestamp(int(FinishDateTime)/1e3) + except: + pass + + customer_obj = Customer.objects.get(id=int(c_id)) + newAction = Action.objects.get(id=action_id) + newAction.actionType = actionType + newAction.actionDateTime = dateTimeAnjam + newAction.desc = desc + newAction.forUser = User.objects.get(id=int(userID)) + newAction.customer = customer_obj + if is_done: + newAction.status = 'finish' + newAction.finishDateTime = FinishDateTime + newAction.result = result + else: + newAction.status = 'waite' + newAction.save() + + if newAction.status == 'finish' and newAction.actionType == 'bazdid': + customer_obj.last_bazdid_dt = newAction.finishDateTime + customer_obj.save() + #return redirect('/panel/customer/'+str(c_id)+'/action') + return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found')) + + +@login_required(login_url='/panel/p_login/') +def ActionListAll(request): + actions = Action.objects.filter().order_by("-id") + allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') + data = {'actions': actions, 'allUsers': allUsers} + return render(request, 'admin_panel/ActionList.html', data) + +@login_required(login_url='/panel/p_login/') +def ActionListToday(request): + startTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 00:00:00') + endTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 23:59:59') + #startTodayDT = datetime.datetime.strptime(startTodayDT, '%Y-%m-%d-00:00:00') + #endTodayDT = datetime.datetime.strptime(endTodayDT, '%Y-%m-%d-23:59:59') + #return HttpResponse(todayDT) + actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=request.user.id, status='waite').order_by("-actionDateTime")[:50] + allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') + data = {'actions': actions, 'allUsers': allUsers} + return render(request, 'admin_panel/ActionList.html', data) + +@login_required(login_url='/panel/p_login/') +def ActionListUserToday(request, u_id): + dateTimeAction = request.GET.get('dateTime', None) + status = request.GET.get('status', None) + unixADT = dateTimeAction + if dateTimeAction: + dateTimeAction = datetime.datetime.fromtimestamp(int(dateTimeAction)/1e3) + startTodayDT = dateTimeAction.strftime('%Y-%m-%d 00:00:00') + endTodayDT= dateTimeAction.strftime('%Y-%m-%d 23:59:59') + else: + startTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 00:00:00') + endTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 23:59:59') + #startTodayDT = datetime.datetime.strptime(startTodayDT, '%Y-%m-%d-00:00:00') + #endTodayDT = datetime.datetime.strptime(endTodayDT, '%Y-%m-%d-23:59:59') + #return HttpResponse(todayDT) + if status == 'waite' or status == None or status == '' or status == ' ': + status = 'waite' + actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:50] + actionPr = Action.objects.filter(actionDateTime__lt=startTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:200] + elif status == 'finish': + actionPr = None + actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:50] + + allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') + data = {'actions': actions, 'allUsers': allUsers, 'u_id': u_id, "unixADT": unixADT, "status": status, 'actionPr': actionPr} + return render(request, 'admin_panel/ActionList.html', data) + +@login_required(login_url='/panel/p_login/') +def rezayat(request): + dateTimeAction = request.GET.get('dateTime', None) + statusRezayat = request.GET.get('status', None) + status='finish' + if statusRezayat=='finish': + actions = Action.objects.exclude(actionType='vosol').exclude(Q(nazar='') | Q(nazar=None)).filter(status='finish').order_by("-id")[:50] + else: + statusRezayat='waite' + actions = Action.objects.filter(Q(nazar='') | Q(nazar=None) & Q(status='finish')).exclude(actionType='vosol').order_by("id")[:50] + data = {'actions': actions, "status": statusRezayat} + return render(request, 'admin_panel/customerRezayat.html', data) + + +@login_required(login_url='/panel/p_login/') +def rezayatEdit(request, ac_id): + csGrade = request.POST.get('csGrade', None) + csDesc = request.POST.get('csDesc', None) + if csDesc and csGrade: + action = Action.objects.get(id=ac_id) + action.nazar = csDesc + action.grade = csGrade + action.nazarDT = datetime.datetime.now() + action.save() + return redirect('/panel/rezayat/') + else: + return HttpResponse('لطفا همه فیلد ها را پر کنید') + #data = {'actions': actions, "status": status} + #return render(request, 'admin_panel/customerRezayat.html', data) + + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewStore', raise_exception=True) +def passCheck(request, articl_id): + + sarresidINC = Sarresid.objects.get(id=articl_id) + user_op = request.user + + desc = request.POST.get('message') + + articleHesabDari2 = Article() + articleHesabDari2.debit = 0 + articleHesabDari2.credit = sarresidINC.price + articleHesabDari2.customer = sarresidINC.customer + articleHesabDari2.factor = sarresidINC.factor + articleHesabDari2.desc = 'وصول چک' + " " + str(sarresidINC.customer.name_family) + if sarresidINC.factor: + articleHesabDari2.desc += " فاکتور شماره: " + str(sarresidINC.factor.id) + articleHesabDari2.nahvePardakht = 'وصول چک' + articleHesabDari2.payStatus = "true" + articleHesabDari2.user = user_op + articleHesabDari2.save() + + sarresidINC.payStatus = "true" + sarresidINC.desc = desc + sarresidINC.save() + + return HttpResponse("ok") + #return redirect(request.META.get('HTTP_REFERER')) + + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewStore', raise_exception=True) +def addSarresid(request, cs_id): + try: + customerObj = Customer.objects.get(id=cs_id) + + sarrresidUNIX = request.POST.get('n_sarresidDateTime', None) + sarresidPrice = request.POST.get('n_sarresidPrice', None) + sarresidType = request.POST.get('n_sarresidType', None) + + sarResidDateTime01 = datetime.datetime.fromtimestamp(int(sarrresidUNIX)/1000.0) + + articleHesabDariObj = Article() + articleHesabDariObj.debit = int(sarresidPrice) + articleHesabDariObj.credit = 0 + articleHesabDariObj.desc = "بدهی بابت ثبت " + if sarresidType == "check": + articleHesabDariObj.desc += " چک به صورت دستی " + elif sarresidType == "nesiye": + articleHesabDariObj.desc += " نسیه به صورت دستی " + articleHesabDariObj.customer = customerObj + articleHesabDariObj.save() + + sarresidINC = Sarresid() + sarresidINC.customer = customerObj + sarresidINC.price = int(sarresidPrice) + sarresidINC.payStatus = "false" + #check ya nesiye + sarresidINC.type = sarresidType + sarresidINC.user = request.user + sarresidINC.sarresidDT = sarResidDateTime01 + sarresidINC.save() + + return redirect(request.META.get('HTTP_REFERER')) + + except: + return HttpResponse("خطا در ثبت ") + + #return HttpResponse(customerObj.id) + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.ViewStore', raise_exception=True) +def pardakhtNesiye(request, cs_id): + try: + customerObj = Customer.objects.get(id=cs_id) + user_op = request.user + + sarrresidUNIX = request.POST.get('next_sarresidDateTime', None) + sarresidPrice = int(request.POST.get('next_sarresidPrice', None)) + sr_articlID = request.POST.get('sr_articlID', None) + nahvePardakht = request.POST.get('nahvePardakht', None) + desc_sar = request.POST.get('desc_sar', None) + + sarResidDateTime01 = datetime.datetime.fromtimestamp(int(sarrresidUNIX)/1000.0) + + SarresidObj = Sarresid.objects.get(id=sr_articlID) + #return HttpResponse(sarresidPrice) + debitNow = SarresidObj.price - sarresidPrice + #return HttpResponse(debitNow) + + if debitNow==0: + #توسیه بدهی بخورد + SarresidObj.price = 0 + SarresidObj.payStatus = "true" + elif debitNow >= 1: + SarresidObj.price = debitNow + SarresidObj.sarresidDT = sarResidDateTime01 + else: + return HttpResponse("خطا در مورد مبلغ") + SarresidObj.save() + + + if str(nahvePardakht) == 'naghd': + nahvePardakht = 'نقد' + elif str(nahvePardakht) == 'kartbekart': + nahvePardakht = 'کارت به کارت' + elif str(nahvePardakht) == 'kartkhan': + nahvePardakht = 'کارت خوان' + elif str(nahvePardakht) == 'takhfif': + nahvePardakht = 'تخفیف' + elif str(nahvePardakht) == 'check': + nahvePardakht = 'چک' + + articleHesabDariObj = Article() + articleHesabDariObj.debit = 0 + articleHesabDariObj.credit = int(sarresidPrice) + + if desc_sar: + articleHesabDariObj.desc = "پرداختی بابت نسیه" + " | " + desc_sar + else: + articleHesabDariObj.desc = "پرداختی بابت نسیه" + + articleHesabDariObj.customer = customerObj + articleHesabDariObj.nahvePardakht = nahvePardakht + articleHesabDariObj.user = user_op + articleHesabDariObj.save() + + return redirect(request.META.get('HTTP_REFERER')) + + except: + return HttpResponse("خطا در ثبت ") + + #return HttpResponse(customerObj.id) \ No newline at end of file diff --git a/admin_panel/views.py b/admin_panel/views.py index bbc4a72..fb83d52 100644 --- a/admin_panel/views.py +++ b/admin_panel/views.py @@ -4,9 +4,11 @@ from django.urls import reverse # تکراری در کد اصلی شما بود from persiantools.jdatetime import JalaliDate from persiantools import characters, digits import datetime +from django.contrib import messages +from django.views.decorators.http import require_POST from django.utils import timezone # جدید from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage -from django.core.exceptions import ValidationError # جدید +from django.core.exceptions import PermissionDenied, ValidationError # جدید from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest # بخشBadRequest جدید @@ -18,8 +20,9 @@ from django.contrib.auth.decorators import login_required, permission_required import os import json # جدید import logging # جدید - from .models import * +from .services import create_transfer_request, approve_transfer_request, reject_transfer_request, cancel_transfer_request + logger = logging.getLogger(__name__) @@ -883,6 +886,7 @@ def entezar(request): return render(request, 'admin_panel/entezar.html', {'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn':searchOn}) @login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.OnlyAnbar', raise_exception=True) def stores(request): if request.user.username not in ('admin', 'office1', 'office2', 'office3', 'khatibi'): all_store = StoreRoom.objects.filter(user__id=request.user.id).order_by('id')[:20] @@ -891,29 +895,87 @@ def stores(request): data = {'all_store': all_store} return render(request, 'admin_panel/stores.html', data) +from django.db.models import Sum, Subquery, OuterRef, IntegerField, Value +from django.db.models.functions import Coalesce +from django.shortcuts import render, get_object_or_404 +from django.contrib.auth.decorators import login_required, permission_required +from .models import StoreRoom, ProductCategory, Product, ProductInStore, StoreTransferRequest + @login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.OnlyAnbar', raise_exception=True) def store(request, s_id): productCategory = ProductCategory.objects.all() - storeO = StoreRoom.objects.get(id=s_id) + storeO = get_object_or_404(StoreRoom, id=s_id) searchStatus = False - searchQS = request.POST.get('searchQS', '') + searchQS = request.POST.get('searchQS', '').strip() mojodi = request.POST.get('mojodi', '') - #return HttpResponse(mojodi) - if searchQS !='': - productSum = Product.objects.filter(productinstore__StoreRoom=storeO ,productinstore__year=1400,product_name__contains=searchQS).annotate(mojodi=( Sum('productinstore__s_in') - Sum('productinstore__s_out') )) - if mojodi == "0": - productSum = productSum.filter(mojodi__lte=0) + + # ۱. محاسبه موجودی فیزیکی کل کالاها با یک کوئری تک‌ضرب و سریع GROUP BY + stock_balances = ProductInStore.objects.filter( + StoreRoom=storeO, year=1400 + ).values('Product_id').annotate( + total=Sum('s_in') - Sum('s_out') + ).order_by() + stock_dict = {item['Product_id']: item['total'] for item in stock_balances} + + # ۲. محاسبه کل کالاهای در انتظار خروج + pending_out_balances = StoreTransferRequest.objects.filter( + source_store=storeO, status=StoreTransferRequest.Status.PENDING + ).values('product_id').annotate( + total=Sum('quantity') + ).order_by() + pending_out_dict = {item['product_id']: item['total'] for item in pending_out_balances} + + # ۳. محاسبه کل کالاهای در انتظار ورود + pending_in_balances = StoreTransferRequest.objects.filter( + target_store=storeO, status=StoreTransferRequest.Status.PENDING + ).values('product_id').annotate( + total=Sum('quantity') + ).order_by() + pending_in_dict = {item['product_id']: item['total'] for item in pending_in_balances} + + # ۴. استخراج سریع تمام آیدی‌های منحصربه‌فرد مرتبط با این انبار در سطح حافظه + all_pids = set(stock_dict.keys()) | set(pending_out_dict.keys()) | set(pending_in_dict.keys()) + + # ۵. کوئری نهایی فوق‌بهینه برای خواندن کالاها (حل باگ N+1 با دستور select_related) + products_qs = Product.objects.select_related('ProductCategory').filter(id__in=all_pids) + + # اعمال فیلتر متنی جستجو در سطح دیتابیس + if searchQS: + products_qs = products_qs.filter(product_name__icontains=searchQS) searchStatus = True - else: - productSum = Product.objects.filter(productinstore__StoreRoom=storeO ,productinstore__year=1400).annotate(mojodi=( Sum('productinstore__s_in') - Sum('productinstore__s_out') )) - if mojodi == "0": - searchStatus = True - productSum = productSum.filter(mojodi__lte=0) - data = {'storeO': storeO,'productSum': productSum, 's_id': s_id, 'searchStatus': searchStatus, "mojodi": mojodi, 'djsearchQS':searchQS, 'productCategory': productCategory} + + # 🛠️ اصلاح فرآیند ترتیب: استفاده از 'id' جهت انطباق ۱۰۰٪ با ترتیب پیش‌فرض و سنتی دیتابیس شما + # نکته: اگر مایلید بر اساس زمان ایجاد مرتب شود، می‌توانید آن را به 'createdDT' یا '-createdDT' تغییر دهید. + products_list = list(products_qs.order_by('id')) + + # ۶. معماری زمان ثابت O(1): چسباندن پرسرعت دیتاها در حافظه رم سرور + for prd in products_list: + prd.mojodi = stock_dict.get(prd.id, 0) + prd.pending_out = pending_out_dict.get(prd.id, 0) + prd.pending_in = pending_in_dict.get(prd.id, 0) + + # ۷. اعمال فیلترهای منطقی موجودی در لایه پایتون + if mojodi == "0": + products_list = [p for p in products_list if p.mojodi <= 0] + searchStatus = True + elif mojodi == "1": + products_list = [p for p in products_list if p.mojodi > 0] + searchStatus = True + + data = { + 'storeO': storeO, + 'productSum': products_list, + 's_id': s_id, + 'searchStatus': searchStatus, + "mojodi": mojodi, + 'djsearchQS': searchQS, + 'productCategory': productCategory + } return render(request, 'admin_panel/store.html', data) @login_required(login_url='/panel/p_login/') -#@permission_required('admin_panel.ViewStore', raise_exception=True) +@permission_required('admin_panel.OnlyAnbar', raise_exception=True) def operations(request, s_id): page = request.GET.get('page', 1) productCategory = ProductCategory.objects.all() @@ -942,7 +1004,7 @@ def operations(request, s_id): @login_required(login_url='/panel/p_login/') -@permission_required('admin_panel.ViewStore', raise_exception=True) +@permission_required('admin_panel.OnlyAnbar', raise_exception=True) def operationProduct(request, s_id, p_id): productCategory = ProductCategory.objects.all() storeO = get_object_or_404(StoreRoom, id=s_id) @@ -961,7 +1023,7 @@ def operationProduct(request, s_id, p_id): return render(request, 'admin_panel/operations.html', data) @login_required(login_url='/panel/p_login/') -@permission_required('admin_panel.ViewStore', raise_exception=True) +@permission_required('admin_panel.OnlyAnbar', raise_exception=True) def newOperation(request, s_id, p_id): productCategory = ProductCategory.objects.all() storeO = StoreRoom.objects.get(id=s_id) @@ -970,6 +1032,92 @@ def newOperation(request, s_id, p_id): data = {'s_id': s_id,'p_id': p_id, 'allStores': allStores, 'storeO': storeO, 'product':product, 'productCategory': productCategory} return render(request, 'admin_panel/newOperation.html', data ) +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.OnlyAnbar', raise_exception=True) +def store_transfers(request, s_id): + """ + داشبورد و کارتابل جامع مدیریت انتقال‌های انبار + """ + storeO = get_object_or_404(StoreRoom, id=s_id) + + # ۱. کارتابل ورودی (بارهایی که به سمت این انبار آمده و منتظر تایید ما هستند) + # استفاده از select_related برای جلوگیری از صدها کوئری اضافی به دیتابیس + incoming_pending = StoreTransferRequest.objects.filter( + target_store=storeO, + status=StoreTransferRequest.Status.PENDING + ).select_related('product', 'source_store', 'requester').order_by('-created_at') + + # ۲. کارتابل خروجی (بارهایی که ما فرستادیم و منتظر تایید انبار مقصد هستیم) + outgoing_pending = StoreTransferRequest.objects.filter( + source_store=storeO, + status=StoreTransferRequest.Status.PENDING + ).select_related('product', 'target_store', 'requester').order_by('-created_at') + + # ۳. تاریخچه انتقالات اخیر (۵۰ مورد آخر تعیین تکلیف شده برای این انبار) + history_list = StoreTransferRequest.objects.filter( + Q(source_store=storeO) | Q(target_store=storeO) + ).exclude( + status=StoreTransferRequest.Status.PENDING + ).select_related('product', 'source_store', 'target_store', 'approver').order_by('-action_date')[:50] + + data = { + 'storeO': storeO, + 's_id': s_id, + 'incoming_pending': incoming_pending, + 'outgoing_pending': outgoing_pending, + 'history_list': history_list, + } + return render(request, 'admin_panel/store_transfers.html', data) + + +@login_required(login_url='/panel/p_login/') +@permission_required('admin_panel.OnlyAnbar', raise_exception=True) +@require_POST +def process_transfer_action(request, transfer_id): + """ + پردازش امن اقدامات انباردار (تایید، رد، انصراف) با تراکنش‌های اتمیک + """ + action = request.POST.get('action') + reason = request.POST.get('reason', '').strip() + + # دریافت درخواست انتقال + transfer = get_object_or_404(StoreTransferRequest, id=transfer_id) + + # تعیین انباری که کاربر از آنجا اقدام کرده جهت ریدایرکت صحیح + redirect_store_id = transfer.target_store_id if action in ['approve', 'reject'] else transfer.source_store_id + + try: + if action == 'approve': + approve_transfer_request(request_id=transfer.id, approver=request.user) + messages.success(request, f"انتقال {transfer.quantity} عدد {transfer.product.product_name} با موفقیت تایید و به موجودی انبار اضافه شد.") + + elif action == 'reject': + if not reason: + messages.error(request, "برای رد کردن بار، وارد کردن علت رد الزامی است.") + return redirect('admin_panel:store_transfers', s_id=redirect_store_id) + + reject_transfer_request(request_id=transfer.id, rejector=request.user, reason=reason) + messages.warning(request, f"درخواست انتقال بار رد شد و به انبار مبدا اطلاع داده شد.") + + elif action == 'cancel': + cancel_transfer_request(request_id=transfer.id, user=request.user) + messages.info(request, "درخواست انتقال توسط شما لغو گردید.") + + else: + messages.error(request, "دستور ارسالی نامعتبر است.") + + except ValidationError as e: + # نمایش خطاهای منطقی (مثلاً کمبود موجودی انبار مبدا در لحظه تایید) + error_text = ' '.join(e.messages) if hasattr(e, 'messages') else str(e) + messages.error(request, f"خطا در انجام عملیات: {error_text}") + except PermissionError as e: + messages.error(request, str(e)) + except Exception as e: + logger.error(f"Error processing transfer action #{transfer_id}: {str(e)}", exc_info=True) + messages.error(request, "خطای سیستمی در پردازش تراکنش رخ داد.") + + return redirect('admin_panel:store_transfers', s_id=redirect_store_id) + @login_required(login_url='/panel/p_login/') @permission_required('admin_panel.ViewStore', raise_exception=True) def newStore(request): @@ -981,7 +1129,7 @@ def newStore(request): return redirect('/panel/store/') @login_required(login_url='/panel/p_login/') -@permission_required('admin_panel.ViewStore', raise_exception=True) +@permission_required('admin_panel.OnlyAnbar', raise_exception=True) def productAdd(request): namePrd = request.POST.get('namePrd', '') category = request.POST.get('category', '') @@ -1031,109 +1179,118 @@ def productDel(request, p_id, s_id): return redirect('/panel/store/'+str(s_id)) @login_required(login_url='/panel/p_login/') -@permission_required('admin_panel.ViewStore', raise_exception=True) +@permission_required('admin_panel.OnlyAnbar', raise_exception=True) def operation(request): if request.method != 'POST': return HttpResponseBadRequest("متد ارسالی نامعتبر است.") - # استفاده از توابع امن اختصاصی پروژه شما برای جداسازی مقادیر عددی + # استفاده از توابع امن اختصاصی پروژه برای جداسازی مقادیر عددی prd_id = _safe_int(request.POST.get('prd_id')) - actionType = request.POST.get('actionType', '') + action_type = request.POST.get('actionType', '') tedad = _safe_int(request.POST.get('tedad')) azanbar = _safe_int(request.POST.get('azanbar')) beanbar = _safe_int(request.POST.get('beanbar')) - desc = request.POST.get('desc', '') + desc = request.POST.get('desc', '').strip() if tedad <= 0: return HttpResponseBadRequest("تعداد وارد شده باید بیشتر از صفر باشد.") - sRoom = get_object_or_404(StoreRoom, id=azanbar) - productO = get_object_or_404(Product, id=prd_id) + s_room = get_object_or_404(StoreRoom, id=azanbar) + product_obj = get_object_or_404(Product, id=prd_id) try: - with transaction.atomic(): - - # ساخت کوئری پایه برای دریافت تراکنش‌های سال جاری کالا - qs = ProductInStore.objects.filter( - Product=productO, StoreRoom=sRoom, year=1400 - ) - - # بررسی هوشمند پشتیبانی دیتابیس از قابلیت قفل ردیف (سازگار با SQLite و PostgreSQL) - if connection.features.has_select_for_update: - qs = qs.select_for_update() - - # بارگذاری امن تراکنش‌ها در حافظه (RAM) جهت جلوگیری از خطای توابع تجمیعی همزمان با قفل دیتابیس - locked_records = list(qs) - - # --- عملیات ورود دستی --- - if actionType == 'vorod': - pInStore = ProductInStore( - StoreRoom=sRoom, - Product=productO, - year=1400 - ) - pInStore.s_in = tedad - pInStore.desc = "ورود: "+str(tedad)+" به انبار " + str(sRoom.store_name) + "#" + str(desc) - pInStore.movement_type = ProductInStore.MovementType.DIRECT_IN - pInStore.save() - - # --- عملیات خروج دستی یا انتقال بین انبارها --- - elif actionType in ['khoroj', 'transfer']: + # ===================================================================== + # بخش اول: انتقال بین انبارها (واگذاری کامل به لایه سرویس) + # ===================================================================== + if action_type == 'transfer': + if not beanbar: + return HttpResponse("انبار مقصد مشخص نشده است.") - # محاسبات دقیق و سریع موجودی کالا در سطح پایتون بدون درگیر کردن دیسک - total_in = sum(item.s_in for item in locked_records if item.s_in) - total_out = sum(item.s_out for item in locked_records if item.s_out) - mojodi_prd = total_in - total_out + target_store = get_object_or_404(StoreRoom, id=_safe_int(beanbar)) + + # فراخوانی لایه سرویس برای ثبت درخواست انتقال (بدون تغییر مستقیم موجودی) + create_transfer_request( + source_store=s_room, + target_store=target_store, + product=product_obj, + quantity=tedad, + requester=request.user, + desc=desc or f"درخواست انتقال از انبار {s_room.store_name} به {target_store.store_name}" + ) + + logger.info( + f"Transfer request created: {tedad}x {product_obj.product_name} from Store #{s_room.id} to Store #{target_store.id} by User #{request.user.id}" + ) + return redirect('/panel/store/' + str(s_room.id)) - if mojodi_prd < tedad: - # تطابق ۱۰۰ درصدی پاسخ کدهای قدیمی با فرانت‌اند شما - return HttpResponse("موجودی کافی نیست، موجودی فعلی: "+ str(mojodi_prd)) + # ===================================================================== + # بخش دوم: ورود و خروج دستی (کنترل مستقیم با Transaction و Lock) + # ===================================================================== + elif action_type in ['vorod', 'khoroj']: + with transaction.atomic(): + # ساخت کوئری پایه برای دریافت تراکنش‌های سال جاری کالا + qs = ProductInStore.objects.filter( + Product=product_obj, StoreRoom=s_room, year=1400 + ) - # الف) خروج دستی ساده - if actionType == 'khoroj': - pInStore = ProductInStore( - StoreRoom=sRoom, - Product=productO, - year=1400 - ) - pInStore.s_out = tedad - pInStore.desc = "خروج: "+str(tedad)+" از انبار " + str(sRoom.store_name) + "#" + str(desc) - pInStore.movement_type = ProductInStore.MovementType.DIRECT_OUT - pInStore.save() + # قفل‌گذاری ردیف‌ها برای جلوگیری از Race Condition (در دیتابیس‌های پشتیبان) + if connection.features.has_select_for_update: + qs = qs.select_for_update() - # ب) انتقال بین دو انبار مجزا - elif actionType == 'transfer': - if not beanbar: - return HttpResponse("انبار مقصد مشخص نشده است.") - - beanbarOjb = get_object_or_404(StoreRoom, id=_safe_int(beanbar)) - - # ردیف اول: ثبت تراکنش خروج از انبار مبدأ - pInStore = ProductInStore( - StoreRoom=sRoom, - Product=productO, - year=1400 - ) - pInStore.s_out = tedad - pInStore.desc = "خروج: "+str(tedad)+" از انبار " + str(sRoom.store_name) + " جهت انتقال به انبار " + str(beanbarOjb.store_name) + "#" + str(desc) - pInStore.movement_type = ProductInStore.MovementType.TRANSFER - pInStore.save() + locked_records = list(qs) - # ردیف دوم: ثبت تراکنش ورود به انبار مقصد (محافظت شده در بلاک اتمیک) + # --- الف) عملیات ورود دستی --- + if action_type == 'vorod': ProductInStore.objects.create( - StoreRoom=beanbarOjb, - Product=productO, + StoreRoom=s_room, + Product=product_obj, s_in=tedad, + s_out=0, year=1400, - desc="ورود: "+str(tedad)+" از انبار " + str(sRoom.store_name) + "#" + str(desc), - movement_type=ProductInStore.MovementType.TRANSFER + desc=f"ورود: {tedad} به انبار {s_room.store_name}#{desc}", + movement_type=ProductInStore.MovementType.DIRECT_IN ) + # --- ب) عملیات خروج دستی --- + elif action_type == 'khoroj': + # محاسبه سریع موجودی در رم بدون فشار مجدد به دیتابیس + total_in = sum(item.s_in for item in locked_records if item.s_in) + total_out = sum(item.s_out for item in locked_records if item.s_out) + mojodi_prd = total_in - total_out + + if mojodi_prd < tedad: + # تطابق ۱۰۰ درصدی با پاسخ متنی مورد انتظار فرانت‌اند + return HttpResponse("موجودی کافی نیست، موجودی فعلی: " + str(mojodi_prd)) + + ProductInStore.objects.create( + StoreRoom=s_room, + Product=product_obj, + s_in=0, + s_out=tedad, + year=1400, + desc=f"خروج: {tedad} از انبار {s_room.store_name}#{desc}", + movement_type=ProductInStore.MovementType.DIRECT_OUT + ) + + else: + return HttpResponseBadRequest("نوع عملیات نامعتبر است.") + + except ValidationError as e: + # مدیریت خطاهای اعتبارسنجی (مثل کمبود موجودی در لایه سرویس یا یکسان بودن انبارها) + error_msg = ' '.join(e.messages) if hasattr(e, 'messages') else str(e) + logger.warning(f"Validation error in store operation: {error_msg}") + return HttpResponse(error_msg) + + except PermissionDenied as e: + # مدیریت خطای دسترسی کاربر به انبار + logger.warning(f"Permission denied in store operation for user {request.user.id}: {str(e)}") + return HttpResponse("شما دسترسی لازم برای انجام این عملیات در این انبار را ندارید.", status=403) + except Exception as e: - logger.error(f"Error in store operation view: {str(e)}", exc_info=True) - return HttpResponse("خطای سیستمی رخ داد. تراکنش لغو شد.") + logger.error(f"Critical error in store operation view: {str(e)}", exc_info=True) + return HttpResponse("خطای سیستمی رخ داد. تراکنش لغو شد.", status=500) - return redirect('/panel/store/'+str(sRoom.id)) + return redirect('/panel/store/' + str(s_room.id)) @login_required(login_url='/panel/p_login/') def newFactor(request, c_id):