Compare commits
11 commits
ce8584f86b
...
976be64028
Author | SHA1 | Date | |
---|---|---|---|
976be64028 | |||
20f00b786e | |||
8083b6d32e | |||
855ad3912c | |||
f95beb726f | |||
741536a227 | |||
5dde5335f1 | |||
e9dec3292c | |||
394546dc67 | |||
9592c00565 | |||
93db2fe7f5 |
33 changed files with 1057 additions and 309 deletions
|
@ -90,6 +90,19 @@ class CustomerForm(forms.ModelForm):
|
|||
return national_code
|
||||
|
||||
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)
|
||||
if self.instance and self.instance.pk:
|
||||
# Update existing profile
|
||||
|
@ -108,6 +121,18 @@ class CustomerForm(forms.ModelForm):
|
|||
profile.affairs = current_user_profile.affairs
|
||||
profile.county = current_user_profile.county
|
||||
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:
|
||||
profile.save()
|
||||
|
@ -142,6 +167,18 @@ class CustomerForm(forms.ModelForm):
|
|||
profile.affairs = current_user_profile.affairs
|
||||
profile.county = current_user_profile.county
|
||||
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:
|
||||
profile.save()
|
||||
|
|
|
@ -172,12 +172,19 @@
|
|||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-4">
|
||||
<td class="text-center py-4">
|
||||
<div class="d-flex flex-column align-items-center">
|
||||
<i class="bx bx-user-x bx-lg text-muted mb-2"></i>
|
||||
<span class="text-muted">هیچ کاربری یافت نشد</span>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
|
@ -16,6 +16,9 @@ layout-wide customizer-hide
|
|||
{% endblock style %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% include '_toasts.html' %}
|
||||
|
||||
<div class="container-xxl">
|
||||
<div class="authentication-wrapper authentication-basic container-p-y">
|
||||
<div class="authentication-inner">
|
||||
|
@ -69,7 +72,7 @@ layout-wide customizer-hide
|
|||
{% csrf_token %}
|
||||
<div class="mb-3 fv-plugins-icon-container">
|
||||
<label for="email" class="form-label">نام کاربری</label>
|
||||
<input type="text" class="form-control" id="email" name="username" placeholder="Enter your email or username" autofocus="">
|
||||
<input type="text" class="form-control" id="email" name="username" placeholder="نام کاربری خود را وارد کنید" autofocus="">
|
||||
<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="d-flex justify-content-between">
|
||||
|
|
|
@ -4,7 +4,7 @@ from accounts.views import login_view, dashboard, customer_list, add_customer_aj
|
|||
|
||||
app_name = "accounts"
|
||||
urlpatterns = [
|
||||
path('login/', login_view, name='login'),
|
||||
path('', login_view, name='login'),
|
||||
path('logout/', logout_view, name='logout'),
|
||||
path('dashboard/', dashboard, name='dashboard'),
|
||||
path('customers/', customer_list, name='customer_list'),
|
||||
|
|
|
@ -8,7 +8,9 @@ from django import forms
|
|||
from django.contrib.auth.decorators import login_required
|
||||
from accounts.models import Profile
|
||||
from accounts.forms import CustomerForm
|
||||
from processes.utils import scope_customers_queryset
|
||||
from common.consts import UserRoles
|
||||
from common.decorators import allowed_roles
|
||||
|
||||
|
||||
# Create your views here.
|
||||
|
@ -17,6 +19,9 @@ def login_view(request):
|
|||
renders login page and authenticating user POST requests
|
||||
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":
|
||||
username = request.POST.get("username")
|
||||
password = request.POST.get("password")
|
||||
|
@ -35,9 +40,11 @@ def dashboard(request):
|
|||
|
||||
|
||||
@login_required
|
||||
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
|
||||
def customer_list(request):
|
||||
# Get all profiles that have customer role
|
||||
customers = Profile.objects.filter(roles__slug=UserRoles.CUSTOMER.value, is_deleted=False).select_related('user')
|
||||
base = Profile.objects.filter(roles__slug=UserRoles.CUSTOMER.value, is_deleted=False).select_related('user')
|
||||
customers = scope_customers_queryset(request.user, base)
|
||||
|
||||
form = CustomerForm()
|
||||
return render(request, "accounts/customer_list.html", {
|
||||
|
@ -47,6 +54,8 @@ def customer_list(request):
|
|||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
|
||||
def add_customer_ajax(request):
|
||||
"""AJAX endpoint for adding customers"""
|
||||
form = CustomerForm(request.POST, request.FILES)
|
||||
|
@ -85,6 +94,8 @@ def add_customer_ajax(request):
|
|||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
|
||||
def edit_customer_ajax(request, customer_id):
|
||||
customer = get_object_or_404(Profile, id=customer_id)
|
||||
form = CustomerForm(request.POST, request.FILES, instance=customer)
|
||||
|
@ -122,6 +133,7 @@ def edit_customer_ajax(request, customer_id):
|
|||
})
|
||||
|
||||
@require_GET
|
||||
@login_required
|
||||
def get_customer_data(request, customer_id):
|
||||
customer = get_object_or_404(Profile, id=customer_id)
|
||||
|
||||
|
@ -162,6 +174,7 @@ def get_customer_data(request, customer_id):
|
|||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def logout_view(request):
|
||||
"""Log out current user and redirect to login page."""
|
||||
logout(request)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from common.models import BaseModel
|
||||
from _helpers.utils import jalali_converter2
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
@ -35,4 +36,7 @@ class CertificateInstance(BaseModel):
|
|||
def __str__(self):
|
||||
return f"گواهی {self.process_instance.code}"
|
||||
|
||||
def jissued_at(self):
|
||||
return jalali_converter2(self.issued_at)
|
||||
|
||||
|
||||
|
|
|
@ -1,28 +1,71 @@
|
|||
{% extends '_base.html' %}
|
||||
<!DOCTYPE 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 %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="text-center mb-4">
|
||||
{% if template.company and template.company.logo %}
|
||||
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:90px">
|
||||
{% endif %}
|
||||
<h4 class="mt-2">{{ cert.rendered_title }}</h4>
|
||||
{% if template.company %}<div class="text-muted">{{ template.company.name }}</div>{% endif %}
|
||||
</div>
|
||||
<div style="white-space:pre-line; line-height:1.9;">
|
||||
{{ cert.rendered_body|safe }}
|
||||
</div>
|
||||
<div class="mt-5 d-flex justify-content-between">
|
||||
<div>تاریخ: {{ cert.issued_at }}</div>
|
||||
<div class="text-center">
|
||||
{% if template.company and template.company.signature %}
|
||||
<img src="{{ template.company.signature.url }}" alt="seal" style="max-height:120px">
|
||||
<!-- Fonts (match project) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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">
|
||||
|
||||
<!-- Core CSS (same as other prints) -->
|
||||
<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; } .no-print { display: none !important; } }
|
||||
.header { border-bottom: 1px solid #dee2e6; padding-bottom: 16px; margin-bottom: 24px; }
|
||||
.company-name { font-weight: 600; }
|
||||
.body-text { white-space: pre-line; line-height: 1.9; }
|
||||
.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 %}
|
||||
<div>مهر و امضای شرکت</div>
|
||||
<h4 class="mt-2">{{ cert.rendered_title }}</h4>
|
||||
{% 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>
|
||||
<script>window.print()</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
<script>
|
||||
window.onload = function() { window.print(); setTimeout(function(){ window.close(); }, 200); };
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -18,40 +18,49 @@
|
|||
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
|
||||
<!-- Persian Date Picker 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 %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_toasts.html' %}
|
||||
|
||||
<!-- Instance Info Modal -->
|
||||
{% instance_info_modal instance %}
|
||||
|
||||
{% csrf_token %}
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 no-print">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
|
||||
<small class="text-muted d-block">
|
||||
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
|
||||
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
|
||||
{% instance_info instance %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary" target="_blank" href="{% url 'certificates:certificate_print' instance.id %}"><i class="bx bx-printer"></i> پرینت</a>
|
||||
<a class="btn btn-outline-secondary" target="_blank" href="{% url 'certificates:certificate_print' instance.id %}">
|
||||
<i class="bx bx-printer me-2"></i> پرینت
|
||||
</a>
|
||||
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</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 class="bs-stepper wizard-vertical vertical mt-2 no-print">
|
||||
<div class="bs-stepper wizard-vertical vertical mt-2">
|
||||
{% stepper_header instance step %}
|
||||
<div class="bs-stepper-content">
|
||||
|
||||
<div class="card">
|
||||
<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">
|
||||
{% if template.company and template.company.logo %}
|
||||
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:80px">
|
||||
|
@ -62,21 +71,22 @@
|
|||
<div class="mt-3" style="white-space:pre-line; line-height:1.9;">
|
||||
{{ cert.rendered_body|safe }}
|
||||
</div>
|
||||
<div class="mt-4 d-flex justify-content-between align-items-end">
|
||||
<div>
|
||||
<div>تاریخ صدور: {{ cert.issued_at }}</div>
|
||||
</div>
|
||||
<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:100px">
|
||||
<img src="{{ template.company.signature.url }}" alt="seal" style="max-height:200px">
|
||||
{% endif %}
|
||||
<div>مهر و امضای شرکت</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">
|
||||
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
|
||||
قبلی
|
||||
</a>
|
||||
{% else %}<span></span>{% endif %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
|
|
@ -12,6 +12,7 @@ from .models import CertificateTemplate, CertificateInstance
|
|||
from common.consts import UserRoles
|
||||
|
||||
from _helpers.jalali import Gregorian
|
||||
from processes.utils import get_scoped_instance_or_404
|
||||
|
||||
|
||||
def _to_jalali(date_obj):
|
||||
|
@ -33,23 +34,25 @@ def _render_template(template: CertificateTemplate, instance: ProcessInstance):
|
|||
'company_name': (template.company.name if template.company else '') or '',
|
||||
'customer_full_name': rep.get_full_name() if rep else '',
|
||||
'water_subscription_number': getattr(well, 'water_subscription_number', '') or '',
|
||||
'address': getattr(well, 'address', '') or '',
|
||||
'address': getattr(well, 'county', '') or '',
|
||||
'visit_date_jalali': _to_jalali(getattr(latest_report, 'visited_date', None)) if latest_report else '',
|
||||
}
|
||||
title = (template.title or '').format(**ctx)
|
||||
body = (template.body or '')
|
||||
# Render body placeholders with bold values
|
||||
for k, v in ctx.items():
|
||||
body = body.replace(f"{{{{ {k} }}}}", str(v))
|
||||
body = body.replace(f"{{{{ {k} }}}}", f"<strong>{str(v)}</strong>")
|
||||
return title, body
|
||||
|
||||
|
||||
@login_required
|
||||
def certificate_step(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
# 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)
|
||||
incomplete = StepInstance.objects.filter(process_instance=instance, step__in=prior_steps).exclude(status='completed').exists()
|
||||
|
||||
if incomplete:
|
||||
messages.error(request, 'ابتدا همه مراحل قبلی را تکمیل کنید')
|
||||
return redirect('processes:request_list')
|
||||
|
@ -87,6 +90,17 @@ def certificate_step(request, instance_id, step_id):
|
|||
except Exception:
|
||||
messages.error(request, 'شما مجوز تایید این مرحله را ندارید')
|
||||
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_at = timezone.now()
|
||||
cert.save()
|
||||
|
@ -115,7 +129,7 @@ def certificate_step(request, instance_id, step_id):
|
|||
|
||||
@login_required
|
||||
def certificate_print(request, instance_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
cert = CertificateInstance.objects.filter(process_instance=instance).order_by('-created').first()
|
||||
template = cert.template if cert else None
|
||||
return render(request, 'certificates/print.html', {
|
||||
|
|
|
@ -3,7 +3,7 @@ from functools import wraps
|
|||
from django.http import JsonResponse, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from extensions.consts import UserRoles
|
||||
from common.consts import UserRoles
|
||||
|
||||
|
||||
def require_ajax(view_func):
|
||||
|
|
|
@ -11,6 +11,7 @@ from .models import ContractTemplate, ContractInstance
|
|||
from invoices.models import Invoice, Quote
|
||||
from _helpers.utils import jalali_converter2
|
||||
from django.http import JsonResponse
|
||||
from processes.utils import get_scoped_instance_or_404
|
||||
|
||||
|
||||
def build_contract_context(instance: ProcessInstance) -> dict:
|
||||
|
@ -52,7 +53,7 @@ def build_contract_context(instance: ProcessInstance) -> dict:
|
|||
|
||||
@login_required
|
||||
def contract_step(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
# Resolve step navigation
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
previous_step = instance.process.steps.filter(order__lt=step.order).last()
|
||||
|
@ -117,7 +118,7 @@ def contract_step(request, instance_id, step_id):
|
|||
|
||||
@login_required
|
||||
def contract_print(request, instance_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
contract = get_object_or_404(ContractInstance, process_instance=instance)
|
||||
return render(request, 'contracts/contract_print.html', {
|
||||
'instance': instance,
|
||||
|
|
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
|
@ -42,8 +42,8 @@ class InstallationReport(BaseModel):
|
|||
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='شماره پلمپ')
|
||||
is_meter_suspicious = models.BooleanField(default=False, verbose_name='کنتور مشکوک است؟')
|
||||
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=6, null=True, blank=True, verbose_name='UTM Y')
|
||||
utm_x = models.DecimalField(max_digits=10, decimal_places=0, 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')
|
||||
description = models.TextField(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='تایید شده')
|
||||
|
|
|
@ -22,7 +22,12 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% include '_toasts.html' %}
|
||||
|
||||
<!-- Instance Info Modal -->
|
||||
{% instance_info_modal instance %}
|
||||
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
|
@ -30,11 +35,13 @@
|
|||
<div>
|
||||
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
|
||||
<small class="text-muted d-block">
|
||||
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
|
||||
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
|
||||
{% instance_info instance %}
|
||||
</small>
|
||||
</div>
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</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 class="bs-stepper wizard-vertical vertical mt-2">
|
||||
|
@ -64,17 +71,17 @@
|
|||
</div>
|
||||
</div>
|
||||
{% if assignment.assigned_by or assignment.installer %}
|
||||
<div class="mt-3 border rounded p-3 bg-light">
|
||||
<div class="mt-3 alert alert-primary">
|
||||
<div class="row g-2">
|
||||
{% if assignment.assigned_by %}
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="small text-muted">تعیینکننده نصاب</div>
|
||||
<div class="small text-dark">تعیینکننده نصاب</div>
|
||||
<div>{{ assignment.assigned_by.get_full_name|default:assignment.assigned_by.username }} <span class="text-muted">({{ assignment.assigned_by.username }})</span></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if assignment.updated %}
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="small text-muted">تاریخ ثبت/ویرایش</div>
|
||||
<div class="small text-dark">تاریخ ثبت/ویرایش</div>
|
||||
<div>{{ assignment.updated|to_jalali }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -83,14 +90,22 @@
|
|||
{% endif %}
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">
|
||||
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
|
||||
قبلی
|
||||
</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
{% if is_manager %}
|
||||
<button class="btn btn-primary" type="submit">ثبت و ادامه</button>
|
||||
<button class="btn btn-primary" type="submit">ثبت و ادامه
|
||||
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
|
||||
بعدی
|
||||
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -35,7 +35,12 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% include '_toasts.html' %}
|
||||
|
||||
<!-- Instance Info Modal -->
|
||||
{% instance_info_modal instance %}
|
||||
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
|
@ -43,11 +48,13 @@
|
|||
<div>
|
||||
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
|
||||
<small class="text-muted d-block">
|
||||
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
|
||||
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
|
||||
{% instance_info instance %}
|
||||
</small>
|
||||
</div>
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</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 class="bs-stepper wizard-vertical vertical mt-2">
|
||||
|
@ -55,18 +62,15 @@
|
|||
|
||||
<div class="bs-stepper-content">
|
||||
{% if report and not edit_mode %}
|
||||
<div class="card mb-3 border">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex gap-2">
|
||||
{% if request.user|is_installer %}
|
||||
<a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-primary" disabled>ویرایش گزارش نصب</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if step_instance and step_instance.status == 'rejected' and step_instance.get_latest_rejection %}
|
||||
<div class="mb-3 text-end">
|
||||
{% if user_is_installer %}
|
||||
<a href="?edit=1" class="btn btn-primary">
|
||||
<i class="bx bx-edit bx-sm me-2"></i>
|
||||
ویرایش گزارش نصب
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if step_instance and step_instance.status == 'rejected' and step_instance.get_latest_rejection %}
|
||||
<div class="alert alert-danger d-flex align-items-start" role="alert">
|
||||
<i class="bx bx-error-circle me-2"></i>
|
||||
<div>
|
||||
|
@ -75,6 +79,8 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card mb-3 border">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}</p>
|
||||
|
@ -151,7 +157,7 @@
|
|||
<h6 class="mb-0">وضعیت تاییدها</h6>
|
||||
{% if user_can_approve %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveModal" {% if step_instance and step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
|
||||
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveModal">تایید</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectModal">رد</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -184,18 +190,28 @@
|
|||
<!-- Persistent nav in edit mode (outside cards) -->
|
||||
<div class="d-flex justify-content-between mt-3">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">
|
||||
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
|
||||
قبلی
|
||||
</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
{% if next_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
|
||||
بعدی
|
||||
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if not request.user|is_installer %}
|
||||
<div class="alert alert-warning">شما مجوز ثبت/ویرایش گزارش نصب را ندارید. اطلاعات به صورت فقط خواندنی نمایش داده میشود.</div>
|
||||
|
||||
{% if not user_is_installer %}
|
||||
<div class="alert alert-warning">شما مجوز ثبت/ویرایش گزارش نصب را ندارید.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user_is_installer %}
|
||||
<!-- Installation Report Form -->
|
||||
<form method="post" enctype="multipart/form-data" id="installation-report-form">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
|
@ -203,40 +219,40 @@
|
|||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">تاریخ مراجعه</label>
|
||||
<input type="text" id="id_visited_date_display" class="form-control" placeholder="انتخاب تاریخ" {% 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="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="hidden" id="id_visited_date" name="visited_date" value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y-m-d' }}{% endif %}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">سریال کنتور جدید</label>
|
||||
<input type="text" class="form-control" name="new_water_meter_serial" value="{% if report and edit_mode %}{{ report.new_water_meter_serial|default_if_none:'' }}{% endif %}" {% if not request.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 user_is_installer %}readonly{% endif %}>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">شماره پلمپ</label>
|
||||
<input type="text" class="form-control" name="seal_number" value="{% if report and edit_mode %}{{ report.seal_number|default_if_none:'' }}{% endif %}" {% if not request.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 user_is_installer %}readonly{% endif %}>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_meter_suspicious" id="id_is_meter_suspicious" {% if not request.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 user_is_installer %}disabled{% endif %} {% if report and edit_mode and report.is_meter_suspicious %}checked{% endif %}>
|
||||
<label class="form-check-label" for="id_is_meter_suspicious">کنتور مشکوک است</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">UTM X</label>
|
||||
<input type="number" step="0.000001" class="form-control" name="utm_x" value="{% if 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 %}>
|
||||
<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 %}>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">UTM Y</label>
|
||||
<input type="number" step="0.000001" class="form-control" name="utm_y" value="{% if 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 %}>
|
||||
<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 %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<label class="form-label">توضیحات (اختیاری)</label>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<label class="form-label mb-0">عکسها</label>
|
||||
{% if request.user|is_installer %}
|
||||
{% if user_is_installer %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -246,7 +262,7 @@
|
|||
<div class="col-6 col-md-3 mb-2" id="existing-photo-{{ p.id }}">
|
||||
<div class="position-relative border rounded p-1">
|
||||
<img class="img-fluid rounded" src="{{ p.image.url }}" alt="photo">
|
||||
{% if request.user|is_installer %}
|
||||
{% if user_is_installer %}
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute" style="top:6px; left:6px;" onclick="markDeletePhoto('{{ p.id }}')" title="حذف/برگردان"><i class="bx bx-trash"></i></button>
|
||||
{% endif %}
|
||||
<input type="hidden" name="del_photo_{{ p.id }}" id="del-photo-{{ p.id }}" value="0">
|
||||
|
@ -350,21 +366,25 @@
|
|||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
<div class="mt-3 d-flex justify-content-between">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">
|
||||
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
|
||||
قبلی
|
||||
</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
<div class="d-flex gap-2">
|
||||
{% if request.user|is_installer %}
|
||||
<button type="submit" class="btn btn-primary" form="installation-report-form">ثبت گزارش</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-primary" disabled>ثبت گزارش</button>
|
||||
{% if user_is_installer %}
|
||||
<button type="submit" class="btn btn-success" form="installation-report-form">ثبت گزارش</button>
|
||||
{% endif %}
|
||||
{% if next_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-success">بعدی</a>
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
|
||||
بعدی
|
||||
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -499,7 +519,6 @@
|
|||
try {
|
||||
if (sessionStorage.getItem('install_report_saved') === '1') {
|
||||
sessionStorage.removeItem('install_report_saved');
|
||||
showToast('گزارش نصب با موفقیت ثبت شد', 'success');
|
||||
}
|
||||
} catch(_) {}
|
||||
})();
|
||||
|
|
|
@ -10,16 +10,17 @@ from accounts.models import Role
|
|||
from invoices.models import Item, Quote, QuoteItem
|
||||
from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from processes.utils import get_scoped_instance_or_404
|
||||
|
||||
@login_required
|
||||
def installation_assign_step(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
previous_step = instance.process.steps.filter(order__lt=step.order).last()
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
|
||||
# Installers list (profiles that have installer role)
|
||||
installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value).select_related('user').all()
|
||||
installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value, county=instance.well.county).select_related('user').all()
|
||||
assignment, _ = InstallationAssignment.objects.get_or_create(process_instance=instance)
|
||||
|
||||
# Role flags
|
||||
|
@ -72,17 +73,56 @@ 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
|
||||
def installation_report_step(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
|
||||
previous_step = instance.process.steps.filter(order__lt=step.order).last()
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
|
||||
assignment = InstallationAssignment.objects.filter(process_instance=instance).first()
|
||||
existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first()
|
||||
# Only installers can enter edit mode
|
||||
user_is_installer = hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.INSTALLER)
|
||||
|
||||
# Only the assigned installer can create/edit the report
|
||||
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
|
||||
|
||||
# current quote items baseline
|
||||
quote = Quote.objects.filter(process_instance=instance).first()
|
||||
quote_items = list(quote.items.select_related('item').all()) if quote else []
|
||||
|
@ -100,7 +140,14 @@ def installation_report_step(request, instance_id, step_id):
|
|||
reqs = list(step.approver_requirements.select_related('role').all())
|
||||
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
|
||||
user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
|
||||
user_can_approve = any(r.role in user_roles for r in reqs)
|
||||
# Align permission check with invoices flow (role id intersection)
|
||||
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_by_role = {a.role_id: a for a in approvals_list}
|
||||
approver_statuses = [
|
||||
|
@ -160,6 +207,13 @@ def installation_report_step(request, instance_id, step_id):
|
|||
StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
|
||||
existing_report.approved = False
|
||||
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, 'گزارش رد شد و برای اصلاح به نصاب بازگشت.')
|
||||
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
|
||||
|
||||
|
@ -177,6 +231,21 @@ def installation_report_step(request, instance_id, step_id):
|
|||
is_suspicious = True if request.POST.get('is_meter_suspicious') == 'on' else False
|
||||
utm_x = request.POST.get('utm_x') 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
|
||||
remove_map = {}
|
||||
|
@ -221,8 +290,6 @@ def installation_report_step(request, instance_id, step_id):
|
|||
unit_price = item_obj.unit_price if item_obj else None
|
||||
add_map[item_id] = {'qty': qty, 'price': unit_price}
|
||||
|
||||
# اجازهٔ ثبت همزمان حذف و افزودن برای یک قلم (بدون محدودیت و ادغام)
|
||||
|
||||
if existing_report and edit_mode:
|
||||
report = existing_report
|
||||
report.description = description
|
||||
|
@ -247,29 +314,7 @@ def installation_report_step(request, instance_id, step_id):
|
|||
InstallationPhoto.objects.create(report=report, image=f)
|
||||
# replace item changes with new submission
|
||||
report.item_changes.all().delete()
|
||||
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,
|
||||
)
|
||||
create_item_changes_for_report(report, remove_map, add_map, quote_price_map)
|
||||
else:
|
||||
report = InstallationReport.objects.create(
|
||||
assignment=assignment,
|
||||
|
@ -286,29 +331,7 @@ def installation_report_step(request, instance_id, step_id):
|
|||
for f in request.FILES.getlist('photos'):
|
||||
InstallationPhoto.objects.create(report=report, image=f)
|
||||
# item 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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
create_item_changes_for_report(report, remove_map, add_map, quote_price_map)
|
||||
|
||||
# After installer submits/edits, set step back to in_progress and clear approvals
|
||||
step_instance.status = 'in_progress'
|
||||
|
@ -319,6 +342,33 @@ def installation_report_step(request, instance_id, step_id):
|
|||
except Exception:
|
||||
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, 'گزارش ثبت شد و در انتظار تایید است.')
|
||||
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
|
||||
|
||||
|
@ -340,6 +390,7 @@ def installation_report_step(request, instance_id, step_id):
|
|||
'assignment': assignment,
|
||||
'report': existing_report,
|
||||
'edit_mode': edit_mode,
|
||||
'user_is_installer': user_is_installer,
|
||||
'quote': quote,
|
||||
'quote_items': quote_items,
|
||||
'all_items': items,
|
||||
|
@ -351,6 +402,7 @@ def installation_report_step(request, instance_id, step_id):
|
|||
'step_instance': step_instance,
|
||||
'approver_statuses': approver_statuses,
|
||||
'user_can_approve': user_can_approve,
|
||||
'can_approve_reject': can_approve_reject,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
@ -1,55 +1,206 @@
|
|||
{% extends '_base.html' %}
|
||||
{% load humanize %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>فاکتور نهایی {{ invoice.name }} - {{ instance.code }}</title>
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="mb-4 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h4 class="mb-1">فاکتور نهایی</h4>
|
||||
<small class="text-muted">کد درخواست: {{ instance.code }}</small>
|
||||
{% load static %}
|
||||
{% load humanize %}
|
||||
|
||||
<!-- Fonts (match base) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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">
|
||||
|
||||
<!-- Icons (optional) -->
|
||||
<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>
|
||||
<!-- Placeholders for logo/signature -->
|
||||
<div class="text-end">لوگو</div>
|
||||
|
||||
<!-- Customer & Well Info -->
|
||||
<div class="row mb-3">
|
||||
<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>
|
||||
<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 %}
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<script>
|
||||
window.onload = function() {
|
||||
window.print();
|
||||
setTimeout(function(){ window.close(); }, 200);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -24,6 +24,10 @@
|
|||
|
||||
{% block content %}
|
||||
{% include '_toasts.html' %}
|
||||
|
||||
<!-- Instance Info Modal -->
|
||||
{% instance_info_modal instance %}
|
||||
|
||||
{% csrf_token %}
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="row">
|
||||
|
@ -32,14 +36,18 @@
|
|||
<div>
|
||||
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
|
||||
<small class="text-muted d-block">
|
||||
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
|
||||
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
|
||||
{% instance_info instance %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت</a>
|
||||
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
|
||||
<i class="bx bx-printer me-2"></i> پرینت
|
||||
</a>
|
||||
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</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>
|
||||
|
||||
|
@ -163,15 +171,24 @@
|
|||
</div>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">
|
||||
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
|
||||
قبلی
|
||||
</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
{% if next_step %}
|
||||
{% if is_manager %}
|
||||
<button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button>
|
||||
<button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">
|
||||
تایید و ادامه
|
||||
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
|
||||
بعدی
|
||||
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -23,6 +23,10 @@
|
|||
|
||||
{% block content %}
|
||||
{% include '_toasts.html' %}
|
||||
|
||||
<!-- Instance Info Modal -->
|
||||
{% instance_info_modal instance %}
|
||||
|
||||
{% csrf_token %}
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="row">
|
||||
|
@ -31,14 +35,18 @@
|
|||
<div>
|
||||
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
|
||||
<small class="text-muted d-block">
|
||||
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
|
||||
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
|
||||
{% instance_info instance %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت</a>
|
||||
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
|
||||
<i class="bx bx-printer me-2"></i> پرینت
|
||||
</a>
|
||||
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</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>
|
||||
|
||||
|
@ -88,7 +96,7 @@
|
|||
<input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
|
||||
</div>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -182,7 +190,7 @@
|
|||
<h6 class="mb-0">وضعیت تاییدها</h6>
|
||||
{% if can_approve_reject %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveFinalSettleModal" {% if step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
|
||||
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveFinalSettleModal">تایید</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -214,13 +222,19 @@
|
|||
{% endif %}
|
||||
<div class="col-12 d-flex justify-content-between mt-3">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">
|
||||
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
|
||||
قبلی
|
||||
</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
{% if step_instance.status == 'completed' %}
|
||||
{% if next_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
|
||||
بعدی
|
||||
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
|
||||
{% endif %}
|
||||
|
@ -264,8 +278,8 @@
|
|||
<div class="modal-body">
|
||||
{% if invoice.remaining_amount != 0 %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
مانده فاکتور صفر نیست: <strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br>
|
||||
تا صفر نشود امکان تایید نیست.
|
||||
مانده فاکتور: <strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br>
|
||||
امکان تایید تا تسویه کامل فاکتور وجود ندارد.
|
||||
</div>
|
||||
{% else %}
|
||||
آیا از تایید این مرحله اطمینان دارید؟
|
||||
|
|
|
@ -31,5 +31,4 @@ 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/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/approve/', views.approve_final_settlement, name='approve_final_settlement'),
|
||||
]
|
||||
|
|
|
@ -12,13 +12,17 @@ import json
|
|||
from processes.models import ProcessInstance, ProcessStep, StepInstance, StepRejection, StepApproval
|
||||
from accounts.models import Role
|
||||
from common.consts import UserRoles
|
||||
from .models import Item, Quote, QuoteItem, Payment, Invoice
|
||||
from .models import Item, Quote, QuoteItem, Payment, Invoice, InvoiceItem
|
||||
from installations.models import InstallationReport, InstallationItemChange
|
||||
|
||||
from processes.utils import get_scoped_instance_or_404
|
||||
|
||||
@login_required
|
||||
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(
|
||||
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
|
||||
id=instance_id
|
||||
|
@ -68,7 +72,7 @@ def quote_step(request, instance_id, step_id):
|
|||
@login_required
|
||||
def create_quote(request, instance_id, step_id):
|
||||
"""ساخت/بروزرسانی پیشفاکتور از اقلام انتخابی"""
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
# enforce permission: only BROKER can create/update quote
|
||||
profile = getattr(request.user, 'profile', None)
|
||||
|
@ -219,6 +223,9 @@ def create_quote(request, instance_id, step_id):
|
|||
@login_required
|
||||
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(
|
||||
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
|
||||
|
@ -261,7 +268,7 @@ def quote_preview_step(request, instance_id, step_id):
|
|||
@login_required
|
||||
def quote_print(request, instance_id):
|
||||
"""صفحه پرینت پیشفاکتور"""
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
quote = get_object_or_404(Quote, process_instance=instance)
|
||||
|
||||
return render(request, 'invoices/quote_print.html', {
|
||||
|
@ -274,7 +281,7 @@ def quote_print(request, instance_id):
|
|||
@login_required
|
||||
def approve_quote(request, instance_id, step_id):
|
||||
"""تایید پیشفاکتور و انتقال به مرحله بعدی"""
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
quote = get_object_or_404(Quote, process_instance=instance)
|
||||
# enforce permission: only BROKER can approve
|
||||
|
@ -316,6 +323,9 @@ def approve_quote(request, instance_id, step_id):
|
|||
@login_required
|
||||
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(
|
||||
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
|
||||
id=instance_id
|
||||
|
@ -408,7 +418,7 @@ def quote_payment_step(request, instance_id, step_id):
|
|||
defaults={'approved_by': request.user, 'decision': 'rejected', '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:
|
||||
if instance.current_step and instance.current_step.order > step.order:
|
||||
instance.current_step = step
|
||||
|
@ -449,7 +459,7 @@ def quote_payment_step(request, instance_id, step_id):
|
|||
@login_required
|
||||
def add_quote_payment(request, instance_id, step_id):
|
||||
"""افزودن فیش واریزی جدید برای پیشفاکتور"""
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
quote = get_object_or_404(Quote, process_instance=instance)
|
||||
invoice, _ = Invoice.objects.get_or_create(
|
||||
|
@ -564,7 +574,7 @@ def add_quote_payment(request, instance_id, step_id):
|
|||
@require_POST
|
||||
@login_required
|
||||
def delete_quote_payment(request, instance_id, step_id, payment_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
quote = get_object_or_404(Quote, process_instance=instance)
|
||||
invoice = Invoice.objects.filter(quote=quote).first()
|
||||
|
@ -632,6 +642,9 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
|
|||
@login_required
|
||||
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(
|
||||
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
|
||||
id=instance_id
|
||||
|
@ -770,7 +783,7 @@ def final_invoice_step(request, instance_id, step_id):
|
|||
|
||||
@login_required
|
||||
def final_invoice_print(request, instance_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
items = invoice.items.select_related('item').filter(is_deleted=False).all()
|
||||
return render(request, 'invoices/final_invoice_print.html', {
|
||||
|
@ -783,7 +796,7 @@ def final_invoice_print(request, instance_id):
|
|||
@require_POST
|
||||
@login_required
|
||||
def approve_final_invoice(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
# only MANAGER can approve
|
||||
|
@ -792,14 +805,7 @@ def approve_final_invoice(request, instance_id, step_id):
|
|||
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
|
||||
except Exception:
|
||||
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
|
||||
# Block approval when there is any remaining (positive or negative)
|
||||
invoice.calculate_totals()
|
||||
# if invoice.remaining_amount != 0:
|
||||
# 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.status = 'completed'
|
||||
step_instance.completed_at = timezone.now()
|
||||
|
@ -818,7 +824,7 @@ def approve_final_invoice(request, instance_id, step_id):
|
|||
@login_required
|
||||
def add_special_charge(request, instance_id, step_id):
|
||||
"""افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی بهصورت آیتم جداگانه"""
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
# only MANAGER can add special charges
|
||||
try:
|
||||
|
@ -826,7 +832,7 @@ def add_special_charge(request, instance_id, step_id):
|
|||
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403)
|
||||
except Exception:
|
||||
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403)
|
||||
# charge_type was removed from UI; we no longer require it
|
||||
|
||||
item_id = request.POST.get('item_id')
|
||||
amount = (request.POST.get('amount') or '').strip()
|
||||
if not item_id:
|
||||
|
@ -841,7 +847,7 @@ def add_special_charge(request, instance_id, step_id):
|
|||
# Fetch existing special item from DB
|
||||
special_item = get_object_or_404(Item, id=item_id, is_special=True)
|
||||
|
||||
from .models import InvoiceItem
|
||||
|
||||
InvoiceItem.objects.create(
|
||||
invoice=invoice,
|
||||
item=special_item,
|
||||
|
@ -855,7 +861,7 @@ def add_special_charge(request, instance_id, step_id):
|
|||
@require_POST
|
||||
@login_required
|
||||
def delete_special_charge(request, instance_id, step_id, item_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
# only MANAGER can delete special charges
|
||||
try:
|
||||
|
@ -863,7 +869,6 @@ def delete_special_charge(request, instance_id, step_id, item_id):
|
|||
return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403)
|
||||
except Exception:
|
||||
return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403)
|
||||
from .models import InvoiceItem
|
||||
inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice)
|
||||
# allow deletion only for special items
|
||||
try:
|
||||
|
@ -878,8 +883,9 @@ def delete_special_charge(request, instance_id, step_id, item_id):
|
|||
|
||||
@login_required
|
||||
def final_settlement_step(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
|
||||
if not instance.can_access_step(step):
|
||||
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
|
||||
return redirect('processes:request_list')
|
||||
|
@ -890,6 +896,7 @@ def final_settlement_step(request, instance_id, step_id):
|
|||
|
||||
# Ensure step instance exists
|
||||
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
|
||||
|
||||
# Build approver statuses for template
|
||||
reqs = list(step.approver_requirements.select_related('role').all())
|
||||
approvals_map = {a.role_id: a.decision for a in step_instance.approvals.select_related('role').all()}
|
||||
|
@ -947,6 +954,13 @@ def final_settlement_step(request, instance_id, step_id):
|
|||
defaults={'approved_by': request.user, 'decision': 'rejected', '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, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.')
|
||||
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
|
||||
|
||||
|
@ -975,7 +989,7 @@ def final_settlement_step(request, instance_id, step_id):
|
|||
@require_POST
|
||||
@login_required
|
||||
def add_final_payment(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
# Only BROKER can add final settlement payments
|
||||
|
@ -984,6 +998,7 @@ def add_final_payment(request, instance_id, step_id):
|
|||
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403)
|
||||
except Exception:
|
||||
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403)
|
||||
|
||||
amount = (request.POST.get('amount') or '').strip()
|
||||
payment_date = (request.POST.get('payment_date') or '').strip()
|
||||
payment_method = (request.POST.get('payment_method') or '').strip()
|
||||
|
@ -1038,12 +1053,14 @@ def add_final_payment(request, instance_id, step_id):
|
|||
)
|
||||
# After creation, totals auto-updated by model save. Respond with redirect and new totals for UX.
|
||||
invoice.refresh_from_db()
|
||||
# After payment change, set step back to in_progress
|
||||
|
||||
# On delete, return to awaiting approval
|
||||
try:
|
||||
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
|
||||
si.status = 'in_progress'
|
||||
si.completed_at = None
|
||||
si.save()
|
||||
si.approvals.all().delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
@ -1065,6 +1082,16 @@ def add_final_payment(request, instance_id, step_id):
|
|||
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]),
|
||||
|
@ -1079,7 +1106,7 @@ def add_final_payment(request, instance_id, step_id):
|
|||
@require_POST
|
||||
@login_required
|
||||
def delete_final_payment(request, instance_id, step_id, payment_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
|
||||
|
@ -1091,44 +1118,47 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
|
|||
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
|
||||
payment.delete()
|
||||
invoice.refresh_from_db()
|
||||
# After payment change, set step back to in_progress
|
||||
|
||||
# On delete, return to awaiting approval
|
||||
try:
|
||||
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
|
||||
si.status = 'in_progress'
|
||||
si.completed_at = None
|
||||
si.save()
|
||||
si.approvals.all().delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 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': {
|
||||
'final_amount': str(invoice.final_amount),
|
||||
'paid_amount': str(invoice.paid_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})
|
||||
|
|
|
@ -11,7 +11,7 @@ class City(NameSlugModel):
|
|||
return self.name
|
||||
|
||||
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:
|
||||
verbose_name = "شهرستان"
|
||||
|
|
|
@ -143,9 +143,9 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
|
|||
|
||||
@admin.register(StepInstance)
|
||||
class StepInstanceAdmin(SimpleHistoryAdmin):
|
||||
list_display = ['process_instance', 'step', 'assigned_to', 'status_display', 'rejection_count', 'edit_count', 'started_at', 'completed_at']
|
||||
list_display = ['process_instance', 'process_instance__code', 'step', 'assigned_to', 'status_display', 'rejection_count', 'edit_count', 'started_at', 'completed_at']
|
||||
list_filter = ['status', 'step__process', 'started_at']
|
||||
search_fields = ['process_instance__name', 'step__name', 'assigned_to__username']
|
||||
search_fields = ['process_instance__name', 'process_instance__code', 'step__name', 'assigned_to__username']
|
||||
readonly_fields = ['started_at', 'completed_at']
|
||||
ordering = ['process_instance', 'step__order']
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
{% extends '_base.html' %}
|
||||
{% extends '_base.html' %}
|
||||
{% load static %}
|
||||
{% load humanize %}
|
||||
{% load common_tags %}
|
||||
{% load processes_tags %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'sidebars/admin.html' %}
|
||||
|
@ -15,23 +16,34 @@
|
|||
|
||||
{% block content %}
|
||||
{% include '_toasts.html' %}
|
||||
|
||||
<!-- Instance Info Modal -->
|
||||
{% instance_info_modal instance %}
|
||||
|
||||
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h4 class="mb-1">گزارش نهایی درخواست {{ instance.code }}</h4>
|
||||
<small class="text-muted d-block">
|
||||
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
|
||||
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
|
||||
<small class="text-muted d-block">
|
||||
{% instance_info instance %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if invoice %}
|
||||
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت فاکتور</a>
|
||||
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
|
||||
<i class="bx bx-printer me-2"></i> پرینت فاکتور
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'certificates:certificate_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت گواهی</a>
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
|
||||
<a href="{% url 'certificates:certificate_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
|
||||
<i class="bx bx-printer me-2"></i> پرینت گواهی
|
||||
</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>
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{% extends '_base.html' %}
|
||||
{% load static %}
|
||||
{% load accounts_tags %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'sidebars/admin.html' %}
|
||||
|
@ -43,10 +44,12 @@
|
|||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{% if request.user|is_broker %}
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#requestModal">
|
||||
<i class="bx bx-plus me-1"></i>
|
||||
درخواست جدید
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -132,6 +135,69 @@
|
|||
</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-datatable table-responsive">
|
||||
<table id="requests-table" class="datatables-basic table border-top">
|
||||
|
@ -178,7 +244,7 @@
|
|||
</div>
|
||||
</td>
|
||||
<td>{{ item.instance.get_status_display_with_color|safe }}</td>
|
||||
<td>{{ item.instance.jcreated }}</td>
|
||||
<td>{{ item.instance.jcreated_date }}</td>
|
||||
<td>
|
||||
<div class="d-inline-block">
|
||||
<a href="javascript:;" class="btn btn-icon dropdown-toggle hide-arrow" data-bs-toggle="dropdown">
|
||||
|
@ -196,19 +262,31 @@
|
|||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if request.user|is_broker %}
|
||||
<div class="dropdown-divider"></div>
|
||||
<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'))">
|
||||
<i class="bx bx-trash me-1"></i>حذف
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="11" class="text-center text-muted">موردی ثبت نشده است</td>
|
||||
<td 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>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
@ -479,7 +557,7 @@
|
|||
$('#requests-table').DataTable({
|
||||
pageLength: 10,
|
||||
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, "همه"]],
|
||||
order: [[0, 'desc']],
|
||||
order: [],
|
||||
responsive: true,
|
||||
});
|
||||
let currentWellId = null;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
from ..models import ProcessInstance, StepInstance
|
||||
from ..utils import count_incomplete_instances
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
@ -104,3 +105,8 @@ def instance_info(instance, modal_id=None):
|
|||
title="اطلاعات کامل چاه و نماینده"></i>
|
||||
'''
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def incomplete_requests_count(user):
|
||||
return count_incomplete_instances(user)
|
||||
|
|
118
processes/utils.py
Normal file
118
processes/utils.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
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 []
|
||||
|
||||
|
|
@ -7,19 +7,62 @@ from django.http import JsonResponse
|
|||
from django.views.decorators.http import require_POST, require_GET
|
||||
from django.db import transaction
|
||||
from django.contrib.auth import get_user_model
|
||||
from .models import Process, ProcessInstance, StepInstance
|
||||
from .models import Process, ProcessInstance, StepInstance, ProcessStep
|
||||
from .utils import scope_instances_queryset, get_scoped_instance_or_404
|
||||
from installations.models import InstallationAssignment
|
||||
from wells.models import Well
|
||||
from accounts.models import Profile
|
||||
from accounts.models import Profile, Broker
|
||||
from locations.models import Affairs
|
||||
from accounts.forms import CustomerForm
|
||||
from wells.forms import WellForm
|
||||
from wells.models import WaterMeterManufacturer
|
||||
from common.consts import UserRoles
|
||||
|
||||
|
||||
@login_required
|
||||
def request_list(request):
|
||||
"""نمایش لیست درخواستها با جدول و مدال ایجاد"""
|
||||
instances = ProcessInstance.objects.select_related('well', 'representative', 'requester').prefetch_related('step_instances__step').filter(is_deleted=False).order_by('-created')
|
||||
instances = ProcessInstance.objects.select_related('well', 'representative', 'requester', 'broker', 'current_step', 'process').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)
|
||||
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')
|
||||
|
||||
# Calculate progress for each instance
|
||||
|
@ -52,6 +95,16 @@ def request_list(request):
|
|||
'completed_count': completed_count,
|
||||
'in_progress_count': in_progress_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,
|
||||
})
|
||||
|
||||
|
||||
|
@ -125,6 +178,13 @@ def lookup_representative_by_national_code(request):
|
|||
def create_request_with_entities(request):
|
||||
"""ایجاد/بهروزرسانی چاه و نماینده و سپس ایجاد درخواست"""
|
||||
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 = Process.objects.get(id=process_id)
|
||||
description = request.POST.get('description', '')
|
||||
|
@ -230,6 +290,14 @@ def create_request_with_entities(request):
|
|||
well.broker = current_profile.broker
|
||||
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
|
||||
instance = ProcessInstance.objects.create(
|
||||
process=process,
|
||||
|
@ -261,7 +329,17 @@ def create_request_with_entities(request):
|
|||
@login_required
|
||||
def delete_request(request, instance_id):
|
||||
"""حذف درخواست"""
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
instance = get_scoped_instance_or_404(request, 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
|
||||
if instance.status == 'completed':
|
||||
return JsonResponse({
|
||||
|
@ -278,10 +356,10 @@ def delete_request(request, instance_id):
|
|||
@login_required
|
||||
def step_detail(request, instance_id, step_id):
|
||||
"""نمایش جزئیات مرحله خاص"""
|
||||
instance = get_object_or_404(
|
||||
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
|
||||
id=instance_id
|
||||
)
|
||||
# Enforce scoped access to prevent URL tampering
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
# Prefetch for performance
|
||||
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)
|
||||
# If the request is already completed, redirect to read-only summary page
|
||||
if instance.status == 'completed':
|
||||
|
@ -339,7 +417,8 @@ def step_detail(request, instance_id, step_id):
|
|||
@login_required
|
||||
def instance_steps(request, instance_id):
|
||||
"""هدایت به مرحله فعلی instance"""
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
# Enforce scoped access to prevent URL tampering
|
||||
instance = get_scoped_instance_or_404(request, instance_id)
|
||||
|
||||
if not instance.current_step:
|
||||
# اگر مرحله فعلی تعریف نشده، به اولین مرحله برو
|
||||
|
@ -361,6 +440,9 @@ def instance_steps(request, instance_id):
|
|||
@login_required
|
||||
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)
|
||||
# Only show for completed requests; otherwise route to steps
|
||||
if instance.status != 'completed':
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{% load static %}
|
||||
{% load accounts_tags %}
|
||||
<!-- Menu -->
|
||||
|
||||
<aside id="layout-menu" class="layout-menu menu-vertical menu bg-menu-theme">
|
||||
|
@ -108,9 +109,12 @@
|
|||
<a href="{% url 'processes:request_list' %}" class="menu-link">
|
||||
<i class="menu-icon tf-icons bx bx-user"></i>
|
||||
<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>
|
||||
</li>
|
||||
|
||||
{% if request.user|is_admin or request.user|is_broker or request.user|is_manager or request.user|is_accountant %}
|
||||
<!-- Customers -->
|
||||
<li class="menu-header small text-uppercase">
|
||||
<span class="menu-header-text">مشترکها</span>
|
||||
|
@ -131,11 +135,11 @@
|
|||
<div class="text-truncate">چاهها</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Apps & Pages -->
|
||||
<li class="menu-header small text-uppercase">
|
||||
<li class="menu-header small text-uppercase d-none">
|
||||
<span class="menu-header-text">گزارشها</span>
|
||||
</li>
|
||||
|
||||
|
|
|
@ -82,12 +82,10 @@ class WellForm(forms.ModelForm):
|
|||
'utm_x': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'X UTM',
|
||||
'step': '0.000001'
|
||||
}),
|
||||
'utm_y': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Y UTM',
|
||||
'step': '0.000001'
|
||||
}),
|
||||
'utm_zone': forms.NumberInput(attrs={
|
||||
'class': 'form-control',
|
||||
|
|
|
@ -78,14 +78,14 @@ class Well(SluggedModel):
|
|||
|
||||
utm_x = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=6,
|
||||
decimal_places=0,
|
||||
verbose_name="X UTM",
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
utm_y = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=6,
|
||||
decimal_places=0,
|
||||
verbose_name="Y UTM",
|
||||
null=True,
|
||||
blank=True
|
||||
|
|
|
@ -163,12 +163,19 @@
|
|||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-4">
|
||||
<td class="text-center py-4">
|
||||
<div class="text-muted">
|
||||
<i class="ti ti-database-off ti-lg mb-2"></i>
|
||||
<p>چاهی یافت نشد</p>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
|
@ -7,17 +7,23 @@ from django.contrib import messages
|
|||
from django import forms
|
||||
from .models import Well, WaterMeterManufacturer
|
||||
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):
|
||||
"""نمایش لیست چاهها"""
|
||||
wells = Well.objects.select_related(
|
||||
base = Well.objects.select_related(
|
||||
'representative',
|
||||
'water_meter_manufacturer',
|
||||
'affairs',
|
||||
'county',
|
||||
'broker'
|
||||
).filter(is_deleted=False)
|
||||
wells = scope_wells_queryset(request.user, base)
|
||||
|
||||
# فرم برای افزودن چاه جدید
|
||||
form = WellForm()
|
||||
|
@ -31,6 +37,8 @@ def well_list(request):
|
|||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
|
||||
def add_well_ajax(request):
|
||||
"""AJAX endpoint for adding wells"""
|
||||
try:
|
||||
|
@ -87,6 +95,8 @@ def add_well_ajax(request):
|
|||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
|
||||
def edit_well_ajax(request, well_id):
|
||||
"""AJAX endpoint for editing wells"""
|
||||
well = get_object_or_404(Well, id=well_id)
|
||||
|
@ -141,6 +151,8 @@ def edit_well_ajax(request, well_id):
|
|||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
|
||||
def delete_well(request, well_id):
|
||||
"""حذف چاه"""
|
||||
well = get_object_or_404(Well, id=well_id)
|
||||
|
@ -154,6 +166,7 @@ def delete_well(request, well_id):
|
|||
|
||||
|
||||
@require_GET
|
||||
@login_required
|
||||
def get_well_data(request, well_id):
|
||||
"""دریافت اطلاعات چاه برای ویرایش"""
|
||||
well = get_object_or_404(Well, id=well_id)
|
||||
|
@ -183,6 +196,7 @@ def get_well_data(request, well_id):
|
|||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def create_water_meter_manufacturer(request):
|
||||
"""ایجاد شرکت سازنده کنتور آب جدید"""
|
||||
form = WaterMeterManufacturerForm(request.POST)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue