Add confirmation and summary
This commit is contained in:
		
							parent
							
								
									9b3973805e
								
							
						
					
					
						commit
						35799b7754
					
				
					 25 changed files with 1419 additions and 265 deletions
				
			
		| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 %}<span></span>{% endif %}
 | 
			
		||||
                <form method="post">
 | 
			
		||||
                  {% csrf_token %}
 | 
			
		||||
                  <button class="btn btn-primary" type="submit">تایید و پایان</button>
 | 
			
		||||
                  {% if request.user|is_broker %}
 | 
			
		||||
                    <button class="btn btn-primary" type="submit">تایید و پایان</button>
 | 
			
		||||
                  {% else %}
 | 
			
		||||
                    <button class="btn btn-primary" type="button" disabled>تایید و پایان</button>
 | 
			
		||||
                  {% endif %}
 | 
			
		||||
                </form>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -41,32 +41,36 @@
 | 
			
		|||
        <div class="bs-stepper-content">
 | 
			
		||||
          <div class="card border">
 | 
			
		||||
            <div class="card-body">
 | 
			
		||||
              {% if template.company.logo %}
 | 
			
		||||
                <div class="text-center mb-3">
 | 
			
		||||
                  <img src="{{ template.company.logo.url }}" alt="لوگوی شرکت" style="max-height:80px;">
 | 
			
		||||
                  <h4 class="text-muted">{{ contract.template.company.name }}</h4>
 | 
			
		||||
                  <h5 class="text-muted">{{ contract.template.name }}</h5>
 | 
			
		||||
                </div>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
              {% if can_view_contract_body %}
 | 
			
		||||
                {% if template.company.logo %}
 | 
			
		||||
                  <div class="text-center mb-3">
 | 
			
		||||
                    <img src="{{ template.company.logo.url }}" alt="لوگوی شرکت" style="max-height:80px;">
 | 
			
		||||
                    <h4 class="text-muted">{{ contract.template.company.name }}</h4>
 | 
			
		||||
                    <h5 class="text-muted">{{ contract.template.name }}</h5>
 | 
			
		||||
                  </div>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
 | 
			
		||||
              <div class="small text-muted mb-2">تاریخ: {{ contract.jcreated }}</div>
 | 
			
		||||
              <hr>
 | 
			
		||||
              <div class="contract-body" style="white-space: pre-line; line-height:1.9;">{{ contract.rendered_body|safe }}</div>
 | 
			
		||||
              <hr>
 | 
			
		||||
              <div class="row mt-4">
 | 
			
		||||
                <div class="col-6 text-center">
 | 
			
		||||
                  <div>امضای مشترک</div>
 | 
			
		||||
                  <div style="height:90px;border:1px dashed #ccc; margin-top:10px;"></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="col-6 text-center">
 | 
			
		||||
                  <div>امضای شرکت</div>
 | 
			
		||||
                  <div style="height:90px;border:1px dashed #ccc; margin-top:10px;">
 | 
			
		||||
                    {% if template.company.signature %}
 | 
			
		||||
                      <img src="{{ template.company.signature.url }}" alt="امضای شرکت" style="max-height:80px;">
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                <div class="small text-muted mb-2">تاریخ: {{ contract.jcreated }}</div>
 | 
			
		||||
                <hr>
 | 
			
		||||
                <div class="contract-body" style="white-space: pre-line; line-height:1.9;">{{ contract.rendered_body|safe }}</div>
 | 
			
		||||
                <hr>
 | 
			
		||||
                <div class="row mt-4">
 | 
			
		||||
                  <div class="col-6 text-center">
 | 
			
		||||
                    <div>امضای مشترک</div>
 | 
			
		||||
                    <div style="height:90px;border:1px dashed #ccc; margin-top:10px;"></div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div class="col-6 text-center">
 | 
			
		||||
                    <div>امضای شرکت</div>
 | 
			
		||||
                    <div style="height:90px;border:1px dashed #ccc; margin-top:10px;">
 | 
			
		||||
                      {% if template.company.signature %}
 | 
			
		||||
                        <img src="{{ template.company.signature.url }}" alt="امضای شرکت" style="max-height:80px;">
 | 
			
		||||
                      {% endif %}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              {% else %}
 | 
			
		||||
                <div class="alert alert-warning mb-0">شما دسترسی به مشاهده متن قرارداد را ندارید.</div>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <form method="post" class="d-flex justify-content-between mt-3">
 | 
			
		||||
| 
						 | 
				
			
			@ -77,9 +81,17 @@
 | 
			
		|||
              <span></span>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if next_step %}
 | 
			
		||||
              <button type="submit" class="btn btn-primary">بعدی</button>
 | 
			
		||||
              {% if is_broker %}
 | 
			
		||||
                <button type="submit" class="btn btn-primary">تایید و بعدی</button>
 | 
			
		||||
              {% else %}
 | 
			
		||||
              <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            {% else %}
 | 
			
		||||
              <button class="btn btn-success" type="button">اتمام</button>
 | 
			
		||||
              {% if is_broker %}
 | 
			
		||||
                <button class="btn btn-success" type="submit">اتمام</button>
 | 
			
		||||
              {% else %}
 | 
			
		||||
                <button class="btn btn-success" type="button" disabled>اتمام</button>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            {% endif %}
 | 
			
		||||
          </form>
 | 
			
		||||
        </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										
											BIN
										
									
								
								db.sqlite3
									
										
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								db.sqlite3
									
										
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
{% extends '_base.html' %}
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load processes_tags %}
 | 
			
		||||
{% load common_tags %}
 | 
			
		||||
{% load humanize %}
 | 
			
		||||
 | 
			
		||||
{% block sidebar %}
 | 
			
		||||
| 
						 | 
				
			
			@ -41,12 +42,15 @@
 | 
			
		|||
        
 | 
			
		||||
        <div class="bs-stepper-content">
 | 
			
		||||
 | 
			
		||||
          {% if show_denied_msg %}
 | 
			
		||||
            <div class="alert alert-warning mb-3">شما اجازه تعیین نصاب را ندارید.</div>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
          <form method="post">
 | 
			
		||||
            {% csrf_token %}
 | 
			
		||||
            <div class="row g-3">
 | 
			
		||||
              <div class="col-md-6">
 | 
			
		||||
                <label class="form-label">نصاب</label>
 | 
			
		||||
                <select name="installer_id" class="form-select" required>
 | 
			
		||||
                <select name="installer_id" class="form-select" {% if read_only %}disabled{% endif %} required>
 | 
			
		||||
                  <option value="">انتخاب کنید...</option>
 | 
			
		||||
                  {% for p in installers %}
 | 
			
		||||
                    <option value="{{ p.user.id }}" {% if assignment.installer and p.user.id == assignment.installer.id %}selected{% endif %}>{{ p.user.get_full_name }} ({{ p.user.username }})</option>
 | 
			
		||||
| 
						 | 
				
			
			@ -55,17 +59,39 @@
 | 
			
		|||
              </div>
 | 
			
		||||
              <div class="col-md-6">
 | 
			
		||||
                <label class="form-label">تاریخ مراجعه نصاب</label>
 | 
			
		||||
                <input type="text" id="id_scheduled_date_display" class="form-control" placeholder="انتخاب تاریخ" readonly required value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y/m/d' }}{% endif %}">
 | 
			
		||||
                <input type="text" id="id_scheduled_date_display" class="form-control" placeholder="انتخاب تاریخ" {% if read_only %}disabled{% endif %} readonly required value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y/m/d' }}{% endif %}">
 | 
			
		||||
                <input type="hidden" id="id_scheduled_date" name="scheduled_date" value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y-m-d' }}{% endif %}">
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            {% if assignment.assigned_by or assignment.installer %}
 | 
			
		||||
            <div class="mt-3 border rounded p-3 bg-light">
 | 
			
		||||
              <div class="row g-2">
 | 
			
		||||
                {% if assignment.assigned_by %}
 | 
			
		||||
                <div class="col-12 col-md-6">
 | 
			
		||||
                  <div class="small text-muted">تعیینکننده نصاب</div>
 | 
			
		||||
                  <div>{{ assignment.assigned_by.get_full_name|default:assignment.assigned_by.username }} <span class="text-muted">({{ assignment.assigned_by.username }})</span></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% if assignment.updated %}
 | 
			
		||||
                <div class="col-12 col-md-6">
 | 
			
		||||
                  <div class="small text-muted">تاریخ ثبت/ویرایش</div>
 | 
			
		||||
                  <div>{{ assignment.updated|to_jalali }}</div>
 | 
			
		||||
                </div>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            <div class="d-flex justify-content-between mt-4">
 | 
			
		||||
              {% if previous_step %}
 | 
			
		||||
                <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
 | 
			
		||||
              {% else %}
 | 
			
		||||
                <span></span>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
              <button class="btn btn-primary" type="submit">ثبت و ادامه</button>
 | 
			
		||||
              {% if is_manager %}
 | 
			
		||||
                <button class="btn btn-primary" type="submit">ثبت و ادامه</button>
 | 
			
		||||
              {% else %}
 | 
			
		||||
              <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            </div>
 | 
			
		||||
          </form>
 | 
			
		||||
        </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 %}
 | 
			
		||||
        
 | 
			
		||||
        <div class="bs-stepper-content">
 | 
			
		||||
                  
 | 
			
		||||
          {% if report and not edit_mode %}
 | 
			
		||||
          <div class="card mb-3 border">
 | 
			
		||||
            <div class="card-header d-flex justify-content-end">
 | 
			
		||||
              <a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a>
 | 
			
		||||
            <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
              <div class="d-flex gap-2">
 | 
			
		||||
                {% if request.user|is_installer %}
 | 
			
		||||
                  <a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a>
 | 
			
		||||
                {% else %}
 | 
			
		||||
                  <button type="button" class="btn btn-primary" disabled>ویرایش گزارش نصب</button>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% if user_can_approve %}
 | 
			
		||||
                  <button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#approveModal" {% if step_instance and step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
 | 
			
		||||
                  <button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#rejectModal">رد</button>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="card-body">
 | 
			
		||||
              {% if step_instance and step_instance.status == 'rejected' and step_instance.get_latest_rejection %}
 | 
			
		||||
              <div class="alert alert-danger d-flex align-items-start" role="alert">
 | 
			
		||||
                <i class="bx bx-error-circle me-2"></i>
 | 
			
		||||
                <div>
 | 
			
		||||
                  <div><strong>این گزارش رد شده است.</strong></div>
 | 
			
		||||
                  <div class="mt-1 small">علت رد: {{ step_instance.get_latest_rejection.reason }}</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
              <div class="row">
 | 
			
		||||
                <div class="col-md-6">
 | 
			
		||||
                  <p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}</p>
 | 
			
		||||
| 
						 | 
				
			
			@ -67,6 +86,9 @@
 | 
			
		|||
              </div>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
              <hr>
 | 
			
		||||
              {% if request.user|is_manager or request.user|is_admin %}
 | 
			
		||||
              <hr>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
              <h6>عکسها</h6>
 | 
			
		||||
              <div class="row">
 | 
			
		||||
                {% for p in report.photos.all %}
 | 
			
		||||
| 
						 | 
				
			
			@ -115,6 +137,42 @@
 | 
			
		|||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          {% if approver_statuses %}
 | 
			
		||||
          <div class="card border mt-2">
 | 
			
		||||
            <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
              <h6 class="mb-0">وضعیت تاییدها</h6>
 | 
			
		||||
              {% if user_can_approve %}
 | 
			
		||||
              <div class="d-flex gap-2">
 | 
			
		||||
                <button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveModal" {% if step_instance and step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
 | 
			
		||||
                <button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectModal">رد</button>
 | 
			
		||||
              </div>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="card-body py-3">
 | 
			
		||||
              <div class="row g-2">
 | 
			
		||||
                {% for st in approver_statuses %}
 | 
			
		||||
                <div class="col-12 col-md-6 col-lg-4">
 | 
			
		||||
                  <div class="d-flex flex-column border rounded px-2 py-1">
 | 
			
		||||
                    <div class="d-flex align-items-center gap-2">
 | 
			
		||||
                      <span class="badge bg-light text-dark">{{ st.role.name }}</span>
 | 
			
		||||
                      {% if st.status == 'approved' %}
 | 
			
		||||
                        <span class="badge bg-success">تایید شد</span>
 | 
			
		||||
                      {% elif st.status == 'rejected' %}
 | 
			
		||||
                        <span class="badge bg-danger">رد شد</span>
 | 
			
		||||
                      {% else %}
 | 
			
		||||
                        <span class="badge bg-warning text-dark">در انتظار</span>
 | 
			
		||||
                      {% endif %}
 | 
			
		||||
                    </div>
 | 
			
		||||
                    {% if st.status == 'rejected' and st.reason %}
 | 
			
		||||
                      <div class="mt-1 small text-danger">علت: {{ st.reason }}</div>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                {% endfor %}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
          <!-- Persistent nav in edit mode (outside cards) -->
 | 
			
		||||
          <div class="d-flex justify-content-between mt-3">
 | 
			
		||||
            {% if previous_step %}
 | 
			
		||||
| 
						 | 
				
			
			@ -127,6 +185,9 @@
 | 
			
		|||
            {% endif %}
 | 
			
		||||
          </div>
 | 
			
		||||
          {% else %}
 | 
			
		||||
          {% if not request.user|is_installer %}
 | 
			
		||||
          <div class="alert alert-warning">شما مجوز ثبت/ویرایش گزارش نصب را ندارید. اطلاعات به صورت فقط خواندنی نمایش داده میشود.</div>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
          <form method="post" enctype="multipart/form-data" id="installation-report-form">
 | 
			
		||||
            {% csrf_token %}
 | 
			
		||||
            <div class="mb-3">
 | 
			
		||||
| 
						 | 
				
			
			@ -134,40 +195,42 @@
 | 
			
		|||
                <div class="row g-3">
 | 
			
		||||
                  <div class="col-md-3">
 | 
			
		||||
                    <label class="form-label">تاریخ مراجعه</label>
 | 
			
		||||
                    <input type="text" id="id_visited_date_display" class="form-control" placeholder="انتخاب تاریخ" readonly required value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y/m/d' }}{% endif %}">
 | 
			
		||||
                    <input type="text" id="id_visited_date_display" class="form-control" placeholder="انتخاب تاریخ" {% if not request.user|is_installer %}disabled{% endif %} readonly required value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y/m/d' }}{% endif %}">
 | 
			
		||||
                    <input type="hidden" id="id_visited_date" name="visited_date" value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y-m-d' }}{% endif %}">
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div class="col-md-3">
 | 
			
		||||
                    <label class="form-label">سریال کنتور جدید</label>
 | 
			
		||||
                    <input type="text" class="form-control" name="new_water_meter_serial">
 | 
			
		||||
                    <input type="text" class="form-control" name="new_water_meter_serial" value="{% if report and edit_mode %}{{ report.new_water_meter_serial|default_if_none:'' }}{% endif %}" {% if not request.user|is_installer %}readonly{% endif %}>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div class="col-md-3">
 | 
			
		||||
                    <label class="form-label">شماره پلمپ</label>
 | 
			
		||||
                    <input type="text" class="form-control" name="seal_number">
 | 
			
		||||
                    <input type="text" class="form-control" name="seal_number" value="{% if report and edit_mode %}{{ report.seal_number|default_if_none:'' }}{% endif %}" {% if not request.user|is_installer %}readonly{% endif %}>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div class="col-md-3 d-flex align-items-end">
 | 
			
		||||
                    <div class="form-check">
 | 
			
		||||
                      <input class="form-check-input" type="checkbox" name="is_meter_suspicious" id="id_is_meter_suspicious">
 | 
			
		||||
                      <input class="form-check-input" type="checkbox" name="is_meter_suspicious" id="id_is_meter_suspicious" {% if not request.user|is_installer %}disabled{% endif %} {% if report and edit_mode and report.is_meter_suspicious %}checked{% endif %}>
 | 
			
		||||
                      <label class="form-check-label" for="id_is_meter_suspicious">کنتور مشکوک است</label>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div class="col-md-3">
 | 
			
		||||
                    <label class="form-label">UTM X</label>
 | 
			
		||||
                    <input type="number" step="0.000001" class="form-control" name="utm_x" value="{% if instance.well.utm_x %}{{ instance.well.utm_x }}{% endif %}">
 | 
			
		||||
                    <input type="number" step="0.000001" class="form-control" name="utm_x" value="{% if report and edit_mode and report.utm_x %}{{ report.utm_x }}{% elif instance.well.utm_x %}{{ instance.well.utm_x }}{% endif %}" {% if not request.user|is_installer %}readonly{% endif %}>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div class="col-md-3">
 | 
			
		||||
                    <label class="form-label">UTM Y</label>
 | 
			
		||||
                    <input type="number" step="0.000001" class="form-control" name="utm_y" value="{% if instance.well.utm_y %}{{ instance.well.utm_y }}{% endif %}">
 | 
			
		||||
                    <input type="number" step="0.000001" class="form-control" name="utm_y" value="{% if report and edit_mode and report.utm_y %}{{ report.utm_y }}{% elif instance.well.utm_y %}{{ instance.well.utm_y }}{% endif %}" {% if not request.user|is_installer %}readonly{% endif %}>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="my-3">
 | 
			
		||||
                  <label class="form-label">توضیحات (اختیاری)</label>
 | 
			
		||||
                  <textarea class="form-control" rows="3" name="description"></textarea>
 | 
			
		||||
                  <textarea class="form-control" rows="3" name="description" {% if not request.user|is_installer %}readonly{% endif %}>{% if report and edit_mode %}{{ report.description|default_if_none:'' }}{% endif %}</textarea>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="mb-3">
 | 
			
		||||
                  <div class="d-flex justify-content-between align-items-center">
 | 
			
		||||
                    <label class="form-label mb-0">عکسها</label>
 | 
			
		||||
                    <button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button>
 | 
			
		||||
                    {% if request.user|is_installer %}
 | 
			
		||||
                      <button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                  </div>
 | 
			
		||||
                  {% if report %}
 | 
			
		||||
                  <div class="row mt-2">
 | 
			
		||||
| 
						 | 
				
			
			@ -175,7 +238,9 @@
 | 
			
		|||
                      <div class="col-6 col-md-3 mb-2" id="existing-photo-{{ p.id }}">
 | 
			
		||||
                        <div class="position-relative border rounded p-1">
 | 
			
		||||
                          <img class="img-fluid rounded" src="{{ p.image.url }}" alt="photo">
 | 
			
		||||
                          <button type="button" class="btn btn-sm btn-danger position-absolute" style="top:6px; left:6px;" onclick="markDeletePhoto({{ p.id }})" title="حذف/برگردان"><i class='bx bx-trash'></i></button>
 | 
			
		||||
                          {% if request.user|is_installer %}
 | 
			
		||||
                            <button type="button" class="btn btn-sm btn-danger position-absolute" style="top:6px; left:6px;" onclick="markDeletePhoto('{{ p.id }}')" title="حذف/برگردان"><i class="bx bx-trash"></i></button>
 | 
			
		||||
                          {% endif %}
 | 
			
		||||
                          <input type="hidden" name="del_photo_{{ p.id }}" id="del-photo-{{ p.id }}" value="0">
 | 
			
		||||
                        </div>
 | 
			
		||||
                      </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -285,7 +350,11 @@
 | 
			
		|||
              <span></span>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            <div class="d-flex gap-2">
 | 
			
		||||
              <button type="submit" class="btn btn-primary" form="installation-report-form">ثبت گزارش</button>
 | 
			
		||||
              {% if request.user|is_installer %}
 | 
			
		||||
                <button type="submit" class="btn btn-primary" form="installation-report-form">ثبت گزارش</button>
 | 
			
		||||
              {% else %}
 | 
			
		||||
                <button type="button" class="btn btn-primary" disabled>ثبت گزارش</button>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
              {% if next_step %}
 | 
			
		||||
                <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-success">بعدی</a>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
| 
						 | 
				
			
			@ -298,6 +367,58 @@
 | 
			
		|||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<!-- Approve Modal  -->
 | 
			
		||||
<div class="modal fade" id="approveModal" tabindex="-1" aria-hidden="true">
 | 
			
		||||
  <div class="modal-dialog">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <form method="post">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <input type="hidden" name="action" value="approve">
 | 
			
		||||
        <div class="modal-header">
 | 
			
		||||
          <h5 class="modal-title">تایید گزارش نصب</h5>
 | 
			
		||||
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-body">
 | 
			
		||||
          آیا از تایید این گزارش اطمینان دارید؟
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-footer">
 | 
			
		||||
          <button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
 | 
			
		||||
          <button type="submit" class="btn btn-success">تایید</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
<!-- Reject Modal -->
 | 
			
		||||
<div class="modal fade" id="rejectModal" tabindex="-1" aria-hidden="true">
 | 
			
		||||
  <div class="modal-dialog">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <form method="post">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <input type="hidden" name="action" value="reject">
 | 
			
		||||
        <div class="modal-header">
 | 
			
		||||
          <h5 class="modal-title">رد گزارش نصب</h5>
 | 
			
		||||
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-body">
 | 
			
		||||
          <div class="mb-2">
 | 
			
		||||
            <label class="form-label">علت رد</label>
 | 
			
		||||
            <textarea class="form-control" name="reject_reason" rows="3" required></textarea>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-footer">
 | 
			
		||||
          <button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
 | 
			
		||||
          <button type="submit" class="btn btn-danger">ثبت رد</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block script %}
 | 
			
		||||
| 
						 | 
				
			
			@ -445,4 +566,3 @@
 | 
			
		|||
</script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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']
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -50,7 +50,9 @@
 | 
			
		|||
      <div class="card border">
 | 
			
		||||
        <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
          <h5 class="mb-0">فاکتور نهایی</h5>
 | 
			
		||||
          <button type="button" class="btn btn-sm btn-outline-primary" onclick="openSpecialChargeModal()"><i class="bx bx-plus"></i> افزودن هزینه تعمیر/تعویض</button>
 | 
			
		||||
          {% if is_manager %}
 | 
			
		||||
            <button type="button" class="btn btn-sm btn-outline-primary" onclick="openSpecialChargeModal()"><i class="bx bx-plus"></i> افزودن هزینه تعمیر/تعویض</button>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="card-body">
 | 
			
		||||
| 
						 | 
				
			
			@ -127,7 +129,9 @@
 | 
			
		|||
                  <td class="text-end">{{ si.unit_price|floatformat:0|intcomma:False }}</td>
 | 
			
		||||
                  <td class="text-end">
 | 
			
		||||
                    {{ si.total_price|floatformat:0|intcomma:False }}
 | 
			
		||||
                    <button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="deleteSpecial('{{ si.id }}')" title="حذف"><i class="bx bx-trash"></i></button>
 | 
			
		||||
                    {% if is_manager %}
 | 
			
		||||
                      <button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="deleteSpecial('{{ si.id }}')" title="حذف"><i class="bx bx-trash"></i></button>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                  </td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                {% endfor %}
 | 
			
		||||
| 
						 | 
				
			
			@ -164,7 +168,11 @@
 | 
			
		|||
            <span></span>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
          {% if next_step %}
 | 
			
		||||
            <button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button>
 | 
			
		||||
            {% if is_manager %}
 | 
			
		||||
              <button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button>
 | 
			
		||||
            {% else %}
 | 
			
		||||
            <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,7 @@
 | 
			
		|||
{% load static %}
 | 
			
		||||
{% load processes_tags %}
 | 
			
		||||
{% load common_tags %}
 | 
			
		||||
{% load accounts_tags %}
 | 
			
		||||
{% load humanize %}
 | 
			
		||||
 | 
			
		||||
{% block sidebar %}
 | 
			
		||||
| 
						 | 
				
			
			@ -46,6 +47,7 @@
 | 
			
		|||
        <div class="bs-stepper-content">
 | 
			
		||||
 | 
			
		||||
      <div class="row g-3">
 | 
			
		||||
        {% if is_broker %}
 | 
			
		||||
        <div class="col-12 col-lg-5">
 | 
			
		||||
          <div class="card border h-100">
 | 
			
		||||
            <div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div>
 | 
			
		||||
| 
						 | 
				
			
			@ -78,11 +80,11 @@
 | 
			
		|||
                  </select>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="mb-3">
 | 
			
		||||
                  <label class="form-label">شماره مرجع</label>
 | 
			
		||||
                  <label class="form-label">شماره مرجع/چک</label>
 | 
			
		||||
                  <input type="text" class="form-control" name="reference_number" id="id_reference_number" required>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="mb-3">
 | 
			
		||||
                  <label class="form-label">تصویر فیش</label>
 | 
			
		||||
                  <label class="form-label">تصویر فیش/چک</label>
 | 
			
		||||
                  <input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="d-flex justify-content-end">
 | 
			
		||||
| 
						 | 
				
			
			@ -92,23 +94,39 @@
 | 
			
		|||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="col-12 col-lg-7">
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        <div class="col-12 {% if is_broker %}col-lg-7{% else %}col-lg-12{% endif %}">
 | 
			
		||||
          <div class="card mb-3 border">
 | 
			
		||||
            <div class="card-header"><h5 class="mb-0">وضعیت فاکتور</h5></div>
 | 
			
		||||
            <div class="card-header d-flex justify-content-between">
 | 
			
		||||
                <h5 class="mb-0">وضعیت فاکتور</h5>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="card-body">
 | 
			
		||||
              <div class="row g-3">
 | 
			
		||||
                <div class="col-6">
 | 
			
		||||
                  <div class="border rounded p-3">
 | 
			
		||||
                <div class="col-6 col-md-4">
 | 
			
		||||
                  <div class="border rounded p-3 h-100">
 | 
			
		||||
                    <div class="small text-muted">مبلغ نهایی</div>
 | 
			
		||||
                    <div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="col-6">
 | 
			
		||||
                  <div class="border rounded p-3">
 | 
			
		||||
                <div class="col-6 col-md-4">
 | 
			
		||||
                  <div class="border rounded p-3 h-100">
 | 
			
		||||
                    <div class="small text-muted">پرداختیها</div>
 | 
			
		||||
                    <div class="h5 mt-1 text-success">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="col-6 col-md-4">
 | 
			
		||||
                  <div class="border rounded p-3 h-100">
 | 
			
		||||
                    <div class="small text-muted">مانده</div>
 | 
			
		||||
                    <div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="col-6 d-flex align-items-center">
 | 
			
		||||
                  {% if invoice.remaining_amount <= 0 %}
 | 
			
		||||
                    <span class="badge bg-success">تسویه کامل</span>
 | 
			
		||||
                  {% else %}
 | 
			
		||||
                    <span class="badge bg-warning text-dark">باقیمانده دارد</span>
 | 
			
		||||
                  {% endif %}
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -123,8 +141,8 @@
 | 
			
		|||
                    <th>مبلغ</th>
 | 
			
		||||
                    <th>تاریخ</th>
 | 
			
		||||
                    <th>روش</th>
 | 
			
		||||
                    <th>شماره مرجع</th>
 | 
			
		||||
                    <th style="width:150px">عملیات</th>
 | 
			
		||||
                    <th class="text-nowrap">شماره مرجع/چک</th>
 | 
			
		||||
                    <th>عملیات</th>
 | 
			
		||||
                  </tr>
 | 
			
		||||
                </thead>
 | 
			
		||||
                <tbody>
 | 
			
		||||
| 
						 | 
				
			
			@ -132,7 +150,7 @@
 | 
			
		|||
                  <tr>
 | 
			
		||||
                    <td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td>
 | 
			
		||||
                    <td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
 | 
			
		||||
                    <td>{{ p.payment_date|to_jalali }}</td>
 | 
			
		||||
                    <td>{{ p.payment_date|date:'Y/m/d' }}</td>
 | 
			
		||||
                    <td>{{ p.get_payment_method_display }}</td>
 | 
			
		||||
                    <td>{{ p.reference_number|default:'-' }}</td>
 | 
			
		||||
                    <td>
 | 
			
		||||
| 
						 | 
				
			
			@ -142,7 +160,9 @@
 | 
			
		|||
                            <i class="bx bx-show"></i>
 | 
			
		||||
                          </a>
 | 
			
		||||
                        {% endif %}
 | 
			
		||||
                        <button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteFinalPayment({{ p.id }})" title="حذف" aria-label="حذف"><i class="bx bx-trash"></i></button>
 | 
			
		||||
                        {% if is_broker %}
 | 
			
		||||
                          <button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف"><i class="bx bx-trash"></i></button>
 | 
			
		||||
                        {% endif %}
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </td>
 | 
			
		||||
                  </tr>
 | 
			
		||||
| 
						 | 
				
			
			@ -152,20 +172,141 @@
 | 
			
		|||
                </tbody>
 | 
			
		||||
              </table>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="card-footer d-flex justify-content-between">
 | 
			
		||||
              {% if previous_step %}
 | 
			
		||||
                <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
 | 
			
		||||
              {% else %}
 | 
			
		||||
                <span></span>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
              <button type="button" id="btnApproveFinalSettlement" class="btn btn-primary">تایید و ادامه</button>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      {% if approver_statuses %}
 | 
			
		||||
      <div class="card border mt-2">
 | 
			
		||||
        <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
          <h6 class="mb-0">وضعیت تاییدها</h6>
 | 
			
		||||
          {% if can_approve_reject %}
 | 
			
		||||
          <div class="d-flex gap-2">
 | 
			
		||||
            <button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveFinalSettleModal" {% if step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
 | 
			
		||||
            <button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
 | 
			
		||||
          </div>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="card-body py-3">
 | 
			
		||||
          <div class="row g-2">
 | 
			
		||||
            {% for st in approver_statuses %}
 | 
			
		||||
            <div class="col-12 col-md-6 col-lg-4">
 | 
			
		||||
              <div class="d-flex flex-column border rounded px-2 py-1">
 | 
			
		||||
                <div class="d-flex align-items-center gap-2">
 | 
			
		||||
                  <span class="badge bg-light text-dark">{{ st.role.name }}</span>
 | 
			
		||||
                  {% if st.status == 'approved' %}
 | 
			
		||||
                    <span class="badge bg-success">تایید شد</span>
 | 
			
		||||
                  {% elif st.status == 'rejected' %}
 | 
			
		||||
                    <span class="badge bg-danger">رد شد</span>
 | 
			
		||||
                  {% else %}
 | 
			
		||||
                    <span class="badge bg-warning text-dark">در انتظار</span>
 | 
			
		||||
                  {% endif %}
 | 
			
		||||
                </div>
 | 
			
		||||
                {% if st.status == 'rejected' and st.reason %}
 | 
			
		||||
                  <div class="mt-1 small text-danger">علت: {{ st.reason }}</div>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      {% endif %}
 | 
			
		||||
      <div class="col-12 d-flex justify-content-between mt-3">
 | 
			
		||||
        {% if previous_step %}
 | 
			
		||||
          <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
 | 
			
		||||
        {% else %}
 | 
			
		||||
          <span></span>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
        {% if step_instance.status == 'completed' %}
 | 
			
		||||
          {% if next_step %}
 | 
			
		||||
            <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
 | 
			
		||||
          {% else %}
 | 
			
		||||
            <a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<!-- Delete Confirmation Modal (final settlement payments) -->
 | 
			
		||||
<div class="modal fade" id="deletePaymentModal" tabindex="-1" aria-labelledby="deletePaymentModalLabel" aria-hidden="true">
 | 
			
		||||
  <div class="modal-dialog">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <div class="modal-header">
 | 
			
		||||
        <h5 class="modal-title" id="deletePaymentModalLabel">تایید حذف تراکنش</h5>
 | 
			
		||||
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-body">
 | 
			
		||||
        آیا از حذف این تراکنش مطمئن هستید؟ این عمل قابل بازگشت نیست.
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-footer">
 | 
			
		||||
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
 | 
			
		||||
        <button type="button" class="btn btn-danger" onclick="confirmDeletePayment()" data-bs-dismiss="modal">حذف</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<!-- Approve Final Settlement Modal -->
 | 
			
		||||
<div class="modal fade" id="approveFinalSettleModal" tabindex="-1" aria-hidden="true">
 | 
			
		||||
  <div class="modal-dialog">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <form method="post">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <input type="hidden" name="action" value="approve">
 | 
			
		||||
        <div class="modal-header">
 | 
			
		||||
          <h5 class="modal-title">تایید تسویه نهایی</h5>
 | 
			
		||||
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-body">
 | 
			
		||||
          {% if invoice.remaining_amount != 0 %}
 | 
			
		||||
            <div class="alert alert-warning" role="alert">
 | 
			
		||||
              مانده فاکتور صفر نیست: <strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br>
 | 
			
		||||
              تا صفر نشود امکان تایید نیست.
 | 
			
		||||
            </div>
 | 
			
		||||
          {% else %}
 | 
			
		||||
            آیا از تایید این مرحله اطمینان دارید؟
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-footer">
 | 
			
		||||
          <button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
 | 
			
		||||
          <button type="submit" class="btn btn-success" {% if invoice.remaining_amount != 0 %}disabled{% endif %}>تایید</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<!-- Reject Final Settlement Modal -->
 | 
			
		||||
<div class="modal fade" id="rejectFinalSettleModal" tabindex="-1" aria-hidden="true">
 | 
			
		||||
  <div class="modal-dialog">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <form method="post">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <input type="hidden" name="action" value="reject">
 | 
			
		||||
        <div class="modal-header">
 | 
			
		||||
          <h5 class="modal-title">رد تسویه نهایی</h5>
 | 
			
		||||
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-body">
 | 
			
		||||
          <div class="mb-2">
 | 
			
		||||
            <label class="form-label">علت رد</label>
 | 
			
		||||
            <textarea class="form-control" name="reject_reason" rows="3" required></textarea>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-footer">
 | 
			
		||||
          <button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
 | 
			
		||||
          <button type="submit" class="btn btn-danger">ثبت رد</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
{% 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
 | 
			
		||||
</script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
{% extends '_base.html' %}
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load processes_tags %}
 | 
			
		||||
{% load accounts_tags %}
 | 
			
		||||
{% load humanize %}
 | 
			
		||||
 | 
			
		||||
{% block sidebar %}
 | 
			
		||||
| 
						 | 
				
			
			@ -55,14 +56,15 @@
 | 
			
		|||
            <div class="content active dstepper-block">
 | 
			
		||||
              <div class="content-header mb-3">
 | 
			
		||||
                <h6 class="mb-0">{{ step.name }}</h6>
 | 
			
		||||
                <small>ثبت فیشهای واریزی برای پیشفاکتور</small>
 | 
			
		||||
                <small>ثبت فیشها/چکهای واریزی برای پیشفاکتور</small>
 | 
			
		||||
              </div>
 | 
			
		||||
              
 | 
			
		||||
              <div class="row g-3">
 | 
			
		||||
                {% if can_manage_payments %}
 | 
			
		||||
                <div class="col-12 col-lg-5">
 | 
			
		||||
                  <div class="card h-100 border">
 | 
			
		||||
                    <div class="card-header">
 | 
			
		||||
                      <h5 class="card-title mb-0">ثبت فیش جدید</h5>
 | 
			
		||||
                      <h5 class="card-title mb-0">ثبت فیش/چک جدید</h5>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="card-body">
 | 
			
		||||
                      <div class="mb-3">
 | 
			
		||||
| 
						 | 
				
			
			@ -84,11 +86,11 @@
 | 
			
		|||
                        </select>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      <div class="mb-3">
 | 
			
		||||
                        <label class="form-label">شماره مرجع</label>
 | 
			
		||||
                        <label class="form-label">شماره مرجع/چک</label>
 | 
			
		||||
                        <input type="text" class="form-control" name="reference_number" id="id_reference_number" placeholder="..." required>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      <div class="mb-3">
 | 
			
		||||
                        <label class="form-label">تصویر فیش</label>
 | 
			
		||||
                        <label class="form-label">تصویر فیش/چک</label>
 | 
			
		||||
                        <input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      <div class="mb-3">
 | 
			
		||||
| 
						 | 
				
			
			@ -96,16 +98,16 @@
 | 
			
		|||
                        <textarea class="form-control" rows="2" name="notes" id="id_notes"></textarea>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      <div class="d-flex justify-content-end">
 | 
			
		||||
                        <button type="button" id="btnAddPayment" class="btn btn-primary">افزودن فیش</button>
 | 
			
		||||
                        <button type="button" id="btnAddPayment" class="btn btn-primary">افزودن فیش/چک</button>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="col-12 col-lg-7">
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                <div class="col-12 {% if can_manage_payments %}col-lg-7{% else %}col-lg-12{% endif %}">
 | 
			
		||||
                  <div class="card mb-3 border">
 | 
			
		||||
                    <div class="card-header d-flex justify-content-between">
 | 
			
		||||
                      <h5 class="card-title mb-0">وضعیت پیشفاکتور</h5>
 | 
			
		||||
                      <a href="{% url 'invoices:quote_preview_step' instance.id step.id|add:'-1' %}" class="btn btn-sm btn-label-secondary">مشاهده پیشفاکتور</a>
 | 
			
		||||
                        <h5 class="card-title mb-0">وضعیت پیشفاکتور</h5>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="card-body">
 | 
			
		||||
                      <div class="row g-3">
 | 
			
		||||
| 
						 | 
				
			
			@ -139,8 +141,10 @@
 | 
			
		|||
                  </div>
 | 
			
		||||
 | 
			
		||||
                  <div class="card border">
 | 
			
		||||
                    <div class="card-header">
 | 
			
		||||
                      <h5 class="card-title mb-0">فیشهای ثبت شده</h5>
 | 
			
		||||
                    <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
                      <div>
 | 
			
		||||
                        <h5 class="card-title mb-0">فیشها/چکهای ثبت شده</h5>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="table-responsive">
 | 
			
		||||
                      <table class="table table-striped mb-0">
 | 
			
		||||
| 
						 | 
				
			
			@ -149,9 +153,8 @@
 | 
			
		|||
                            <th>مبلغ</th>
 | 
			
		||||
                            <th>تاریخ</th>
 | 
			
		||||
                            <th>روش</th>
 | 
			
		||||
                            <th>شماره مرجع</th>
 | 
			
		||||
                            <th>تصویر</th>
 | 
			
		||||
                            <th style="width:120px">عملیات</th>
 | 
			
		||||
                            <th>شماره مرجع/چک</th>
 | 
			
		||||
                            <th>عملیات</th>
 | 
			
		||||
                          </tr>
 | 
			
		||||
                        </thead>
 | 
			
		||||
                        <tbody>
 | 
			
		||||
| 
						 | 
				
			
			@ -162,28 +165,23 @@
 | 
			
		|||
                            <td>{{ p.get_payment_method_display }}</td>
 | 
			
		||||
                            <td>{{ p.reference_number|default:'-' }}</td>
 | 
			
		||||
                            <td>
 | 
			
		||||
                              {% if p.receipt_image %}
 | 
			
		||||
                              <div class="btn-group">
 | 
			
		||||
                                {% if p.receipt_image %}
 | 
			
		||||
                                <a href="{{ p.receipt_image.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="مشاهده" aria-label="مشاهده">
 | 
			
		||||
                                  <i class="bx bx-show"></i>
 | 
			
		||||
                                </a>
 | 
			
		||||
                              {% else %}
 | 
			
		||||
                                -
 | 
			
		||||
                              {% endif %}
 | 
			
		||||
                            </td>
 | 
			
		||||
                            <td>
 | 
			
		||||
                              <div class="btn-group">
 | 
			
		||||
                                <button type="button" class="btn btn-sm btn-outline-primary" onclick="editPayment({{ p.id }})" title="ویرایش" aria-label="ویرایش">
 | 
			
		||||
                                  <i class="bx bx-edit"></i>
 | 
			
		||||
                                </button>
 | 
			
		||||
                                <button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal({{ p.id }})" title="حذف" aria-label="حذف">
 | 
			
		||||
                                {% endif %}
 | 
			
		||||
                                {% if can_manage_payments %}
 | 
			
		||||
                                <button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف">
 | 
			
		||||
                                  <i class="bx bx-trash"></i>
 | 
			
		||||
                                </button>
 | 
			
		||||
                                {% endif %}
 | 
			
		||||
                              </div>
 | 
			
		||||
                            </td>
 | 
			
		||||
                          </tr>
 | 
			
		||||
                          {% empty %}
 | 
			
		||||
                          <tr>
 | 
			
		||||
                            <td colspan="6" class="text-center text-muted">تا کنون فیشی ثبت نشده است</td>
 | 
			
		||||
                            <td colspan="6" class="text-center text-muted">تا کنون فیش/چکی ثبت نشده است</td>
 | 
			
		||||
                          </tr>
 | 
			
		||||
                          {% endfor %}
 | 
			
		||||
                        </tbody>
 | 
			
		||||
| 
						 | 
				
			
			@ -191,6 +189,42 @@
 | 
			
		|||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                {% if approver_statuses %}
 | 
			
		||||
                  <div class="card border mt-2">
 | 
			
		||||
                    <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
                      <h6 class="mb-0">وضعیت تاییدها</h6>
 | 
			
		||||
                      {% if can_approve_reject %}
 | 
			
		||||
                      <div class="d-flex gap-2">
 | 
			
		||||
                        <button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2" {% if step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
 | 
			
		||||
                        <button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      {% endif %}
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="card-body py-3">
 | 
			
		||||
                      <div class="row g-2">
 | 
			
		||||
                        {% for st in approver_statuses %}
 | 
			
		||||
                        <div class="col-12 col-md-6 col-lg-4">
 | 
			
		||||
                          <div class="d-flex flex-column border rounded px-2 py-1">
 | 
			
		||||
                            <div class="d-flex align-items-center gap-2">
 | 
			
		||||
                              <span class="badge bg-light text-dark">{{ st.role.name }}</span>
 | 
			
		||||
                              {% if st.status == 'approved' %}
 | 
			
		||||
                                <span class="badge bg-success">تایید شد</span>
 | 
			
		||||
                              {% elif st.status == 'rejected' %}
 | 
			
		||||
                                <span class="badge bg-danger">رد شد</span>
 | 
			
		||||
                              {% else %}
 | 
			
		||||
                                <span class="badge bg-warning text-dark">در انتظار</span>
 | 
			
		||||
                              {% endif %}
 | 
			
		||||
                            </div>
 | 
			
		||||
                            {% if st.status == 'rejected' and st.reason %}
 | 
			
		||||
                              <div class="mt-1 small text-danger">علت: {{ st.reason }}</div>
 | 
			
		||||
                            {% endif %}
 | 
			
		||||
                          </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        {% endfor %}
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
 | 
			
		||||
                <div class="col-12 d-flex justify-content-between mt-3">
 | 
			
		||||
                  {% if previous_step %}
 | 
			
		||||
| 
						 | 
				
			
			@ -201,30 +235,114 @@
 | 
			
		|||
                  {% else %}
 | 
			
		||||
                    <span></span>
 | 
			
		||||
                  {% endif %}
 | 
			
		||||
                  <button type="button" id="btnApprovePayments" class="btn btn-primary">  
 | 
			
		||||
                    تایید پرداختها
 | 
			
		||||
                    <i class="bx bx-chevron-left bx-sm ms-sm-2"></i>
 | 
			
		||||
                  </button>
 | 
			
		||||
                  {% if step_instance.status == 'completed' %}
 | 
			
		||||
                    {% if next_step %}
 | 
			
		||||
                      <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
 | 
			
		||||
                        <span class="align-middle d-sm-inline-block d-none me-sm-1">بعدی</span>
 | 
			
		||||
                        <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
 | 
			
		||||
                      </a>
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                      <a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                  {% endif %}
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </form>
 | 
			
		||||
          
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<!-- Delete Confirmation Modal -->
 | 
			
		||||
<div class="modal fade" id="deletePaymentModal" tabindex="-1" aria-labelledby="deletePaymentModalLabel" aria-hidden="true">
 | 
			
		||||
  <div class="modal-dialog">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <div class="modal-header">
 | 
			
		||||
        <h5 class="modal-title" id="deletePaymentModalLabel">تایید حذف فیش</h5>
 | 
			
		||||
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-body">
 | 
			
		||||
        آیا از حذف این فیش مطمئن هستید؟ این عمل قابل بازگشت نیست.
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-footer">
 | 
			
		||||
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
 | 
			
		||||
        <button type="button" class="btn btn-danger" onclick="confirmDeletePayment()" data-bs-dismiss="modal">حذف</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
<!-- Removed legacy approvePaymentsModal; using approvePaymentsModal2 with form POST -->
 | 
			
		||||
<!-- Approve Modal 2 (direct approve button in header) -->
 | 
			
		||||
<div class="modal fade" id="approvePaymentsModal2" tabindex="-1" aria-hidden="true">
 | 
			
		||||
  <div class="modal-dialog">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <form method="post">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <input type="hidden" name="action" value="approve">
 | 
			
		||||
        <div class="modal-header">
 | 
			
		||||
          <h5 class="modal-title">تایید پرداختها</h5>
 | 
			
		||||
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-body">
 | 
			
		||||
          {% if not totals.is_fully_paid %}
 | 
			
		||||
            <div class="alert alert-warning" role="alert">
 | 
			
		||||
              مبلغی از پیشفاکتور هنوز پرداخت نشده است.
 | 
			
		||||
              <div class="mt-1">مانده: <strong>{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان</strong></div>
 | 
			
		||||
            </div>
 | 
			
		||||
            آیا مطمئن هستید که میخواهید مرحله را تایید کنید؟
 | 
			
		||||
          {% else %}
 | 
			
		||||
            آیا از تایید این مرحله اطمینان دارید؟
 | 
			
		||||
          {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-footer">
 | 
			
		||||
          <button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
 | 
			
		||||
          <button type="submit" class="btn btn-success">تایید</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
<!-- Reject Modal for payments step -->
 | 
			
		||||
<div class="modal fade" id="rejectPaymentsModal" tabindex="-1" aria-hidden="true">
 | 
			
		||||
  <div class="modal-dialog">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <form method="post">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
        <input type="hidden" name="action" value="reject">
 | 
			
		||||
        <div class="modal-header">
 | 
			
		||||
          <h5 class="modal-title">رد مرحله پرداختها</h5>
 | 
			
		||||
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-body">
 | 
			
		||||
          <div class="mb-2">
 | 
			
		||||
            <label class="form-label">علت رد</label>
 | 
			
		||||
            <textarea class="form-control" name="reject_reason" rows="3" required></textarea>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="modal-footer">
 | 
			
		||||
          <button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
 | 
			
		||||
          <button type="submit" class="btn btn-danger">ثبت رد</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block script %}
 | 
			
		||||
<script>
 | 
			
		||||
  const isFullyPaid = {{ totals.is_fully_paid|yesno:'true,false' }};
 | 
			
		||||
  // Removed legacy isFullyPaid-driven approve flow; approval now via modal submit
 | 
			
		||||
  function buildFormData(form) {
 | 
			
		||||
    const fd = new FormData(form);
 | 
			
		||||
    fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
 | 
			
		||||
    return fd;
 | 
			
		||||
  }
 | 
			
		||||
  document.getElementById('btnAddPayment').addEventListener('click', function() {
 | 
			
		||||
  const btnAddPayment = document.getElementById('btnAddPayment');
 | 
			
		||||
  if (btnAddPayment) btnAddPayment.addEventListener('click', function() {
 | 
			
		||||
    // Front-end validation
 | 
			
		||||
    const amount = document.getElementById('id_amount').value.trim();
 | 
			
		||||
    const payDate = document.getElementById('id_payment_date').value.trim();
 | 
			
		||||
| 
						 | 
				
			
			@ -283,51 +401,7 @@
 | 
			
		|||
    alert('ویرایش فیش را بعدا با مدال تکمیل میکنیم. فعلا حذف و افزودن مجدد انجام دهید.');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function performApprovePayments() {
 | 
			
		||||
    const fd = new FormData();
 | 
			
		||||
    fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
 | 
			
		||||
    fetch('{% url "invoices:approve_payments" 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 || resp.error || 'خطا در تایید پرداختها', 'danger');
 | 
			
		||||
      }
 | 
			
		||||
    }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function openApproveModal() {
 | 
			
		||||
    const el = document.getElementById('approvePaymentsModal');
 | 
			
		||||
    const remEl = document.getElementById('remainingAmountText');
 | 
			
		||||
    if (remEl) {
 | 
			
		||||
      remEl.textContent = '{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان';
 | 
			
		||||
    }
 | 
			
		||||
    // Prefer jQuery plugin if available to avoid namespace issues
 | 
			
		||||
    if (window.$ && typeof $(el).modal === 'function') {
 | 
			
		||||
      $(el).modal('show');
 | 
			
		||||
    } else if (window.bootstrap && window.bootstrap.Modal) {
 | 
			
		||||
      const modal = new window.bootstrap.Modal(el);
 | 
			
		||||
      modal.show();
 | 
			
		||||
    } else {
 | 
			
		||||
      // fallback: force display
 | 
			
		||||
      el.classList.add('show');
 | 
			
		||||
      el.style.display = 'block';
 | 
			
		||||
      el.removeAttribute('aria-hidden');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  document.getElementById('btnApprovePayments').addEventListener('click', function() {
 | 
			
		||||
    if (isFullyPaid) {
 | 
			
		||||
      performApprovePayments();
 | 
			
		||||
    } else {
 | 
			
		||||
      openApproveModal();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  // Legacy approve JS removed; approval handled by modal forms in header
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<!-- Persian Date Picker JS -->
 | 
			
		||||
| 
						 | 
				
			
			@ -365,42 +439,4 @@
 | 
			
		|||
  })();
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<!-- Delete Confirmation Modal -->
 | 
			
		||||
<div class="modal fade" id="deletePaymentModal" tabindex="-1" aria-labelledby="deletePaymentModalLabel" aria-hidden="true">
 | 
			
		||||
  <div class="modal-dialog">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <div class="modal-header">
 | 
			
		||||
        <h5 class="modal-title" id="deletePaymentModalLabel">تایید حذف فیش</h5>
 | 
			
		||||
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-body">
 | 
			
		||||
        آیا از حذف این فیش مطمئن هستید؟ این عمل قابل بازگشت نیست.
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-footer">
 | 
			
		||||
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
 | 
			
		||||
        <button type="button" class="btn btn-danger" onclick="confirmDeletePayment()" data-bs-dismiss="modal">حذف</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
<!-- Approve Confirmation Modal (shown when remaining amount > 0) -->
 | 
			
		||||
<div class="modal fade" id="approvePaymentsModal" tabindex="-1" aria-labelledby="approvePaymentsModalLabel" aria-hidden="true">
 | 
			
		||||
  <div class="modal-dialog">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <div class="modal-header">
 | 
			
		||||
        <h5 class="modal-title" id="approvePaymentsModalLabel">تایید نهایی پرداختها</h5>
 | 
			
		||||
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-body">
 | 
			
		||||
        مبلغی از پیشفاکتور هنوز پرداخت نشده است.
 | 
			
		||||
        <div class="mt-2">مانده: <strong id="remainingAmountText"></strong></div>
 | 
			
		||||
        آیا مطمئن هستید که میخواهید مرحله را تایید و به مرحله بعد بروید؟
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-footer">
 | 
			
		||||
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
 | 
			
		||||
        <button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="performApprovePayments()">بله، تایید</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -221,20 +221,32 @@
 | 
			
		|||
              <span></span>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            
 | 
			
		||||
            {% if step_instance.status == 'completed' %}
 | 
			
		||||
            {% if is_broker %}
 | 
			
		||||
              {% if step_instance.status == 'completed' %}
 | 
			
		||||
                {% if next_step %}
 | 
			
		||||
                  <a href="{% url 'processes:step_detail' instance.id next_step.id %}" 
 | 
			
		||||
                     class="btn btn-primary">
 | 
			
		||||
                    <span class="align-middle d-sm-inline-block d-none me-sm-1">بعدی</span>
 | 
			
		||||
                    <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
 | 
			
		||||
                  </a>
 | 
			
		||||
                {% else %}
 | 
			
		||||
                  <button class="btn btn-success" type="button">اتمام</button>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
              {% else %}
 | 
			
		||||
                <button type="button" class="btn btn-primary" id="btnApproveQuote">
 | 
			
		||||
                  تایید پیشفاکتور
 | 
			
		||||
                </button>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            {% else %}
 | 
			
		||||
              {% if next_step %}
 | 
			
		||||
                <a href="{% url 'processes:step_detail' instance.id next_step.id %}" 
 | 
			
		||||
                   class="btn btn-primary">
 | 
			
		||||
                  <span class="align-middle d-sm-inline-block d-none me-sm-1">بعدی</span>
 | 
			
		||||
                   class="btn btn-label-primary">
 | 
			
		||||
                  مرحله بعد
 | 
			
		||||
                  <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
 | 
			
		||||
                </a>
 | 
			
		||||
              {% else %}
 | 
			
		||||
                <button class="btn btn-success" type="button">اتمام</button>
 | 
			
		||||
                <a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            {% else %}
 | 
			
		||||
              <button type="button" class="btn btn-primary" id="btnApproveQuote">
 | 
			
		||||
                تایید پیشفاکتور
 | 
			
		||||
              </button>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -58,6 +58,7 @@
 | 
			
		|||
                {% endif %}
 | 
			
		||||
                
 | 
			
		||||
                <div class="col-12">
 | 
			
		||||
                  {% if is_broker or existing_quote %}
 | 
			
		||||
                  <div class="table-responsive">
 | 
			
		||||
                    <table class="table table-sm align-middle">
 | 
			
		||||
                      <thead>
 | 
			
		||||
| 
						 | 
				
			
			@ -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 %}>
 | 
			
		||||
                          </td>
 | 
			
		||||
                          <td>
 | 
			
		||||
                            <div class="d-flex flex-column">
 | 
			
		||||
| 
						 | 
				
			
			@ -86,7 +88,6 @@
 | 
			
		|||
                                <span class="badge bg-label-primary me-2">پیشفرض</span>
 | 
			
		||||
                              {% endif %}
 | 
			
		||||
                              </span>
 | 
			
		||||
                              
 | 
			
		||||
                              {% if item.description %}<small class="text-muted">{{ item.description }}</small>{% endif %}
 | 
			
		||||
                            </div>
 | 
			
		||||
                          </td>
 | 
			
		||||
| 
						 | 
				
			
			@ -94,7 +95,8 @@
 | 
			
		|||
                          <td>
 | 
			
		||||
                            <input type="number" class="form-control form-control-sm quote-item-qty" min="1"
 | 
			
		||||
                                   data-item-id="{{ item.id }}"
 | 
			
		||||
                                   value="{% if selected_qty %}{{ selected_qty }}{% else %}{{ item.default_quantity }}{% endif %}">
 | 
			
		||||
                                   value="{% if selected_qty %}{{ selected_qty }}{% else %}{{ item.default_quantity }}{% endif %}"
 | 
			
		||||
                                   {% if not is_broker %}disabled title="فقط کارگزار مجاز به تغییر تعداد است"{% endif %}>
 | 
			
		||||
                          </td>
 | 
			
		||||
                        </tr>
 | 
			
		||||
                        {% endwith %}
 | 
			
		||||
| 
						 | 
				
			
			@ -102,8 +104,9 @@
 | 
			
		|||
                      </tbody>
 | 
			
		||||
                    </table>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  
 | 
			
		||||
                  
 | 
			
		||||
                  {% else %}
 | 
			
		||||
                  <div class="alert alert-warning mb-0">شما دسترسی به ثبت اقلام ندارید.</div>
 | 
			
		||||
                  {% endif %}
 | 
			
		||||
                </div>
 | 
			
		||||
                
 | 
			
		||||
                <div class="col-12 d-flex justify-content-between">
 | 
			
		||||
| 
						 | 
				
			
			@ -118,27 +121,35 @@
 | 
			
		|||
                  {% endif %}
 | 
			
		||||
                  
 | 
			
		||||
                  
 | 
			
		||||
                  {% if step_instance.status == 'completed' %}
 | 
			
		||||
                    {% if next_step %}
 | 
			
		||||
                    <div class="d-flex justify-content-end mt-3">
 | 
			
		||||
                      <button type="button" class="btn btn-primary" id="btnCreateQuote">
 | 
			
		||||
                        
 | 
			
		||||
                        {% if existing_quote %}بروزرسانی پیشفاکتور{% else %}ثبت پیشفاکتور{% endif %}
 | 
			
		||||
                        و بعدی
 | 
			
		||||
                        <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
 | 
			
		||||
                      </button>
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
                  {% if is_broker %}
 | 
			
		||||
                    {% if step_instance.status == 'completed' %}
 | 
			
		||||
                      {% if next_step %}
 | 
			
		||||
                      <div class="d-flex justify-content-end mt-3">
 | 
			
		||||
                        <button type="button" class="btn btn-primary" id="btnCreateQuote">
 | 
			
		||||
                          {% if existing_quote %}بروزرسانی پیشفاکتور{% else %}ثبت پیشفاکتور{% endif %}
 | 
			
		||||
                          و بعدی
 | 
			
		||||
                          <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
 | 
			
		||||
                        </button>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      {% else %}
 | 
			
		||||
                        <button class="btn btn-success" type="button">اتمام</button>
 | 
			
		||||
                      {% endif %}
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                      <button class="btn btn-success" type="button">اتمام</button>
 | 
			
		||||
                    <button type="button" class="btn btn-primary" id="btnCreateQuote">
 | 
			
		||||
                      {% if existing_quote %}بروزرسانی پیشفاکتور{% else %}ثبت پیشفاکتور{% endif %}
 | 
			
		||||
                      و بعدی
 | 
			
		||||
                      <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
 | 
			
		||||
                    </button>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                  {% else %}
 | 
			
		||||
                  <button type="button" class="btn btn-primary" id="btnCreateQuote">
 | 
			
		||||
                        
 | 
			
		||||
                    {% if existing_quote %}بروزرسانی پیشفاکتور{% else %}ثبت پیشفاکتور{% endif %}
 | 
			
		||||
                    و بعدی
 | 
			
		||||
                    <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
 | 
			
		||||
                  </button>
 | 
			
		||||
                    {% if next_step %}
 | 
			
		||||
                      <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-label-primary">
 | 
			
		||||
                        مرحله بعد
 | 
			
		||||
                        <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
 | 
			
		||||
                      </a>
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                      <a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                  {% endif %}
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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')},
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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}"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,7 @@
 | 
			
		|||
    <div class="step 
 | 
			
		||||
      {% if not can_access %}disabled
 | 
			
		||||
      {% elif status == 'completed' %}completed
 | 
			
		||||
      {% elif status == 'rejected' %}rejected
 | 
			
		||||
      {% elif is_todo %}active
 | 
			
		||||
      {% endif %}
 | 
			
		||||
      {% if is_selected %} selected{% endif %}" 
 | 
			
		||||
| 
						 | 
				
			
			@ -19,9 +20,9 @@
 | 
			
		|||
        <span class="step-trigger">
 | 
			
		||||
      {% endif %}
 | 
			
		||||
      
 | 
			
		||||
        <span class="bs-stepper-circle">{{ forloop.counter }}</span>
 | 
			
		||||
        <span class="bs-stepper-circle {% if status == 'rejected' %}bg-danger text-white{% endif %}">{{ forloop.counter }}</span>
 | 
			
		||||
        <span class="bs-stepper-label mt-1">
 | 
			
		||||
          <span class="bs-stepper-title">{{ step.name }}</span>
 | 
			
		||||
          <span class="bs-stepper-title {% if status == 'rejected' %}text-danger{% endif %}">{{ step.name }}</span>
 | 
			
		||||
          <span class="bs-stepper-subtitle">{{ step.description|default:' ' }}</span>
 | 
			
		||||
        </span>
 | 
			
		||||
        
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										168
									
								
								processes/templates/processes/instance_summary.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								processes/templates/processes/instance_summary.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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' %}
 | 
			
		||||
<div class="container-xxl flex-grow-1 container-p-y">
 | 
			
		||||
  <div class="row">
 | 
			
		||||
    <div class="col-12 mb-4">
 | 
			
		||||
      <div class="d-flex align-items-center justify-content-between mb-3">
 | 
			
		||||
        <div>
 | 
			
		||||
          <h4 class="mb-1">گزارش نهایی درخواست {{ instance.code }}</h4>
 | 
			
		||||
          <small class="text-muted d-block">
 | 
			
		||||
            اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
 | 
			
		||||
            | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
 | 
			
		||||
          </small>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="d-flex gap-2">
 | 
			
		||||
          {% if invoice %}
 | 
			
		||||
            <a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت فاکتور</a>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
          <a href="{% url 'certificates:certificate_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت گواهی</a>
 | 
			
		||||
          <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="row g-3">
 | 
			
		||||
        <div class="col-12">
 | 
			
		||||
          <div class="card border">
 | 
			
		||||
            <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
              <h6 class="mb-0">فاکتور نهایی</h6>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="card-body">
 | 
			
		||||
              {% if invoice %}
 | 
			
		||||
              <div class="row g-3 mb-3">
 | 
			
		||||
                <div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">مبلغ نهایی</div><div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div></div></div>
 | 
			
		||||
                <div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">پرداختیها</div><div class="h5 mt-1 text-success">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</div></div></div>
 | 
			
		||||
                <div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">مانده</div><div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div></div></div>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="table-responsive">
 | 
			
		||||
                <table class="table table-striped mb-0">
 | 
			
		||||
                  <thead>
 | 
			
		||||
                    <tr>
 | 
			
		||||
                      <th>آیتم</th>
 | 
			
		||||
                      <th class="text-center">تعداد</th>
 | 
			
		||||
                      <th class="text-end">قیمت واحد</th>
 | 
			
		||||
                      <th class="text-end">قیمت کل</th>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                  </thead>
 | 
			
		||||
                  <tbody>
 | 
			
		||||
                    {% for it in rows %}
 | 
			
		||||
                    <tr>
 | 
			
		||||
                      <td>{{ it.item.name }}</td>
 | 
			
		||||
                      <td class="text-center">{{ it.quantity }}</td>
 | 
			
		||||
                      <td class="text-end">{{ it.unit_price|floatformat:0|intcomma:False }}</td>
 | 
			
		||||
                      <td class="text-end">{{ it.total_price|floatformat:0|intcomma:False }}</td>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                    {% empty %}
 | 
			
		||||
                    <tr><td colspan="4" class="text-center text-muted">اطلاعاتی ندارد</td></tr>
 | 
			
		||||
                    {% endfor %}
 | 
			
		||||
                  </tbody>
 | 
			
		||||
                </table>
 | 
			
		||||
              </div>
 | 
			
		||||
              {% else %}
 | 
			
		||||
                <div class="text-muted">فاکتور نهایی ثبت نشده است.</div>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="col-12">
 | 
			
		||||
          <div class="card border">
 | 
			
		||||
            <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
              <h6 class="mb-0">گزارش نصب</h6>
 | 
			
		||||
              {% if latest_report and latest_report.assignment and latest_report.assignment.installer %}
 | 
			
		||||
                <span class="small text-muted">نصاب: {{ latest_report.assignment.installer.get_full_name|default:latest_report.assignment.installer.username }}</span>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="card-body">
 | 
			
		||||
              {% if latest_report %}
 | 
			
		||||
                <div class="row g-3">
 | 
			
		||||
                  <div class="col-12 col-md-6">
 | 
			
		||||
                    <p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ latest_report.visited_date|to_jalali|default:'-' }}</p>
 | 
			
		||||
                    <p class="text-nowrap mb-2"><i class="bx bx-purchase-tag bx-sm me-2"></i>سریال کنتور جدید: {{ latest_report.new_water_meter_serial|default:'-' }}</p>
 | 
			
		||||
                    <p class="text-nowrap mb-2"><i class="bx bx-lock-alt bx-sm me-2"></i>شماره پلمپ: {{ latest_report.seal_number|default:'-' }}</p>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div class="col-12 col-md-6">
 | 
			
		||||
                    <p class="text-nowrap mb-2"><i class="bx bx-help-circle bx-sm me-2"></i>کنتور مشکوک: {{ latest_report.is_meter_suspicious|yesno:'بله,خیر' }}</p>
 | 
			
		||||
                    <p class="text-nowrap mb-2"><i class="bx bx-map bx-sm me-2"></i>UTM X: {{ latest_report.utm_x|default:'-' }}</p>
 | 
			
		||||
                    <p class="text-nowrap mb-2"><i class="bx bx-map-pin bx-sm me-2"></i>UTM Y: {{ latest_report.utm_y|default:'-' }}</p>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                {% if latest_report.description %}
 | 
			
		||||
                  <div class="mt-2">
 | 
			
		||||
                    <p class="mb-0"><i class="bx bx-text bx-sm me-2"></i><strong>توضیحات:</strong></p>
 | 
			
		||||
                    <div class="text-muted">{{ latest_report.description }}</div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                <hr>
 | 
			
		||||
                <h6>عکسها</h6>
 | 
			
		||||
                <div class="row">
 | 
			
		||||
                  {% for p in latest_report.photos.all %}
 | 
			
		||||
                    <div class="col-6 col-md-3 mb-2"><img class="img-fluid rounded border" src="{{ p.image.url }}" alt="photo"></div>
 | 
			
		||||
                  {% empty %}
 | 
			
		||||
                    <div class="text-muted">بدون عکس</div>
 | 
			
		||||
                  {% endfor %}
 | 
			
		||||
                </div>
 | 
			
		||||
              {% else %}
 | 
			
		||||
                <div class="text-muted">گزارش نصب ثبت نشده است.</div>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="col-12">
 | 
			
		||||
          <div class="card border">
 | 
			
		||||
            <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
              <h6 class="mb-0">تراکنشها</h6>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="card-body">
 | 
			
		||||
              <div class="table-responsive">
 | 
			
		||||
                <table class="table table-striped mb-0">
 | 
			
		||||
                  <thead>
 | 
			
		||||
                    <tr>
 | 
			
		||||
                      <th>نوع</th>
 | 
			
		||||
                      <th>مبلغ</th>
 | 
			
		||||
                      <th>تاریخ</th>
 | 
			
		||||
                      <th>روش</th>
 | 
			
		||||
                      <th>شماره مرجع/چک</th>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                  </thead>
 | 
			
		||||
                  <tbody>
 | 
			
		||||
                    {% for p in payments %}
 | 
			
		||||
                    <tr>
 | 
			
		||||
                      <td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td>
 | 
			
		||||
                      <td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
 | 
			
		||||
                      <td>{{ p.payment_date|date:'Y/m/d' }}</td>
 | 
			
		||||
                      <td>{{ p.get_payment_method_display }}</td>
 | 
			
		||||
                      <td>{{ p.reference_number|default:'-' }}</td>
 | 
			
		||||
                    </tr>
 | 
			
		||||
                    {% empty %}
 | 
			
		||||
                    <tr><td colspan="5" class="text-center text-muted">بدون تراکنش</td></tr>
 | 
			
		||||
                    {% endfor %}
 | 
			
		||||
                  </tbody>
 | 
			
		||||
                </table>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -72,9 +72,15 @@
 | 
			
		|||
                </a>
 | 
			
		||||
                <ul class="dropdown-menu dropdown-menu-end m-0">
 | 
			
		||||
                  <li>
 | 
			
		||||
                    <a href="{% url 'processes:instance_steps' inst.id %}" class="dropdown-item">
 | 
			
		||||
                      <i class="bx bx-show me-1"></i>مشاهده جزئیات
 | 
			
		||||
                    </a>
 | 
			
		||||
                    {% if inst.status == 'completed' %}
 | 
			
		||||
                      <a href="{% url 'processes:instance_summary' inst.id %}" class="dropdown-item">
 | 
			
		||||
                        <i class="bx bx-show me-1"></i>مشاهده گزارش
 | 
			
		||||
                      </a>
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                      <a href="{% url 'processes:instance_steps' inst.id %}" class="dropdown-item">
 | 
			
		||||
                        <i class="bx bx-show me-1"></i>مشاهده جزئیات
 | 
			
		||||
                      </a>
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                  </li>
 | 
			
		||||
                  <div class="dropdown-divider"></div>
 | 
			
		||||
                  <li>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,8 +26,10 @@ def stepper_header(instance, current_step=None):
 | 
			
		|||
        step_instance = next((si for si in step_instances if si.step_id == step.id), None)
 | 
			
		||||
        status = step_id_to_status.get(step.id, 'pending')
 | 
			
		||||
        
 | 
			
		||||
        # بررسی دسترسی به مرحله
 | 
			
		||||
        can_access = instance.can_access_step(step)
 | 
			
		||||
        # بررسی دسترسی به مرحله (UI navigation constraints):
 | 
			
		||||
        # can_access = instance.can_access_step(step)
 | 
			
		||||
        # فقط مراحل تکمیلشده یا مرحله جاری قابل کلیک هستند
 | 
			
		||||
        can_access = (step_id_to_status.get(step.id) == 'completed') or (instance.current_step and step.id == instance.current_step.id)
 | 
			
		||||
        # مرحله انتخابشده (نمایش فعلی)
 | 
			
		||||
        is_selected = bool(current_step and step.id == current_step.id)
 | 
			
		||||
        # مرحلهای که باید انجام شود (مرحله جاری در instance)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,6 +14,7 @@ urlpatterns = [
 | 
			
		|||
    # New step-based architecture
 | 
			
		||||
    path('instance/<int:instance_id>/steps/', views.instance_steps, name='instance_steps'),
 | 
			
		||||
    path('instance/<int:instance_id>/step/<int:step_id>/', views.step_detail, name='step_detail'),
 | 
			
		||||
    path('instance/<int:instance_id>/summary/', views.instance_summary, name='instance_summary'),
 | 
			
		||||
 | 
			
		||||
    # Legacy process views
 | 
			
		||||
    path('', views.process_list, name='process_list'),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -357,6 +357,17 @@ def step_detail(request, instance_id, step_id):
 | 
			
		|||
        id=instance_id
 | 
			
		||||
    )
 | 
			
		||||
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
			
		||||
    # If the request is already completed, redirect to read-only summary page
 | 
			
		||||
    if instance.status == 'completed':
 | 
			
		||||
        return redirect('processes:instance_summary', instance_id=instance.id)
 | 
			
		||||
    
 | 
			
		||||
    # جلوگیری از پرش به مراحل آینده: فقط اجازه نمایش مرحله جاری یا مراحل تکمیلشده
 | 
			
		||||
    try:
 | 
			
		||||
        if instance.current_step and step.order > instance.current_step.order:
 | 
			
		||||
            messages.error(request, 'ابتدا مراحل قبلی را تکمیل کنید.')
 | 
			
		||||
            return redirect('processes:step_detail', instance_id=instance.id, step_id=instance.current_step.id)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    # بررسی دسترسی به مرحله
 | 
			
		||||
    if not instance.can_access_step(step):
 | 
			
		||||
| 
						 | 
				
			
			@ -414,8 +425,43 @@ def instance_steps(request, instance_id):
 | 
			
		|||
            messages.error(request, 'هیچ مرحلهای برای این فرآیند تعریف نشده است.')
 | 
			
		||||
            return redirect('processes:request_list')
 | 
			
		||||
    
 | 
			
		||||
    # If completed, go to summary instead of steps
 | 
			
		||||
    if instance.status == 'completed':
 | 
			
		||||
        return redirect('processes:instance_summary', instance_id=instance.id)
 | 
			
		||||
    return redirect('processes:step_detail', instance_id=instance.id, step_id=instance.current_step.id)
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
def instance_summary(request, instance_id):
 | 
			
		||||
    """نمای خلاصهٔ فقطخواندنی برای درخواستهای تکمیلشده."""
 | 
			
		||||
    instance = get_object_or_404(ProcessInstance.objects.select_related('well', 'representative'), id=instance_id)
 | 
			
		||||
    # Only show for completed requests; otherwise route to steps
 | 
			
		||||
    if instance.status != 'completed':
 | 
			
		||||
        return redirect('processes:instance_steps', instance_id=instance.id)
 | 
			
		||||
 | 
			
		||||
    # Collect final invoice, payments, and certificate if any
 | 
			
		||||
    from invoices.models import Invoice
 | 
			
		||||
    from installations.models import InstallationReport
 | 
			
		||||
    from certificates.models import CertificateInstance
 | 
			
		||||
    invoice = Invoice.objects.filter(process_instance=instance).first()
 | 
			
		||||
    payments = invoice.payments.filter(is_deleted=False).all() if invoice else []
 | 
			
		||||
    latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
 | 
			
		||||
    certificate = CertificateInstance.objects.filter(process_instance=instance).order_by('-created').first()
 | 
			
		||||
 | 
			
		||||
    # Build rows like final invoice step
 | 
			
		||||
    rows = []
 | 
			
		||||
    if invoice:
 | 
			
		||||
        items_qs = invoice.items.select_related('item').filter(is_deleted=False).all()
 | 
			
		||||
        rows = list(items_qs)
 | 
			
		||||
 | 
			
		||||
    return render(request, 'processes/instance_summary.html', {
 | 
			
		||||
        'instance': instance,
 | 
			
		||||
        'invoice': invoice,
 | 
			
		||||
        'payments': payments,
 | 
			
		||||
        'rows': rows,
 | 
			
		||||
        'latest_report': latest_report,
 | 
			
		||||
        'certificate': certificate,
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
def my_processes(request):
 | 
			
		||||
    """نمایش فرآیندهای کاربر"""
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue