Compare commits

...

11 commits

33 changed files with 1057 additions and 309 deletions

View file

@ -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()

View file

@ -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>

View file

@ -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">

View file

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

View file

@ -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)

View file

@ -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)

View file

@ -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>

View file

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

View file

@ -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', {

View file

@ -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):

View file

@ -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,

Binary file not shown.

View file

@ -42,8 +42,8 @@ class InstallationReport(BaseModel):
new_water_meter_serial = models.CharField(max_length=50, null=True, blank=True, verbose_name='سریال کنتور جدید')
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='تایید شده')

View file

@ -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>

View file

@ -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">
@ -347,24 +363,28 @@
</div>
</div>
</div>
</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(_) {}
})();

View file

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

View file

@ -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>
{% load static %}
{% load humanize %}
{% 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>
<!-- 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>

View file

@ -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>

View file

@ -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 %}
آیا از تایید این مرحله اطمینان دارید؟

View file

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

View file

@ -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})

View file

@ -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 = "شهرستان"

View file

@ -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']

View file

@ -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>

View file

@ -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;

View file

@ -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
View 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 []

View file

@ -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':

View file

@ -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>

View file

@ -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',

View file

@ -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

View file

@ -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>

View file

@ -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)