Compare commits

..

No commits in common. "976be64028dfa267b8ca38676e88425eeed859c6" and "ce8584f86b848fb06e762fbe8f154f50262323b0" have entirely different histories.

33 changed files with 309 additions and 1057 deletions

View file

@ -90,19 +90,6 @@ class CustomerForm(forms.ModelForm):
return national_code return national_code
def save(self, commit=True): def save(self, commit=True):
def _compute_completed(cleaned):
try:
first_ok = bool((cleaned.get('first_name') or '').strip())
last_ok = bool((cleaned.get('last_name') or '').strip())
nc_ok = bool((cleaned.get('national_code') or '').strip())
phone_ok = bool((cleaned.get('phone_number_1') or '').strip() or (cleaned.get('phone_number_2') or '').strip())
addr_ok = bool((cleaned.get('address') or '').strip())
bank_ok = bool(cleaned.get('bank_name'))
card_ok = bool((cleaned.get('card_number') or '').strip())
acc_ok = bool((cleaned.get('account_number') or '').strip())
return all([first_ok, last_ok, nc_ok, phone_ok, addr_ok, bank_ok, card_ok, acc_ok])
except Exception:
return False
# Check if this is an update (instance exists) # Check if this is an update (instance exists)
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
# Update existing profile # Update existing profile
@ -121,18 +108,6 @@ class CustomerForm(forms.ModelForm):
profile.affairs = current_user_profile.affairs profile.affairs = current_user_profile.affairs
profile.county = current_user_profile.county profile.county = current_user_profile.county
profile.broker = current_user_profile.broker profile.broker = current_user_profile.broker
# Set completion flag based on provided form data
profile.is_completed = _compute_completed({
'first_name': user.first_name,
'last_name': user.last_name,
'national_code': self.cleaned_data.get('national_code'),
'phone_number_1': self.cleaned_data.get('phone_number_1'),
'phone_number_2': self.cleaned_data.get('phone_number_2'),
'address': self.cleaned_data.get('address'),
'bank_name': self.cleaned_data.get('bank_name'),
'card_number': self.cleaned_data.get('card_number'),
'account_number': self.cleaned_data.get('account_number'),
})
if commit: if commit:
profile.save() profile.save()
@ -167,18 +142,6 @@ class CustomerForm(forms.ModelForm):
profile.affairs = current_user_profile.affairs profile.affairs = current_user_profile.affairs
profile.county = current_user_profile.county profile.county = current_user_profile.county
profile.broker = current_user_profile.broker profile.broker = current_user_profile.broker
# Set completion flag based on provided form data
profile.is_completed = _compute_completed({
'first_name': user.first_name,
'last_name': user.last_name,
'national_code': self.cleaned_data.get('national_code'),
'phone_number_1': self.cleaned_data.get('phone_number_1'),
'phone_number_2': self.cleaned_data.get('phone_number_2'),
'address': self.cleaned_data.get('address'),
'bank_name': self.cleaned_data.get('bank_name'),
'card_number': self.cleaned_data.get('card_number'),
'account_number': self.cleaned_data.get('account_number'),
})
if commit: if commit:
profile.save() profile.save()

View file

@ -172,19 +172,12 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td class="text-center py-4"> <td colspan="7" class="text-center py-4">
<div class="d-flex flex-column align-items-center"> <div class="d-flex flex-column align-items-center">
<i class="bx bx-user-x bx-lg text-muted mb-2"></i> <i class="bx bx-user-x bx-lg text-muted mb-2"></i>
<span class="text-muted">هیچ کاربری یافت نشد</span> <span class="text-muted">هیچ کاربری یافت نشد</span>
</div> </div>
</td> </td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -16,9 +16,6 @@ layout-wide customizer-hide
{% endblock style %} {% endblock style %}
{% block content %} {% block content %}
{% include '_toasts.html' %}
<div class="container-xxl"> <div class="container-xxl">
<div class="authentication-wrapper authentication-basic container-p-y"> <div class="authentication-wrapper authentication-basic container-p-y">
<div class="authentication-inner"> <div class="authentication-inner">
@ -72,7 +69,7 @@ layout-wide customizer-hide
{% csrf_token %} {% csrf_token %}
<div class="mb-3 fv-plugins-icon-container"> <div class="mb-3 fv-plugins-icon-container">
<label for="email" class="form-label">نام کاربری</label> <label for="email" class="form-label">نام کاربری</label>
<input type="text" class="form-control" id="email" name="username" placeholder="نام کاربری خود را وارد کنید" autofocus=""> <input type="text" class="form-control" id="email" name="username" placeholder="Enter your email or username" autofocus="">
<div class="fv-plugins-message-container fv-plugins-message-container--enabled invalid-feedback"></div></div> <div class="fv-plugins-message-container fv-plugins-message-container--enabled invalid-feedback"></div></div>
<div class="mb-3 form-password-toggle fv-plugins-icon-container"> <div class="mb-3 form-password-toggle fv-plugins-icon-container">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">

View file

@ -4,7 +4,7 @@ from accounts.views import login_view, dashboard, customer_list, add_customer_aj
app_name = "accounts" app_name = "accounts"
urlpatterns = [ urlpatterns = [
path('', login_view, name='login'), path('login/', login_view, name='login'),
path('logout/', logout_view, name='logout'), path('logout/', logout_view, name='logout'),
path('dashboard/', dashboard, name='dashboard'), path('dashboard/', dashboard, name='dashboard'),
path('customers/', customer_list, name='customer_list'), path('customers/', customer_list, name='customer_list'),

View file

@ -8,9 +8,7 @@ from django import forms
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from accounts.models import Profile from accounts.models import Profile
from accounts.forms import CustomerForm from accounts.forms import CustomerForm
from processes.utils import scope_customers_queryset
from common.consts import UserRoles from common.consts import UserRoles
from common.decorators import allowed_roles
# Create your views here. # Create your views here.
@ -19,9 +17,6 @@ def login_view(request):
renders login page and authenticating user POST requests renders login page and authenticating user POST requests
to log user in to log user in
""" """
# If already authenticated, go straight to request list
if request.user.is_authenticated:
return redirect("processes:request_list")
if request.method == "POST": if request.method == "POST":
username = request.POST.get("username") username = request.POST.get("username")
password = request.POST.get("password") password = request.POST.get("password")
@ -40,11 +35,9 @@ def dashboard(request):
@login_required @login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
def customer_list(request): def customer_list(request):
# Get all profiles that have customer role # Get all profiles that have customer role
base = Profile.objects.filter(roles__slug=UserRoles.CUSTOMER.value, is_deleted=False).select_related('user') customers = Profile.objects.filter(roles__slug=UserRoles.CUSTOMER.value, is_deleted=False).select_related('user')
customers = scope_customers_queryset(request.user, base)
form = CustomerForm() form = CustomerForm()
return render(request, "accounts/customer_list.html", { return render(request, "accounts/customer_list.html", {
@ -54,8 +47,6 @@ def customer_list(request):
@require_POST @require_POST
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
def add_customer_ajax(request): def add_customer_ajax(request):
"""AJAX endpoint for adding customers""" """AJAX endpoint for adding customers"""
form = CustomerForm(request.POST, request.FILES) form = CustomerForm(request.POST, request.FILES)
@ -94,8 +85,6 @@ def add_customer_ajax(request):
@require_POST @require_POST
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
def edit_customer_ajax(request, customer_id): def edit_customer_ajax(request, customer_id):
customer = get_object_or_404(Profile, id=customer_id) customer = get_object_or_404(Profile, id=customer_id)
form = CustomerForm(request.POST, request.FILES, instance=customer) form = CustomerForm(request.POST, request.FILES, instance=customer)
@ -133,7 +122,6 @@ def edit_customer_ajax(request, customer_id):
}) })
@require_GET @require_GET
@login_required
def get_customer_data(request, customer_id): def get_customer_data(request, customer_id):
customer = get_object_or_404(Profile, id=customer_id) customer = get_object_or_404(Profile, id=customer_id)
@ -174,7 +162,6 @@ def get_customer_data(request, customer_id):
}) })
@login_required
def logout_view(request): def logout_view(request):
"""Log out current user and redirect to login page.""" """Log out current user and redirect to login page."""
logout(request) logout(request)

View file

@ -1,7 +1,6 @@
from django.db import models from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from common.models import BaseModel from common.models import BaseModel
from _helpers.utils import jalali_converter2
User = get_user_model() User = get_user_model()
@ -36,7 +35,4 @@ class CertificateInstance(BaseModel):
def __str__(self): def __str__(self):
return f"گواهی {self.process_instance.code}" return f"گواهی {self.process_instance.code}"
def jissued_at(self):
return jalali_converter2(self.issued_at)

View file

@ -1,71 +1,28 @@
<!DOCTYPE html> {% extends '_base.html' %}
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>تاییدیه - {{ instance.code }}</title>
{% load static %}
<!-- Fonts (match project) --> {% block content %}
<link rel="preconnect" href="https://fonts.googleapis.com"> <div class="container py-4">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <div class="text-center mb-4">
<link href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap" rel="stylesheet"> {% if template.company and template.company.logo %}
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:90px">
<!-- Core CSS (same as other prints) --> {% endif %}
<link rel="stylesheet" href="{% static 'assets/vendor/css/rtl/core.css' %}"> <h4 class="mt-2">{{ cert.rendered_title }}</h4>
<link rel="stylesheet" href="{% static 'assets/vendor/css/rtl/theme-default.css' %}"> {% if template.company %}<div class="text-muted">{{ template.company.name }}</div>{% endif %}
<link rel="stylesheet" href="{% static 'assets/css/demo.css' %}"> </div>
<link rel="stylesheet" href="{% static 'assets/css/persian-fonts.css' %}"> <div style="white-space:pre-line; line-height:1.9;">
{{ cert.rendered_body|safe }}
<style> </div>
@page { size: A4; margin: 1cm; } <div class="mt-5 d-flex justify-content-between">
@media print { body { print-color-adjust: exact; } .no-print { display: none !important; } } <div>تاریخ: {{ cert.issued_at }}</div>
.header { border-bottom: 1px solid #dee2e6; padding-bottom: 16px; margin-bottom: 24px; } <div class="text-center">
.company-name { font-weight: 600; } {% if template.company and template.company.signature %}
.body-text { white-space: pre-line; line-height: 1.9; } <img src="{{ template.company.signature.url }}" alt="seal" style="max-height:120px">
.signature-section { margin-top: 40px; border-top: 1px solid #dee2e6; padding-top: 24px; }
</style>
</head>
<body>
<div class="container-fluid py-3">
<!-- Top-left request info -->
<div class="d-flex mb-2">
<div class="ms-auto text-end">
<div class="">شماره درخواست: {{ instance.code }}</div>
<div class="">تاریخ: {{ cert.jissued_at }}</div>
</div>
</div>
<!-- Header with logo and company -->
<div class="header text-center">
{% if template.company and template.company.logo %}
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:90px">
{% endif %} {% endif %}
<h4 class="mt-2">{{ cert.rendered_title }}</h4> <div>مهر و امضای شرکت</div>
{% if template.company %}
<div class="text-muted company-name">{{ template.company.name }}</div>
{% endif %}
</div>
<!-- Certificate body -->
<div class="body-text">
{{ cert.rendered_body|safe }}
</div>
<!-- Signature -->
<div class="signature-section d-flex justify-content-end">
<div class="text-center">
<div>مهر و امضای تایید کننده</div>
<div class="text-muted">{{ template.company.name }}</div>
{% if template.company and template.company.signature %}
<img src="{{ template.company.signature.url }}" alt="seal" style="max-height:200px">
{% endif %}
</div>
</div> </div>
</div> </div>
</div>
<script>window.print()</script>
{% endblock %}
<script>
window.onload = function() { window.print(); setTimeout(function(){ window.close(); }, 200); };
</script>
</body>
</html>

View file

@ -18,49 +18,40 @@
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}"> <link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
<!-- Persian Date Picker CSS --> <!-- Persian Date Picker CSS -->
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css"> <link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
<style>
@media print {
.no-print { display: none !important; }
}
</style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
{% csrf_token %} {% csrf_token %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3 no-print">
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
{% instance_info instance %} اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a class="btn btn-outline-secondary" target="_blank" href="{% url 'certificates:certificate_print' instance.id %}"> <a class="btn btn-outline-secondary" target="_blank" href="{% url 'certificates:certificate_print' instance.id %}"><i class="bx bx-printer"></i> پرینت</a>
<i class="bx bx-printer me-2"></i> پرینت
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
</a>
</div> </div>
</div> </div>
<div class="bs-stepper wizard-vertical vertical mt-2"> <div class="bs-stepper wizard-vertical vertical mt-2 no-print">
{% stepper_header instance step %} {% stepper_header instance step %}
<div class="bs-stepper-content"> <div class="bs-stepper-content">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="d-flex mb-2">
<div class="ms-auto text-end">
<div>شماره درخواست: {{ instance.code }}</div>
<div>تاریخ: {{ cert.jissued_at }}</div>
</div>
</div>
<div class="text-center mb-3"> <div class="text-center mb-3">
{% if template.company and template.company.logo %} {% if template.company and template.company.logo %}
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:80px"> <img src="{{ template.company.logo.url }}" alt="logo" style="max-height:80px">
@ -71,22 +62,21 @@
<div class="mt-3" style="white-space:pre-line; line-height:1.9;"> <div class="mt-3" style="white-space:pre-line; line-height:1.9;">
{{ cert.rendered_body|safe }} {{ cert.rendered_body|safe }}
</div> </div>
<div class="signature-section d-flex justify-content-end"> <div class="mt-4 d-flex justify-content-between align-items-end">
<div>
<div>تاریخ صدور: {{ cert.issued_at }}</div>
</div>
<div class="text-center"> <div class="text-center">
<div>مهر و امضای تایید کننده</div>
<div class="text-muted">{{ template.company.name }}</div>
{% if template.company and template.company.signature %} {% if template.company and template.company.signature %}
<img src="{{ template.company.signature.url }}" alt="seal" style="max-height:200px"> <img src="{{ template.company.signature.url }}" alt="seal" style="max-height:100px">
{% endif %} {% endif %}
<div>مهر و امضای شرکت</div>
</div> </div>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between"> <div class="card-footer d-flex justify-content-between">
{% if previous_step %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary"> <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
قبلی
</a>
{% else %}<span></span>{% endif %} {% else %}<span></span>{% endif %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}

View file

@ -12,7 +12,6 @@ from .models import CertificateTemplate, CertificateInstance
from common.consts import UserRoles from common.consts import UserRoles
from _helpers.jalali import Gregorian from _helpers.jalali import Gregorian
from processes.utils import get_scoped_instance_or_404
def _to_jalali(date_obj): def _to_jalali(date_obj):
@ -34,25 +33,23 @@ def _render_template(template: CertificateTemplate, instance: ProcessInstance):
'company_name': (template.company.name if template.company else '') or '', 'company_name': (template.company.name if template.company else '') or '',
'customer_full_name': rep.get_full_name() if rep else '', 'customer_full_name': rep.get_full_name() if rep else '',
'water_subscription_number': getattr(well, 'water_subscription_number', '') or '', 'water_subscription_number': getattr(well, 'water_subscription_number', '') or '',
'address': getattr(well, 'county', '') or '', 'address': getattr(well, 'address', '') or '',
'visit_date_jalali': _to_jalali(getattr(latest_report, 'visited_date', None)) if latest_report else '', 'visit_date_jalali': _to_jalali(getattr(latest_report, 'visited_date', None)) if latest_report else '',
} }
title = (template.title or '').format(**ctx) title = (template.title or '').format(**ctx)
body = (template.body or '') body = (template.body or '')
# Render body placeholders with bold values
for k, v in ctx.items(): for k, v in ctx.items():
body = body.replace(f"{{{{ {k} }}}}", f"<strong>{str(v)}</strong>") body = body.replace(f"{{{{ {k} }}}}", str(v))
return title, body return title, body
@login_required @login_required
def certificate_step(request, instance_id, step_id): def certificate_step(request, instance_id, step_id):
instance = get_scoped_instance_or_404(request, 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)
# Ensure all previous steps are completed and invoice settled # Ensure all previous steps are completed and invoice settled
prior_steps = instance.process.steps.filter(order__lt=instance.current_step.order if instance.current_step else 9999) prior_steps = instance.process.steps.filter(order__lt=instance.current_step.order if instance.current_step else 9999)
incomplete = StepInstance.objects.filter(process_instance=instance, step__in=prior_steps).exclude(status='completed').exists() incomplete = StepInstance.objects.filter(process_instance=instance, step__in=prior_steps).exclude(status='completed').exists()
if incomplete: if incomplete:
messages.error(request, 'ابتدا همه مراحل قبلی را تکمیل کنید') messages.error(request, 'ابتدا همه مراحل قبلی را تکمیل کنید')
return redirect('processes:request_list') return redirect('processes:request_list')
@ -90,17 +87,6 @@ def certificate_step(request, instance_id, step_id):
except Exception: except Exception:
messages.error(request, 'شما مجوز تایید این مرحله را ندارید') messages.error(request, 'شما مجوز تایید این مرحله را ندارید')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
# Safety check: ensure ALL previous steps are completed before approval
try:
prev_steps_qs = instance.process.steps.filter(order__lt=step.order)
has_incomplete = StepInstance.objects.filter(process_instance=instance, step__in=prev_steps_qs).exclude(status='completed').exists()
if has_incomplete:
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()
@ -129,7 +115,7 @@ def certificate_step(request, instance_id, step_id):
@login_required @login_required
def certificate_print(request, instance_id): def certificate_print(request, instance_id):
instance = get_scoped_instance_or_404(request, instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
cert = CertificateInstance.objects.filter(process_instance=instance).order_by('-created').first() cert = CertificateInstance.objects.filter(process_instance=instance).order_by('-created').first()
template = cert.template if cert else None template = cert.template if cert else None
return render(request, 'certificates/print.html', { return render(request, 'certificates/print.html', {

View file

@ -3,7 +3,7 @@ from functools import wraps
from django.http import JsonResponse, HttpResponse from django.http import JsonResponse, HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from common.consts import UserRoles from extensions.consts import UserRoles
def require_ajax(view_func): def require_ajax(view_func):

View file

@ -11,7 +11,6 @@ from .models import ContractTemplate, ContractInstance
from invoices.models import Invoice, Quote from invoices.models import Invoice, Quote
from _helpers.utils import jalali_converter2 from _helpers.utils import jalali_converter2
from django.http import JsonResponse from django.http import JsonResponse
from processes.utils import get_scoped_instance_or_404
def build_contract_context(instance: ProcessInstance) -> dict: def build_contract_context(instance: ProcessInstance) -> dict:
@ -53,7 +52,7 @@ def build_contract_context(instance: ProcessInstance) -> dict:
@login_required @login_required
def contract_step(request, instance_id, step_id): def contract_step(request, instance_id, step_id):
instance = get_scoped_instance_or_404(request, instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
# Resolve step navigation # Resolve step navigation
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()
@ -118,7 +117,7 @@ def contract_step(request, instance_id, step_id):
@login_required @login_required
def contract_print(request, instance_id): def contract_print(request, instance_id):
instance = get_scoped_instance_or_404(request, instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
contract = get_object_or_404(ContractInstance, process_instance=instance) contract = get_object_or_404(ContractInstance, process_instance=instance)
return render(request, 'contracts/contract_print.html', { return render(request, 'contracts/contract_print.html', {
'instance': instance, 'instance': instance,

Binary file not shown.

View file

@ -42,8 +42,8 @@ class InstallationReport(BaseModel):
new_water_meter_serial = models.CharField(max_length=50, null=True, blank=True, verbose_name='سریال کنتور جدید') new_water_meter_serial = models.CharField(max_length=50, null=True, blank=True, verbose_name='سریال کنتور جدید')
seal_number = models.CharField(max_length=50, null=True, blank=True, verbose_name='شماره پلمپ') seal_number = models.CharField(max_length=50, null=True, blank=True, verbose_name='شماره پلمپ')
is_meter_suspicious = models.BooleanField(default=False, verbose_name='کنتور مشکوک است؟') is_meter_suspicious = models.BooleanField(default=False, verbose_name='کنتور مشکوک است؟')
utm_x = models.DecimalField(max_digits=10, decimal_places=0, null=True, blank=True, verbose_name='UTM X') utm_x = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True, verbose_name='UTM X')
utm_y = models.DecimalField(max_digits=10, decimal_places=0, null=True, blank=True, verbose_name='UTM Y') utm_y = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True, verbose_name='UTM Y')
description = models.TextField(blank=True, verbose_name='توضیحات') description = models.TextField(blank=True, verbose_name='توضیحات')
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='ایجادکننده') created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='ایجادکننده')
approved = models.BooleanField(default=False, verbose_name='تایید شده') approved = models.BooleanField(default=False, verbose_name='تایید شده')

View file

@ -22,12 +22,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
@ -35,13 +30,11 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
{% instance_info instance %} اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
</a>
</div> </div>
<div class="bs-stepper wizard-vertical vertical mt-2"> <div class="bs-stepper wizard-vertical vertical mt-2">
@ -71,17 +64,17 @@
</div> </div>
</div> </div>
{% if assignment.assigned_by or assignment.installer %} {% if assignment.assigned_by or assignment.installer %}
<div class="mt-3 alert alert-primary"> <div class="mt-3 border rounded p-3 bg-light">
<div class="row g-2"> <div class="row g-2">
{% if assignment.assigned_by %} {% if assignment.assigned_by %}
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<div class="small text-dark">تعیین‌کننده نصاب</div> <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>{{ assignment.assigned_by.get_full_name|default:assignment.assigned_by.username }} <span class="text-muted">({{ assignment.assigned_by.username }})</span></div>
</div> </div>
{% endif %} {% endif %}
{% if assignment.updated %} {% if assignment.updated %}
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<div class="small text-dark">تاریخ ثبت/ویرایش</div> <div class="small text-muted">تاریخ ثبت/ویرایش</div>
<div>{{ assignment.updated|to_jalali }}</div> <div>{{ assignment.updated|to_jalali }}</div>
</div> </div>
{% endif %} {% endif %}
@ -90,22 +83,14 @@
{% endif %} {% 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 href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
قبلی
</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if is_manager %} {% if is_manager %}
<button class="btn btn-primary" type="submit">ثبت و ادامه <button class="btn btn-primary" type="submit">ثبت و ادامه</button>
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button>
{% else %} {% else %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary"> <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% endif %} {% endif %}
</div> </div>
</form> </form>

View file

@ -35,12 +35,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
@ -48,13 +43,11 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
{% instance_info instance %} اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
</a>
</div> </div>
<div class="bs-stepper wizard-vertical vertical mt-2"> <div class="bs-stepper wizard-vertical vertical mt-2">
@ -62,15 +55,18 @@
<div class="bs-stepper-content"> <div class="bs-stepper-content">
{% if report and not edit_mode %} {% if report and not edit_mode %}
<div class="mb-3 text-end"> <div class="card mb-3 border">
{% if user_is_installer %} <div class="card-header d-flex justify-content-between align-items-center">
<a href="?edit=1" class="btn btn-primary"> <div class="d-flex gap-2">
<i class="bx bx-edit bx-sm me-2"></i> {% if request.user|is_installer %}
ویرایش گزارش نصب <a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a>
</a> {% else %}
{% endif %} <button type="button" class="btn btn-primary" disabled>ویرایش گزارش نصب</button>
</div> {% endif %}
{% if step_instance and step_instance.status == 'rejected' and step_instance.get_latest_rejection %} </div>
</div>
<div class="card-body">
{% if step_instance and step_instance.status == 'rejected' and step_instance.get_latest_rejection %}
<div class="alert alert-danger d-flex align-items-start" role="alert"> <div class="alert alert-danger d-flex align-items-start" role="alert">
<i class="bx bx-error-circle me-2"></i> <i class="bx bx-error-circle me-2"></i>
<div> <div>
@ -79,8 +75,6 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="card mb-3 border">
<div class="card-body">
<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>
@ -157,7 +151,7 @@
<h6 class="mb-0">وضعیت تاییدها</h6> <h6 class="mb-0">وضعیت تاییدها</h6>
{% if user_can_approve %} {% if user_can_approve %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveModal">تایید</button> <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> <button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectModal">رد</button>
</div> </div>
{% endif %} {% endif %}
@ -190,28 +184,18 @@
<!-- 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 %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary"> <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
قبلی
</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary"> <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
{% if not request.user|is_installer %}
{% if not user_is_installer %} <div class="alert alert-warning">شما مجوز ثبت/ویرایش گزارش نصب را ندارید. اطلاعات به صورت فقط خواندنی نمایش داده می‌شود.</div>
<div class="alert alert-warning">شما مجوز ثبت/ویرایش گزارش نصب را ندارید.</div>
{% endif %} {% endif %}
{% if user_is_installer %}
<!-- Installation Report Form -->
<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">
@ -219,40 +203,40 @@
<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="انتخاب تاریخ" {% if not 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="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" value="{% if report and edit_mode %}{{ report.new_water_meter_serial|default_if_none:'' }}{% endif %}" {% if not user_is_installer %}readonly{% endif %}> <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" value="{% if report and edit_mode %}{{ report.seal_number|default_if_none:'' }}{% endif %}" {% if not user_is_installer %}readonly{% endif %}> <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" {% if not user_is_installer %}disabled{% endif %} {% if report and edit_mode and report.is_meter_suspicious %}checked{% endif %}> <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="1" 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 user_is_installer %}readonly{% 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="1" 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 user_is_installer %}readonly{% 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" {% if not user_is_installer %}readonly{% endif %}>{% if report and edit_mode %}{{ report.description|default_if_none:'' }}{% endif %}</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 user_is_installer %} {% 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 %} {% endif %}
</div> </div>
@ -262,7 +246,7 @@
<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">
{% if user_is_installer %} {% 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> <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 %} {% 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">
@ -363,28 +347,24 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</form> </form>
{% endif %}
<div class="mt-3 d-flex justify-content-between"> <div class="mt-3 d-flex justify-content-between">
{% if previous_step %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary"> <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
قبلی
</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if user_is_installer %} {% if request.user|is_installer %}
<button type="submit" class="btn btn-success" 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 %} {% endif %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary"> <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-success">بعدی</a>
بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -519,6 +499,7 @@
try { try {
if (sessionStorage.getItem('install_report_saved') === '1') { if (sessionStorage.getItem('install_report_saved') === '1') {
sessionStorage.removeItem('install_report_saved'); sessionStorage.removeItem('install_report_saved');
showToast('گزارش نصب با موفقیت ثبت شد', 'success');
} }
} catch(_) {} } catch(_) {}
})(); })();

View file

@ -10,17 +10,16 @@ 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
from processes.utils import get_scoped_instance_or_404
@login_required @login_required
def installation_assign_step(request, instance_id, step_id): def installation_assign_step(request, instance_id, step_id):
instance = get_scoped_instance_or_404(request, 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)
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()
# Installers list (profiles that have installer role) # Installers list (profiles that have installer role)
installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value, county=instance.well.county).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 # Role flags
@ -73,56 +72,17 @@ def installation_assign_step(request, instance_id, step_id):
}) })
def create_item_changes_for_report(report, remove_map, add_map, quote_price_map):
"""Helper function to create item changes for a report"""
# Create remove changes
for item_id, qty in remove_map.items():
up = quote_price_map.get(item_id)
total = (up * qty) if up is not None else None
InstallationItemChange.objects.create(
report=report,
item_id=item_id,
change_type='remove',
quantity=qty,
unit_price=up,
total_price=total,
)
# Create add changes
for item_id, data in add_map.items():
unit_price = data.get('price')
qty = data.get('qty') or 1
total = (unit_price * qty) if (unit_price is not None) else None
InstallationItemChange.objects.create(
report=report,
item_id=item_id,
change_type='add',
quantity=qty,
unit_price=unit_price,
total_price=total,
)
@login_required @login_required
def installation_report_step(request, instance_id, step_id): def installation_report_step(request, instance_id, step_id):
instance = get_scoped_instance_or_404(request, 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)
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()
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()
# Only installers can enter edit mode
# Only the assigned installer can create/edit the report user_is_installer = hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.INSTALLER)
try:
has_installer_role = bool(getattr(request.user, 'profile', None) and request.user.profile.has_role(UserRoles.INSTALLER))
except Exception:
has_installer_role = False
is_assigned_installer = bool(assignment and assignment.installer_id == request.user.id)
user_is_installer = bool(has_installer_role and is_assigned_installer)
edit_mode = True if (request.GET.get('edit') == '1' and user_is_installer) else False 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 []
@ -140,14 +100,7 @@ def installation_report_step(request, instance_id, step_id):
reqs = list(step.approver_requirements.select_related('role').all()) reqs = list(step.approver_requirements.select_related('role').all())
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None) 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_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
# Align permission check with invoices flow (role id intersection) user_can_approve = any(r.role in user_roles for r in reqs)
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
user_can_approve = can_approve_reject
approvals_list = list(step_instance.approvals.select_related('role').all()) approvals_list = list(step_instance.approvals.select_related('role').all())
approvals_by_role = {a.role_id: a for a in approvals_list} approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [ approver_statuses = [
@ -207,13 +160,6 @@ def installation_report_step(request, instance_id, step_id):
StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason) StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
existing_report.approved = False existing_report.approved = False
existing_report.save() existing_report.save()
# If current step moved ahead of this step, reset it back for correction (align with invoices)
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
messages.success(request, 'گزارش رد شد و برای اصلاح به نصاب بازگشت.') messages.success(request, 'گزارش رد شد و برای اصلاح به نصاب بازگشت.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
@ -231,21 +177,6 @@ def installation_report_step(request, instance_id, step_id):
is_suspicious = True if request.POST.get('is_meter_suspicious') == 'on' else False is_suspicious = True if request.POST.get('is_meter_suspicious') == 'on' else False
utm_x = request.POST.get('utm_x') or None utm_x = request.POST.get('utm_x') or None
utm_y = request.POST.get('utm_y') or None utm_y = request.POST.get('utm_y') or None
# Normalize UTM to integer meters
if utm_x is not None and utm_x != '':
try:
utm_x = int(Decimal(str(utm_x)))
except InvalidOperation:
utm_x = None
else:
utm_x = None
if utm_y is not None and utm_y != '':
try:
utm_y = int(Decimal(str(utm_y)))
except InvalidOperation:
utm_y = None
else:
utm_y = None
# Build maps from form fields: remove and add # Build maps from form fields: remove and add
remove_map = {} remove_map = {}
@ -290,6 +221,8 @@ def installation_report_step(request, instance_id, step_id):
unit_price = item_obj.unit_price if item_obj else None unit_price = item_obj.unit_price if item_obj else None
add_map[item_id] = {'qty': qty, 'price': unit_price} add_map[item_id] = {'qty': qty, 'price': unit_price}
# اجازهٔ ثبت همزمان حذف و افزودن برای یک قلم (بدون محدودیت و ادغام)
if existing_report and edit_mode: if existing_report and edit_mode:
report = existing_report report = existing_report
report.description = description report.description = description
@ -314,7 +247,29 @@ def installation_report_step(request, instance_id, step_id):
InstallationPhoto.objects.create(report=report, image=f) InstallationPhoto.objects.create(report=report, image=f)
# replace item changes with new submission # replace item changes with new submission
report.item_changes.all().delete() report.item_changes.all().delete()
create_item_changes_for_report(report, remove_map, add_map, quote_price_map) for item_id, qty in remove_map.items():
up = quote_price_map.get(item_id)
total = (up * qty) if up is not None else None
InstallationItemChange.objects.create(
report=report,
item_id=item_id,
change_type='remove',
quantity=qty,
unit_price=up,
total_price=total,
)
for item_id, data in add_map.items():
unit_price = data.get('price')
qty = data.get('qty') or 1
total = (unit_price * qty) if (unit_price is not None) else None
InstallationItemChange.objects.create(
report=report,
item_id=item_id,
change_type='add',
quantity=qty,
unit_price=unit_price,
total_price=total,
)
else: else:
report = InstallationReport.objects.create( report = InstallationReport.objects.create(
assignment=assignment, assignment=assignment,
@ -331,7 +286,29 @@ def installation_report_step(request, instance_id, step_id):
for f in request.FILES.getlist('photos'): for f in request.FILES.getlist('photos'):
InstallationPhoto.objects.create(report=report, image=f) InstallationPhoto.objects.create(report=report, image=f)
# item changes # item changes
create_item_changes_for_report(report, remove_map, add_map, quote_price_map) for item_id, qty in remove_map.items():
up = quote_price_map.get(item_id)
total = (up * qty) if up is not None else None
InstallationItemChange.objects.create(
report=report,
item_id=item_id,
change_type='remove',
quantity=qty,
unit_price=up,
total_price=total,
)
for item_id, data in add_map.items():
unit_price = data.get('price')
qty = data.get('qty') or 1
total = (unit_price * qty) if (unit_price is not None) else None
InstallationItemChange.objects.create(
report=report,
item_id=item_id,
change_type='add',
quantity=qty,
unit_price=unit_price,
total_price=total,
)
# After installer submits/edits, set step back to in_progress and clear approvals # After installer submits/edits, set step back to in_progress and clear approvals
step_instance.status = 'in_progress' step_instance.status = 'in_progress'
@ -342,33 +319,6 @@ def installation_report_step(request, instance_id, step_id):
except Exception: except Exception:
pass pass
# If the report was edited, ensure downstream steps reopen like invoices flow
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance and subsequent_step_instance.status == 'completed':
# Reopen the step
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
# Clear previous approvals if any
try:
subsequent_step_instance.approvals.all().delete()
except Exception:
pass
except Exception:
pass
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
messages.success(request, 'گزارش ثبت شد و در انتظار تایید است.') messages.success(request, 'گزارش ثبت شد و در انتظار تایید است.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
@ -390,7 +340,6 @@ def installation_report_step(request, instance_id, step_id):
'assignment': assignment, 'assignment': assignment,
'report': existing_report, 'report': existing_report,
'edit_mode': edit_mode, 'edit_mode': edit_mode,
'user_is_installer': user_is_installer,
'quote': quote, 'quote': quote,
'quote_items': quote_items, 'quote_items': quote_items,
'all_items': items, 'all_items': items,
@ -402,7 +351,6 @@ def installation_report_step(request, instance_id, step_id):
'step_instance': step_instance, 'step_instance': step_instance,
'approver_statuses': approver_statuses, 'approver_statuses': approver_statuses,
'user_can_approve': user_can_approve, 'user_can_approve': user_can_approve,
'can_approve_reject': can_approve_reject,
}) })

View file

@ -1,206 +1,55 @@
<!DOCTYPE html> {% extends '_base.html' %}
<html lang="fa" dir="rtl"> {% load humanize %}
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>فاکتور نهایی {{ invoice.name }} - {{ instance.code }}</title>
{% load static %}
{% load humanize %}
<!-- Fonts (match base) --> {% block content %}
<link rel="preconnect" href="https://fonts.googleapis.com"> <div class="container py-4">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <div class="mb-4 d-flex justify-content-between align-items-center">
<link href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap" rel="stylesheet"> <div>
<h4 class="mb-1">فاکتور نهایی</h4>
<!-- Icons (optional) --> <small class="text-muted">کد درخواست: {{ instance.code }}</small>
<link rel="stylesheet" href="{% static 'assets/vendor/fonts/boxicons.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/fonts/fontawesome.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/fonts/flag-icons.css' %}">
<!-- Core CSS (same as preview) -->
<link rel="stylesheet" href="{% static 'assets/vendor/css/rtl/core.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/css/rtl/theme-default.css' %}">
<link rel="stylesheet" href="{% static 'assets/css/demo.css' %}">
<link rel="stylesheet" href="{% static 'assets/css/persian-fonts.css' %}">
<style>
@page {
size: A4;
margin: 1cm;
}
@media print {
body { print-color-adjust: exact; }
.page-break { page-break-before: always; }
.no-print { display: none !important; }
}
.invoice-header { border-bottom: 1px solid #dee2e6; padding-bottom: 20px; margin-bottom: 30px; }
.company-logo { font-size: 24px; font-weight: bold; color: #696cff; }
.invoice-title { font-size: 28px; font-weight: bold; color: #333; }
.info-table td { padding: 5px 10px; border: none; }
.items-table { border: 1px solid #dee2e6; }
.items-table th { background-color: #f8f9fa; border-bottom: 2px solid #dee2e6; font-weight: bold; text-align: center; }
.items-table td { border-bottom: 1px solid #dee2e6; text-align: center; }
.total-section { background-color: #f8f9fa; font-weight: bold; }
.signature-section { margin-top: 50px; border-top: 1px solid #dee2e6; padding-top: 30px; }
.signature-box { border: 1px dashed #ccc; height: 80px; text-align: center; display: flex; align-items: center; justify-content: center; color: #666; }
</style>
</head>
<body>
<div class="container-fluid">
<!-- Header -->
<div class="invoice-header">
<div class="row align-items-center">
<div class="col-6 d-flex align-items-center">
<div class="me-3" style="width:64px;height:64px;display:flex;align-items:center;justify-content:center;background:#eef2ff;border-radius:8px;">
{% if instance.broker.company and instance.broker.company.logo %}
<img src="{{ instance.broker.company.logo.url }}" alt="لوگو" style="max-height:58px;max-width:120px;">
{% else %}
<span class="company-logo">شرکت</span>
{% endif %}
</div>
<div>
{% if instance.broker.company %}
{{ instance.broker.company.name }}
{% endif %}
{% if instance.broker.company %}
<div class="text-muted small">
{% if instance.broker.company.address %}
<div>{{ instance.broker.company.address }}</div>
{% endif %}
{% if instance.broker.affairs.county.city.name %}
<div>{{ instance.broker.affairs.county.city.name }}، ایران</div>
{% endif %}
{% if instance.broker.company.phone %}
<div>تلفن: {{ instance.broker.company.phone }}</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div class="col-6 text-end">
<div class="mt-2">
<div><strong>#فاکتور نهایی {{ instance.code }}</strong></div>
<div class="text-muted small">تاریخ صدور: {{ invoice.jcreated_date }}</div>
</div>
</div>
</div>
</div> </div>
<div>
<!-- Customer & Well Info --> <!-- Placeholders for logo/signature -->
<div class="row mb-3"> <div class="text-end">لوگو</div>
<div class="col-6">
<h6 class="fw-bold mb-2">اطلاعات مشترک</h6>
<div class="small mb-1"><span class="text-muted">نام:</span> {{ invoice.customer.get_full_name|default:instance.representative.get_full_name }}</div>
{% if instance.representative.profile and instance.representative.profile.national_code %}
<div class="small mb-1"><span class="text-muted">کد ملی:</span> {{ instance.representative.profile.national_code }}</div>
{% endif %}
{% if instance.representative.profile and instance.representative.profile.phone_number_1 %}
<div class="small mb-1"><span class="text-muted">تلفن:</span> {{ instance.representative.profile.phone_number_1 }}</div>
{% endif %}
{% if instance.representative.profile and instance.representative.profile.address %}
<div class="small"><span class="text-muted">آدرس:</span> {{ instance.representative.profile.address }}</div>
{% endif %}
</div>
<div class="col-6">
<h6 class="fw-bold mb-2">اطلاعات چاه</h6>
<div class="small mb-1"><span class="text-muted">شماره اشتراک آب:</span> {{ instance.well.water_subscription_number }}</div>
<div class="small mb-1"><span class="text-muted">شماره اشتراک برق:</span> {{ instance.well.electricity_subscription_number|default:"-" }}</div>
<div class="small mb-1"><span class="text-muted">سریال کنتور:</span> {{ instance.well.water_meter_serial_number|default:"-" }}</div>
<div class="small"><span class="text-muted">قدرت چاه:</span> {{ instance.well.well_power|default:"-" }}</div>
</div>
</div> </div>
<!-- Items Table -->
<div class="mb-4">
<table class="table border-top m-0 items-table">
<thead>
<tr>
<th style="width: 5%">ردیف</th>
<th style="width: 30%">شرح کالا/خدمات</th>
<th style="width: 30%">توضیحات</th>
<th style="width: 10%">تعداد</th>
<th style="width: 12.5%">قیمت واحد(تومان)</th>
<th style="width: 12.5%">قیمت کل(تومان)</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr>
<td>{{ forloop.counter }}</td>
<td class="text-nowrap">{{ it.item.name }}</td>
<td class="text-nowrap">{{ it.item.description|default:"-" }}</td>
<td>{{ it.quantity }}</td>
<td>{{ it.unit_price|floatformat:0|intcomma:False }}</td>
<td>{{ it.total_price|floatformat:0|intcomma:False }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted">آیتمی ندارد</td></tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>جمع کل(تومان):</strong></td>
<td><strong>{{ invoice.total_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
{% if invoice.discount_amount > 0 %}
<tr class="total-section">
<td colspan="5" class="text-end"><strong>تخفیف(تومان):</strong></td>
<td><strong>{{ invoice.discount_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
{% endif %}
<tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی(تومان):</strong></td>
<td><strong>{{ invoice.final_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>پرداختی‌ها(تومان):</strong></td>
<td><strong">{{ invoice.paid_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>مانده(تومان):</strong></td>
<td><strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
</tfoot>
</table>
</div>
<!-- Conditions & Payment -->
<div class="row">
<div class="col-8">
<h6 class="fw-bold">مهر و امضا:</h6>
<ul class="small mb-0">
{% if instance.broker.company and instance.broker.company.signature %}
<li class="mt-3" style="list-style:none;"><img src="{{ instance.broker.company.signature.url }}" alt="امضا" style="height: 200px;"></li>
{% endif %}
</ul>
</div>
{% if instance.broker.company %}
<div class="col-4">
<h6 class="fw-bold mb-2">اطلاعات پرداخت</h6>
{% if instance.broker.company.card_number %}
<div class="small mb-1"><span class="text-muted">شماره کارت:</span> {{ instance.broker.company.card_number }}</div>
{% endif %}
{% if instance.broker.company.account_number %}
<div class="small mb-1"><span class="text-muted">شماره حساب:</span> {{ instance.broker.company.account_number }}</div>
{% endif %}
{% if instance.broker.company.sheba_number %}
<div class="small mb-1"><span class="text-muted">شماره شبا:</span> {{ instance.broker.company.sheba_number }}</div>
{% endif %}
{% if instance.broker.company.bank_name %}
<div class="small"><span class="text-muted">بانک:</span> {{ instance.broker.company.get_bank_name_display }}</div>
{% endif %}
</div>
{% endif %}
</div>
</div> </div>
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>آیتم</th>
<th>تعداد</th>
<th>قیمت واحد</th>
<th>قیمت کل</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr>
<td>{{ it.item.name }}</td>
<td>{{ it.quantity }}</td>
<td>{{ it.unit_price|floatformat:0|intcomma:False }}</td>
<td>{{ it.total_price|floatformat:0|intcomma:False }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="text-center text-muted">آیتمی ندارد</td></tr>
{% endfor %}
</tbody>
<tfoot>
<tr><th colspan="3" class="text-end">مبلغ کل</th><th>{{ invoice.total_amount|floatformat:0|intcomma:False }}</th></tr>
<tr><th colspan="3" class="text-end">تخفیف</th><th>{{ invoice.discount_amount|floatformat:0|intcomma:False }}</th></tr>
<tr><th colspan="3" class="text-end">مبلغ نهایی</th><th>{{ invoice.final_amount|floatformat:0|intcomma:False }}</th></tr>
<tr><th colspan="3" class="text-end">پرداختی‌ها</th><th>{{ invoice.paid_amount|floatformat:0|intcomma:False }}</th></tr>
<tr><th colspan="3" class="text-end">مانده</th><th>{{ invoice.remaining_amount|floatformat:0|intcomma:False }}</th></tr>
</tfoot>
</table>
</div>
<div class="mt-5 d-flex justify-content-between">
<div>امضا مشتری</div>
<div>امضا شرکت</div>
</div>
</div>
<script>window.print()</script>
{% endblock %}
<script>
window.onload = function() {
window.print();
setTimeout(function(){ window.close(); }, 200);
};
</script>
</body>
</html>

View file

@ -24,10 +24,6 @@
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
{% csrf_token %} {% csrf_token %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
@ -36,18 +32,14 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
{% instance_info instance %} اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"> <a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت</a>
<i class="bx bx-printer me-2"></i> پرینت
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
</a>
</div> </div>
</div> </div>
@ -171,24 +163,15 @@
</div> </div>
<div class="card-footer d-flex justify-content-between"> <div class="card-footer d-flex justify-content-between">
{% if previous_step %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary"> <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
قبلی
</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if next_step %} {% if next_step %}
{% if is_manager %} {% if is_manager %}
<button type="button" class="btn btn-primary" id="btnApproveFinalInvoice"> <button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button>
تایید و ادامه
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button>
{% else %} {% else %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary"> <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>

View file

@ -23,10 +23,6 @@
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
{% csrf_token %} {% csrf_token %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
@ -35,18 +31,14 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
{% instance_info instance %} اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"> <a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت</a>
<i class="bx bx-printer me-2"></i> پرینت
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
</a>
</div> </div>
</div> </div>
@ -96,7 +88,7 @@
<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">
<button type="button" id="btnAddFinalPayment" class="btn btn-primary">افزودن فیش/چک</button> <button type="button" id="btnAddFinalPayment" class="btn btn-primary">افزودن</button>
</div> </div>
</form> </form>
</div> </div>
@ -190,7 +182,7 @@
<h6 class="mb-0">وضعیت تاییدها</h6> <h6 class="mb-0">وضعیت تاییدها</h6>
{% if can_approve_reject %} {% if can_approve_reject %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveFinalSettleModal">تایید</button> <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> <button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
</div> </div>
{% endif %} {% endif %}
@ -222,19 +214,13 @@
{% endif %} {% 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 %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary"> <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
قبلی
</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% 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 %}" class="btn btn-primary"> <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% else %} {% else %}
<a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a> <a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
{% endif %} {% endif %}
@ -278,8 +264,8 @@
<div class="modal-body"> <div class="modal-body">
{% if invoice.remaining_amount != 0 %} {% if invoice.remaining_amount != 0 %}
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
مانده فاکتور: <strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br> مانده فاکتور صفر نیست: <strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br>
امکان تایید تا تسویه کامل فاکتور وجود ندارد. تا صفر نشود امکان تایید نیست.
</div> </div>
{% else %} {% else %}
آیا از تایید این مرحله اطمینان دارید؟ آیا از تایید این مرحله اطمینان دارید؟

View file

@ -31,4 +31,5 @@ urlpatterns = [
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/', views.final_settlement_step, name='final_settlement_step'), path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/', views.final_settlement_step, name='final_settlement_step'),
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/add/', views.add_final_payment, name='add_final_payment'), path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/add/', views.add_final_payment, name='add_final_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/<int:payment_id>/delete/', views.delete_final_payment, name='delete_final_payment'), path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/<int:payment_id>/delete/', views.delete_final_payment, name='delete_final_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/approve/', views.approve_final_settlement, name='approve_final_settlement'),
] ]

View file

@ -12,17 +12,13 @@ import json
from processes.models import ProcessInstance, ProcessStep, StepInstance, StepRejection, StepApproval from processes.models import ProcessInstance, ProcessStep, StepInstance, StepRejection, StepApproval
from accounts.models import Role from accounts.models import Role
from common.consts import UserRoles from common.consts import UserRoles
from .models import Item, Quote, QuoteItem, Payment, Invoice, InvoiceItem from .models import Item, Quote, QuoteItem, Payment, Invoice
from installations.models import InstallationReport, InstallationItemChange from installations.models import InstallationReport, InstallationItemChange
from processes.utils import get_scoped_instance_or_404
@login_required @login_required
def quote_step(request, instance_id, step_id): def quote_step(request, instance_id, step_id):
"""مرحله انتخاب اقلام و ساخت پیش‌فاکتور""" """مرحله انتخاب اقلام و ساخت پیش‌فاکتور"""
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
# Enforce scoped access to prevent URL tampering
instance = get_object_or_404( instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'), ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id id=instance_id
@ -72,7 +68,7 @@ def quote_step(request, instance_id, step_id):
@login_required @login_required
def create_quote(request, instance_id, step_id): def create_quote(request, instance_id, step_id):
"""ساخت/بروزرسانی پیش‌فاکتور از اقلام انتخابی""" """ساخت/بروزرسانی پیش‌فاکتور از اقلام انتخابی"""
instance = get_scoped_instance_or_404(request, 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 # enforce permission: only BROKER can create/update quote
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
@ -223,9 +219,6 @@ def create_quote(request, instance_id, step_id):
@login_required @login_required
def quote_preview_step(request, instance_id, step_id): def quote_preview_step(request, instance_id, step_id):
"""مرحله صدور پیش‌فاکتور - نمایش و تایید فاکتور""" """مرحله صدور پیش‌فاکتور - نمایش و تایید فاکتور"""
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
instance = get_object_or_404( instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile', 'broker', 'broker__company', 'broker__affairs', 'broker__affairs__county', 'broker__affairs__county__city'), ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile', 'broker', 'broker__company', 'broker__affairs', 'broker__affairs__county', 'broker__affairs__county__city'),
id=instance_id id=instance_id
@ -268,7 +261,7 @@ def quote_preview_step(request, instance_id, step_id):
@login_required @login_required
def quote_print(request, instance_id): def quote_print(request, instance_id):
"""صفحه پرینت پیش‌فاکتور""" """صفحه پرینت پیش‌فاکتور"""
instance = get_scoped_instance_or_404(request, instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
quote = get_object_or_404(Quote, process_instance=instance) quote = get_object_or_404(Quote, process_instance=instance)
return render(request, 'invoices/quote_print.html', { return render(request, 'invoices/quote_print.html', {
@ -281,7 +274,7 @@ def quote_print(request, instance_id):
@login_required @login_required
def approve_quote(request, instance_id, step_id): def approve_quote(request, instance_id, step_id):
"""تایید پیش‌فاکتور و انتقال به مرحله بعدی""" """تایید پیش‌فاکتور و انتقال به مرحله بعدی"""
instance = get_scoped_instance_or_404(request, 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 # enforce permission: only BROKER can approve
@ -323,9 +316,6 @@ def approve_quote(request, instance_id, step_id):
@login_required @login_required
def quote_payment_step(request, instance_id, step_id): def quote_payment_step(request, instance_id, step_id):
"""مرحله سوم: ثبت فیش‌های واریزی پیش‌فاکتور""" """مرحله سوم: ثبت فیش‌های واریزی پیش‌فاکتور"""
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
instance = get_object_or_404( instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'), ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id id=instance_id
@ -418,7 +408,7 @@ def quote_payment_step(request, instance_id, step_id):
defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason} defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
) )
StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason) StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
# If current step is ahead of this step, reset it back to this step # If current step is ahead of this step, reset it back to this step
try: try:
if instance.current_step and instance.current_step.order > step.order: if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step instance.current_step = step
@ -459,7 +449,7 @@ def quote_payment_step(request, instance_id, step_id):
@login_required @login_required
def add_quote_payment(request, instance_id, step_id): def add_quote_payment(request, instance_id, step_id):
"""افزودن فیش واریزی جدید برای پیش‌فاکتور""" """افزودن فیش واریزی جدید برای پیش‌فاکتور"""
instance = get_scoped_instance_or_404(request, 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)
invoice, _ = Invoice.objects.get_or_create( invoice, _ = Invoice.objects.get_or_create(
@ -574,7 +564,7 @@ def add_quote_payment(request, instance_id, step_id):
@require_POST @require_POST
@login_required @login_required
def delete_quote_payment(request, instance_id, step_id, payment_id): def delete_quote_payment(request, instance_id, step_id, payment_id):
instance = get_scoped_instance_or_404(request, 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)
invoice = Invoice.objects.filter(quote=quote).first() invoice = Invoice.objects.filter(quote=quote).first()
@ -642,9 +632,6 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
@login_required @login_required
def final_invoice_step(request, instance_id, step_id): def final_invoice_step(request, instance_id, step_id):
"""تجمیع اقلام پیش‌فاکتور با تغییرات نصب و صدور فاکتور نهایی""" """تجمیع اقلام پیش‌فاکتور با تغییرات نصب و صدور فاکتور نهایی"""
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
instance = get_object_or_404( instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'), ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id id=instance_id
@ -783,7 +770,7 @@ def final_invoice_step(request, instance_id, step_id):
@login_required @login_required
def final_invoice_print(request, instance_id): def final_invoice_print(request, instance_id):
instance = get_scoped_instance_or_404(request, 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)
items = invoice.items.select_related('item').filter(is_deleted=False).all() items = invoice.items.select_related('item').filter(is_deleted=False).all()
return render(request, 'invoices/final_invoice_print.html', { return render(request, 'invoices/final_invoice_print.html', {
@ -796,7 +783,7 @@ def final_invoice_print(request, instance_id):
@require_POST @require_POST
@login_required @login_required
def approve_final_invoice(request, instance_id, step_id): def approve_final_invoice(request, instance_id, step_id):
instance = get_scoped_instance_or_404(request, 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 # only MANAGER can approve
@ -805,7 +792,14 @@ def approve_final_invoice(request, instance_id, step_id):
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
# Block approval when there is any remaining (positive or negative)
invoice.calculate_totals()
# if invoice.remaining_amount != 0:
# return JsonResponse({
# 'success': False,
# 'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})"
# })
# mark step completed
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
step_instance.status = 'completed' step_instance.status = 'completed'
step_instance.completed_at = timezone.now() step_instance.completed_at = timezone.now()
@ -824,7 +818,7 @@ def approve_final_invoice(request, instance_id, step_id):
@login_required @login_required
def add_special_charge(request, instance_id, step_id): def add_special_charge(request, instance_id, step_id):
"""افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی به‌صورت آیتم جداگانه""" """افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی به‌صورت آیتم جداگانه"""
instance = get_scoped_instance_or_404(request, 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 # only MANAGER can add special charges
try: try:
@ -832,7 +826,7 @@ def add_special_charge(request, instance_id, step_id):
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403)
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403)
# charge_type was removed from UI; we no longer require it
item_id = request.POST.get('item_id') item_id = request.POST.get('item_id')
amount = (request.POST.get('amount') or '').strip() amount = (request.POST.get('amount') or '').strip()
if not item_id: if not item_id:
@ -847,7 +841,7 @@ def add_special_charge(request, instance_id, step_id):
# Fetch existing special item from DB # Fetch existing special item from DB
special_item = get_object_or_404(Item, id=item_id, is_special=True) special_item = get_object_or_404(Item, id=item_id, is_special=True)
from .models import InvoiceItem
InvoiceItem.objects.create( InvoiceItem.objects.create(
invoice=invoice, invoice=invoice,
item=special_item, item=special_item,
@ -861,7 +855,7 @@ def add_special_charge(request, instance_id, step_id):
@require_POST @require_POST
@login_required @login_required
def delete_special_charge(request, instance_id, step_id, item_id): def delete_special_charge(request, instance_id, step_id, item_id):
instance = get_scoped_instance_or_404(request, 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 # only MANAGER can delete special charges
try: try:
@ -869,6 +863,7 @@ def delete_special_charge(request, instance_id, step_id, item_id):
return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403)
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403)
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
try: try:
@ -883,9 +878,8 @@ def delete_special_charge(request, instance_id, step_id, item_id):
@login_required @login_required
def final_settlement_step(request, instance_id, step_id): def final_settlement_step(request, instance_id, step_id):
instance = get_scoped_instance_or_404(request, 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)
if not instance.can_access_step(step): if not instance.can_access_step(step):
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.') messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
return redirect('processes:request_list') return redirect('processes:request_list')
@ -896,7 +890,6 @@ def final_settlement_step(request, instance_id, step_id):
# Ensure step instance exists # Ensure step instance exists
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'}) step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
# Build approver statuses for template # Build approver statuses for template
reqs = list(step.approver_requirements.select_related('role').all()) 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()} approvals_map = {a.role_id: a.decision for a in step_instance.approvals.select_related('role').all()}
@ -954,13 +947,6 @@ def final_settlement_step(request, instance_id, step_id):
defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason} defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
) )
StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason) StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
# If current step is ahead of this step, reset it back to this step (align behavior with other steps)
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
messages.success(request, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.') messages.success(request, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id) return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
@ -989,7 +975,7 @@ def final_settlement_step(request, instance_id, step_id):
@require_POST @require_POST
@login_required @login_required
def add_final_payment(request, instance_id, step_id): def add_final_payment(request, instance_id, step_id):
instance = get_scoped_instance_or_404(request, 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 BROKER can add final settlement payments # Only BROKER can add final settlement payments
@ -998,7 +984,6 @@ def add_final_payment(request, instance_id, step_id):
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403)
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403) 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()
@ -1053,14 +1038,12 @@ 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
# On delete, return to awaiting approval
try: try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress' si.status = 'in_progress'
si.completed_at = None si.completed_at = None
si.save() si.save()
si.approvals.all().delete()
except Exception: except Exception:
pass pass
@ -1082,16 +1065,6 @@ def add_final_payment(request, instance_id, step_id):
pass pass
except Exception: except Exception:
pass pass
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
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]),
@ -1106,7 +1079,7 @@ def add_final_payment(request, instance_id, step_id):
@require_POST @require_POST
@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_scoped_instance_or_404(request, 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)
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
@ -1118,47 +1091,44 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403) 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
# On delete, return to awaiting approval
try: try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress' si.status = 'in_progress'
si.completed_at = None si.completed_at = None
si.save() si.save()
si.approvals.all().delete()
except Exception: except Exception:
pass pass
# Reset ALL subsequent completed steps to in_progress
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance and subsequent_step_instance.status == 'completed':
# Bypass validation by using update() instead of save()
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
except Exception:
pass
except Exception:
pass
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
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),
'remaining_amount': str(invoice.remaining_amount), 'remaining_amount': str(invoice.remaining_amount),
}}) }})
@require_POST
@login_required
def approve_final_settlement(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance)
# Block approval if any remaining exists (positive or negative)
invoice.calculate_totals()
if invoice.remaining_amount != 0:
return JsonResponse({
'success': False,
'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})"
})
# complete step
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
# move next
next_step = instance.process.steps.filter(order__gt=step.order).first()
redirect_url = reverse('processes:request_list')
if next_step:
instance.current_step = next_step
instance.save()
redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
return JsonResponse({'success': True, 'message': 'تسویه حساب نهایی ثبت شد', 'redirect': redirect_url})

View file

@ -11,7 +11,7 @@ class City(NameSlugModel):
return self.name return self.name
class County(NameSlugModel): class County(NameSlugModel):
city = models.ForeignKey(City, on_delete=models.CASCADE, verbose_name="استان") city = models.ForeignKey(City, on_delete=models.CASCADE, verbose_name="شهرستان")
class Meta: class Meta:
verbose_name = "شهرستان" verbose_name = "شهرستان"

View file

@ -143,9 +143,9 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
@admin.register(StepInstance) @admin.register(StepInstance)
class StepInstanceAdmin(SimpleHistoryAdmin): class StepInstanceAdmin(SimpleHistoryAdmin):
list_display = ['process_instance', 'process_instance__code', 'step', 'assigned_to', 'status_display', 'rejection_count', 'edit_count', 'started_at', 'completed_at'] list_display = ['process_instance', 'step', 'assigned_to', 'status_display', 'rejection_count', 'edit_count', 'started_at', 'completed_at']
list_filter = ['status', 'step__process', 'started_at'] list_filter = ['status', 'step__process', 'started_at']
search_fields = ['process_instance__name', 'process_instance__code', 'step__name', 'assigned_to__username'] search_fields = ['process_instance__name', 'step__name', 'assigned_to__username']
readonly_fields = ['started_at', 'completed_at'] readonly_fields = ['started_at', 'completed_at']
ordering = ['process_instance', 'step__order'] ordering = ['process_instance', 'step__order']

View file

@ -1,8 +1,7 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static %} {% load static %}
{% load humanize %} {% load humanize %}
{% load common_tags %} {% load common_tags %}
{% load processes_tags %}
{% block sidebar %} {% block sidebar %}
{% include 'sidebars/admin.html' %} {% include 'sidebars/admin.html' %}
@ -16,34 +15,23 @@
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<div> <div>
<h4 class="mb-1">گزارش نهایی درخواست {{ instance.code }}</h4> <h4 class="mb-1">گزارش نهایی درخواست {{ instance.code }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
{% instance_info instance %} اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if invoice %} {% if invoice %}
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"> <a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت فاکتور</a>
<i class="bx bx-printer me-2"></i> پرینت فاکتور
</a>
{% endif %} {% endif %}
<a href="{% url 'certificates:certificate_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"> <a href="{% url 'certificates:certificate_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت گواهی</a>
<i class="bx bx-printer me-2"></i> پرینت گواهی <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
</a>
</div> </div>
</div> </div>

View file

@ -1,6 +1,5 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static %} {% load static %}
{% load accounts_tags %}
{% block sidebar %} {% block sidebar %}
{% include 'sidebars/admin.html' %} {% include 'sidebars/admin.html' %}
@ -44,12 +43,10 @@
</span> </span>
</span> </span>
</button> </button>
{% if request.user|is_broker %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#requestModal"> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#requestModal">
<i class="bx bx-plus me-1"></i> <i class="bx bx-plus me-1"></i>
درخواست جدید درخواست جدید
</button> </button>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -135,69 +132,6 @@
</div> </div>
</div> </div>
{% if access_denied %}
<div class="alert alert-warning d-flex align-items-center mb-3" role="alert">
<i class="bx bx-info-circle me-2"></i>
<div>شما به این بخش دسترسی ندارید.</div>
</div>
{% endif %}
<div class="card mb-3">
<div class="card-body">
<form method="get" class="row g-2 align-items-end">
<div class="col-sm-6 col-md-3">
<label class="form-label">وضعیت درخواست</label>
<select class="form-select" name="status">
<option value="">همه</option>
{% for val, label in status_choices %}
<option value="{{ val }}" {% if filter_status == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
{% if request.user|is_admin or request.user|is_manager or request.user|is_accountant %}
<div class="col-sm-6 col-md-3">
<label class="form-label">امور</label>
<select class="form-select" name="affairs">
<option value="">همه</option>
{% for a in affairs_list %}
<option value="{{ a.id }}" {% if filter_affairs|default:''|stringformat:'s' == a.id|stringformat:'s' %}selected{% endif %}>{{ a.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if request.user|is_admin or request.user|is_manager or request.user|is_accountant %}
<div class="col-sm-6 col-md-3">
<label class="form-label">کارگزار</label>
<select class="form-select" name="broker">
<option value="">همه</option>
{% for b in brokers_list %}
<option value="{{ b.id }}" {% if filter_broker|default:''|stringformat:'s' == b.id|stringformat:'s' %}selected{% endif %}>{{ b.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-sm-6 col-md-3">
<label class="form-label">مرحله فعلی</label>
<select class="form-select" name="step">
<option value="">همه</option>
{% for s in steps_list %}
<option value="{{ s.id }}" {% if filter_step|default:''|stringformat:'s' == s.id|stringformat:'s' %}selected{% endif %}>{{ s.process.name }} - {{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 d-flex gap-2 justify-content-end mt-3">
<button type="submit" class="btn btn-primary">
<i class="bx bx-filter-alt me-1"></i>
اعمال فیلتر
</button>
<a href="?" class="btn btn-outline-secondary">
<i class="bx bx-x me-1"></i>
حذف فیلتر
</a>
</div>
</form>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-datatable table-responsive"> <div class="card-datatable table-responsive">
<table id="requests-table" class="datatables-basic table border-top"> <table id="requests-table" class="datatables-basic table border-top">
@ -244,7 +178,7 @@
</div> </div>
</td> </td>
<td>{{ item.instance.get_status_display_with_color|safe }}</td> <td>{{ item.instance.get_status_display_with_color|safe }}</td>
<td>{{ item.instance.jcreated_date }}</td> <td>{{ item.instance.jcreated }}</td>
<td> <td>
<div class="d-inline-block"> <div class="d-inline-block">
<a href="javascript:;" class="btn btn-icon dropdown-toggle hide-arrow" data-bs-toggle="dropdown"> <a href="javascript:;" class="btn btn-icon dropdown-toggle hide-arrow" data-bs-toggle="dropdown">
@ -262,31 +196,19 @@
</a> </a>
{% endif %} {% endif %}
</li> </li>
{% if request.user|is_broker %}
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<li> <li>
<a href="#" class="dropdown-item text-danger" data-instance-id="{{ item.instance.id }}" data-instance-code="{{ item.instance.code }}" onclick="deleteRequest(this.getAttribute('data-instance-id'), this.getAttribute('data-instance-code'))"> <a href="#" class="dropdown-item text-danger" data-instance-id="{{ item.instance.id }}" data-instance-code="{{ item.instance.code }}" onclick="deleteRequest(this.getAttribute('data-instance-id'), this.getAttribute('data-instance-code'))">
<i class="bx bx-trash me-1"></i>حذف <i class="bx bx-trash me-1"></i>حذف
</a> </a>
</li> </li>
{% endif %}
</ul> </ul>
</div> </div>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td class="text-center text-muted">موردی ثبت نشده است</td> <td colspan="11" class="text-center text-muted">موردی ثبت نشده است</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -557,7 +479,7 @@
$('#requests-table').DataTable({ $('#requests-table').DataTable({
pageLength: 10, pageLength: 10,
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, "همه"]], lengthMenu: [[10, 25, 50, -1], [10, 25, 50, "همه"]],
order: [], order: [[0, 'desc']],
responsive: true, responsive: true,
}); });
let currentWellId = null; let currentWellId = null;

View file

@ -1,7 +1,6 @@
from django import template from django import template
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from ..models import ProcessInstance, StepInstance from ..models import ProcessInstance, StepInstance
from ..utils import count_incomplete_instances
register = template.Library() register = template.Library()
@ -105,8 +104,3 @@ def instance_info(instance, modal_id=None):
title="اطلاعات کامل چاه و نماینده"></i> title="اطلاعات کامل چاه و نماینده"></i>
''' '''
return mark_safe(html) return mark_safe(html)
@register.simple_tag
def incomplete_requests_count(user):
return count_incomplete_instances(user)

View file

@ -1,118 +0,0 @@
from django.shortcuts import get_object_or_404
from .models import ProcessInstance
from common.consts import UserRoles
def scope_instances_queryset(user, queryset=None):
"""Return a queryset of ProcessInstance scoped by the user's role.
If no profile/role, returns an empty queryset.
"""
qs = queryset if queryset is not None else ProcessInstance.objects.all()
profile = getattr(user, 'profile', None)
if not profile:
return qs.none()
try:
if profile.has_role(UserRoles.INSTALLER):
# Only instances assigned to this installer
from installations.models import InstallationAssignment
assign_ids = InstallationAssignment.objects.filter(installer=user).values_list('process_instance', flat=True)
return qs.filter(id__in=assign_ids)
if profile.has_role(UserRoles.BROKER):
return qs.filter(broker=profile.broker)
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER):
return qs.filter(broker__affairs__county=profile.county)
if profile.has_role(UserRoles.ADMIN):
return qs
# if profile.has_role(UserRoles.WATER_RESOURCE_MANAGER) or profile.has_role(UserRoles.HEADQUARTER):
# return qs.filter(well__county=profile.county)
# Fallback: no special scope
# return qs
except Exception:
return qs.none()
def count_incomplete_instances(user):
"""Count non-completed, non-deleted requests within the user's scope."""
base = ProcessInstance.objects.select_related('well').filter(is_deleted=False).exclude(status='completed')
return scope_instances_queryset(user, base).count()
def user_can_access_instance(user, instance: ProcessInstance) -> bool:
"""Check if user can access a specific instance based on scoping rules."""
try:
scoped = scope_instances_queryset(user, ProcessInstance.objects.filter(id=instance.id))
return scoped.exists()
except Exception:
return False
def get_scoped_instance_or_404(request, instance_id: int) -> ProcessInstance:
"""Return instance only if it's within the user's scope; otherwise 404.
Use this in any view receiving instance_id from URL to prevent URL tampering.
"""
base = ProcessInstance.objects.filter(is_deleted=False)
qs = scope_instances_queryset(request.user, base)
return get_object_or_404(qs, id=instance_id)
def scope_wells_queryset(user, queryset=None):
"""Return a queryset of Well scoped by the user's role (parity with instances)."""
try:
from wells.models import Well
qs = queryset if queryset is not None else Well.objects.all()
profile = getattr(user, 'profile', None)
if not profile:
return qs.none()
if profile.has_role(UserRoles.ADMIN):
return qs
if profile.has_role(UserRoles.BROKER):
return qs.filter(broker=profile.broker)
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER):
return qs.filter(broker__affairs__county=profile.county)
if profile.has_role(UserRoles.INSTALLER):
# Wells that have instances assigned to this installer
from installations.models import InstallationAssignment
assign_ids = InstallationAssignment.objects.filter(installer=user).values_list('process_instance', flat=True)
inst_qs = ProcessInstance.objects.filter(id__in=assign_ids)
return qs.filter(process_instances__in=inst_qs).distinct()
# Fallback
return qs.none()
except Exception:
return qs.none() if 'qs' in locals() else []
def scope_customers_queryset(user, queryset=None):
"""Return a queryset of customer Profiles scoped by user's role.
Assumes queryset is Profiles already filtered to customers, otherwise we filter here.
"""
try:
from accounts.models import Profile
qs = queryset if queryset is not None else Profile.objects.all()
# Ensure we're only looking at customer profiles
from common.consts import UserRoles as UR
qs = qs.filter(roles__slug=UR.CUSTOMER.value, is_deleted=False)
profile = getattr(user, 'profile', None)
if not profile:
return qs.none()
if profile.has_role(UserRoles.ADMIN):
return qs
if profile.has_role(UserRoles.BROKER):
return qs.filter(broker=profile.broker)
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER):
return qs.filter(county=profile.county)
if profile.has_role(UserRoles.INSTALLER):
# Customers that are representatives of instances assigned to this installer
from installations.models import InstallationAssignment
assign_ids = InstallationAssignment.objects.filter(installer=user).values_list('process_instance', flat=True)
rep_ids = ProcessInstance.objects.filter(id__in=assign_ids).values_list('representative', flat=True)
return qs.filter(user_id__in=rep_ids)
# Fallback
return qs.none()
except Exception:
return qs.none() if 'qs' in locals() else []

View file

@ -7,62 +7,19 @@ from django.http import JsonResponse
from django.views.decorators.http import require_POST, require_GET from django.views.decorators.http import require_POST, require_GET
from django.db import transaction from django.db import transaction
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .models import Process, ProcessInstance, StepInstance, ProcessStep from .models import Process, ProcessInstance, StepInstance
from .utils import scope_instances_queryset, get_scoped_instance_or_404
from installations.models import InstallationAssignment
from wells.models import Well from wells.models import Well
from accounts.models import Profile, Broker from accounts.models import Profile
from locations.models import Affairs
from accounts.forms import CustomerForm from accounts.forms import CustomerForm
from wells.forms import WellForm from wells.forms import WellForm
from wells.models import WaterMeterManufacturer from wells.models import WaterMeterManufacturer
from common.consts import UserRoles
@login_required @login_required
def request_list(request): def request_list(request):
"""نمایش لیست درخواست‌ها با جدول و مدال ایجاد""" """نمایش لیست درخواست‌ها با جدول و مدال ایجاد"""
instances = ProcessInstance.objects.select_related('well', 'representative', 'requester', 'broker', 'current_step', 'process').prefetch_related('step_instances__step').filter(is_deleted=False).order_by('-created') instances = ProcessInstance.objects.select_related('well', 'representative', 'requester').prefetch_related('step_instances__step').filter(is_deleted=False).order_by('-created')
access_denied = False
# filter by roles (scoped queryset)
try:
instances = scope_instances_queryset(request.user, instances)
if not instances.exists() and not getattr(request.user, 'profile', None):
access_denied = True
instances = instances.none()
except Exception:
access_denied = True
instances = instances.none()
# Filters
status_q = (request.GET.get('status') or '').strip()
affairs_q = (request.GET.get('affairs') or '').strip()
broker_q = (request.GET.get('broker') or '').strip()
step_q = (request.GET.get('step') or '').strip()
if status_q:
instances = instances.filter(status=status_q)
if affairs_q:
try:
instances = instances.filter(well__affairs_id=int(affairs_q))
except Exception:
pass
if broker_q:
try:
instances = instances.filter(broker_id=int(broker_q))
except Exception:
pass
if step_q:
try:
instances = instances.filter(current_step_id=int(step_q))
except Exception:
pass
processes = Process.objects.filter(is_active=True) processes = Process.objects.filter(is_active=True)
status_choices = list(ProcessInstance.STATUS_CHOICES)
affairs_list = Affairs.objects.all().order_by('name')
brokers_list = Broker.objects.all().order_by('name')
steps_list = ProcessStep.objects.select_related('process').all().order_by('process__name', 'order')
manufacturers = WaterMeterManufacturer.objects.all().order_by('name') manufacturers = WaterMeterManufacturer.objects.all().order_by('name')
# Calculate progress for each instance # Calculate progress for each instance
@ -95,16 +52,6 @@ def request_list(request):
'completed_count': completed_count, 'completed_count': completed_count,
'in_progress_count': in_progress_count, 'in_progress_count': in_progress_count,
'pending_count': pending_count, 'pending_count': pending_count,
# filter context
'status_choices': status_choices,
'affairs_list': affairs_list,
'brokers_list': brokers_list,
'steps_list': steps_list,
'filter_status': status_q,
'filter_affairs': affairs_q,
'filter_broker': broker_q,
'filter_step': step_q,
'access_denied': access_denied,
}) })
@ -178,13 +125,6 @@ def lookup_representative_by_national_code(request):
def create_request_with_entities(request): def create_request_with_entities(request):
"""ایجاد/به‌روزرسانی چاه و نماینده و سپس ایجاد درخواست""" """ایجاد/به‌روزرسانی چاه و نماینده و سپس ایجاد درخواست"""
User = get_user_model() User = get_user_model()
# Only BROKER can create requests
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)):
return JsonResponse({'ok': False, 'error': 'فقط کارگزار مجاز به ایجاد درخواست است'}, status=403)
except Exception:
return JsonResponse({'ok': False, 'error': 'فقط کارگزار مجاز به ایجاد درخواست است'}, status=403)
process_id = request.POST.get('process') process_id = request.POST.get('process')
process = Process.objects.get(id=process_id) process = Process.objects.get(id=process_id)
description = request.POST.get('description', '') description = request.POST.get('description', '')
@ -290,14 +230,6 @@ def create_request_with_entities(request):
well.broker = current_profile.broker well.broker = current_profile.broker
well.save() well.save()
# Ensure no active (non-deleted, non-completed) request exists for this well
try:
active_exists = ProcessInstance.objects.filter(well=well, is_deleted=False).exclude(status='completed').exists()
if active_exists:
return JsonResponse({'ok': False, 'error': 'برای این چاه یک درخواست جاری وجود دارد. ابتدا آن را تکمیل یا حذف کنید.'}, status=400)
except Exception:
return JsonResponse({'ok': False, 'error': 'خطا در بررسی وضعیت درخواست‌های قبلی این چاه'}, status=400)
# Create request instance # Create request instance
instance = ProcessInstance.objects.create( instance = ProcessInstance.objects.create(
process=process, process=process,
@ -329,17 +261,7 @@ def create_request_with_entities(request):
@login_required @login_required
def delete_request(request, instance_id): def delete_request(request, instance_id):
"""حذف درخواست""" """حذف درخواست"""
instance = get_scoped_instance_or_404(request, instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
# Only BROKER can delete requests and only within their scope
try:
profile = getattr(request.user, 'profile', None)
if not (profile and profile.has_role(UserRoles.BROKER)):
return JsonResponse({'success': False, 'message': 'فقط کارگزار مجاز به حذف درخواست است'}, status=403)
# Enforce ownership by broker (prevent deleting others' requests)
if instance.broker_id and profile.broker and instance.broker_id != profile.broker.id:
return JsonResponse({'success': False, 'message': 'شما مجاز به حذف این درخواست نیستید'}, status=403)
except Exception:
return JsonResponse({'success': False, 'message': 'فقط کارگزار مجاز به حذف درخواست است'}, status=403)
code = instance.code code = instance.code
if instance.status == 'completed': if instance.status == 'completed':
return JsonResponse({ return JsonResponse({
@ -356,10 +278,10 @@ def delete_request(request, instance_id):
@login_required @login_required
def step_detail(request, instance_id, step_id): def step_detail(request, instance_id, step_id):
"""نمایش جزئیات مرحله خاص""" """نمایش جزئیات مرحله خاص"""
# Enforce scoped access to prevent URL tampering instance = get_object_or_404(
instance = get_scoped_instance_or_404(request, instance_id) ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
# Prefetch for performance id=instance_id
instance = ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile').get(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 the request is already completed, redirect to read-only summary page
if instance.status == 'completed': if instance.status == 'completed':
@ -417,8 +339,7 @@ def step_detail(request, instance_id, step_id):
@login_required @login_required
def instance_steps(request, instance_id): def instance_steps(request, instance_id):
"""هدایت به مرحله فعلی instance""" """هدایت به مرحله فعلی instance"""
# Enforce scoped access to prevent URL tampering instance = get_object_or_404(ProcessInstance, id=instance_id)
instance = get_scoped_instance_or_404(request, instance_id)
if not instance.current_step: if not instance.current_step:
# اگر مرحله فعلی تعریف نشده، به اولین مرحله برو # اگر مرحله فعلی تعریف نشده، به اولین مرحله برو
@ -440,9 +361,6 @@ def instance_steps(request, instance_id):
@login_required @login_required
def instance_summary(request, instance_id): def instance_summary(request, instance_id):
"""نمای خلاصهٔ فقط‌خواندنی برای درخواست‌های تکمیل‌شده.""" """نمای خلاصهٔ فقط‌خواندنی برای درخواست‌های تکمیل‌شده."""
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
instance = get_object_or_404(ProcessInstance.objects.select_related('well', 'representative'), id=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 # Only show for completed requests; otherwise route to steps
if instance.status != 'completed': if instance.status != 'completed':

View file

@ -1,5 +1,4 @@
{% load static %} {% load static %}
{% load accounts_tags %}
<!-- Menu --> <!-- Menu -->
<aside id="layout-menu" class="layout-menu menu-vertical menu bg-menu-theme"> <aside id="layout-menu" class="layout-menu menu-vertical menu bg-menu-theme">
@ -109,12 +108,9 @@
<a href="{% url 'processes:request_list' %}" class="menu-link"> <a href="{% url 'processes:request_list' %}" class="menu-link">
<i class="menu-icon tf-icons bx bx-user"></i> <i class="menu-icon tf-icons bx bx-user"></i>
<div class="text-truncate">درخواست‌ها</div> <div class="text-truncate">درخواست‌ها</div>
{% load processes_tags %}
<span class="badge badge-center rounded-pill bg-danger ms-auto">{% incomplete_requests_count request.user %}</span>
</a> </a>
</li> </li>
{% if request.user|is_admin or request.user|is_broker or request.user|is_manager or request.user|is_accountant %}
<!-- Customers --> <!-- Customers -->
<li class="menu-header small text-uppercase"> <li class="menu-header small text-uppercase">
<span class="menu-header-text">مشترک‌ها</span> <span class="menu-header-text">مشترک‌ها</span>
@ -135,11 +131,11 @@
<div class="text-truncate">چاه‌ها</div> <div class="text-truncate">چاه‌ها</div>
</a> </a>
</li> </li>
{% endif %}
<!-- Apps & Pages --> <!-- Apps & Pages -->
<li class="menu-header small text-uppercase d-none"> <li class="menu-header small text-uppercase">
<span class="menu-header-text">گزارش‌ها</span> <span class="menu-header-text">گزارش‌ها</span>
</li> </li>

View file

@ -82,10 +82,12 @@ class WellForm(forms.ModelForm):
'utm_x': forms.NumberInput(attrs={ 'utm_x': forms.NumberInput(attrs={
'class': 'form-control', 'class': 'form-control',
'placeholder': 'X UTM', 'placeholder': 'X UTM',
'step': '0.000001'
}), }),
'utm_y': forms.NumberInput(attrs={ 'utm_y': forms.NumberInput(attrs={
'class': 'form-control', 'class': 'form-control',
'placeholder': 'Y UTM', 'placeholder': 'Y UTM',
'step': '0.000001'
}), }),
'utm_zone': forms.NumberInput(attrs={ 'utm_zone': forms.NumberInput(attrs={
'class': 'form-control', 'class': 'form-control',

View file

@ -78,14 +78,14 @@ class Well(SluggedModel):
utm_x = models.DecimalField( utm_x = models.DecimalField(
max_digits=10, max_digits=10,
decimal_places=0, decimal_places=6,
verbose_name="X UTM", verbose_name="X UTM",
null=True, null=True,
blank=True blank=True
) )
utm_y = models.DecimalField( utm_y = models.DecimalField(
max_digits=10, max_digits=10,
decimal_places=0, decimal_places=6,
verbose_name="Y UTM", verbose_name="Y UTM",
null=True, null=True,
blank=True blank=True

View file

@ -163,19 +163,12 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td class="text-center py-4"> <td colspan="7" class="text-center py-4">
<div class="text-muted"> <div class="text-muted">
<i class="ti ti-database-off ti-lg mb-2"></i> <i class="ti ti-database-off ti-lg mb-2"></i>
<p>چاهی یافت نشد</p> <p>چاهی یافت نشد</p>
</div> </div>
</td> </td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -7,23 +7,17 @@ from django.contrib import messages
from django import forms from django import forms
from .models import Well, WaterMeterManufacturer from .models import Well, WaterMeterManufacturer
from .forms import WellForm, WaterMeterManufacturerForm from .forms import WellForm, WaterMeterManufacturerForm
from django.contrib.auth.decorators import login_required
from common.decorators import allowed_roles
from common.consts import UserRoles
from processes.utils import scope_wells_queryset
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
def well_list(request): def well_list(request):
"""نمایش لیست چاه‌ها""" """نمایش لیست چاه‌ها"""
base = Well.objects.select_related( wells = Well.objects.select_related(
'representative', 'representative',
'water_meter_manufacturer', 'water_meter_manufacturer',
'affairs', 'affairs',
'county', 'county',
'broker' 'broker'
).filter(is_deleted=False) ).filter(is_deleted=False)
wells = scope_wells_queryset(request.user, base)
# فرم برای افزودن چاه جدید # فرم برای افزودن چاه جدید
form = WellForm() form = WellForm()
@ -37,8 +31,6 @@ def well_list(request):
@require_POST @require_POST
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
def add_well_ajax(request): def add_well_ajax(request):
"""AJAX endpoint for adding wells""" """AJAX endpoint for adding wells"""
try: try:
@ -95,8 +87,6 @@ def add_well_ajax(request):
@require_POST @require_POST
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
def edit_well_ajax(request, well_id): def edit_well_ajax(request, well_id):
"""AJAX endpoint for editing wells""" """AJAX endpoint for editing wells"""
well = get_object_or_404(Well, id=well_id) well = get_object_or_404(Well, id=well_id)
@ -151,8 +141,6 @@ def edit_well_ajax(request, well_id):
@require_POST @require_POST
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
def delete_well(request, well_id): def delete_well(request, well_id):
"""حذف چاه""" """حذف چاه"""
well = get_object_or_404(Well, id=well_id) well = get_object_or_404(Well, id=well_id)
@ -166,7 +154,6 @@ def delete_well(request, well_id):
@require_GET @require_GET
@login_required
def get_well_data(request, well_id): def get_well_data(request, well_id):
"""دریافت اطلاعات چاه برای ویرایش""" """دریافت اطلاعات چاه برای ویرایش"""
well = get_object_or_404(Well, id=well_id) well = get_object_or_404(Well, id=well_id)
@ -196,7 +183,6 @@ def get_well_data(request, well_id):
@require_POST @require_POST
@login_required
def create_water_meter_manufacturer(request): def create_water_meter_manufacturer(request):
"""ایجاد شرکت سازنده کنتور آب جدید""" """ایجاد شرکت سازنده کنتور آب جدید"""
form = WaterMeterManufacturerForm(request.POST) form = WaterMeterManufacturerForm(request.POST)