Add confirmation and summary

This commit is contained in:
aminhashemi92 2025-09-05 13:35:33 +03:30
parent 9b3973805e
commit 35799b7754
25 changed files with 1419 additions and 265 deletions

View file

@ -167,7 +167,7 @@ JAZZMIN_SETTINGS = {
# Copyright on the footer # Copyright on the footer
"copyright": "سامانه شفافیت", "copyright": "سامانه شفافیت",
# Logo to use for your site, must be present in static files, used for brand on top left # 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) # Relative paths to custom CSS/JS scripts (must be present in static files)
"custom_css": "../static/admin/css/custom_rtl.css", "custom_css": "../static/admin/css/custom_rtl.css",
"custom_js": None, "custom_js": None,

View file

@ -2,6 +2,7 @@
{% load static %} {% load static %}
{% load processes_tags %} {% load processes_tags %}
{% load humanize %} {% load humanize %}
{% load accounts_tags %}
{% block sidebar %} {% block sidebar %}
{% include 'sidebars/admin.html' %} {% include 'sidebars/admin.html' %}
@ -79,7 +80,11 @@
{% else %}<span></span>{% endif %} {% else %}<span></span>{% endif %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% if request.user|is_broker %}
<button class="btn btn-primary" type="submit">تایید و پایان</button> <button class="btn btn-primary" type="submit">تایید و پایان</button>
{% else %}
<button class="btn btn-primary" type="button" disabled>تایید و پایان</button>
{% endif %}
</form> </form>
</div> </div>
</div> </div>

View file

@ -9,6 +9,7 @@ from processes.models import ProcessInstance, StepInstance
from invoices.models import Invoice from invoices.models import Invoice
from installations.models import InstallationReport from installations.models import InstallationReport
from .models import CertificateTemplate, CertificateInstance from .models import CertificateTemplate, CertificateInstance
from common.consts import UserRoles
from _helpers.jalali import Gregorian 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 next_step = instance.process.steps.filter(order__gt=instance.current_step.order).first() if instance.current_step else None
if request.method == 'POST': 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 = True
cert.approved_at = timezone.now() cert.approved_at = timezone.now()
cert.save() cert.save()
@ -89,7 +98,10 @@ def certificate_step(request, instance_id, step_id):
instance.current_step = next_step instance.current_step = next_step
instance.save() instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id) 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', { return render(request, 'certificates/step.html', {
'instance': instance, 'instance': instance,

View file

@ -41,6 +41,7 @@
<div class="bs-stepper-content"> <div class="bs-stepper-content">
<div class="card border"> <div class="card border">
<div class="card-body"> <div class="card-body">
{% if can_view_contract_body %}
{% if template.company.logo %} {% if template.company.logo %}
<div class="text-center mb-3"> <div class="text-center mb-3">
<img src="{{ template.company.logo.url }}" alt="لوگوی شرکت" style="max-height:80px;"> <img src="{{ template.company.logo.url }}" alt="لوگوی شرکت" style="max-height:80px;">
@ -67,6 +68,9 @@
</div> </div>
</div> </div>
</div> </div>
{% else %}
<div class="alert alert-warning mb-0">شما دسترسی به مشاهده متن قرارداد را ندارید.</div>
{% endif %}
</div> </div>
</div> </div>
<form method="post" class="d-flex justify-content-between mt-3"> <form method="post" class="d-flex justify-content-between mt-3">
@ -77,9 +81,17 @@
<span></span> <span></span>
{% endif %} {% endif %}
{% if next_step %} {% if next_step %}
<button type="submit" class="btn btn-primary">بعدی</button> {% if is_broker %}
<button type="submit" class="btn btn-primary">تایید و بعدی</button>
{% else %} {% else %}
<button class="btn btn-success" type="button">اتمام</button> <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
{% endif %}
{% else %}
{% if is_broker %}
<button class="btn btn-success" type="submit">اتمام</button>
{% else %}
<button class="btn btn-success" type="button" disabled>اتمام</button>
{% endif %}
{% endif %} {% endif %}
</form> </form>
</div> </div>

View file

@ -4,6 +4,7 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.template import Template, Context from django.template import Template, Context
from processes.models import ProcessInstance, StepInstance from processes.models import ProcessInstance, StepInstance
from common.consts import UserRoles
from .models import ContractTemplate, ContractInstance from .models import ContractTemplate, ContractInstance
from _helpers.utils import jalali_converter2 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) step = get_object_or_404(instance.process.steps, id=step_id)
previous_step = instance.process.steps.filter(order__lt=step.order).last() previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first() 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() template_obj = ContractTemplate.objects.first()
if not template_obj: if not template_obj:
return render(request, 'contracts/contract_missing.html', {'instance': instance}) 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.rendered_body = rendered
contract.save() 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 request.method == 'POST':
if not is_broker:
from django.http import JsonResponse
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
StepInstance.objects.update_or_create( StepInstance.objects.update_or_create(
process_instance=instance, process_instance=instance,
step=step, step=step,
@ -74,6 +92,8 @@ def contract_step(request, instance_id, step_id):
'template': template_obj, 'template': template_obj,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'is_broker': is_broker,
'can_view_contract_body': can_view_contract_body,
}) })

Binary file not shown.

View file

@ -1,6 +1,7 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static %} {% load static %}
{% load processes_tags %} {% load processes_tags %}
{% load common_tags %}
{% load humanize %} {% load humanize %}
{% block sidebar %} {% block sidebar %}
@ -41,12 +42,15 @@
<div class="bs-stepper-content"> <div class="bs-stepper-content">
{% if show_denied_msg %}
<div class="alert alert-warning mb-3">شما اجازه تعیین نصاب را ندارید.</div>
{% endif %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">نصاب</label> <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> <option value="">انتخاب کنید...</option>
{% for p in installers %} {% 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> <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>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">تاریخ مراجعه نصاب</label> <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 %}"> <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>
</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"> <div class="d-flex justify-content-between mt-4">
{% if previous_step %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a> <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if is_manager %}
<button class="btn btn-primary" type="submit">ثبت و ادامه</button> <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> </div>
</form> </form>
</div> </div>

View file

@ -2,6 +2,7 @@
{% load static %} {% load static %}
{% load processes_tags %} {% load processes_tags %}
{% load common_tags %} {% load common_tags %}
{% load accounts_tags %}
{% load humanize %} {% load humanize %}
{% block sidebar %} {% block sidebar %}
@ -41,13 +42,31 @@
{% stepper_header instance step %} {% stepper_header instance step %}
<div class="bs-stepper-content"> <div class="bs-stepper-content">
{% if report and not edit_mode %} {% if report and not edit_mode %}
<div class="card mb-3 border"> <div class="card mb-3 border">
<div class="card-header d-flex justify-content-end"> <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> <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>
<div class="card-body"> <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="row">
<div class="col-md-6"> <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> <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> </div>
{% endif %} {% endif %}
<hr> <hr>
{% if request.user|is_manager or request.user|is_admin %}
<hr>
{% endif %}
<h6>عکس‌ها</h6> <h6>عکس‌ها</h6>
<div class="row"> <div class="row">
{% for p in report.photos.all %} {% for p in report.photos.all %}
@ -115,6 +137,42 @@
</div> </div>
</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 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) --> <!-- Persistent nav in edit mode (outside cards) -->
<div class="d-flex justify-content-between mt-3"> <div class="d-flex justify-content-between mt-3">
{% if previous_step %} {% if previous_step %}
@ -127,6 +185,9 @@
{% endif %} {% endif %}
</div> </div>
{% else %} {% 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"> <form method="post" enctype="multipart/form-data" id="installation-report-form">
{% csrf_token %} {% csrf_token %}
<div class="mb-3"> <div class="mb-3">
@ -134,40 +195,42 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">تاریخ مراجعه</label> <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 %}"> <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>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">سریال کنتور جدید</label> <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>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">شماره پلمپ</label> <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>
<div class="col-md-3 d-flex align-items-end"> <div class="col-md-3 d-flex align-items-end">
<div class="form-check"> <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> <label class="form-check-label" for="id_is_meter_suspicious">کنتور مشکوک است</label>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">UTM X</label> <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>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">UTM Y</label> <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> </div>
<div class="my-3"> <div class="my-3">
<label class="form-label">توضیحات (اختیاری)</label> <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>
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<label class="form-label mb-0">عکس‌ها</label> <label class="form-label mb-0">عکس‌ها</label>
{% if request.user|is_installer %}
<button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button> <button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button>
{% endif %}
</div> </div>
{% if report %} {% if report %}
<div class="row mt-2"> <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="col-6 col-md-3 mb-2" id="existing-photo-{{ p.id }}">
<div class="position-relative border rounded p-1"> <div class="position-relative border rounded p-1">
<img class="img-fluid rounded" src="{{ p.image.url }}" alt="photo"> <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"> <input type="hidden" name="del_photo_{{ p.id }}" id="del-photo-{{ p.id }}" value="0">
</div> </div>
</div> </div>
@ -285,7 +350,11 @@
<span></span> <span></span>
{% endif %} {% endif %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if request.user|is_installer %}
<button type="submit" class="btn btn-primary" form="installation-report-form">ثبت گزارش</button> <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 %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-success">بعدی</a> <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-success">بعدی</a>
{% endif %} {% endif %}
@ -298,6 +367,58 @@
</div> </div>
</div> </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 %} {% endblock %}
{% block script %} {% block script %}
@ -445,4 +566,3 @@
</script> </script>
{% endblock %} {% endblock %}

View file

@ -5,7 +5,8 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from accounts.models import Profile from accounts.models import Profile
from common.consts import UserRoles 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 invoices.models import Item, Quote, QuoteItem
from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange
from decimal import Decimal, InvalidOperation 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() installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value).select_related('user').all()
assignment, _ = InstallationAssignment.objects.get_or_create(process_instance=instance) 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 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') installer_id = request.POST.get('installer_id')
scheduled_date = (request.POST.get('scheduled_date') or '').strip() scheduled_date = (request.POST.get('scheduled_date') or '').strip()
assignment.installer_id = installer_id or None 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:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:request_list') 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', { return render(request, 'installations/installation_assign_step.html', {
'instance': instance, 'instance': instance,
'step': step, 'step': step,
@ -50,6 +66,9 @@ def installation_assign_step(request, instance_id, step_id):
'installers': installers, 'installers': installers,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_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() next_step = instance.process.steps.filter(order__gt=step.order).first()
assignment = InstallationAssignment.objects.filter(process_instance=instance).first() assignment = InstallationAssignment.objects.filter(process_instance=instance).first()
existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first() existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first()
edit_mode = True if request.GET.get('edit') == '1' else False # Only installers can enter edit mode
print("edit_mode", 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 # current quote items baseline
quote = Quote.objects.filter(process_instance=instance).first() quote = Quote.objects.filter(process_instance=instance).first()
quote_items = list(quote.items.select_related('item').all()) if quote else [] 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} 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': 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() description = (request.POST.get('description') or '').strip()
visited_date = (request.POST.get('visited_date') or '').strip() visited_date = (request.POST.get('visited_date') or '').strip()
if '/' in visited_date: if '/' in visited_date:
@ -134,6 +232,7 @@ def installation_report_step(request, instance_id, step_id):
report.is_meter_suspicious = is_suspicious report.is_meter_suspicious = is_suspicious
report.utm_x = utm_x report.utm_x = utm_x
report.utm_y = utm_y report.utm_y = utm_y
report.approved = False # back to awaiting approval after edits
report.save() report.save()
# delete selected existing photos # delete selected existing photos
for key, val in request.POST.items(): for key, val in request.POST.items():
@ -211,18 +310,17 @@ def installation_report_step(request, instance_id, step_id):
total_price=total, total_price=total,
) )
# complete step # After installer submits/edits, set step back to in_progress and clear approvals
StepInstance.objects.update_or_create( step_instance.status = 'in_progress'
process_instance=instance, step_instance.completed_at = None
step=step, step_instance.save()
defaults={'status': 'completed', 'completed_at': timezone.now()} try:
) step_instance.approvals.all().delete()
except Exception:
pass
if next_step: messages.success(request, 'گزارش ثبت شد و در انتظار تایید است.')
instance.current_step = next_step return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:request_list')
# Build prefill maps from existing report changes # Build prefill maps from existing report changes
removed_ids = set() removed_ids = set()
@ -250,6 +348,9 @@ def installation_report_step(request, instance_id, step_id):
'added_map': added_map, 'added_map': added_map,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'step_instance': step_instance,
'approver_statuses': approver_statuses,
'user_can_approve': user_can_approve,
}) })

View file

@ -6,8 +6,8 @@ from .models import Item, Quote, QuoteItem, Invoice, InvoiceItem, Payment
@admin.register(Item) @admin.register(Item)
class ItemAdmin(SimpleHistoryAdmin): class ItemAdmin(SimpleHistoryAdmin):
list_display = ['name', 'unit_price', 'default_quantity', '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_active', 'created_by'] list_filter = ['is_default_in_quotes', 'is_special', 'is_active', 'created_by']
search_fields = ['name', 'description'] search_fields = ['name', 'description']
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
readonly_fields = ['deleted_at', 'created', 'updated'] readonly_fields = ['deleted_at', 'created', 'updated']

View file

@ -50,7 +50,9 @@
<div class="card border"> <div class="card border">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">فاکتور نهایی</h5> <h5 class="mb-0">فاکتور نهایی</h5>
{% if is_manager %}
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSpecialChargeModal()"><i class="bx bx-plus"></i> افزودن هزینه تعمیر/تعویض</button> <button type="button" class="btn btn-sm btn-outline-primary" onclick="openSpecialChargeModal()"><i class="bx bx-plus"></i> افزودن هزینه تعمیر/تعویض</button>
{% endif %}
</div> </div>
<div class="card-body"> <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.unit_price|floatformat:0|intcomma:False }}</td>
<td class="text-end"> <td class="text-end">
{{ si.total_price|floatformat:0|intcomma:False }} {{ si.total_price|floatformat:0|intcomma:False }}
{% 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> <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> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -164,7 +168,11 @@
<span></span> <span></span>
{% endif %} {% endif %}
{% if next_step %} {% if next_step %}
{% if is_manager %}
<button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button> <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 %} {% endif %}
</div> </div>
</div> </div>

View file

@ -2,6 +2,7 @@
{% load static %} {% load static %}
{% load processes_tags %} {% load processes_tags %}
{% load common_tags %} {% load common_tags %}
{% load accounts_tags %}
{% load humanize %} {% load humanize %}
{% block sidebar %} {% block sidebar %}
@ -46,6 +47,7 @@
<div class="bs-stepper-content"> <div class="bs-stepper-content">
<div class="row g-3"> <div class="row g-3">
{% if is_broker %}
<div class="col-12 col-lg-5"> <div class="col-12 col-lg-5">
<div class="card border h-100"> <div class="card border h-100">
<div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div> <div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div>
@ -78,11 +80,11 @@
</select> </select>
</div> </div>
<div class="mb-3"> <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> <input type="text" class="form-control" name="reference_number" id="id_reference_number" required>
</div> </div>
<div class="mb-3"> <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> <input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@ -92,23 +94,39 @@
</div> </div>
</div> </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 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="card-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-6"> <div class="col-6 col-md-4">
<div class="border rounded p-3"> <div class="border rounded p-3 h-100">
<div class="small text-muted">مبلغ نهایی</div> <div class="small text-muted">مبلغ نهایی</div>
<div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div> <div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
</div> </div>
</div> </div>
<div class="col-6"> <div class="col-6 col-md-4">
<div class="border rounded p-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-4">
<div class="border rounded p-3 h-100">
<div class="small text-muted">مانده</div> <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 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>
<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> </div>
</div> </div>
@ -123,8 +141,8 @@
<th>مبلغ</th> <th>مبلغ</th>
<th>تاریخ</th> <th>تاریخ</th>
<th>روش</th> <th>روش</th>
<th>شماره مرجع</th> <th class="text-nowrap">شماره مرجع/چک</th>
<th style="width:150px">عملیات</th> <th>عملیات</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -132,7 +150,7 @@
<tr> <tr>
<td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td> <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.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.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td> <td>{{ p.reference_number|default:'-' }}</td>
<td> <td>
@ -142,7 +160,9 @@
<i class="bx bx-show"></i> <i class="bx bx-show"></i>
</a> </a>
{% endif %} {% 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> </div>
</td> </td>
</tr> </tr>
@ -152,20 +172,141 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="card-footer d-flex justify-content-between">
</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 %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a> <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
<button type="button" id="btnApproveFinalSettlement" class="btn btn-primary">تایید و ادامه</button> {% 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>
</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>
</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 %} {% endblock %}
{% block script %} {% block script %}
@ -191,7 +332,10 @@
if (g) { fd.set('payment_date', g); } if (g) { fd.set('payment_date', g); }
return fd; return fd;
} }
document.getElementById('btnAddFinalPayment').addEventListener('click', function(){ (function(){
const btn = document.getElementById('btnAddFinalPayment');
if (!btn) return;
btn.addEventListener('click', function(){
const fd = buildForm(); const fd = buildForm();
// Frontend validation // Frontend validation
const amount = document.getElementById('id_amount').value.trim(); const amount = document.getElementById('id_amount').value.trim();
@ -214,11 +358,19 @@
} }
}).catch(()=> showToast('خطا در ارتباط با سرور', '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(); const fd = new FormData();
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value); 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=>{ .then(r=>r.json()).then(resp=>{
if (resp.success) { if (resp.success) {
showToast('حذف شد', 'success'); showToast('حذف شد', 'success');
@ -229,20 +381,7 @@
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger')); }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
} }
document.getElementById('btnApproveFinalSettlement').addEventListener('click', function(){ // Legacy approve button removed; using modal forms below
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'));
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -1,6 +1,7 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static %} {% load static %}
{% load processes_tags %} {% load processes_tags %}
{% load accounts_tags %}
{% load humanize %} {% load humanize %}
{% block sidebar %} {% block sidebar %}
@ -55,14 +56,15 @@
<div class="content active dstepper-block"> <div class="content active dstepper-block">
<div class="content-header mb-3"> <div class="content-header mb-3">
<h6 class="mb-0">{{ step.name }}</h6> <h6 class="mb-0">{{ step.name }}</h6>
<small>ثبت فیش‌های واریزی برای پیش‌فاکتور</small> <small>ثبت فیش‌ها/چک‌های واریزی برای پیش‌فاکتور</small>
</div> </div>
<div class="row g-3"> <div class="row g-3">
{% if can_manage_payments %}
<div class="col-12 col-lg-5"> <div class="col-12 col-lg-5">
<div class="card h-100 border"> <div class="card h-100 border">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0">ثبت فیش جدید</h5> <h5 class="card-title mb-0">ثبت فیش/چک جدید</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
@ -84,11 +86,11 @@
</select> </select>
</div> </div>
<div class="mb-3"> <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> <input type="text" class="form-control" name="reference_number" id="id_reference_number" placeholder="..." required>
</div> </div>
<div class="mb-3"> <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> <input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -96,16 +98,16 @@
<textarea class="form-control" rows="2" name="notes" id="id_notes"></textarea> <textarea class="form-control" rows="2" name="notes" id="id_notes"></textarea>
</div> </div>
<div class="d-flex justify-content-end"> <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> </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 mb-3 border">
<div class="card-header d-flex justify-content-between"> <div class="card-header d-flex justify-content-between">
<h5 class="card-title mb-0">وضعیت پیش‌فاکتور</h5> <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>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
@ -139,8 +141,10 @@
</div> </div>
<div class="card border"> <div class="card border">
<div class="card-header"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">فیش‌های ثبت شده</h5> <div>
<h5 class="card-title mb-0">فیش‌ها/چک‌های ثبت شده</h5>
</div>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped mb-0"> <table class="table table-striped mb-0">
@ -149,9 +153,8 @@
<th>مبلغ</th> <th>مبلغ</th>
<th>تاریخ</th> <th>تاریخ</th>
<th>روش</th> <th>روش</th>
<th>شماره مرجع</th> <th>شماره مرجع/چک</th>
<th>تصویر</th> <th>عملیات</th>
<th style="width:120px">عملیات</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -162,28 +165,23 @@
<td>{{ p.get_payment_method_display }}</td> <td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td> <td>{{ p.reference_number|default:'-' }}</td>
<td> <td>
<div class="btn-group">
{% if p.receipt_image %} {% if p.receipt_image %}
<a href="{{ p.receipt_image.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="مشاهده" aria-label="مشاهده"> <a href="{{ p.receipt_image.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="مشاهده" aria-label="مشاهده">
<i class="bx bx-show"></i> <i class="bx bx-show"></i>
</a> </a>
{% else %}
-
{% endif %} {% endif %}
</td> {% if can_manage_payments %}
<td> <button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف">
<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="حذف">
<i class="bx bx-trash"></i> <i class="bx bx-trash"></i>
</button> </button>
{% endif %}
</div> </div>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="6" class="text-center text-muted">تا کنون فیشی ثبت نشده است</td> <td colspan="6" class="text-center text-muted">تا کنون فیش/چکی ثبت نشده است</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -191,6 +189,42 @@
</div> </div>
</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="#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"> <div class="col-12 d-flex justify-content-between mt-3">
{% if previous_step %} {% if previous_step %}
@ -201,30 +235,114 @@
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
<button type="button" id="btnApprovePayments" class="btn btn-primary"> {% if step_instance.status == 'completed' %}
تایید پرداخت‌ها {% if next_step %}
<i class="bx bx-chevron-left bx-sm ms-sm-2"></i> <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
</button> <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> </div>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </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 %} {% endblock %}
{% block script %} {% block script %}
<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) { function buildFormData(form) {
const fd = new FormData(form); const fd = new FormData(form);
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value); fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
return fd; return fd;
} }
document.getElementById('btnAddPayment').addEventListener('click', function() { const btnAddPayment = document.getElementById('btnAddPayment');
if (btnAddPayment) btnAddPayment.addEventListener('click', function() {
// Front-end validation // Front-end validation
const amount = document.getElementById('id_amount').value.trim(); const amount = document.getElementById('id_amount').value.trim();
const payDate = document.getElementById('id_payment_date').value.trim(); const payDate = document.getElementById('id_payment_date').value.trim();
@ -283,51 +401,7 @@
alert('ویرایش فیش را بعدا با مدال تکمیل می‌کنیم. فعلا حذف و افزودن مجدد انجام دهید.'); alert('ویرایش فیش را بعدا با مدال تکمیل می‌کنیم. فعلا حذف و افزودن مجدد انجام دهید.');
} }
function performApprovePayments() { // Legacy approve JS removed; approval handled by modal forms in header
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();
}
});
</script> </script>
<!-- Persian Date Picker JS --> <!-- Persian Date Picker JS -->
@ -365,42 +439,4 @@
})(); })();
</script> </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 %} {% endblock %}

View file

@ -221,6 +221,7 @@
<span></span> <span></span>
{% endif %} {% endif %}
{% if is_broker %}
{% if step_instance.status == 'completed' %} {% if step_instance.status == 'completed' %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" <a href="{% url 'processes:step_detail' instance.id next_step.id %}"
@ -236,6 +237,17 @@
تایید پیش‌فاکتور تایید پیش‌فاکتور
</button> </button>
{% endif %} {% endif %}
{% else %}
{% 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>
</div> </div>
</div> </div>

View file

@ -58,6 +58,7 @@
{% endif %} {% endif %}
<div class="col-12"> <div class="col-12">
{% if is_broker or existing_quote %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm align-middle"> <table class="table table-sm align-middle">
<thead> <thead>
@ -77,7 +78,8 @@
data-item-id="{{ item.id }}" data-item-id="{{ item.id }}"
data-is-default="{% if item.is_default_in_quotes %}1{% else %}0{% endif %}" 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 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>
<td> <td>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
@ -86,7 +88,6 @@
<span class="badge bg-label-primary me-2">پیش‌فرض</span> <span class="badge bg-label-primary me-2">پیش‌فرض</span>
{% endif %} {% endif %}
</span> </span>
{% if item.description %}<small class="text-muted">{{ item.description }}</small>{% endif %} {% if item.description %}<small class="text-muted">{{ item.description }}</small>{% endif %}
</div> </div>
</td> </td>
@ -94,7 +95,8 @@
<td> <td>
<input type="number" class="form-control form-control-sm quote-item-qty" min="1" <input type="number" class="form-control form-control-sm quote-item-qty" min="1"
data-item-id="{{ item.id }}" 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> </td>
</tr> </tr>
{% endwith %} {% endwith %}
@ -102,8 +104,9 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %}
<div class="alert alert-warning mb-0">شما دسترسی به ثبت اقلام ندارید.</div>
{% endif %}
</div> </div>
<div class="col-12 d-flex justify-content-between"> <div class="col-12 d-flex justify-content-between">
@ -118,28 +121,36 @@
{% endif %} {% endif %}
{% if is_broker %}
{% if step_instance.status == 'completed' %} {% if step_instance.status == 'completed' %}
{% if next_step %} {% if next_step %}
<div class="d-flex justify-content-end mt-3"> <div class="d-flex justify-content-end mt-3">
<button type="button" class="btn btn-primary" id="btnCreateQuote"> <button type="button" class="btn btn-primary" id="btnCreateQuote">
{% if existing_quote %}بروزرسانی پیش‌فاکتور{% else %}ثبت پیش‌فاکتور{% endif %} {% if existing_quote %}بروزرسانی پیش‌فاکتور{% else %}ثبت پیش‌فاکتور{% endif %}
و بعدی و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i> <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button> </button>
</div> </div>
{% else %} {% else %}
<button class="btn btn-success" type="button">اتمام</button> <button class="btn btn-success" type="button">اتمام</button>
{% endif %} {% endif %}
{% else %} {% else %}
<button type="button" class="btn btn-primary" id="btnCreateQuote"> <button type="button" class="btn btn-primary" id="btnCreateQuote">
{% if existing_quote %}بروزرسانی پیش‌فاکتور{% else %}ثبت پیش‌فاکتور{% endif %} {% if existing_quote %}بروزرسانی پیش‌فاکتور{% else %}ثبت پیش‌فاکتور{% endif %}
و بعدی و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i> <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button> </button>
{% endif %} {% endif %}
{% else %}
{% 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>
</div> </div>
</div> </div>

View file

@ -9,7 +9,9 @@ from django.urls import reverse
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
import json 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 .models import Item, Quote, QuoteItem, Payment, Invoice
from installations.models import InstallationReport, InstallationItemChange from installations.models import InstallationReport, InstallationItemChange
@ -28,7 +30,7 @@ def quote_step(request, instance_id, step_id):
return redirect('processes:request_list') 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 = Quote.objects.filter(process_instance=instance).first()
existing_quote_items = {} existing_quote_items = {}
if existing_quote: 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() previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first() 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', { return render(request, 'invoices/quote_step.html', {
'instance': instance, 'instance': instance,
'step': step, 'step': step,
@ -49,6 +59,7 @@ def quote_step(request, instance_id, step_id):
'existing_quote': existing_quote, 'existing_quote': existing_quote,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'is_broker': is_broker,
}) })
@require_POST @require_POST
@ -57,6 +68,13 @@ def create_quote(request, instance_id, step_id):
"""ساخت/بروزرسانی پیش‌فاکتور از اقلام انتخابی""" """ساخت/بروزرسانی پیش‌فاکتور از اقلام انتخابی"""
instance = get_object_or_404(ProcessInstance, id=instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_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: try:
items_payload = json.loads(request.POST.get('items') or '[]') items_payload = json.loads(request.POST.get('items') or '[]')
@ -72,7 +90,7 @@ def create_quote(request, instance_id, step_id):
except Exception: except Exception:
continue 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: if default_item_ids:
for default_id in default_item_ids: for default_id in default_item_ids:
if default_id not in payload_by_id: 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() previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first() 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', { return render(request, 'invoices/quote_preview_step.html', {
'instance': instance, 'instance': instance,
'step': step, 'step': step,
@ -170,6 +196,7 @@ def quote_preview_step(request, instance_id, step_id):
'quote': quote, 'quote': quote,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'is_broker': is_broker,
}) })
@login_required @login_required
@ -190,6 +217,13 @@ def approve_quote(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance) 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' quote.status = 'sent'
@ -247,7 +281,97 @@ def quote_payment_step(request, instance_id, step_id):
'is_fully_paid': quote.get_remaining_amount() <= 0, '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', { return render(request, 'invoices/quote_payment_step.html', {
'instance': instance, 'instance': instance,
@ -258,6 +382,12 @@ def quote_payment_step(request, instance_id, step_id):
'totals': totals, 'totals': totals,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_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__) logger = logging.getLogger(__name__)
try: try:
amount = (request.POST.get('amount') or '').strip() 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) logger.exception('Error adding quote payment (instance=%s, step=%s)', instance_id, step_id)
return JsonResponse({'success': False, 'message': 'خطا در ثبت فیش', 'error': str(e)}) 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]) redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url}) return JsonResponse({'success': True, 'redirect': redirect_url})
@ -360,6 +509,15 @@ def update_quote_payment(request, instance_id, step_id, payment_id):
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'خطا در ویرایش فیش'}) 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]) redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url}) 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: if not invoice:
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'}) return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) 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: try:
# soft delete using project's BaseModel delete override # soft delete using project's BaseModel delete override
payment.delete() payment.delete()
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'خطا در حذف فیش'}) 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]) redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url}) 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 # Choices for special items from DB
special_choices = list(Item.objects.filter(is_special=True).values('id', 'name')) 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', { return render(request, 'invoices/final_invoice_step.html', {
'instance': instance, 'instance': instance,
'step': step, '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(), 'invoice_specials': invoice.items.select_related('item').filter(item__is_special=True, is_deleted=False).all(),
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_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) instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance) 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) # Block approval when there is any remaining (positive or negative)
invoice.calculate_totals() invoice.calculate_totals()
# if invoice.remaining_amount != 0: # 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) instance = get_object_or_404(ProcessInstance, id=instance_id)
invoice = get_object_or_404(Invoice, process_instance=instance) 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 # charge_type was removed from UI; we no longer require it
item_id = request.POST.get('item_id') item_id = request.POST.get('item_id')
amount = (request.POST.get('amount') or '').strip() 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): def delete_special_charge(request, instance_id, step_id, item_id):
instance = get_object_or_404(ProcessInstance, id=instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
invoice = get_object_or_404(Invoice, process_instance=instance) 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 from .models import InvoiceItem
inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice) inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice)
# allow deletion only for special items # 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() previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first() 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', { return render(request, 'invoices/final_settlement_step.html', {
'instance': instance, 'instance': instance,
'step': step, 'step': step,
'invoice': invoice, 'invoice': invoice,
'payments': invoice.payments.filter(is_deleted=False).all(), 'payments': invoice.payments.filter(is_deleted=False).all(),
'step_instance': step_instance,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_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 @login_required
def add_final_payment(request, instance_id, step_id): def add_final_payment(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_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) 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() amount = (request.POST.get('amount') or '').strip()
payment_date = (request.POST.get('payment_date') or '').strip() payment_date = (request.POST.get('payment_date') or '').strip()
payment_method = (request.POST.get('payment_method') 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. # After creation, totals auto-updated by model save. Respond with redirect and new totals for UX.
invoice.refresh_from_db() 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({ return JsonResponse({
'success': True, 'success': True,
'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), '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 @login_required
def delete_final_payment(request, instance_id, step_id, payment_id): def delete_final_payment(request, instance_id, step_id, payment_id):
instance = get_object_or_404(ProcessInstance, id=instance_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) invoice = get_object_or_404(Invoice, process_instance=instance)
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) 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() payment.delete()
invoice.refresh_from_db() 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': { return JsonResponse({'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'totals': {
'final_amount': str(invoice.final_amount), 'final_amount': str(invoice.final_amount),
'paid_amount': str(invoice.paid_amount), 'paid_amount': str(invoice.paid_amount),

View file

@ -2,7 +2,7 @@ from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin from simple_history.admin import SimpleHistoryAdmin
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe 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) @admin.register(Process)
class ProcessAdmin(SimpleHistoryAdmin): class ProcessAdmin(SimpleHistoryAdmin):
@ -179,3 +179,17 @@ class StepRevisionAdmin(SimpleHistoryAdmin):
def changes_short(self, obj): def changes_short(self, obj):
return obj.changes_description[:50] + "..." if len(obj.changes_description) > 50 else obj.changes_description return obj.changes_description[:50] + "..." if len(obj.changes_description) > 50 else obj.changes_description
changes_short.short_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")

View file

@ -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')},
},
),
]

View file

@ -4,6 +4,8 @@ from common.models import NameSlugModel, SluggedModel
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils import timezone from django.utils import timezone
from django.conf import settings
from accounts.models import Role
from _helpers.utils import generate_unique_slug from _helpers.utils import generate_unique_slug
import random import random
@ -46,6 +48,9 @@ class ProcessStep(NameSlugModel):
) )
history = HistoricalRecords() history = HistoricalRecords()
# Note: approver requirements are defined via StepApproverRequirement through model
# See StepApproverRequirement below
class Meta: class Meta:
verbose_name = "مرحله فرآیند" verbose_name = "مرحله فرآیند"
verbose_name_plural = "مراحل فرآیند" verbose_name_plural = "مراحل فرآیند"
@ -353,6 +358,26 @@ class StepInstance(models.Model):
"""دریافت آخرین رد شدن""" """دریافت آخرین رد شدن"""
return self.rejections.order_by('-created_at').first() 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): class StepRejection(models.Model):
"""مدل رد شدن مرحله""" """مدل رد شدن مرحله"""
step_instance = models.ForeignKey( step_instance = models.ForeignKey(
@ -424,3 +449,36 @@ class StepRevision(models.Model):
def __str__(self): def __str__(self):
return f"بازبینی {self.step_instance} توسط {self.revised_by.get_full_name()}" 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}"

View file

@ -6,6 +6,7 @@
<div class="step <div class="step
{% if not can_access %}disabled {% if not can_access %}disabled
{% elif status == 'completed' %}completed {% elif status == 'completed' %}completed
{% elif status == 'rejected' %}rejected
{% elif is_todo %}active {% elif is_todo %}active
{% endif %} {% endif %}
{% if is_selected %} selected{% endif %}" {% if is_selected %} selected{% endif %}"
@ -19,9 +20,9 @@
<span class="step-trigger"> <span class="step-trigger">
{% endif %} {% 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-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 class="bs-stepper-subtitle">{{ step.description|default:' ' }}</span>
</span> </span>

View 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 %}

View file

@ -72,9 +72,15 @@
</a> </a>
<ul class="dropdown-menu dropdown-menu-end m-0"> <ul class="dropdown-menu dropdown-menu-end m-0">
<li> <li>
{% 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"> <a href="{% url 'processes:instance_steps' inst.id %}" class="dropdown-item">
<i class="bx bx-show me-1"></i>مشاهده جزئیات <i class="bx bx-show me-1"></i>مشاهده جزئیات
</a> </a>
{% endif %}
</li> </li>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<li> <li>

View file

@ -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) 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') status = step_id_to_status.get(step.id, 'pending')
# بررسی دسترسی به مرحله # بررسی دسترسی به مرحله (UI navigation constraints):
can_access = instance.can_access_step(step) # 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) is_selected = bool(current_step and step.id == current_step.id)
# مرحله‌ای که باید انجام شود (مرحله جاری در instance) # مرحله‌ای که باید انجام شود (مرحله جاری در instance)

View file

@ -14,6 +14,7 @@ urlpatterns = [
# New step-based architecture # New step-based architecture
path('instance/<int:instance_id>/steps/', views.instance_steps, name='instance_steps'), 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>/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 # Legacy process views
path('', views.process_list, name='process_list'), path('', views.process_list, name='process_list'),

View file

@ -357,6 +357,17 @@ def step_detail(request, instance_id, step_id):
id=instance_id id=instance_id
) )
step = get_object_or_404(instance.process.steps, id=step_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): if not instance.can_access_step(step):
@ -414,8 +425,43 @@ def instance_steps(request, instance_id):
messages.error(request, 'هیچ مرحله‌ای برای این فرآیند تعریف نشده است.') messages.error(request, 'هیچ مرحله‌ای برای این فرآیند تعریف نشده است.')
return redirect('processes:request_list') 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) 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 @login_required
def my_processes(request): def my_processes(request):
"""نمایش فرآیندهای کاربر""" """نمایش فرآیندهای کاربر"""