diff --git a/_base/settings.py b/_base/settings.py index e53a6f1..8261409 100644 --- a/_base/settings.py +++ b/_base/settings.py @@ -167,7 +167,7 @@ JAZZMIN_SETTINGS = { # Copyright on the footer "copyright": "سامانه شفافیت", # Logo to use for your site, must be present in static files, used for brand on top left - "site_logo": "../static/dist/img/iconlogo.png", + # "site_logo": "../static/dist/img/iconlogo.png", # Relative paths to custom CSS/JS scripts (must be present in static files) "custom_css": "../static/admin/css/custom_rtl.css", "custom_js": None, diff --git a/certificates/templates/certificates/step.html b/certificates/templates/certificates/step.html index 3392f82..b8923c2 100644 --- a/certificates/templates/certificates/step.html +++ b/certificates/templates/certificates/step.html @@ -2,6 +2,7 @@ {% load static %} {% load processes_tags %} {% load humanize %} + {% load accounts_tags %} {% block sidebar %} {% include 'sidebars/admin.html' %} @@ -79,7 +80,11 @@ {% else %}{% endif %}
{% csrf_token %} - + {% if request.user|is_broker %} + + {% else %} + + {% endif %}
diff --git a/certificates/views.py b/certificates/views.py index 428c5ba..761ee83 100644 --- a/certificates/views.py +++ b/certificates/views.py @@ -9,6 +9,7 @@ from processes.models import ProcessInstance, StepInstance from invoices.models import Invoice from installations.models import InstallationReport from .models import CertificateTemplate, CertificateInstance +from common.consts import UserRoles from _helpers.jalali import Gregorian @@ -78,6 +79,14 @@ def certificate_step(request, instance_id, step_id): next_step = instance.process.steps.filter(order__gt=instance.current_step.order).first() if instance.current_step else None if request.method == 'POST': + # Only broker can approve and finish certificate step + try: + if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)): + messages.error(request, 'شما مجوز تایید این مرحله را ندارید') + return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) + except Exception: + messages.error(request, 'شما مجوز تایید این مرحله را ندارید') + return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) cert.approved = True cert.approved_at = timezone.now() cert.save() @@ -89,7 +98,10 @@ def certificate_step(request, instance_id, step_id): instance.current_step = next_step instance.save() return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id) - return redirect('processes:request_list') + # Mark the whole process instance as completed on the last step + instance.status = 'completed' + instance.save() + return redirect('processes:instance_summary', instance_id=instance.id) return render(request, 'certificates/step.html', { 'instance': instance, diff --git a/contracts/templates/contracts/contract_step.html b/contracts/templates/contracts/contract_step.html index 33ff326..df4fdfc 100644 --- a/contracts/templates/contracts/contract_step.html +++ b/contracts/templates/contracts/contract_step.html @@ -41,32 +41,36 @@
- {% if template.company.logo %} -
- لوگوی شرکت -

{{ contract.template.company.name }}

-
{{ contract.template.name }}
-
- {% endif %} + {% if can_view_contract_body %} + {% if template.company.logo %} +
+ لوگوی شرکت +

{{ contract.template.company.name }}

+
{{ contract.template.name }}
+
+ {% endif %} -
تاریخ: {{ contract.jcreated }}
-
-
{{ contract.rendered_body|safe }}
-
-
-
-
امضای مشترک
-
-
-
-
امضای شرکت
-
- {% if template.company.signature %} - امضای شرکت - {% endif %} +
تاریخ: {{ contract.jcreated }}
+
+
{{ contract.rendered_body|safe }}
+
+
+
+
امضای مشترک
+
+
+
+
امضای شرکت
+
+ {% if template.company.signature %} + امضای شرکت + {% endif %} +
-
+ {% else %} +
شما دسترسی به مشاهده متن قرارداد را ندارید.
+ {% endif %}
@@ -77,9 +81,17 @@ {% endif %} {% if next_step %} - + {% if is_broker %} + + {% else %} + بعدی + {% endif %} {% else %} - + {% if is_broker %} + + {% else %} + + {% endif %} {% endif %}
diff --git a/contracts/views.py b/contracts/views.py index f2d0deb..1949665 100644 --- a/contracts/views.py +++ b/contracts/views.py @@ -4,6 +4,7 @@ from django.urls import reverse from django.utils import timezone from django.template import Template, Context from processes.models import ProcessInstance, StepInstance +from common.consts import UserRoles from .models import ContractTemplate, ContractInstance from _helpers.utils import jalali_converter2 @@ -34,6 +35,20 @@ def contract_step(request, instance_id, step_id): step = get_object_or_404(instance.process.steps, id=step_id) previous_step = instance.process.steps.filter(order__lt=step.order).last() next_step = instance.process.steps.filter(order__gt=step.order).first() + # Access control: + # - INSTALLER: can open step but cannot view contract body (show inline message) + # - Others: can view + # - Only BROKER can submit/complete this step + profile = getattr(request.user, 'profile', None) + is_broker = False + can_view_contract_body = True + try: + is_broker = bool(profile and profile.has_role(UserRoles.BROKER)) + if profile and profile.has_role(UserRoles.INSTALLER): + can_view_contract_body = False + except Exception: + pass + template_obj = ContractTemplate.objects.first() if not template_obj: return render(request, 'contracts/contract_missing.html', {'instance': instance}) @@ -54,8 +69,11 @@ def contract_step(request, instance_id, step_id): contract.rendered_body = rendered contract.save() - # If user submits to go next, mark this step completed and go to next + # If user submits to go next, only broker can complete and go to next if request.method == 'POST': + if not is_broker: + from django.http import JsonResponse + return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403) StepInstance.objects.update_or_create( process_instance=instance, step=step, @@ -74,6 +92,8 @@ def contract_step(request, instance_id, step_id): 'template': template_obj, 'previous_step': previous_step, 'next_step': next_step, + 'is_broker': is_broker, + 'can_view_contract_body': can_view_contract_body, }) diff --git a/db.sqlite3 b/db.sqlite3 index 805325d..d0474e8 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/installations/templates/installations/installation_assign_step.html b/installations/templates/installations/installation_assign_step.html index c1d6c79..2bdbe4d 100644 --- a/installations/templates/installations/installation_assign_step.html +++ b/installations/templates/installations/installation_assign_step.html @@ -1,6 +1,7 @@ {% extends '_base.html' %} {% load static %} {% load processes_tags %} +{% load common_tags %} {% load humanize %} {% block sidebar %} @@ -41,12 +42,15 @@
+ {% if show_denied_msg %} +
شما اجازه تعیین نصاب را ندارید.
+ {% endif %}
{% csrf_token %}
- {% for p in installers %} @@ -55,17 +59,39 @@
- +
+ {% if assignment.assigned_by or assignment.installer %} +
+
+ {% if assignment.assigned_by %} +
+
تعیین‌کننده نصاب
+
{{ assignment.assigned_by.get_full_name|default:assignment.assigned_by.username }} ({{ assignment.assigned_by.username }})
+
+ {% endif %} + {% if assignment.updated %} +
+
تاریخ ثبت/ویرایش
+
{{ assignment.updated|to_jalali }}
+
+ {% endif %} +
+
+ {% endif %}
{% if previous_step %} قبلی {% else %} {% endif %} - + {% if is_manager %} + + {% else %} + بعدی + {% endif %}
diff --git a/installations/templates/installations/installation_report_step.html b/installations/templates/installations/installation_report_step.html index 275f7bc..16b1eb6 100644 --- a/installations/templates/installations/installation_report_step.html +++ b/installations/templates/installations/installation_report_step.html @@ -2,6 +2,7 @@ {% load static %} {% load processes_tags %} {% load common_tags %} +{% load accounts_tags %} {% load humanize %} {% block sidebar %} @@ -41,13 +42,31 @@ {% stepper_header instance step %}
- {% if report and not edit_mode %}
-
- ویرایش گزارش نصب +
+
+ {% if request.user|is_installer %} + ویرایش گزارش نصب + {% else %} + + {% endif %} + {% if user_can_approve %} + + + {% endif %} +
+ {% if step_instance and step_instance.status == 'rejected' and step_instance.get_latest_rejection %} + + {% endif %}

تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}

@@ -67,6 +86,9 @@
{% endif %}
+ {% if request.user|is_manager or request.user|is_admin %} +
+ {% endif %}
عکس‌ها
{% for p in report.photos.all %} @@ -115,6 +137,42 @@
+ {% if approver_statuses %} +
+
+
وضعیت تاییدها
+ {% if user_can_approve %} +
+ + +
+ {% endif %} +
+
+
+ {% for st in approver_statuses %} +
+
+
+ {{ st.role.name }} + {% if st.status == 'approved' %} + تایید شد + {% elif st.status == 'rejected' %} + رد شد + {% else %} + در انتظار + {% endif %} +
+ {% if st.status == 'rejected' and st.reason %} +
علت: {{ st.reason }}
+ {% endif %} +
+
+ {% endfor %} +
+
+
+ {% endif %}
{% if previous_step %} @@ -127,6 +185,9 @@ {% endif %}
{% else %} + {% if not request.user|is_installer %} +
شما مجوز ثبت/ویرایش گزارش نصب را ندارید. اطلاعات به صورت فقط خواندنی نمایش داده می‌شود.
+ {% endif %}
{% csrf_token %}
@@ -134,40 +195,42 @@
- +
- +
- +
- +
- +
- +
- +
- + {% if request.user|is_installer %} + + {% endif %}
{% if report %}
@@ -175,7 +238,9 @@
photo - + {% if request.user|is_installer %} + + {% endif %}
@@ -285,7 +350,11 @@ {% endif %}
- + {% if request.user|is_installer %} + + {% else %} + + {% endif %} {% if next_step %} بعدی {% endif %} @@ -298,6 +367,58 @@
+ + + + + + + + + {% endblock %} {% block script %} @@ -445,4 +566,3 @@ {% endblock %} - diff --git a/installations/views.py b/installations/views.py index 4692886..8c3dc7e 100644 --- a/installations/views.py +++ b/installations/views.py @@ -5,7 +5,8 @@ from django.urls import reverse from django.utils import timezone from accounts.models import Profile from common.consts import UserRoles -from processes.models import ProcessInstance, StepInstance +from processes.models import ProcessInstance, StepInstance, StepRejection, StepApproval +from accounts.models import Role from invoices.models import Item, Quote, QuoteItem from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange from decimal import Decimal, InvalidOperation @@ -21,7 +22,18 @@ def installation_assign_step(request, instance_id, step_id): installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value).select_related('user').all() assignment, _ = InstallationAssignment.objects.get_or_create(process_instance=instance) + # Role flags + profile = getattr(request.user, 'profile', None) + is_manager = False + try: + is_manager = bool(profile and profile.has_role(UserRoles.MANAGER)) + except Exception: + is_manager = False + if request.method == 'POST': + if not is_manager: + messages.error(request, 'شما اجازه تعیین نصاب را ندارید') + return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) installer_id = request.POST.get('installer_id') scheduled_date = (request.POST.get('scheduled_date') or '').strip() assignment.installer_id = installer_id or None @@ -43,6 +55,10 @@ def installation_assign_step(request, instance_id, step_id): return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id) return redirect('processes:request_list') + # Read-only logic for non-managers + read_only = not is_manager + show_denied_msg = (not is_manager) and (assignment.installer_id is None) + return render(request, 'installations/installation_assign_step.html', { 'instance': instance, 'step': step, @@ -50,6 +66,9 @@ def installation_assign_step(request, instance_id, step_id): 'installers': installers, 'previous_step': previous_step, 'next_step': next_step, + 'is_manager': is_manager, + 'read_only': read_only, + 'show_denied_msg': show_denied_msg, }) @@ -61,15 +80,94 @@ def installation_report_step(request, instance_id, step_id): next_step = instance.process.steps.filter(order__gt=step.order).first() assignment = InstallationAssignment.objects.filter(process_instance=instance).first() existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first() - edit_mode = True if request.GET.get('edit') == '1' else False - print("edit_mode", edit_mode) + # Only installers can enter edit mode + user_is_installer = hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.INSTALLER) + edit_mode = True if (request.GET.get('edit') == '1' and user_is_installer) else False # current quote items baseline quote = Quote.objects.filter(process_instance=instance).first() quote_items = list(quote.items.select_related('item').all()) if quote else [] quote_price_map = {qi.item_id: qi.unit_price for qi in quote_items} - items = Item.objects.all().order_by('name') + items = Item.objects.filter(is_active=True, is_special=False, is_deleted=False).order_by('name') + + # Ensure a StepInstance exists for this step + step_instance, _ = StepInstance.objects.get_or_create( + process_instance=instance, + step=step, + defaults={'status': 'in_progress'} + ) + + # Build approver requirements/status for UI + reqs = list(step.approver_requirements.select_related('role').all()) + user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None) + user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else [] + user_can_approve = any(r.role in user_roles for r in reqs) + approvals_list = list(step_instance.approvals.select_related('role').all()) + approvals_by_role = {a.role_id: a for a in approvals_list} + approver_statuses = [ + { + 'role': r.role, + 'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None), + 'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''), + } + for r in reqs + ] + + # Manager approval/rejection actions + if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']: + action = request.POST.get('action') + # find a matching approver role based on step requirements + req_roles = [req.role for req in step.approver_requirements.select_related('role').all()] + user_roles = list(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).all()) + matching_role = next((r for r in user_roles if r in req_roles), None) + if matching_role is None: + messages.error(request, 'شما دسترسی لازم برای این عملیات را ندارید.') + return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) + + if not existing_report: + messages.error(request, 'گزارش برای تایید/رد وجود ندارد.') + return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) + + if action == 'approve': + existing_report.approved = True + existing_report.save() + StepApproval.objects.update_or_create( + step_instance=step_instance, + role=matching_role, + defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''} + ) + if step_instance.is_fully_approved(): + step_instance.status = 'completed' + step_instance.completed_at = timezone.now() + step_instance.save() + if next_step: + instance.current_step = next_step + instance.save() + return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id) + return redirect('processes:request_list') + messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقش‌ها.') + return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) + + if action == 'reject': + reason = (request.POST.get('reject_reason') or '').strip() + if not reason: + messages.error(request, 'لطفاً علت رد شدن را وارد کنید.') + return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) + StepApproval.objects.update_or_create( + step_instance=step_instance, + role=matching_role, + defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason} + ) + StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason) + existing_report.approved = False + existing_report.save() + messages.success(request, 'گزارش رد شد و برای اصلاح به نصاب بازگشت.') + return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) if request.method == 'POST': + # Only installers can submit or edit reports (non-approval actions) + if request.POST.get('action') not in ['approve', 'reject'] and not user_is_installer: + messages.error(request, 'شما مجوز ثبت/ویرایش گزارش نصب را ندارید') + return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) description = (request.POST.get('description') or '').strip() visited_date = (request.POST.get('visited_date') or '').strip() if '/' in visited_date: @@ -134,6 +232,7 @@ def installation_report_step(request, instance_id, step_id): report.is_meter_suspicious = is_suspicious report.utm_x = utm_x report.utm_y = utm_y + report.approved = False # back to awaiting approval after edits report.save() # delete selected existing photos for key, val in request.POST.items(): @@ -211,18 +310,17 @@ def installation_report_step(request, instance_id, step_id): total_price=total, ) - # complete step - StepInstance.objects.update_or_create( - process_instance=instance, - step=step, - defaults={'status': 'completed', 'completed_at': timezone.now()} - ) + # After installer submits/edits, set step back to in_progress and clear approvals + step_instance.status = 'in_progress' + step_instance.completed_at = None + step_instance.save() + try: + step_instance.approvals.all().delete() + except Exception: + pass - if next_step: - instance.current_step = next_step - instance.save() - return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id) - return redirect('processes:request_list') + messages.success(request, 'گزارش ثبت شد و در انتظار تایید است.') + return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) # Build prefill maps from existing report changes removed_ids = set() @@ -250,6 +348,9 @@ def installation_report_step(request, instance_id, step_id): 'added_map': added_map, 'previous_step': previous_step, 'next_step': next_step, + 'step_instance': step_instance, + 'approver_statuses': approver_statuses, + 'user_can_approve': user_can_approve, }) diff --git a/invoices/admin.py b/invoices/admin.py index 8428a2a..f8a46cb 100644 --- a/invoices/admin.py +++ b/invoices/admin.py @@ -6,8 +6,8 @@ from .models import Item, Quote, QuoteItem, Invoice, InvoiceItem, Payment @admin.register(Item) class ItemAdmin(SimpleHistoryAdmin): - list_display = ['name', 'unit_price', 'default_quantity', 'is_default_in_quotes', 'is_active', 'created_by'] - list_filter = ['is_default_in_quotes', 'is_active', 'created_by'] + list_display = ['name', 'unit_price', 'default_quantity', 'is_default_in_quotes', 'is_special', 'is_active', 'created_by'] + list_filter = ['is_default_in_quotes', 'is_special', 'is_active', 'created_by'] search_fields = ['name', 'description'] prepopulated_fields = {'slug': ('name',)} readonly_fields = ['deleted_at', 'created', 'updated'] diff --git a/invoices/templates/invoices/final_invoice_step.html b/invoices/templates/invoices/final_invoice_step.html index 9376705..dfee339 100644 --- a/invoices/templates/invoices/final_invoice_step.html +++ b/invoices/templates/invoices/final_invoice_step.html @@ -50,7 +50,9 @@
فاکتور نهایی
- + {% if is_manager %} + + {% endif %}
@@ -127,7 +129,9 @@ {{ si.unit_price|floatformat:0|intcomma:False }} {{ si.total_price|floatformat:0|intcomma:False }} - + {% if is_manager %} + + {% endif %} {% endfor %} @@ -164,7 +168,11 @@ {% endif %} {% if next_step %} - + {% if is_manager %} + + {% else %} + بعدی + {% endif %} {% endif %}
diff --git a/invoices/templates/invoices/final_settlement_step.html b/invoices/templates/invoices/final_settlement_step.html index 5058a09..ca11dec 100644 --- a/invoices/templates/invoices/final_settlement_step.html +++ b/invoices/templates/invoices/final_settlement_step.html @@ -2,6 +2,7 @@ {% load static %} {% load processes_tags %} {% load common_tags %} +{% load accounts_tags %} {% load humanize %} {% block sidebar %} @@ -46,6 +47,7 @@
+ {% if is_broker %}
ثبت تراکنش تسویه
@@ -78,11 +80,11 @@
- +
- +
@@ -92,23 +94,39 @@
-
+ {% endif %} +
-
وضعیت فاکتور
+
+
وضعیت فاکتور
+
-
-
+
+
مبلغ نهایی
{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان
-
-
+
+
+
پرداختی‌ها
+
{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان
+
+
+
+
مانده
{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان
+
+ {% if invoice.remaining_amount <= 0 %} + تسویه کامل + {% else %} + باقی‌مانده دارد + {% endif %} +
@@ -123,8 +141,8 @@ مبلغ تاریخ روش - شماره مرجع - عملیات + شماره مرجع/چک + عملیات @@ -132,7 +150,7 @@ {% if p.direction == 'in' %}دریافتی{% else %}پرداختی{% endif %} {{ p.amount|floatformat:0|intcomma:False }} تومان - {{ p.payment_date|to_jalali }} + {{ p.payment_date|date:'Y/m/d' }} {{ p.get_payment_method_display }} {{ p.reference_number|default:'-' }} @@ -142,7 +160,9 @@ {% endif %} - + {% if is_broker %} + + {% endif %}
@@ -152,20 +172,141 @@
- +
+ {% if approver_statuses %} +
+
+
وضعیت تاییدها
+ {% if can_approve_reject %} +
+ + +
+ {% endif %} +
+
+
+ {% for st in approver_statuses %} +
+
+
+ {{ st.role.name }} + {% if st.status == 'approved' %} + تایید شد + {% elif st.status == 'rejected' %} + رد شد + {% else %} + در انتظار + {% endif %} +
+ {% if st.status == 'rejected' and st.reason %} +
علت: {{ st.reason }}
+ {% endif %} +
+
+ {% endfor %} +
+
+
+ {% endif %} +
+ {% if previous_step %} + قبلی + {% else %} + + {% endif %} + {% if step_instance.status == 'completed' %} + {% if next_step %} + بعدی + {% else %} + اتمام + {% endif %} + {% endif %} +
+ + + + + + + + + + + + {% endblock %} {% block script %} @@ -191,8 +332,11 @@ if (g) { fd.set('payment_date', g); } return fd; } - document.getElementById('btnAddFinalPayment').addEventListener('click', function(){ - const fd = buildForm(); + (function(){ + const btn = document.getElementById('btnAddFinalPayment'); + if (!btn) return; + btn.addEventListener('click', function(){ + const fd = buildForm(); // Frontend validation const amount = document.getElementById('id_amount').value.trim(); const payDate = document.getElementById('id_payment_date').value.trim(); @@ -204,7 +348,7 @@ showToast('همه فیلدها الزامی است', 'danger'); return; } - fetch('{% url "invoices:add_final_payment" instance.id step.id %}', { method:'POST', body: fd }) + fetch('{% url "invoices:add_final_payment" instance.id step.id %}', { method:'POST', body: fd }) .then(r=>r.json()).then(resp=>{ if (resp.success) { showToast('تراکنش ثبت شد', 'success'); @@ -213,12 +357,20 @@ showToast(resp.message || 'خطا در ثبت تراکنش', 'danger'); } }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger')); - }); + }); + })(); - function deleteFinalPayment(id){ + let deleteTargetId = null; + function openDeleteModal(id){ + deleteTargetId = id; + const modal = new bootstrap.Modal(document.getElementById('deletePaymentModal')); + modal.show(); + } + function confirmDeletePayment(){ + if (!deleteTargetId) return; const fd = new FormData(); fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value); - fetch(`{% url "invoices:delete_final_payment" instance.id step.id 0 %}`.replace('/0/', `/${id}/`), { method:'POST', body: fd }) + fetch(`{% url "invoices:delete_final_payment" instance.id step.id 0 %}`.replace('/0/', `/${deleteTargetId}/`), { method:'POST', body: fd }) .then(r=>r.json()).then(resp=>{ if (resp.success) { showToast('حذف شد', 'success'); @@ -229,20 +381,7 @@ }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger')); } - document.getElementById('btnApproveFinalSettlement').addEventListener('click', function(){ - const fd = new FormData(); - fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value); - fetch('{% url "invoices:approve_final_settlement" instance.id step.id %}', { method:'POST', body: fd }) - .then(r=>r.json()).then(resp=>{ - if (resp.success) { - showToast(resp.message || 'تایید شد', 'success'); - if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 600); - } else { - showToast(resp.message || 'خطا در تایید', 'danger'); - } - }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger')); - }); + // Legacy approve button removed; using modal forms below {% endblock %} - diff --git a/invoices/templates/invoices/quote_payment_step.html b/invoices/templates/invoices/quote_payment_step.html index ab9f518..1b963ee 100644 --- a/invoices/templates/invoices/quote_payment_step.html +++ b/invoices/templates/invoices/quote_payment_step.html @@ -1,6 +1,7 @@ {% extends '_base.html' %} {% load static %} {% load processes_tags %} +{% load accounts_tags %} {% load humanize %} {% block sidebar %} @@ -55,14 +56,15 @@
{{ step.name }}
- ثبت فیش‌های واریزی برای پیش‌فاکتور + ثبت فیش‌ها/چک‌های واریزی برای پیش‌فاکتور
+ {% if can_manage_payments %}
-
ثبت فیش جدید
+
ثبت فیش/چک جدید
@@ -84,11 +86,11 @@
- +
- +
@@ -96,16 +98,16 @@
- +
-
+ {% endif %} +
-
وضعیت پیش‌فاکتور
- مشاهده پیش‌فاکتور +
وضعیت پیش‌فاکتور
@@ -139,8 +141,10 @@
-
-
فیش‌های ثبت شده
+
+
+
فیش‌ها/چک‌های ثبت شده
+
@@ -149,9 +153,8 @@ - - - + + @@ -162,28 +165,23 @@ - {% empty %} - + {% endfor %} @@ -191,6 +189,42 @@ + {% if approver_statuses %} +
+
+
وضعیت تاییدها
+ {% if can_approve_reject %} +
+ + +
+ {% endif %} +
+
+
+ {% for st in approver_statuses %} +
+
+
+ {{ st.role.name }} + {% if st.status == 'approved' %} + تایید شد + {% elif st.status == 'rejected' %} + رد شد + {% else %} + در انتظار + {% endif %} +
+ {% if st.status == 'rejected' and st.reason %} +
علت: {{ st.reason }}
+ {% endif %} +
+
+ {% endfor %} +
+
+
+ {% endif %}
{% if previous_step %} @@ -201,30 +235,114 @@ {% else %} {% endif %} - + {% if step_instance.status == 'completed' %} + {% if next_step %} + + بعدی + + + {% else %} + اتمام + {% endif %} + {% endif %}
+ + + + + + + + + + {% endblock %} {% block script %} @@ -365,42 +439,4 @@ })(); - - - - {% endblock %} diff --git a/invoices/templates/invoices/quote_preview_step.html b/invoices/templates/invoices/quote_preview_step.html index 4ba69f1..e6e405a 100644 --- a/invoices/templates/invoices/quote_preview_step.html +++ b/invoices/templates/invoices/quote_preview_step.html @@ -221,20 +221,32 @@ {% endif %} - {% if step_instance.status == 'completed' %} + {% if is_broker %} + {% if step_instance.status == 'completed' %} + {% if next_step %} + + بعدی + + + {% else %} + + {% endif %} + {% else %} + + {% endif %} + {% else %} {% if next_step %} - بعدی + class="btn btn-label-primary"> + مرحله بعد {% else %} - + اتمام {% endif %} - {% else %} - {% endif %} diff --git a/invoices/templates/invoices/quote_step.html b/invoices/templates/invoices/quote_step.html index 5219c40..ca17747 100644 --- a/invoices/templates/invoices/quote_step.html +++ b/invoices/templates/invoices/quote_step.html @@ -58,6 +58,7 @@ {% endif %}
+ {% if is_broker or existing_quote %}
مبلغ تاریخ روششماره مرجعتصویرعملیاتشماره مرجع/چکعملیات
{{ p.get_payment_method_display }} {{ p.reference_number|default:'-' }} - {% if p.receipt_image %} +
+ {% if p.receipt_image %} - {% else %} - - - {% endif %} -
-
- - + {% endif %}
تا کنون فیشی ثبت نشده استتا کنون فیش/چکی ثبت نشده است
@@ -77,7 +78,8 @@ data-item-id="{{ item.id }}" data-is-default="{% if item.is_default_in_quotes %}1{% else %}0{% endif %}" {% if selected_qty %}checked{% elif item.is_default_in_quotes %}checked{% endif %} - {% if item.is_default_in_quotes %}disabled title="آیتم پیش‌فرض است و قابل حذف نیست"{% endif %}> + {% if item.is_default_in_quotes or not is_broker %}disabled{% endif %} + {% if item.is_default_in_quotes %}title="آیتم پیش‌فرض است و قابل حذف نیست"{% elif not is_broker %}title="فقط کارگزار مجاز به تغییر اقلام است"{% endif %}> {% endwith %} @@ -102,8 +104,9 @@
@@ -86,15 +88,15 @@ پیش‌فرض {% endif %} - {% if item.description %}{{ item.description }}{% endif %}
{{ item.unit_price|floatformat:0|intcomma:False }} تومان - +
- - + {% else %} +
شما دسترسی به ثبت اقلام ندارید.
+ {% endif %}
@@ -118,27 +121,35 @@ {% endif %} - {% if step_instance.status == 'completed' %} - {% if next_step %} -
- -
- + {% if is_broker %} + {% if step_instance.status == 'completed' %} + {% if next_step %} +
+ +
+ {% else %} + + {% endif %} {% else %} - + {% endif %} {% else %} - + {% if next_step %} + + مرحله بعد + + + {% else %} + اتمام + {% endif %} {% endif %}
diff --git a/invoices/views.py b/invoices/views.py index b39aafe..6429499 100644 --- a/invoices/views.py +++ b/invoices/views.py @@ -9,7 +9,9 @@ from django.urls import reverse from decimal import Decimal, InvalidOperation import json -from processes.models import ProcessInstance, ProcessStep, StepInstance +from processes.models import ProcessInstance, ProcessStep, StepInstance, StepRejection, StepApproval +from accounts.models import Role +from common.consts import UserRoles from .models import Item, Quote, QuoteItem, Payment, Invoice from installations.models import InstallationReport, InstallationItemChange @@ -28,7 +30,7 @@ def quote_step(request, instance_id, step_id): return redirect('processes:request_list') # دریافت آیتم‌ها - items = Item.objects.all().order_by('name') + items = Item.objects.filter(is_active=True, is_special=False, is_deleted=False).order_by('name') existing_quote = Quote.objects.filter(process_instance=instance).first() existing_quote_items = {} if existing_quote: @@ -40,6 +42,14 @@ def quote_step(request, instance_id, step_id): previous_step = instance.process.steps.filter(order__lt=step.order).last() next_step = instance.process.steps.filter(order__gt=step.order).first() + # determine if current user is broker + profile = getattr(request.user, 'profile', None) + is_broker = False + try: + is_broker = bool(profile and profile.has_role(UserRoles.BROKER)) + except Exception: + is_broker = False + return render(request, 'invoices/quote_step.html', { 'instance': instance, 'step': step, @@ -49,6 +59,7 @@ def quote_step(request, instance_id, step_id): 'existing_quote': existing_quote, 'previous_step': previous_step, 'next_step': next_step, + 'is_broker': is_broker, }) @require_POST @@ -57,6 +68,13 @@ def create_quote(request, instance_id, step_id): """ساخت/بروزرسانی پیش‌فاکتور از اقلام انتخابی""" instance = get_object_or_404(ProcessInstance, id=instance_id) step = get_object_or_404(instance.process.steps, id=step_id) + # enforce permission: only BROKER can create/update quote + profile = getattr(request.user, 'profile', None) + try: + if not (profile and profile.has_role(UserRoles.BROKER)): + return JsonResponse({'success': False, 'message': 'شما مجوز ثبت/ویرایش پیش‌فاکتور را ندارید'}) + except Exception: + return JsonResponse({'success': False, 'message': 'شما مجوز ثبت/ویرایش پیش‌فاکتور را ندارید'}) try: items_payload = json.loads(request.POST.get('items') or '[]') @@ -72,7 +90,7 @@ def create_quote(request, instance_id, step_id): except Exception: continue - default_item_ids = set(Item.objects.filter(is_default_in_quotes=True).values_list('id', flat=True)) + default_item_ids = set(Item.objects.filter(is_default_in_quotes=True, is_deleted=False).values_list('id', flat=True)) if default_item_ids: for default_id in default_item_ids: if default_id not in payload_by_id: @@ -163,6 +181,14 @@ def quote_preview_step(request, instance_id, step_id): previous_step = instance.process.steps.filter(order__lt=step.order).last() next_step = instance.process.steps.filter(order__gt=step.order).first() + # determine if current user is broker for UI controls + profile = getattr(request.user, 'profile', None) + is_broker = False + try: + is_broker = bool(profile and profile.has_role(UserRoles.BROKER)) + except Exception: + is_broker = False + return render(request, 'invoices/quote_preview_step.html', { 'instance': instance, 'step': step, @@ -170,6 +196,7 @@ def quote_preview_step(request, instance_id, step_id): 'quote': quote, 'previous_step': previous_step, 'next_step': next_step, + 'is_broker': is_broker, }) @login_required @@ -190,6 +217,13 @@ def approve_quote(request, instance_id, step_id): instance = get_object_or_404(ProcessInstance, id=instance_id) step = get_object_or_404(instance.process.steps, id=step_id) quote = get_object_or_404(Quote, process_instance=instance) + # enforce permission: only BROKER can approve + profile = getattr(request.user, 'profile', None) + try: + if not (profile and profile.has_role(UserRoles.BROKER)): + return JsonResponse({'success': False, 'message': 'شما مجوز تایید پیش‌فاکتور را ندارید'}) + except Exception: + return JsonResponse({'success': False, 'message': 'شما مجوز تایید پیش‌فاکتور را ندارید'}) # تایید پیش‌فاکتور quote.status = 'sent' @@ -247,7 +281,97 @@ def quote_payment_step(request, instance_id, step_id): 'is_fully_paid': quote.get_remaining_amount() <= 0, } - step_instance = instance.step_instances.filter(step=step).first() + step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'}) + reqs = list(step.approver_requirements.select_related('role').all()) + user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None) + user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else [] + approvals_list = list(step_instance.approvals.select_related('role').all()) + approvals_by_role = {a.role_id: a for a in approvals_list} + approver_statuses = [ + { + 'role': r.role, + 'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None), + 'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''), + } + for r in reqs + ] + # dynamic permission: who can approve/reject this step (based on requirements) + try: + req_role_ids = {r.role_id for r in reqs} + user_role_ids = {ur.id for ur in user_roles} + can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0 + except Exception: + can_approve_reject = False + # approver status map for template + reqs = list(step.approver_requirements.select_related('role').all()) + user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None) + user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else [] + approvals_list = list(step_instance.approvals.select_related('role').all()) + approvals_by_role = {a.role_id: a for a in approvals_list} + approver_statuses = [ + { + 'role': r.role, + 'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None), + 'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''), + } + for r in reqs + ] + + # Accountant/Admin approval and rejection via POST (multi-role) + if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']: + # match user's role against step required approver roles + req_roles = [req.role for req in step.approver_requirements.select_related('role').all()] + user_roles = list(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).all()) + matching_role = next((r for r in user_roles if r in req_roles), None) + if matching_role is None: + messages.error(request, 'شما دسترسی لازم برای تایید/رد این مرحله را ندارید.') + return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id) + + action = request.POST.get('action') + if action == 'approve': + StepApproval.objects.update_or_create( + step_instance=step_instance, + role=matching_role, + defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''} + ) + if step_instance.is_fully_approved(): + step_instance.status = 'completed' + step_instance.completed_at = timezone.now() + step_instance.save() + # move to next step + redirect_url = 'processes:request_list' + if next_step: + instance.current_step = next_step + instance.save() + return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id) + return redirect(redirect_url) + messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقش‌ها.') + return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id) + + if action == 'reject': + reason = (request.POST.get('reject_reason') or '').strip() + if not reason: + messages.error(request, 'علت رد شدن را وارد کنید') + return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id) + StepApproval.objects.update_or_create( + step_instance=step_instance, + role=matching_role, + defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason} + ) + StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason) + messages.success(request, 'مرحله پرداخت‌ها رد شد و برای اصلاح بازگشت.') + return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id) + + # role flags for permissions (legacy flags kept for compatibility) + profile = getattr(request.user, 'profile', None) + is_broker = False + is_accountant = False + try: + is_broker = bool(profile and profile.has_role(UserRoles.BROKER)) + is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT)) + except Exception: + is_broker = False + is_accountant = False return render(request, 'invoices/quote_payment_step.html', { 'instance': instance, @@ -258,6 +382,12 @@ def quote_payment_step(request, instance_id, step_id): 'totals': totals, 'previous_step': previous_step, 'next_step': next_step, + 'approver_statuses': approver_statuses, + 'is_broker': is_broker, + 'is_accountant': is_accountant, + # dynamic permissions: any role required to approve can also manage payments + 'can_manage_payments': can_approve_reject, + 'can_approve_reject': can_approve_reject, }) @@ -279,6 +409,16 @@ def add_quote_payment(request, instance_id, step_id): } ) + # dynamic permission: users whose roles are among required approvers can add payments + try: + req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True)) + user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()) + user_role_ids = set(user_roles_qs.values_list('id', flat=True)) + if len(req_role_ids.intersection(user_role_ids)) == 0: + return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'}) + except Exception: + return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'}) + logger = logging.getLogger(__name__) try: amount = (request.POST.get('amount') or '').strip() @@ -325,6 +465,15 @@ def add_quote_payment(request, instance_id, step_id): logger.exception('Error adding quote payment (instance=%s, step=%s)', instance_id, step_id) return JsonResponse({'success': False, 'message': 'خطا در ثبت فیش', 'error': str(e)}) + # After modifying payments, set step back to in_progress (awaiting approval) + try: + si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) + si.status = 'in_progress' + si.completed_at = None + si.save() + si.approvals.all().delete() + except Exception: + pass redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id]) return JsonResponse({'success': True, 'redirect': redirect_url}) @@ -360,6 +509,15 @@ def update_quote_payment(request, instance_id, step_id, payment_id): except Exception: return JsonResponse({'success': False, 'message': 'خطا در ویرایش فیش'}) + # On update, return to awaiting approval + try: + si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) + si.status = 'in_progress' + si.completed_at = None + si.save() + si.approvals.all().delete() + except Exception: + pass redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id]) return JsonResponse({'success': True, 'redirect': redirect_url}) @@ -374,11 +532,30 @@ def delete_quote_payment(request, instance_id, step_id, payment_id): if not invoice: return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'}) payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) + # dynamic permission: users whose roles are among required approvers can delete payments + try: + req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True)) + user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()) + user_role_ids = set(user_roles_qs.values_list('id', flat=True)) + if len(req_role_ids.intersection(user_role_ids)) == 0: + return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'}) + except Exception: + return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'}) + try: # soft delete using project's BaseModel delete override payment.delete() except Exception: return JsonResponse({'success': False, 'message': 'خطا در حذف فیش'}) + # On delete, return to awaiting approval + try: + si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) + si.status = 'in_progress' + si.completed_at = None + si.save() + si.approvals.all().delete() + except Exception: + pass redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id]) return JsonResponse({'success': True, 'redirect': redirect_url}) @@ -534,6 +711,14 @@ def final_invoice_step(request, instance_id, step_id): # Choices for special items from DB special_choices = list(Item.objects.filter(is_special=True).values('id', 'name')) + # role flag for manager-only actions + profile = getattr(request.user, 'profile', None) + is_manager = False + try: + is_manager = bool(profile and profile.has_role(UserRoles.MANAGER)) + except Exception: + is_manager = False + return render(request, 'invoices/final_invoice_step.html', { 'instance': instance, 'step': step, @@ -543,6 +728,7 @@ def final_invoice_step(request, instance_id, step_id): 'invoice_specials': invoice.items.select_related('item').filter(item__is_special=True, is_deleted=False).all(), 'previous_step': previous_step, 'next_step': next_step, + 'is_manager': is_manager, }) @@ -564,6 +750,12 @@ def approve_final_invoice(request, instance_id, step_id): instance = get_object_or_404(ProcessInstance, id=instance_id) step = get_object_or_404(instance.process.steps, id=step_id) invoice = get_object_or_404(Invoice, process_instance=instance) + # only MANAGER can approve + try: + if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)): + return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403) + except Exception: + return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403) # Block approval when there is any remaining (positive or negative) invoice.calculate_totals() # if invoice.remaining_amount != 0: @@ -592,6 +784,12 @@ def add_special_charge(request, instance_id, step_id): """افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی به‌صورت آیتم جداگانه""" instance = get_object_or_404(ProcessInstance, id=instance_id) invoice = get_object_or_404(Invoice, process_instance=instance) + # only MANAGER can add special charges + try: + if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)): + return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403) + except Exception: + return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403) # charge_type was removed from UI; we no longer require it item_id = request.POST.get('item_id') amount = (request.POST.get('amount') or '').strip() @@ -623,6 +821,12 @@ def add_special_charge(request, instance_id, step_id): def delete_special_charge(request, instance_id, step_id, item_id): instance = get_object_or_404(ProcessInstance, id=instance_id) invoice = get_object_or_404(Invoice, process_instance=instance) + # only MANAGER can delete special charges + try: + if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)): + return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403) + except Exception: + return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403) from .models import InvoiceItem inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice) # allow deletion only for special items @@ -648,13 +852,87 @@ def final_settlement_step(request, instance_id, step_id): previous_step = instance.process.steps.filter(order__lt=step.order).last() next_step = instance.process.steps.filter(order__gt=step.order).first() + # Ensure step instance exists + step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'}) + # Build approver statuses for template + reqs = list(step.approver_requirements.select_related('role').all()) + approvals_map = {a.role_id: a.decision for a in step_instance.approvals.select_related('role').all()} + approver_statuses = [{'role': r.role, 'status': approvals_map.get(r.role_id)} for r in reqs] + # dynamic permission to control approve/reject UI + try: + user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()) + user_role_ids = set(user_roles_qs.values_list('id', flat=True)) + req_role_ids = {r.role_id for r in reqs} + can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0 + except Exception: + can_approve_reject = False + + # Accountant/Admin approval and rejection (multi-role) + if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']: + req_roles = [req.role for req in step.approver_requirements.select_related('role').all()] + user_roles = list(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).all()) + matching_role = next((r for r in user_roles if r in req_roles), None) + if matching_role is None: + messages.error(request, 'شما دسترسی لازم برای تایید/رد این مرحله را ندارید.') + return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id) + + action = request.POST.get('action') + if action == 'approve': + # enforce zero remaining + invoice.calculate_totals() + if invoice.remaining_amount != 0: + messages.error(request, f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})") + return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id) + StepApproval.objects.update_or_create( + step_instance=step_instance, + role=matching_role, + defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''} + ) + if step_instance.is_fully_approved(): + step_instance.status = 'completed' + step_instance.completed_at = timezone.now() + step_instance.save() + if next_step: + instance.current_step = next_step + instance.save() + return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id) + return redirect('processes:request_list') + messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقش‌ها.') + return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id) + + if action == 'reject': + reason = (request.POST.get('reject_reason') or '').strip() + if not reason: + messages.error(request, 'علت رد شدن را وارد کنید') + return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id) + StepApproval.objects.update_or_create( + step_instance=step_instance, + role=matching_role, + defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason} + ) + StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason) + messages.success(request, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.') + return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id) + + # broker flag for payment management permission + profile = getattr(request.user, 'profile', None) + is_broker = False + try: + is_broker = bool(profile and profile.has_role(UserRoles.BROKER)) + except Exception: + is_broker = False + return render(request, 'invoices/final_settlement_step.html', { 'instance': instance, 'step': step, 'invoice': invoice, 'payments': invoice.payments.filter(is_deleted=False).all(), + 'step_instance': step_instance, 'previous_step': previous_step, 'next_step': next_step, + 'approver_statuses': approver_statuses, + 'can_approve_reject': can_approve_reject, + 'is_broker': is_broker, }) @@ -662,7 +940,14 @@ def final_settlement_step(request, instance_id, step_id): @login_required def add_final_payment(request, instance_id, step_id): instance = get_object_or_404(ProcessInstance, id=instance_id) + step = get_object_or_404(instance.process.steps, id=step_id) invoice = get_object_or_404(Invoice, process_instance=instance) + # Only BROKER can add final settlement payments + try: + if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)): + return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403) + except Exception: + return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403) amount = (request.POST.get('amount') or '').strip() payment_date = (request.POST.get('payment_date') or '').strip() payment_method = (request.POST.get('payment_method') or '').strip() @@ -717,6 +1002,14 @@ def add_final_payment(request, instance_id, step_id): ) # After creation, totals auto-updated by model save. Respond with redirect and new totals for UX. invoice.refresh_from_db() + # After payment change, set step back to in_progress + try: + si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) + si.status = 'in_progress' + si.completed_at = None + si.save() + except Exception: + pass return JsonResponse({ 'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), @@ -732,10 +1025,25 @@ def add_final_payment(request, instance_id, step_id): @login_required def delete_final_payment(request, instance_id, step_id, payment_id): instance = get_object_or_404(ProcessInstance, id=instance_id) + step = get_object_or_404(instance.process.steps, id=step_id) invoice = get_object_or_404(Invoice, process_instance=instance) payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) + # Only BROKER can delete final settlement payments + try: + if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)): + return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403) + except Exception: + return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403) payment.delete() invoice.refresh_from_db() + # After payment change, set step back to in_progress + try: + si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) + si.status = 'in_progress' + si.completed_at = None + si.save() + except Exception: + pass return JsonResponse({'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'totals': { 'final_amount': str(invoice.final_amount), 'paid_amount': str(invoice.paid_amount), diff --git a/processes/admin.py b/processes/admin.py index 4c83c14..c8ad3ac 100644 --- a/processes/admin.py +++ b/processes/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin from django.utils.html import format_html from django.utils.safestring import mark_safe -from .models import Process, ProcessStep, ProcessInstance, StepInstance, StepDependency, StepRejection, StepRevision +from .models import Process, ProcessStep, ProcessInstance, StepInstance, StepDependency, StepRejection, StepRevision, StepApproverRequirement, StepApproval @admin.register(Process) class ProcessAdmin(SimpleHistoryAdmin): @@ -179,3 +179,17 @@ class StepRevisionAdmin(SimpleHistoryAdmin): def changes_short(self, obj): return obj.changes_description[:50] + "..." if len(obj.changes_description) > 50 else obj.changes_description changes_short.short_description = "تغییرات" + + +@admin.register(StepApproverRequirement) +class StepApproverRequirementAdmin(admin.ModelAdmin): + list_display = ("step", "role", "required_count") + list_filter = ("step__process", "role") + search_fields = ("step__name", "role__name") + + +@admin.register(StepApproval) +class StepApprovalAdmin(admin.ModelAdmin): + list_display = ("step_instance", "role", "decision", "approved_by", "created_at") + list_filter = ("decision", "role", "step_instance__step__process") + search_fields = ("step_instance__process_instance__code", "role__name", "approved_by__username") diff --git a/processes/migrations/0002_stepapproval_stepapproverrequirement.py b/processes/migrations/0002_stepapproval_stepapproverrequirement.py new file mode 100644 index 0000000..6a771b1 --- /dev/null +++ b/processes/migrations/0002_stepapproval_stepapproverrequirement.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.4 on 2025-09-01 10:33 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_historicalprofile_bank_name_profile_bank_name'), + ('processes', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='StepApproval', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('decision', models.CharField(choices=[('approved', 'تایید'), ('rejected', 'رد')], max_length=8, verbose_name='نتیجه')), + ('reason', models.TextField(blank=True, verbose_name='علت (برای رد)')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ')), + ('approved_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='تاییدکننده')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.role', verbose_name='نقش')), + ('step_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to='processes.stepinstance', verbose_name='نمونه مرحله')), + ], + options={ + 'verbose_name': 'تایید مرحله', + 'verbose_name_plural': 'تاییدهای مرحله', + 'unique_together': {('step_instance', 'role')}, + }, + ), + migrations.CreateModel( + name='StepApproverRequirement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('required_count', models.PositiveIntegerField(default=1, verbose_name='تعداد موردنیاز')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.role', verbose_name='نقش تاییدکننده')), + ('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approver_requirements', to='processes.processstep', verbose_name='مرحله')), + ], + options={ + 'verbose_name': 'نیازمندی تایید نقش', + 'verbose_name_plural': 'نیازمندی\u200cهای تایید نقش', + 'unique_together': {('step', 'role')}, + }, + ), + ] diff --git a/processes/models.py b/processes/models.py index cfbdfa7..604bb7b 100644 --- a/processes/models.py +++ b/processes/models.py @@ -4,6 +4,8 @@ from common.models import NameSlugModel, SluggedModel from simple_history.models import HistoricalRecords from django.core.exceptions import ValidationError from django.utils import timezone +from django.conf import settings +from accounts.models import Role from _helpers.utils import generate_unique_slug import random @@ -46,6 +48,9 @@ class ProcessStep(NameSlugModel): ) history = HistoricalRecords() + # Note: approver requirements are defined via StepApproverRequirement through model + # See StepApproverRequirement below + class Meta: verbose_name = "مرحله فرآیند" verbose_name_plural = "مراحل فرآیند" @@ -353,6 +358,26 @@ class StepInstance(models.Model): """دریافت آخرین رد شدن""" return self.rejections.order_by('-created_at').first() + # -------- Multi-role approval helpers -------- + def required_roles(self): + return [req.role for req in self.step.approver_requirements.select_related('role').all()] + + def approvals_by_role(self): + decisions = {} + for a in self.approvals.select_related('role').order_by('created_at'): + decisions[a.role_id] = a.decision + return decisions + + def is_fully_approved(self) -> bool: + req_roles = self.required_roles() + if not req_roles: + return True + role_to_decision = self.approvals_by_role() + for r in req_roles: + if role_to_decision.get(r.id) != 'approved': + return False + return True + class StepRejection(models.Model): """مدل رد شدن مرحله""" step_instance = models.ForeignKey( @@ -424,3 +449,36 @@ class StepRevision(models.Model): def __str__(self): return f"بازبینی {self.step_instance} توسط {self.revised_by.get_full_name()}" + + +class StepApproverRequirement(models.Model): + """Required approver roles for a step.""" + step = models.ForeignKey(ProcessStep, on_delete=models.CASCADE, related_name='approver_requirements', verbose_name="مرحله") + role = models.ForeignKey(Role, on_delete=models.CASCADE, verbose_name="نقش تاییدکننده") + required_count = models.PositiveIntegerField(default=1, verbose_name="تعداد موردنیاز") + + class Meta: + unique_together = ('step', 'role') + verbose_name = "نیازمندی تایید نقش" + verbose_name_plural = "نیازمندی‌های تایید نقش" + + def __str__(self): + return f"{self.step} ← {self.role} (x{self.required_count})" + + +class StepApproval(models.Model): + """Approvals per role for a concrete step instance.""" + step_instance = models.ForeignKey(StepInstance, on_delete=models.CASCADE, related_name='approvals', verbose_name="نمونه مرحله") + role = models.ForeignKey(Role, on_delete=models.CASCADE, verbose_name="نقش") + approved_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="تاییدکننده") + decision = models.CharField(max_length=8, choices=[('approved', 'تایید'), ('rejected', 'رد')], verbose_name='نتیجه') + reason = models.TextField(blank=True, verbose_name='علت (برای رد)') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ') + + class Meta: + unique_together = ('step_instance', 'role') + verbose_name = 'تایید مرحله' + verbose_name_plural = 'تاییدهای مرحله' + + def __str__(self): + return f"{self.step_instance} - {self.role} - {self.decision}" diff --git a/processes/templates/processes/includes/stepper_header.html b/processes/templates/processes/includes/stepper_header.html index bd30927..505ecd8 100644 --- a/processes/templates/processes/includes/stepper_header.html +++ b/processes/templates/processes/includes/stepper_header.html @@ -6,6 +6,7 @@
{% endif %} - {{ forloop.counter }} + {{ forloop.counter }} - {{ step.name }} + {{ step.name }} {{ step.description|default:' ' }} diff --git a/processes/templates/processes/instance_summary.html b/processes/templates/processes/instance_summary.html new file mode 100644 index 0000000..cb5171e --- /dev/null +++ b/processes/templates/processes/instance_summary.html @@ -0,0 +1,168 @@ + {% extends '_base.html' %} +{% load static %} +{% load humanize %} +{% load common_tags %} + +{% block sidebar %} + {% include 'sidebars/admin.html' %} +{% endblock sidebar %} + +{% block navbar %} + {% include 'navbars/admin.html' %} +{% endblock navbar %} + +{% block title %}گزارش نهایی - درخواست {{ instance.code }}{% endblock %} + +{% block content %} +{% include '_toasts.html' %} +
+
+
+
+
+

گزارش نهایی درخواست {{ instance.code }}

+ + اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} + | نماینده: {{ instance.representative.profile.national_code|default:"-" }} + +
+
+ {% if invoice %} + پرینت فاکتور + {% endif %} + پرینت گواهی + بازگشت +
+
+ +
+
+
+
+
فاکتور نهایی
+
+
+ {% if invoice %} +
+
مبلغ نهایی
{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان
+
پرداختی‌ها
{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان
+
مانده
{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان
+
+
+ + + + + + + + + + + {% for it in rows %} + + + + + + + {% empty %} + + {% endfor %} + +
آیتمتعدادقیمت واحدقیمت کل
{{ it.item.name }}{{ it.quantity }}{{ it.unit_price|floatformat:0|intcomma:False }}{{ it.total_price|floatformat:0|intcomma:False }}
اطلاعاتی ندارد
+
+ {% else %} +
فاکتور نهایی ثبت نشده است.
+ {% endif %} +
+
+
+ +
+
+
+
گزارش نصب
+ {% if latest_report and latest_report.assignment and latest_report.assignment.installer %} + نصاب: {{ latest_report.assignment.installer.get_full_name|default:latest_report.assignment.installer.username }} + {% endif %} +
+
+ {% if latest_report %} +
+
+

تاریخ مراجعه: {{ latest_report.visited_date|to_jalali|default:'-' }}

+

سریال کنتور جدید: {{ latest_report.new_water_meter_serial|default:'-' }}

+

شماره پلمپ: {{ latest_report.seal_number|default:'-' }}

+
+
+

کنتور مشکوک: {{ latest_report.is_meter_suspicious|yesno:'بله,خیر' }}

+

UTM X: {{ latest_report.utm_x|default:'-' }}

+

UTM Y: {{ latest_report.utm_y|default:'-' }}

+
+
+ {% if latest_report.description %} +
+

توضیحات:

+
{{ latest_report.description }}
+
+ {% endif %} +
+
عکس‌ها
+
+ {% for p in latest_report.photos.all %} +
photo
+ {% empty %} +
بدون عکس
+ {% endfor %} +
+ {% else %} +
گزارش نصب ثبت نشده است.
+ {% endif %} +
+
+
+ +
+
+
+
تراکنش‌ها
+
+
+
+ + + + + + + + + + + + {% for p in payments %} + + + + + + + + {% empty %} + + {% endfor %} + +
نوعمبلغتاریخروششماره مرجع/چک
{% if p.direction == 'in' %}دریافتی{% else %}پرداختی{% endif %}{{ p.amount|floatformat:0|intcomma:False }} تومان{{ p.payment_date|date:'Y/m/d' }}{{ p.get_payment_method_display }}{{ p.reference_number|default:'-' }}
بدون تراکنش
+
+
+
+
+ +
+
+
+
+{% endblock %} + + diff --git a/processes/templates/processes/request_list.html b/processes/templates/processes/request_list.html index acf274c..6ca6d95 100644 --- a/processes/templates/processes/request_list.html +++ b/processes/templates/processes/request_list.html @@ -72,9 +72,15 @@