diff --git a/admin_panel/migrations/0047_alter_productinstore_movement_type.py b/admin_panel/migrations/0047_alter_productinstore_movement_type.py new file mode 100644 index 0000000..18eaf0c --- /dev/null +++ b/admin_panel/migrations/0047_alter_productinstore_movement_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2026-06-25 15:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin_panel', '0046_auto_20260624_1818'), + ] + + operations = [ + migrations.AlterField( + model_name='productinstore', + name='movement_type', + field=models.CharField(blank=True, choices=[('SALE', 'فروش عادی'), ('WARRANTY', 'گارانتی'), ('PROMOTION', 'اشانتیون'), ('TRANSFER', 'انتقال'), ('INITIAL', 'موجودی اولیه'), ('DIRECT_IN', 'ورود دستی'), ('DIRECT_OUT', 'خروج دستی'), ('RETURN', 'برگشت از فروش'), ('ERROR_CORRECTION', 'اصلاح خطای ثبت')], db_index=True, max_length=20, null=True), + ), + ] diff --git a/admin_panel/models.py b/admin_panel/models.py index bc5935d..526dd49 100644 --- a/admin_panel/models.py +++ b/admin_panel/models.py @@ -155,6 +155,8 @@ class ProductInStore(models.Model): INITIAL = 'INITIAL', 'موجودی اولیه' DIRECT_IN = 'DIRECT_IN', 'ورود دستی' DIRECT_OUT = 'DIRECT_OUT', 'خروج دستی' + RETURN = 'RETURN', 'برگشت از فروش' + ERROR_CORRECTION = 'ERROR_CORRECTION', 'اصلاح خطای ثبت' StoreRoom = models.ForeignKey(StoreRoom, on_delete=models.CASCADE) Product = models.ForeignKey(Product, on_delete=models.CASCADE) diff --git a/admin_panel/templates/admin_panel/base.html b/admin_panel/templates/admin_panel/base.html index da4d95f..aef92a6 100644 --- a/admin_panel/templates/admin_panel/base.html +++ b/admin_panel/templates/admin_panel/base.html @@ -95,6 +95,10 @@ گزارش + + + گزارش جامع انبار + diff --git a/admin_panel/templates/admin_panel/customerFactor.html b/admin_panel/templates/admin_panel/customerFactor.html index 7c4a488..0b38e5b 100644 --- a/admin_panel/templates/admin_panel/customerFactor.html +++ b/admin_panel/templates/admin_panel/customerFactor.html @@ -4,35 +4,35 @@ {% block BodyCustomer1 %} -
-

شماره فاکتور {{ factorO.id }}

-
+
+

شماره فاکتور {{ factorO.id }}

+ +
-
- +
+
- +
-
- +
+
- +
- -
- +
+
- - - + + + - + @@ -49,82 +49,195 @@ {% for iteam in fiteam %} - - - - + + + {% endfor %}
نام محصولقیمت واحدقیمت کلنام محصولقیمت واحدقیمت کل تعداد بابتعملیاتعملیات
- {{ iteam.product_name }} + {{ iteam.product_name }} {{ iteam.product.product_price }} {{ iteam.amount }}{{ iteam.num }}{{ iteam.garantyEsh }} - - * - - {{ iteam.num }}{{ iteam.garantyEsh }}*
-
- +
+
- +
-
- +
+
- -
- +
-
-
- +
+
- +
-
- +
+
-
+ +
-
- {% if perms.admin_panel.DelFactor %} - حذف فاکتور و اصلاح انبار +
+ {% if factorO.is_active and factorO.pay_status != 'cancell' %} + {% if perms.admin_panel.DelFactor %} + + {% endif %} + {% else %} +
+ این فاکتور باطل شده است. (اسناد مالی و انبار معکوس شده‌اند) +
{% endif %}
-
+ {% endblock %} \ No newline at end of file diff --git a/admin_panel/templates/admin_panel/report_inventory_movements.html b/admin_panel/templates/admin_panel/report_inventory_movements.html new file mode 100644 index 0000000..efed2c3 --- /dev/null +++ b/admin_panel/templates/admin_panel/report_inventory_movements.html @@ -0,0 +1,202 @@ +{% extends "./base.html" %} +{% load custom_tags %} + +{% block bodyPage %} +
+
+

گزارش تفکیکی گردش و خروج کالا از انبار

+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+
+
+ +
+
+
+ جمع کل خروجی در این گزارش: + {{ summary_stats.total_out_qty|default:0 }} عدد +
+
+
+
+ جمع کل ورودی در این گزارش: + {{ summary_stats.total_in_qty|default:0 }} عدد +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + {% for item in page_obj %} + + + + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
#کد کالانام کالانوع تراکنشخروج (-)ورود (+)موجودی لحظه‌ایمشتری / گیرندهکاربر ثبت‌کنندهانبارتاریخ ثبتتوضیحات سند
{{ forloop.counter }}{{ item.Product.id }}{{ item.Product.product_name }} + + {{ item.get_movement_type_display }} + + {{ item.s_out|default:"-" }}{{ item.s_in|default:"-" }}{{ item.current_stock_at_time }} + {% if item.factor and item.factor.customer %} + + {{ item.factor.customer.name_family }} + + {% else %} + ثبت دستی/داخلی + {% endif %} + + {% if item.factor and item.factor.user %} + {{ item.factor.user.first_name }} {{ item.factor.user.last_name }} + {% else %} + سیستم / نامشخص + {% endif %} + {{ item.StoreRoom.store_name }}{{ item.createdDT|toJalali }}{{ item.desc }}
هیچ تراکنشی با این مشخصات یافت نشد.
+
+ + {% if page_obj.has_other_pages %} + + {% endif %} + +
+{% endblock %} + +{% block jsEnd %} + +{% endblock %} \ No newline at end of file diff --git a/admin_panel/urls.py b/admin_panel/urls.py index f938399..1625511 100644 --- a/admin_panel/urls.py +++ b/admin_panel/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path('p_logout/', views.p_logout, name='p_logout'), path('bedehi/', views.bedehi, name='bedehi'), path('report/', views.report, name='report'), + path('report/inventory-movements/', views.inventory_movements_report, name='inventory_movements_report'), path('hesabdari/', views.hesabdari, name='hesabdari'), path('hesabdari/sarresid_nashode/', views.hesabdari_residnashone, name='hesabdari_residnashone'), path('hesabdari/check/pass/', views.hesabdari_checkPass, name='hesabdari_check_pass'), diff --git a/admin_panel/views.py b/admin_panel/views.py index 7706833..4c08ae2 100644 --- a/admin_panel/views.py +++ b/admin_panel/views.py @@ -8,6 +8,7 @@ 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 @@ -183,7 +184,83 @@ def report(request): 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', '') + az_tarikh = request.GET.get('azTarikh', '') + ta_tarikh = request.GET.get('taTarikh', '') + + # ۱. کوئری پایه: اعمال select_related جهت جلوگیری از فاجعه N+1 در پایگاه داده + 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 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 '', + '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) @@ -1215,13 +1292,105 @@ def newFactor2(request, c_id): @login_required(login_url='/panel/p_login/') @permission_required('admin_panel.DelFactor', raise_exception=True) -def FactorDel(request, c_id ,f_id): +@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: - #ProductInStore.objects.filter(factor__id= f_id).delete() - Factor.objects.get(id=f_id).delete() - return redirect('/panel/customer/'+str(c_id)+'/factors/') - except: - return HttpResponse("خطا در حذف فاکتور") + 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)