Merge remote-tracking branch 'origin/main' into shafafiyat/production

This commit is contained in:
Hadi 2025-09-13 13:38:21 +03:30
commit 20f00b786e
54 changed files with 2391 additions and 758 deletions

View file

@ -33,9 +33,9 @@ class ProfileAdmin(admin.ModelAdmin):
@admin.register(Company) @admin.register(Company)
class CompanyAdmin(admin.ModelAdmin): class CompanyAdmin(admin.ModelAdmin):
list_display = ['name', 'logo', 'signature', 'address', 'phone'] list_display = ['name', 'logo', 'signature', 'address', 'phone', 'broker', 'registration_number']
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
search_fields = ['name', 'address', 'phone'] search_fields = ['name', 'address', 'phone']
list_filter = ['is_active'] list_filter = ['is_active', 'broker']
date_hierarchy = 'created' date_hierarchy = 'created'
ordering = ['-created'] ordering = ['-created']

View file

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

View file

@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2025-09-07 13:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
('locations', '0003_remove_broker_company'),
]
operations = [
migrations.AddField(
model_name='company',
name='broker',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='company', to='locations.broker', verbose_name='کارگزار'),
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 5.2.4 on 2025-09-07 14:11
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_company_broker'),
]
operations = [
migrations.AddField(
model_name='company',
name='account_number',
field=models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب'),
),
migrations.AddField(
model_name='company',
name='bank_name',
field=models.CharField(blank=True, choices=[('mellat', 'بانک ملت'), ('saman', 'بانک سامان'), ('parsian', 'بانک پارسیان'), ('sina', 'بانک سینا'), ('tejarat', 'بانک تجارت'), ('tosee', 'بانک توسعه'), ('iran_zamin', 'بانک ایران زمین'), ('meli', 'بانک ملی'), ('saderat', 'بانک توسعه صادرات'), ('iran_zamin', 'بانک ایران زمین'), ('refah', 'بانک رفاه'), ('eghtesad_novin', 'بانک اقتصاد نوین'), ('pasargad', 'بانک پاسارگاد'), ('other', 'سایر')], max_length=255, null=True, verbose_name='نام بانک'),
),
migrations.AddField(
model_name='company',
name='card_number',
field=models.CharField(blank=True, max_length=16, null=True, validators=[django.core.validators.RegexValidator(code='invalid_card_number', message='شماره کارت باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره کارت'),
),
migrations.AddField(
model_name='company',
name='sheba_number',
field=models.CharField(blank=True, max_length=30, null=True, verbose_name='شماره شبا'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-07 14:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_company_account_number_company_bank_name_and_more'),
]
operations = [
migrations.AddField(
model_name='company',
name='branch_name',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='شعبه بانک'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-08 10:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_company_branch_name'),
]
operations = [
migrations.AddField(
model_name='company',
name='registration_number',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='شماره ثبت شرکت'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-08 10:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_company_registration_number'),
]
operations = [
migrations.AddField(
model_name='company',
name='card_holder_name',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='نام دارنده کارت'),
),
]

View file

@ -181,11 +181,94 @@ class Profile(BaseModel):
class Company(NameSlugModel): class Company(NameSlugModel):
logo = models.ImageField(upload_to='companies/logos', null=True, blank=True, verbose_name='لوگوی شرکت') logo = models.ImageField(
signature = models.ImageField(upload_to='companies/signatures', null=True, blank=True, verbose_name='امضای شرکت') upload_to='companies/logos',
address = models.TextField(null=True, blank=True, verbose_name='آدرس') null=True,
phone = models.CharField(max_length=11, null=True, blank=True, verbose_name='شماره تماس') blank=True,
verbose_name='لوگوی شرکت'
)
signature = models.ImageField(
upload_to='companies/signatures',
null=True,
blank=True,
verbose_name='امضای شرکت'
)
address = models.TextField(
null=True,
blank=True,
verbose_name='آدرس'
)
phone = models.CharField(
max_length=11,
null=True,
blank=True,
verbose_name='شماره تماس'
)
registration_number = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name='شماره ثبت شرکت'
)
broker = models.OneToOneField(
Broker,
on_delete=models.SET_NULL,
verbose_name="کارگزار",
null=True,
blank=True,
related_name='company'
)
card_number = models.CharField(
max_length=16,
null=True,
verbose_name="شماره کارت",
blank=True,
validators=[
RegexValidator(
regex=r'^\d+$',
message='شماره کارت باید فقط شامل اعداد باشد.',
code='invalid_card_number'
)
]
)
account_number = models.CharField(
max_length=20,
null=True,
verbose_name="شماره حساب",
blank=True,
validators=[
RegexValidator(
regex=r'^\d+$',
message='شماره حساب باید فقط شامل اعداد باشد.',
code='invalid_account_number'
)
]
)
card_holder_name = models.CharField(
max_length=255,
null=True,
verbose_name="نام دارنده کارت",
blank=True,
)
sheba_number = models.CharField(
max_length=30,
null=True,
verbose_name="شماره شبا",
blank=True,
)
bank_name = models.CharField(
max_length=255,
choices=BANK_CHOICES,
null=True,
verbose_name="نام بانک",
blank=True
)
branch_name = models.CharField(
max_length=255,
null=True,
verbose_name="شعبه بانک",
blank=True
)
class Meta: class Meta:
verbose_name = 'شرکت' verbose_name = 'شرکت'
verbose_name_plural = 'شرکت‌ها' verbose_name_plural = 'شرکت‌ها'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,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 %} <!-- Fonts (match project) -->
<div class="container py-4"> <link rel="preconnect" href="https://fonts.googleapis.com">
<div class="text-center mb-4"> <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 %} {% if template.company and template.company.logo %}
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:90px"> <img src="{{ template.company.logo.url }}" alt="logo" style="max-height:90px">
{% endif %} {% endif %}
<h4 class="mt-2">{{ cert.rendered_title }}</h4> <h4 class="mt-2">{{ cert.rendered_title }}</h4>
{% if template.company %}<div class="text-muted">{{ template.company.name }}</div>{% endif %} {% if template.company %}
<div class="text-muted company-name">{{ template.company.name }}</div>
{% endif %}
</div> </div>
<div style="white-space:pre-line; line-height:1.9;">
<!-- Certificate body -->
<div class="body-text">
{{ cert.rendered_body|safe }} {{ cert.rendered_body|safe }}
</div> </div>
<div class="mt-5 d-flex justify-content-between">
<div>تاریخ: {{ cert.issued_at }}</div> <!-- Signature -->
<div class="signature-section d-flex justify-content-end">
<div class="text-center"> <div class="text-center">
<div>مهر و امضای تایید کننده</div>
<div class="text-muted">{{ template.company.name }}</div>
{% if template.company and template.company.signature %} {% if template.company and template.company.signature %}
<img src="{{ template.company.signature.url }}" alt="seal" style="max-height:120px"> <img src="{{ template.company.signature.url }}" alt="seal" style="max-height:200px">
{% endif %} {% endif %}
<div>مهر و امضای شرکت</div>
</div> </div>
</div> </div>
</div> </div>
<script>window.print()</script>
{% endblock %}
<script>
window.onload = function() { window.print(); setTimeout(function(){ window.close(); }, 200); };
</script>
</body>
</html>

View file

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

View file

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

View file

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

View file

@ -2,45 +2,71 @@
<html lang="fa" dir="rtl"> <html lang="fa" dir="rtl">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>چاپ قرارداد {{ instance.code }}</title> <title>چاپ قرارداد {{ instance.code }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> {% load static %}
<!-- Match app fonts and theme -->
<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">
<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/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> <style>
@page { size: A4; margin: 1.2cm; } @page { size: A4; margin: 1.2cm; }
body { font-family: 'Vazirmatn', sans-serif; } .invoice-header { border-bottom: 1px solid #dee2e6; padding-bottom: 16px; margin-bottom: 24px; }
.logo { max-height: 80px; } .brand-box { width:64px; height:64px; display:flex; align-items:center; justify-content:center; background:#eef2ff; border-radius:8px; }
.signature { height: 90px; border: 1px dashed #ccc; } .logo { max-height: 58px; max-width: 120px; }
.contract-title { font-size: 20px; font-weight: 600; }
.small-muted { font-size: 12px; color: #6c757d; }
.signature-box { border: 1px dashed #ccc; height: 210px; display:flex; align-items:center; justify-content:center; }
</style> </style>
<script> <script>
window.addEventListener('load', function(){ setTimeout(function(){ window.print(); }, 300); }); window.onload = function(){
window.print();
setTimeout(function(){ window.close(); }, 200);
};
</script> </script>
</head> </head>
<body> <body>
<div class="container-fluid"> <div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-3"> <!-- Header: Company and contract info -->
<div> <div class="invoice-header">
<h5>{{ contract.template.company.name }}</h5> <div class="small text-end text-muted mb-2">تاریخ: {{ contract.jcreated_date }} | کد درخواست: {{ instance.code }}</div>
<h5 class="mb-1">{{ contract.template.name }}</h5> <h5 class="text-center mb-3">
<div class="text-muted small">کد درخواست: {{ instance.code }} | تاریخ: {{ contract.jcreated }}</div> {% if instance.broker and instance.broker.company %}
</div> {{ instance.broker.company.name }}
{% if contract.template.company.logo %} {% elif template.company %}
<img class="logo" src="{{ contract.template.company.logo.url }}" alt="لوگو" /> {{ template.company.name }}
{% else %}
شرکت آب منطقه‌ای
{% endif %} {% endif %}
</h5>
<h4 class="text-center mb-3">{{ contract.template.name }}</h4>
</div> </div>
<hr>
<!-- Contract body -->
<div style="white-space: pre-line; line-height: 1.9;">{{ contract.rendered_body|safe }}</div> <div style="white-space: pre-line; line-height: 1.9;">{{ contract.rendered_body|safe }}</div>
<hr> <hr>
<!-- Signatures -->
<div class="row mt-4"> <div class="row mt-4">
<div class="col-6 text-center"> <div class="col-6 text-center">
<div>امضای مشترک</div> <div>امضای مشترک</div>
<div class="signature mt-2"></div> <div class="signature-box mt-2"></div>
</div> </div>
<div class="col-6 text-center"> <div class="col-6 text-center">
<div>امضای شرکت</div> <div>امضای شرکت</div>
<div class="signature mt-2"> <div class="signature-box mt-2">
{% if contract.template.company.signature %} {% if instance.broker and instance.broker.company and instance.broker.company.signature %}
<img src="{{ contract.template.company.signature.url }}" alt="امضای شرکت" style="max-height: 80px;" /> <img src="{{ instance.broker.company.signature.url }}" alt="امضای شرکت" style="max-height: 200px;" />
{% elif contract.template.company and contract.template.company.signature %}
<img src="{{ contract.template.company.signature.url }}" alt="امضای شرکت" style="max-height: 200px;" />
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -19,6 +19,10 @@
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
@ -26,13 +30,18 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} {% instance_info instance %}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a> <a href="{% url 'contracts:contract_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<a href="{% url 'contracts:contract_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">پرینت</a> <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>
</div> </div>
@ -41,29 +50,32 @@
<div class="bs-stepper-content"> <div class="bs-stepper-content">
<div class="card border"> <div class="card border">
<div class="card-body"> <div class="card-body">
<div class="small text-end text-muted mb-2">تاریخ: {{ contract.jcreated_date }} | کد درخواست: {{ instance.code }}</div>
<h5 class="text-center mb-3">
{% if instance.broker and instance.broker.company %}
{{ instance.broker.company.name }}
{% elif template.company %}
{{ template.company.name }}
{% else %}
شرکت آب منطقه‌ای
{% endif %}</h5>
<h4 class="text-center mb-3">{{ contract.template.name }}</h4>
{% if can_view_contract_body %} {% if can_view_contract_body %}
{% if template.company.logo %}
<div class="text-center mb-3">
<img src="{{ template.company.logo.url }}" alt="لوگوی شرکت" style="max-height:80px;">
<h4 class="text-muted">{{ contract.template.company.name }}</h4>
<h5 class="text-muted">{{ contract.template.name }}</h5>
</div>
{% endif %}
<div class="small text-muted mb-2">تاریخ: {{ contract.jcreated }}</div>
<hr> <hr>
<div class="contract-body" style="white-space: pre-line; line-height:1.9;">{{ contract.rendered_body|safe }}</div> <div class="contract-body" style="white-space: pre-line; line-height:1.9;">{{ contract.rendered_body|safe }}</div>
<hr> <hr>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-6 text-center"> <div class="col-6 text-center">
<div>امضای مشترک</div> <div>امضای مشترک</div>
<div style="height:90px;border:1px dashed #ccc; margin-top:10px;"></div> <div style="height:210px;border:1px dashed #ccc; margin-top:10px;"></div>
</div> </div>
<div class="col-6 text-center"> <div class="col-6 text-center">
<div>امضای شرکت</div> <div>امضای شرکت</div>
<div style="height:90px;border:1px dashed #ccc; margin-top:10px;"> <div style="height:210px;border:1px dashed #ccc; margin-top:10px;">
{% if template.company.signature %} {% if instance.broker and instance.broker.company and instance.broker.company.signature %}
<img src="{{ template.company.signature.url }}" alt="امضای شرکت" style="max-height:80px;"> <img src="{{ instance.broker.company.signature.url }}" alt="امضای شرکت" style="max-height:200px;">
{% elif template.company and template.company.signature %}
<img src="{{ template.company.signature.url }}" alt="امضای شرکت" style="max-height:200px;">
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -76,15 +88,23 @@
<form method="post" class="d-flex justify-content-between mt-3"> <form method="post" class="d-flex justify-content-between mt-3">
{% csrf_token %} {% csrf_token %}
{% if previous_step %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a> <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">
<i class="bx bx-chevron-right bx-sm me-sm-2"></i>
قبلی
</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if next_step %} {% if next_step %}
{% if is_broker %} {% if is_broker %}
<button type="submit" class="btn btn-primary">تایید و بعدی</button> <button type="submit" class="btn btn-primary">تایید و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button>
{% else %} {% else %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a> <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 %}
{% else %} {% else %}
{% if is_broker %} {% if is_broker %}

View file

@ -2,43 +2,63 @@ from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from decimal import Decimal
from django.template import Template, Context from django.template import Template, Context
from django.utils.safestring import mark_safe
from processes.models import ProcessInstance, StepInstance from processes.models import ProcessInstance, StepInstance
from common.consts import UserRoles from common.consts import UserRoles
from .models import ContractTemplate, ContractInstance from .models import ContractTemplate, ContractInstance
from invoices.models import Invoice, Quote
from _helpers.utils import jalali_converter2 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: def build_contract_context(instance: ProcessInstance) -> dict:
representative = instance.representative representative = instance.representative
profile = getattr(representative, 'profile', None) profile = getattr(representative, 'profile', None)
well = instance.well well = instance.well
# Compute prepayment from Quote-linked invoice payments
quote = Quote.objects.filter(process_instance=instance).first()
invoice = Invoice.objects.filter(quote=quote).first() if quote else None
payments_qs = invoice.payments.filter(is_deleted=False, direction='in').all() if invoice else []
total_paid = sum((p.amount for p in payments_qs), Decimal('0'))
try:
latest_payment_date = max((p.payment_date for p in payments_qs)) if payments_qs else None
except Exception:
latest_payment_date = None
return { return {
'customer_full_name': representative.get_full_name() if representative else '', 'customer_full_name': mark_safe(f"<span class=\"fw-bold\">{representative.get_full_name() if representative else ''}</span>"),
'national_code': profile.national_code if profile else '', 'registration_number': mark_safe(f"<span class=\"fw-bold\">{instance.broker.company.registration_number if instance.broker and instance.broker.company else ''}</span>"),
'address': profile.address if profile else '', 'national_code': mark_safe(f"<span class=\"fw-bold\">{profile.national_code if profile else ''}</span>"),
'phone': profile.phone_number_1 if profile else '', 'address': mark_safe(f"<span class=\"fw-bold\">{profile.address if profile else ''}</span>"),
'phone2': profile.phone_number_2 if profile else '', 'phone': mark_safe(f"<span class=\"fw-bold\">{profile.phone_number_1 if profile else ''}</span>"),
'water_subscription_number': well.water_subscription_number if well else '', 'phone2': mark_safe(f"<span class=\"fw-bold\">{profile.phone_number_2 if profile else ''}</span>"),
'electricity_subscription_number': well.electricity_subscription_number if well else '', 'water_subscription_number': mark_safe(f"<span class=\"fw-bold\">{well.water_subscription_number if well else ''}</span>"),
'water_meter_serial_number': well.water_meter_serial_number if well else '', 'electricity_subscription_number': mark_safe(f"<span class=\"fw-bold\">{well.electricity_subscription_number if well else ''}</span>"),
'well_power': well.well_power if well else '', 'water_meter_serial_number': mark_safe(f"<span class=\"fw-bold\">{well.water_meter_serial_number if well else ''}</span>"),
'request_code': instance.code, 'well_power': mark_safe(f"<span class=\"fw-bold\">{well.well_power if well else ''}</span>"),
'today': jalali_converter2(timezone.now()), 'request_code': mark_safe(f"<span class=\"fw-bold\">{instance.code}</span>"),
'today': mark_safe(f"<span class=\"fw-bold\">{jalali_converter2(timezone.now())}</span>"),
'company_name': mark_safe(f"<span class=\"fw-bold\">{instance.broker.company.name if instance.broker and instance.broker.company else ''}</span>"),
'city_name': mark_safe(f"<span class=\"fw-bold\">{instance.broker.affairs.county.city.name if instance.broker and instance.broker.affairs and instance.broker.affairs.county and instance.broker.affairs.county.city else ''}</span>"),
'card_number': mark_safe(f"<span class=\"fw-bold\">{instance.representative.profile.card_number if instance.representative else ''}</span>"),
'account_number': mark_safe(f"<span class=\"fw-bold\">{instance.representative.profile.account_number if instance.representative else ''}</span>"),
'bank_name': mark_safe(f"<span class=\"fw-bold\">{instance.representative.profile.get_bank_name_display() if instance.representative else ''}</span>"),
'prepayment_amount': mark_safe(f"<span class=\"fw-bold\">{int(total_paid):,}</span>"),
'prepayment_date': mark_safe(f"<span class=\"fw-bold\">{jalali_converter2(latest_payment_date)}</span>") if latest_payment_date else '',
} }
@login_required @login_required
def contract_step(request, instance_id, step_id): 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 # Resolve step navigation
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
previous_step = instance.process.steps.filter(order__lt=step.order).last() previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first() next_step = instance.process.steps.filter(order__gt=step.order).first()
# Access control:
# - INSTALLER: can open step but cannot view contract body (show inline message)
# - Others: can view
# - Only BROKER can submit/complete this step
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
is_broker = False is_broker = False
can_view_contract_body = True can_view_contract_body = True
@ -72,7 +92,6 @@ def contract_step(request, instance_id, step_id):
# If user submits to go next, only broker can complete and go to next # If user submits to go next, only broker can complete and go to next
if request.method == 'POST': if request.method == 'POST':
if not is_broker: if not is_broker:
from django.http import JsonResponse
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
StepInstance.objects.update_or_create( StepInstance.objects.update_or_create(
process_instance=instance, process_instance=instance,
@ -99,7 +118,7 @@ def contract_step(request, instance_id, step_id):
@login_required @login_required
def contract_print(request, instance_id): def contract_print(request, instance_id):
instance = get_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) contract = get_object_or_404(ContractInstance, process_instance=instance)
return render(request, 'contracts/contract_print.html', { return render(request, 'contracts/contract_print.html', {
'instance': instance, 'instance': instance,

BIN
db.sqlite3 Normal file

Binary file not shown.

View file

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

View file

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

View file

@ -35,7 +35,12 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
@ -43,11 +48,13 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} {% instance_info instance %}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </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>
<div class="bs-stepper wizard-vertical vertical mt-2"> <div class="bs-stepper wizard-vertical vertical mt-2">
@ -55,17 +62,14 @@
<div class="bs-stepper-content"> <div class="bs-stepper-content">
{% if report and not edit_mode %} {% if report and not edit_mode %}
<div class="card mb-3 border"> <div class="mb-3 text-end">
<div class="card-header d-flex justify-content-between align-items-center"> {% if user_is_installer %}
<div class="d-flex gap-2"> <a href="?edit=1" class="btn btn-primary">
{% if request.user|is_installer %} <i class="bx bx-edit bx-sm me-2"></i>
<a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a> ویرایش گزارش نصب
{% else %} </a>
<button type="button" class="btn btn-primary" disabled>ویرایش گزارش نصب</button>
{% endif %} {% endif %}
</div> </div>
</div>
<div class="card-body">
{% if step_instance and step_instance.status == 'rejected' and step_instance.get_latest_rejection %} {% if step_instance and step_instance.status == 'rejected' and step_instance.get_latest_rejection %}
<div class="alert alert-danger d-flex align-items-start" role="alert"> <div class="alert alert-danger d-flex align-items-start" role="alert">
<i class="bx bx-error-circle me-2"></i> <i class="bx bx-error-circle me-2"></i>
@ -75,6 +79,8 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="card mb-3 border">
<div class="card-body">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}</p>
@ -151,7 +157,7 @@
<h6 class="mb-0">وضعیت تاییدها</h6> <h6 class="mb-0">وضعیت تاییدها</h6>
{% if user_can_approve %} {% if user_can_approve %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveModal" {% 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> <button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectModal">رد</button>
</div> </div>
{% endif %} {% endif %}
@ -184,18 +190,28 @@
<!-- Persistent nav in edit mode (outside cards) --> <!-- Persistent nav in edit mode (outside cards) -->
<div class="d-flex justify-content-between mt-3"> <div class="d-flex justify-content-between mt-3">
{% if previous_step %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a> <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 %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a> <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> </div>
{% else %} {% 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 %} {% endif %}
{% if user_is_installer %}
<!-- Installation Report Form -->
<form method="post" enctype="multipart/form-data" id="installation-report-form"> <form method="post" enctype="multipart/form-data" id="installation-report-form">
{% csrf_token %} {% csrf_token %}
<div class="mb-3"> <div class="mb-3">
@ -203,40 +219,40 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">تاریخ مراجعه</label> <label class="form-label">تاریخ مراجعه</label>
<input type="text" id="id_visited_date_display" class="form-control" placeholder="انتخاب تاریخ" {% if not 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 %}"> <input type="hidden" id="id_visited_date" name="visited_date" value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y-m-d' }}{% endif %}">
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">سریال کنتور جدید</label> <label class="form-label">سریال کنتور جدید</label>
<input type="text" class="form-control" name="new_water_meter_serial" value="{% if report and edit_mode %}{{ report.new_water_meter_serial|default_if_none:'' }}{% endif %}" {% if not 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>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">شماره پلمپ</label> <label class="form-label">شماره پلمپ</label>
<input type="text" class="form-control" name="seal_number" value="{% if report and edit_mode %}{{ report.seal_number|default_if_none:'' }}{% endif %}" {% if not 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>
<div class="col-md-3 d-flex align-items-end"> <div class="col-md-3 d-flex align-items-end">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" name="is_meter_suspicious" id="id_is_meter_suspicious" {% if not 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> <label class="form-check-label" for="id_is_meter_suspicious">کنتور مشکوک است</label>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">UTM X</label> <label class="form-label">UTM X</label>
<input type="number" step="0.000001" class="form-control" name="utm_x" value="{% if 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>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">UTM Y</label> <label class="form-label">UTM Y</label>
<input type="number" step="0.000001" class="form-control" name="utm_y" value="{% if 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> </div>
<div class="my-3"> <div class="my-3">
<label class="form-label">توضیحات (اختیاری)</label> <label class="form-label">توضیحات (اختیاری)</label>
<textarea class="form-control" rows="3" name="description" {% if not 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>
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<label class="form-label mb-0">عکس‌ها</label> <label class="form-label mb-0">عکس‌ها</label>
{% if request.user|is_installer %} {% if user_is_installer %}
<button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button> <button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button>
{% endif %} {% endif %}
</div> </div>
@ -246,7 +262,7 @@
<div class="col-6 col-md-3 mb-2" id="existing-photo-{{ p.id }}"> <div class="col-6 col-md-3 mb-2" id="existing-photo-{{ p.id }}">
<div class="position-relative border rounded p-1"> <div class="position-relative border rounded p-1">
<img class="img-fluid rounded" src="{{ p.image.url }}" alt="photo"> <img class="img-fluid rounded" src="{{ p.image.url }}" alt="photo">
{% if 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> <button type="button" class="btn btn-sm btn-danger position-absolute" style="top:6px; left:6px;" onclick="markDeletePhoto('{{ p.id }}')" title="حذف/برگردان"><i class="bx bx-trash"></i></button>
{% endif %} {% endif %}
<input type="hidden" name="del_photo_{{ p.id }}" id="del-photo-{{ p.id }}" value="0"> <input type="hidden" name="del_photo_{{ p.id }}" id="del-photo-{{ p.id }}" value="0">
@ -350,21 +366,25 @@
</div> </div>
</form> </form>
{% endif %}
<div class="mt-3 d-flex justify-content-between"> <div class="mt-3 d-flex justify-content-between">
{% if previous_step %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a> <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 %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if request.user|is_installer %} {% if user_is_installer %}
<button type="submit" class="btn btn-primary" form="installation-report-form">ثبت گزارش</button> <button type="submit" class="btn btn-success" form="installation-report-form">ثبت گزارش</button>
{% else %}
<button type="button" class="btn btn-primary" disabled>ثبت گزارش</button>
{% endif %} {% endif %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-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 %} {% endif %}
</div> </div>
</div> </div>
@ -499,7 +519,6 @@
try { try {
if (sessionStorage.getItem('install_report_saved') === '1') { if (sessionStorage.getItem('install_report_saved') === '1') {
sessionStorage.removeItem('install_report_saved'); sessionStorage.removeItem('install_report_saved');
showToast('گزارش نصب با موفقیت ثبت شد', 'success');
} }
} catch(_) {} } catch(_) {}
})(); })();

View file

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

View file

@ -7,6 +7,7 @@ from decimal import Decimal
from django.utils import timezone from django.utils import timezone
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.conf import settings from django.conf import settings
from _helpers.utils import jalali_converter2
User = get_user_model() User = get_user_model()
@ -91,6 +92,7 @@ class Quote(NameSlugModel):
verbose_name="ایجاد کننده", verbose_name="ایجاد کننده",
related_name='created_quotes' related_name='created_quotes'
) )
history = HistoricalRecords() history = HistoricalRecords()
class Meta: class Meta:
@ -371,3 +373,6 @@ class Payment(BaseModel):
except Exception: except Exception:
pass pass
return result return result
def jpayment_date(self):
return jalali_converter2(self.payment_date)

View file

@ -1,55 +1,206 @@
{% extends '_base.html' %} <!DOCTYPE html>
{% load humanize %} <html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>فاکتور نهایی {{ invoice.name }} - {{ instance.code }}</title>
{% block content %} {% load static %}
<div class="container py-4"> {% load humanize %}
<div class="mb-4 d-flex justify-content-between align-items-center">
<div> <!-- Fonts (match base) -->
<h4 class="mb-1">فاکتور نهایی</h4> <link rel="preconnect" href="https://fonts.googleapis.com">
<small class="text-muted">کد درخواست: {{ instance.code }}</small> <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>
<div> <div>
<!-- Placeholders for logo/signature --> {% if instance.broker.company %}
<div class="text-end">لوگو</div> {{ 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> </div>
<div class="table-responsive"> <div class="col-6 text-end">
<table class="table table-bordered"> <div class="mt-2">
<div><strong>#فاکتور نهایی {{ instance.code }}</strong></div>
<div class="text-muted small">تاریخ صدور: {{ invoice.jcreated_date }}</div>
</div>
</div>
</div>
</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>
<!-- Items Table -->
<div class="mb-4">
<table class="table border-top m-0 items-table">
<thead> <thead>
<tr> <tr>
<th>آیتم</th> <th style="width: 5%">ردیف</th>
<th>تعداد</th> <th style="width: 30%">شرح کالا/خدمات</th>
<th>قیمت واحد</th> <th style="width: 30%">توضیحات</th>
<th>قیمت کل</th> <th style="width: 10%">تعداد</th>
<th style="width: 12.5%">قیمت واحد(تومان)</th>
<th style="width: 12.5%">قیمت کل(تومان)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for it in items %} {% for it in items %}
<tr> <tr>
<td>{{ it.item.name }}</td> <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.quantity }}</td>
<td>{{ it.unit_price|floatformat:0|intcomma:False }}</td> <td>{{ it.unit_price|floatformat:0|intcomma:False }}</td>
<td>{{ it.total_price|floatformat:0|intcomma:False }}</td> <td>{{ it.total_price|floatformat:0|intcomma:False }}</td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="4" class="text-center text-muted">آیتمی ندارد</td></tr> <tr><td colspan="6" class="text-center text-muted">آیتمی ندارد</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot> <tfoot>
<tr><th colspan="3" class="text-end">مبلغ کل</th><th>{{ invoice.total_amount|floatformat:0|intcomma:False }}</th></tr> <tr class="total-section">
<tr><th colspan="3" class="text-end">تخفیف</th><th>{{ invoice.discount_amount|floatformat:0|intcomma:False }}</th></tr> <td colspan="5" class="text-end"><strong>جمع کل(تومان):</strong></td>
<tr><th colspan="3" class="text-end">مبلغ نهایی</th><th>{{ invoice.final_amount|floatformat:0|intcomma:False }}</th></tr> <td><strong>{{ invoice.total_amount|floatformat:0|intcomma:False }}</strong></td>
<tr><th colspan="3" class="text-end">پرداختی‌ها</th><th>{{ invoice.paid_amount|floatformat:0|intcomma:False }}</th></tr> </tr>
<tr><th colspan="3" class="text-end">مانده</th><th>{{ invoice.remaining_amount|floatformat:0|intcomma:False }}</th></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> </tfoot>
</table> </table>
</div> </div>
<div class="mt-5 d-flex justify-content-between">
<div>امضا مشتری</div> <!-- Conditions & Payment -->
<div>امضا شرکت</div> <div class="row">
<div class="col-8">
<h6 class="fw-bold">مهر و امضا:</h6>
<ul class="small mb-0">
{% if instance.broker.company and instance.broker.company.signature %}
<li class="mt-3" style="list-style:none;"><img src="{{ instance.broker.company.signature.url }}" alt="امضا" style="height: 200px;"></li>
{% endif %}
</ul>
</div>
{% if instance.broker.company %}
<div class="col-4">
<h6 class="fw-bold mb-2">اطلاعات پرداخت</h6>
{% if instance.broker.company.card_number %}
<div class="small mb-1"><span class="text-muted">شماره کارت:</span> {{ instance.broker.company.card_number }}</div>
{% endif %}
{% if instance.broker.company.account_number %}
<div class="small mb-1"><span class="text-muted">شماره حساب:</span> {{ instance.broker.company.account_number }}</div>
{% endif %}
{% if instance.broker.company.sheba_number %}
<div class="small mb-1"><span class="text-muted">شماره شبا:</span> {{ instance.broker.company.sheba_number }}</div>
{% endif %}
{% if instance.broker.company.bank_name %}
<div class="small"><span class="text-muted">بانک:</span> {{ instance.broker.company.get_bank_name_display }}</div>
{% endif %}
</div>
{% endif %}
</div> </div>
</div>
<script>window.print()</script>
{% endblock %}
</div>
<script>
window.onload = function() {
window.print();
setTimeout(function(){ window.close(); }, 200);
};
</script>
</body>
</html>

View file

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

View file

@ -23,6 +23,10 @@
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
{% csrf_token %} {% csrf_token %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
@ -31,14 +35,18 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} {% instance_info instance %}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><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>
</div> </div>
@ -88,7 +96,7 @@
<input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required> <input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<button type="button" id="btnAddFinalPayment" class="btn btn-primary">افزودن</button> <button type="button" id="btnAddFinalPayment" class="btn btn-primary">افزودن فیش/چک</button>
</div> </div>
</form> </form>
</div> </div>
@ -150,7 +158,7 @@
<tr> <tr>
<td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td> <td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td>
<td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td> <td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
<td>{{ p.payment_date|date:'Y/m/d' }}</td> <td>{{ p.jpayment_date }}</td>
<td>{{ p.get_payment_method_display }}</td> <td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td> <td>{{ p.reference_number|default:'-' }}</td>
<td> <td>
@ -182,7 +190,7 @@
<h6 class="mb-0">وضعیت تاییدها</h6> <h6 class="mb-0">وضعیت تاییدها</h6>
{% if can_approve_reject %} {% if can_approve_reject %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveFinalSettleModal" {% 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> <button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
</div> </div>
{% endif %} {% endif %}
@ -214,13 +222,19 @@
{% endif %} {% endif %}
<div class="col-12 d-flex justify-content-between mt-3"> <div class="col-12 d-flex justify-content-between mt-3">
{% if previous_step %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a> <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 %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if step_instance.status == 'completed' %} {% if step_instance.status == 'completed' %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a> <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 %} {% else %}
<a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a> <a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
{% endif %} {% endif %}
@ -264,8 +278,8 @@
<div class="modal-body"> <div class="modal-body">
{% if invoice.remaining_amount != 0 %} {% if invoice.remaining_amount != 0 %}
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
مانده فاکتور صفر نیست: <strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br> مانده فاکتور: <strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br>
تا صفر نشود امکان تایید نیست. امکان تایید تا تسویه کامل فاکتور وجود ندارد.
</div> </div>
{% else %} {% else %}
آیا از تایید این مرحله اطمینان دارید؟ آیا از تایید این مرحله اطمینان دارید؟
@ -316,11 +330,32 @@
(function initPersianDatePicker(){ (function initPersianDatePicker(){
if (window.$ && $.fn.persianDatepicker && $('#id_payment_date').length) { if (window.$ && $.fn.persianDatepicker && $('#id_payment_date').length) {
$('#id_payment_date').persianDatepicker({ $('#id_payment_date').persianDatepicker({
format: 'YYYY/MM/DD', initialValue: false, autoClose: true, persianDigit: false, observer: true, format: 'YYYY/MM/DD',
initialValue: false,
autoClose: true,
persianDigit: false,
observer: true,
calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } }, calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } },
onSelect: function(unix){ onSelect: function(unix){
const g = new window.persianDate(unix).toCalendar('gregorian').format('YYYY-MM-DD'); // تبدیل تاریخ شمسی به میلادی برای ارسال به سرور
$('#id_payment_date').attr('data-gregorian', g); const gregorianDate = new Date(unix);
const year = gregorianDate.getFullYear();
const month = String(gregorianDate.getMonth() + 1).padStart(2, '0');
const day = String(gregorianDate.getDate()).padStart(2, '0');
const gregorianDateString = `${year}-${month}-${day}`;
// نمایش تاریخ شمسی در فیلد
if (window.persianDate) {
const persianDate = new window.persianDate(unix);
const persianDateString = persianDate.format('YYYY/MM/DD');
$('#id_payment_date').val(persianDateString);
} else {
// اگر persianDate در دسترس نبود، تاریخ میلادی را نمایش بده
$('#id_payment_date').val(gregorianDateString);
}
// ذخیره تاریخ میلادی در فیلد مخفی برای ارسال به سرور
$('#id_payment_date').attr('data-gregorian', gregorianDateString);
} }
}); });
} }
@ -328,8 +363,14 @@
function buildForm(){ function buildForm(){
const fd = new FormData(document.getElementById('formFinalPayment')); const fd = new FormData(document.getElementById('formFinalPayment'));
const g = document.getElementById('id_payment_date').getAttribute('data-gregorian');
if (g) { fd.set('payment_date', g); } // تبدیل تاریخ شمسی به میلادی برای ارسال
const persianDateValue = $('#id_payment_date').val();
const gregorianDateValue = $('#id_payment_date').attr('data-gregorian');
if (persianDateValue && gregorianDateValue) {
fd.set('payment_date', gregorianDateValue);
}
return fd; return fd;
} }
(function(){ (function(){

View file

@ -18,15 +18,16 @@
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}"> <link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
<!-- Persian Date Picker CSS --> <!-- Persian Date Picker CSS -->
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css"> <link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
<style>
@media print {
.no-print { display: none !important; }
}
</style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
{% csrf_token %} {% csrf_token %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
@ -35,19 +36,21 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} {% instance_info instance %}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"> <a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer"></i> پرینت <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> </a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</div> </div>
</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 %} {% stepper_header instance step %}
<div class="bs-stepper-content"> <div class="bs-stepper-content">
@ -60,7 +63,7 @@
</div> </div>
<div class="row g-3"> <div class="row g-3">
{% if can_manage_payments %} {% if is_broker %}
<div class="col-12 col-lg-5"> <div class="col-12 col-lg-5">
<div class="card h-100 border"> <div class="card h-100 border">
<div class="card-header"> <div class="card-header">
@ -104,7 +107,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="col-12 {% if can_manage_payments %}col-lg-7{% else %}col-lg-12{% endif %}"> <div class="col-12 {% if is_broker %}col-lg-7{% else %}col-lg-12{% endif %}">
<div class="card mb-3 border"> <div class="card mb-3 border">
<div class="card-header d-flex justify-content-between"> <div class="card-header d-flex justify-content-between">
<h5 class="card-title mb-0">وضعیت پیش‌فاکتور</h5> <h5 class="card-title mb-0">وضعیت پیش‌فاکتور</h5>
@ -161,7 +164,7 @@
{% for p in payments %} {% for p in payments %}
<tr> <tr>
<td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td> <td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
<td>{{ p.payment_date|date:'Y/m/d' }}</td> <td>{{ p.jpayment_date }}</td>
<td>{{ p.get_payment_method_display }}</td> <td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td> <td>{{ p.reference_number|default:'-' }}</td>
<td> <td>
@ -171,7 +174,7 @@
<i class="bx bx-show"></i> <i class="bx bx-show"></i>
</a> </a>
{% endif %} {% endif %}
{% if can_manage_payments %} {% if is_broker %}
<button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف"> <button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف">
<i class="bx bx-trash"></i> <i class="bx bx-trash"></i>
</button> </button>
@ -195,7 +198,7 @@
<h6 class="mb-0">وضعیت تاییدها</h6> <h6 class="mb-0">وضعیت تاییدها</h6>
{% if can_approve_reject %} {% if can_approve_reject %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2" {% if step_instance.status == 'completed' %}disabled{% endif %}>تایید</button> <button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button> <button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
</div> </div>
{% endif %} {% endif %}
@ -356,6 +359,13 @@
} }
const form = document.getElementById('formAddPayment'); const form = document.getElementById('formAddPayment');
const fd = buildFormData(form); const fd = buildFormData(form);
// تبدیل تاریخ شمسی به میلادی برای ارسال
const persianDateValue = $('#id_payment_date').val();
const gregorianDateValue = $('#id_payment_date').attr('data-gregorian');
if (persianDateValue && gregorianDateValue) {
fd.set('payment_date', gregorianDateValue);
}
fetch('{% url "invoices:add_quote_payment" instance.id step.id %}', { fetch('{% url "invoices:add_quote_payment" instance.id step.id %}', {
method: 'POST', method: 'POST',
body: fd body: fd
@ -419,18 +429,24 @@
observer: true, observer: true,
calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } }, calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } },
onSelect: function(unix) { onSelect: function(unix) {
// تبدیل تاریخ شمسی به میلادی برای ارسال به سرور
const gregorianDate = new Date(unix); const gregorianDate = new Date(unix);
const year = gregorianDate.getFullYear(); const year = gregorianDate.getFullYear();
const month = String(gregorianDate.getMonth() + 1).padStart(2, '0'); const month = String(gregorianDate.getMonth() + 1).padStart(2, '0');
const day = String(gregorianDate.getDate()).padStart(2, '0'); const day = String(gregorianDate.getDate()).padStart(2, '0');
const gregorianDateString = `${year}-${month}-${day}`; const gregorianDateString = `${year}-${month}-${day}`;
// نمایش تاریخ شمسی در فیلد
if (window.persianDate) { if (window.persianDate) {
const persianDate = new window.persianDate(unix); const persianDate = new window.persianDate(unix);
const persianDateString = persianDate.format('YYYY/MM/DD'); const persianDateString = persianDate.format('YYYY/MM/DD');
$('#id_payment_date').val(persianDateString); $('#id_payment_date').val(persianDateString);
} else { } else {
// اگر persianDate در دسترس نبود، تاریخ میلادی را نمایش بده
$('#id_payment_date').val(gregorianDateString); $('#id_payment_date').val(gregorianDateString);
} }
// ذخیره تاریخ میلادی در فیلد مخفی برای ارسال به سرور
$('#id_payment_date').attr('data-gregorian', gregorianDateString); $('#id_payment_date').attr('data-gregorian', gregorianDateString);
} }
}); });

View file

@ -24,6 +24,10 @@
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
{% csrf_token %} {% csrf_token %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
@ -32,15 +36,17 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} {% instance_info instance %}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"> <a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer"></i> پرینت <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> </a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</div> </div>
</div> </div>
@ -50,100 +56,116 @@
<!-- Invoice Preview Card --> <!-- Invoice Preview Card -->
<div class="card invoice-preview-card mt-4 border"> <div class="card invoice-preview-card mt-4 border">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between flex-xl-row flex-md-column flex-sm-row flex-column p-sm-3 p-0"> <div class="d-flex justify-content-between flex-xl-row flex-md-column flex-sm-row flex-column p-sm-3 p-0 align-items-center">
<div class="mb-xl-0 mb-4"> <div class="mb-xl-0 mb-4">
<div class="d-flex svg-illustration mb-3 gap-2"> <!-- Company Logo & Info -->
<span class="app-brand-logo demo"> <div class="d-flex align-items-center">
<svg width="25" viewBox="0 0 25 42" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <div class="avatar avatar-lg me-3">
<defs> <span class="avatar-initial rounded bg-label-primary">
<path d="M13.7918663,0.358365126 L3.39788168,7.44174259 C0.566865006,9.69408886 -0.379795268,12.4788597 0.557900856,15.7960551 C0.68998853,16.2305145 1.09562888,17.7872135 3.12357076,19.2293357 C3.8146334,19.7207684 5.32369333,20.3834223 7.65075054,21.2172976 L7.59773219,21.2525164 L2.63468769,24.5493413 C0.445452254,26.3002124 0.0884951797,28.5083815 1.56381646,31.1738486 C2.83770406,32.8170431 5.20850219,33.2640127 7.09180128,32.5391577 C8.347334,32.0559211 11.4559176,30.0011079 16.4175519,26.3747182 C18.0338572,24.4997857 18.6973423,22.4544883 18.4080071,20.2388261 C17.963753,17.5346866 16.1776345,15.5799961 13.0496516,14.3747546 L10.9194936,13.4715819 L18.6192054,7.984237 L13.7918663,0.358365126 Z" id="path-1"></path> {% if instance.broker.company %}
<path d="M5.47320593,6.00457225 C4.05321814,8.216144 4.36334763,10.0722806 6.40359441,11.5729822 C8.61520715,12.571656 10.0999176,13.2171421 10.8577257,13.5094407 L15.5088241,14.433041 L18.6192054,7.984237 C15.5364148,3.11535317 13.9273018,0.573395879 13.7918663,0.358365126 C13.5790555,0.511491653 10.8061687,2.3935607 5.47320593,6.00457225 Z" id="path-3"></path> <img src="{{ instance.broker.company.logo.url }}" alt="لوگوی شرکت" style="max-height:80px;">
<path d="M7.50063644,21.2294429 L12.3234468,23.3159332 C14.1688022,24.7579751 14.397098,26.4880487 13.008334,28.506154 C11.6195701,30.5242593 10.3099883,31.790241 9.07958868,32.3040991 C5.78142938,33.4346997 4.13234973,34 4.13234973,34 C4.13234973,34 2.75489982,33.0538207 2.37032616e-14,31.1614621 C-0.55822714,27.8186216 -0.55822714,26.0572515 -4.05231404e-15,25.8773518 C0.83734071,25.6075023 2.77988457,22.8248993 3.3049379,22.52991 C3.65497346,22.3332504 5.05353963,21.8997614 7.50063644,21.2294429 Z" id="path-4"></path> {% else %}
<path d="M20.6,7.13333333 L25.6,13.8 C26.2627417,14.6836556 26.0836556,15.9372583 25.2,16.6 C24.8538077,16.8596443 24.4327404,17 24,17 L14,17 C12.8954305,17 12,16.1045695 12,15 C12,14.5672596 12.1403557,14.1461923 12.4,13.8 L17.4,7.13333333 C18.0627417,6.24967773 19.3163444,6.07059163 20.2,6.73333333 C20.3516113,6.84704183 20.4862915,6.981722 20.6,7.13333333 Z" id="path-5"></path> <i class="bx bx-buildings bx-md"></i>
</defs> {% endif %}
<g id="g-app-brand" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Brand-Logo" transform="translate(-27.000000, -15.000000)">
<g id="Icon" transform="translate(27.000000, 15.000000)">
<g id="Mask" transform="translate(0.000000, 8.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use fill="#696cff" xlink:href="#path-1"></use>
<g id="Path-3" mask="url(#mask-2)">
<use fill="#696cff" xlink:href="#path-3"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-3"></use>
</g>
<g id="Path-4" mask="url(#mask-2)">
<use fill="#696cff" xlink:href="#path-4"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-4"></use>
</g>
</g>
<g id="Triangle" transform="translate(19.000000, 11.000000) rotate(-300.000000) translate(-19.000000, -11.000000) ">
<use fill="#696cff" xlink:href="#path-5"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-5"></use>
</g>
</g>
</g>
</g>
</svg>
</span> </span>
<span class="app-brand-text demo text-body fw-bold">شرکت آب منطقه‌ای</span>
</div>
<p class="mb-1">دفتر مرکزی، خیابان اصلی</p>
<p class="mb-1">تهران، ایران</p>
<p class="mb-0">۰۲۱-۱۲۳۴۵۶۷۸</p>
</div> </div>
<div> <div>
<h4>پیش‌فاکتور #{{ quote.name }}</h4> <h5 class="mb-1">
<div class="mb-2"> {% if instance.broker.company %}
<span class="me-1">تاریخ صدور:</span> {{ instance.broker.company.name }}
<span class="fw-medium">{{ quote.jcreated }}</span> {% else %}
شرکت آب منطقه‌ای
{% endif %}
</h5>
{% if instance.broker.company %}
<div class="text-muted small">
{% if instance.broker.company.address %}
<div><i class="bx bx-map me-1"></i>{{ instance.broker.company.address }}</div>
{% endif %}
{% if instance.broker.affairs.county.city.name %}
<div><i class="bx bx-current-location me-1"></i>{{ instance.broker.affairs.county.city.name }}، ایران</div>
{% endif %}
{% if instance.broker.company.phone %}
<div><i class="bx bx-phone me-1"></i>{{ instance.broker.company.phone }}</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Invoice Details -->
<div class="text-center">
<div class="mb-3">
<h5 class="text-body">#{{ quote.name }}</h5>
</div>
<div class="invoice-details">
<div class="d-flex justify-content-end align-items-center mb-2">
<span class="text-muted me-2">تاریخ صدور:</span>
<span class="fw-medium text-body">{{ quote.jcreated_date }}</span>
</div> </div>
<div>
<span class="me-1">معتبر تا:</span>
<span class="fw-medium">{{ quote.valid_until|date:"Y/m/d" }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<hr class="my-0"> <hr class="my-0">
<div class="card-body"> <div class="card-body py-1">
<div class="row p-sm-3 p-0"> <div class="row">
<div class="col-xl-6 col-md-12 col-sm-5 col-12 mb-xl-0 mb-md-4 mb-sm-0 mb-4"> <div class="col-xl-6 col-md-12 col-sm-6 col-12 mb-3">
<h6 class="pb-2">صادر شده برای:</h6> <div class="">
<p class="mb-1">{{ quote.customer.get_full_name }}</p> <div class="card-body p-3">
{% if instance.representative.profile %} <h6 class="card-title text-primary mb-2">
<p class="mb-1">کد ملی: {{ instance.representative.profile.national_code }}</p> <i class="bx bx-user me-1"></i>اطلاعات مشترک
<p class="mb-1">{{ instance.representative.profile.address|default:"آدرس نامشخص" }}</p> </h6>
<p class="mb-1">{{ instance.representative.profile.phone_number_1|default:"" }}</p> <div class="d-flex gap-2 mb-1">
{% endif %} <span class="text-muted small">نام:</span>
<span class="fw-medium small">{{ quote.customer.get_full_name }}</span>
</div>
{% if instance.representative.profile.national_code %}
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">کد ملی:</span>
<span class="fw-medium small">{{ instance.representative.profile.national_code }}</span>
</div>
{% endif %}
{% if instance.representative.profile.phone_number_1 %}
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">تلفن:</span>
<span class="fw-medium small">{{ instance.representative.profile.phone_number_1 }}</span>
</div>
{% endif %}
{% if instance.representative.profile.address %}
<div class="d-flex gap-2">
<span class="text-muted small">آدرس:</span>
<span class="fw-medium small">{{ instance.representative.profile.address }}</span>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-xl-6 col-md-12 col-sm-6 col-12 mb-3">
<div class="border-0 bg-light">
<div class="card-body p-3">
<h6 class="card-title text-primary mb-2">
<i class="bx bx-droplet me-1"></i>اطلاعات چاه
</h6>
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">شماره اشتراک آب:</span>
<span class="fw-medium small">{{ instance.well.water_subscription_number }}</span>
</div>
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">شماره اشتراک برق:</span>
<span class="fw-medium small">{{ instance.well.electricity_subscription_number|default:"-" }}</span>
</div>
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">سریال کنتور:</span>
<span class="fw-medium small">{{ instance.well.water_meter_serial_number|default:"-" }}</span>
</div>
<div class="d-flex gap-2">
<span class="text-muted small">قدرت چاه:</span>
<span class="fw-medium small">{{ instance.well.well_power|default:"-" }}</span>
</div>
</div>
</div> </div>
<div class="col-xl-6 col-md-12 col-sm-7 col-12">
<h6 class="pb-2">اطلاعات چاه:</h6>
<table>
<tbody>
<tr>
<td class="pe-3">شماره اشتراک آب:</td>
<td>{{ instance.well.water_subscription_number }}</td>
</tr>
<tr>
<td class="pe-3">شماره اشتراک برق:</td>
<td>{{ instance.well.electricity_subscription_number|default:"-" }}</td>
</tr>
<tr>
<td class="pe-3">سریال کنتور:</td>
<td>{{ instance.well.water_meter_serial_number|default:"-" }}</td>
</tr>
<tr>
<td class="pe-3">قدرت چاه:</td>
<td>{{ instance.well.well_power|default:"-" }}</td>
</tr>
<tr>
<td class="pe-3">کد درخواست:</td>
<td>{{ instance.code }}</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
@ -170,11 +192,6 @@
{% endfor %} {% endfor %}
<tr> <tr>
<td colspan="3" class="align-top px-4 py-5"> <td colspan="3" class="align-top px-4 py-5">
<p class="mb-2">
<span class="me-1 fw-medium">صادر کننده:</span>
<span>{{ quote.created_by.get_full_name }}</span>
</p>
<span>با تشکر از انتخاب شما</span>
</td> </td>
<td class="text-end px-4 py-5"> <td class="text-end px-4 py-5">
<p class="mb-2">جمع کل:</p> <p class="mb-2">جمع کل:</p>
@ -193,6 +210,72 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- Footer Information -->
<div class="card-body border-top">
<div class="row">
<div class="col-md-8">
<h6 class="mb-3">شرایط و ضوابط:</h6>
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bx bx-time-five text-muted me-2"></i>
اعتبار پیش‌فاکتور صادر شده ۴۸ ساعت پس از تاریخ صدور می‌باشد
</li>
<li class="mb-2">
<i class="bx bx-money text-muted me-2"></i>
مبلغ فوق به صورت علی‌الحساب دریافت می‌گردد
</li>
<li class="mb-0">
<i class="bx bx-info-circle text-muted me-2"></i>
این برگه صرفاً جهت اعلام قیمت بوده و ارزش قانونی دیگری ندارد
</li>
{% if instance.broker.company.signature %}
<li class="mb-0 text-start mt-4 ms-5">
<img src="{{ instance.broker.company.signature.url }}" alt="امضای شرکت" style="height: 200px;">
</li>
{% endif %}
</ul>
</div>
{% if instance.broker.company %}
<div class="col-md-4">
<h6 class="mb-3">اطلاعات پرداخت:</h6>
<div class="d-flex flex-column gap-2">
{% if instance.broker.company.card_number %}
<div>
<small class="text-muted">شماره کارت:</small>
<div class="fw-medium">{{ instance.broker.company.card_number }}</div>
</div>
{% endif %}
{% if instance.broker.company.account_number %}
<div>
<small class="text-muted">شماره حساب:</small>
<div class="fw-medium">{{ instance.broker.company.account_number }}</div>
</div>
{% endif %}
{% if instance.broker.company.sheba_number %}
<div>
<small class="text-muted">شماره شبا:</small>
<div class="fw-medium">{{ instance.broker.company.sheba_number }}</div>
</div>
{% endif %}
{% if instance.broker.company.bank_name %}
<div>
<small class="text-muted">بانک:</small>
<div class="fw-medium">{{ instance.broker.company.get_bank_name_display }}</div>
</div>
{% endif %}
{% if instance.broker.company.branch_name %}
<div>
<small class="text-muted">شعبه:</small>
<div class="fw-medium">{{ instance.broker.company.branch_name }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div> </div>
{% if quote.notes %} {% if quote.notes %}
@ -240,7 +323,7 @@
{% else %} {% else %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" <a href="{% url 'processes:step_detail' instance.id next_step.id %}"
class="btn btn-label-primary"> class="btn btn-primary">
مرحله بعد مرحله بعد
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i> <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a> </a>

View file

@ -5,8 +5,24 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>پیش‌فاکتور {{ quote.name }} - {{ instance.code }}</title> <title>پیش‌فاکتور {{ quote.name }} - {{ instance.code }}</title>
<!-- Bootstrap CSS --> {% load static %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> {% load humanize %}
<!-- Fonts (match base) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap" rel="stylesheet">
<!-- Icons (optional) -->
<link rel="stylesheet" href="{% static 'assets/vendor/fonts/boxicons.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/fonts/fontawesome.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/fonts/flag-icons.css' %}">
<!-- Core CSS (same as preview) -->
<link rel="stylesheet" href="{% static 'assets/vendor/css/rtl/core.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/css/rtl/theme-default.css' %}">
<link rel="stylesheet" href="{% static 'assets/css/demo.css' %}">
<link rel="stylesheet" href="{% static 'assets/css/persian-fonts.css' %}">
<style> <style>
@page { @page {
@ -14,11 +30,7 @@
margin: 1cm; margin: 1cm;
} }
body { /* Inherit project fonts and sizes from core.css/persian-fonts */
font-family: 'Vazirmatn', sans-serif;
font-size: 14px;
line-height: 1.6;
}
@media print { @media print {
body { print-color-adjust: exact; } body { print-color-adjust: exact; }
@ -27,7 +39,7 @@
} }
.invoice-header { .invoice-header {
border-bottom: 2px solid #696cff; border-bottom: 1px solid #dee2e6;
padding-bottom: 20px; padding-bottom: 20px;
margin-bottom: 30px; margin-bottom: 30px;
} }
@ -89,195 +101,159 @@
</head> </head>
<body> <body>
<div class="container-fluid"> <div class="container-fluid">
<!-- Print Button (hidden in print) --> <!-- Auto print: buttons removed -->
<div class="no-print mb-3">
<button onclick="window.print()" class="btn btn-primary">
<i class="bi bi-printer"></i> پرینت
</button>
<button onclick="window.close()" class="btn btn-secondary">
بستن
</button>
</div>
<!-- Invoice Header --> <!-- Invoice Header (compact, matches preview) -->
<div class="invoice-header"> <div class="invoice-header">
<div class="row"> <div class="row align-items-center">
<div class="col-6"> <div class="col-6 d-flex align-items-center">
<div class="company-logo mb-3"> <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>
<div class="company-info"> <div>
<p class="mb-1">دفتر مرکزی، خیابان اصلی</p> {% if instance.broker.company %}
<p class="mb-1">تهران، ایران</p> {{ instance.broker.company.name }}
<p class="mb-1">تلفن: ۰۲۱-۱۲۳۴۵۶۷۸</p> {% endif %}
<p class="mb-0">ایمیل: info@watercompany.ir</p> {% 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> </div>
<div class="col-6 text-end"> <div class="col-6 text-end">
<div class="invoice-title">پیش‌فاکتور</div> <div class="mt-2">
<div class="mt-3"> <div><strong>#{{ quote.name }}</strong></div>
<table class="info-table"> <div class="text-muted small">تاریخ صدور: {{ quote.jcreated_date }}</div>
<tr>
<td><strong>شماره پیش‌فاکتور:</strong></td>
<td>{{ quote.name }}</td>
</tr>
<tr>
<td><strong>کد درخواست:</strong></td>
<td>{{ instance.code }}</td>
</tr>
<tr>
<td><strong>تاریخ صدور:</strong></td>
<td>{{ quote.created|date:"Y/m/d" }}</td>
</tr>
<tr>
<td><strong>معتبر تا:</strong></td>
<td>{{ quote.valid_until|date:"Y/m/d" }}</td>
</tr>
</table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Customer & Well Info --> <!-- Customer & Well Info (compact to match preview) -->
<div class="row mb-4"> <div class="row mb-3">
<div class="col-6"> <div class="col-6">
<h6 class="fw-bold mb-3">مشخصات مشترک:</h6> <h6 class="fw-bold mb-2">اطلاعات مشترک</h6>
<table class="info-table"> <div class="small mb-1"><span class="text-muted">نام:</span> {{ quote.customer.get_full_name }}</div>
<tr> {% if instance.representative.profile and instance.representative.profile.national_code %}
<td><strong>نام و نام خانوادگی:</strong></td> <div class="small mb-1"><span class="text-muted">کد ملی:</span> {{ instance.representative.profile.national_code }}</div>
<td>{{ quote.customer.get_full_name }}</td> {% endif %}
</tr> {% if instance.representative.profile and instance.representative.profile.phone_number_1 %}
{% if instance.representative.profile %} <div class="small mb-1"><span class="text-muted">تلفن:</span> {{ instance.representative.profile.phone_number_1 }}</div>
<tr> {% endif %}
<td><strong>کد ملی:</strong></td> {% if instance.representative.profile and instance.representative.profile.address %}
<td>{{ instance.representative.profile.national_code }}</td> <div class="small"><span class="text-muted">آدرس:</span> {{ instance.representative.profile.address }}</div>
</tr>
<tr>
<td><strong>تلفن:</strong></td>
<td>{{ instance.representative.profile.phone_number_1|default:"-" }}</td>
</tr>
<tr>
<td><strong>آدرس:</strong></td>
<td>{{ instance.representative.profile.address|default:"آدرس نامشخص" }}</td>
</tr>
{% endif %} {% endif %}
</table>
</div> </div>
<div class="col-6"> <div class="col-6">
<h6 class="fw-bold mb-3">مشخصات چاه:</h6> <h6 class="fw-bold mb-2">اطلاعات چاه</h6>
<table class="info-table"> <div class="small mb-1"><span class="text-muted">شماره اشتراک آب:</span> {{ instance.well.water_subscription_number }}</div>
<tr> <div class="small mb-1"><span class="text-muted">شماره اشتراک برق:</span> {{ instance.well.electricity_subscription_number|default:"-" }}</div>
<td><strong>شماره اشتراک آب:</strong></td> <div class="small mb-1"><span class="text-muted">سریال کنتور:</span> {{ instance.well.water_meter_serial_number|default:"-" }}</div>
<td>{{ instance.well.water_subscription_number }}</td> <div class="small"><span class="text-muted">قدرت چاه:</span> {{ instance.well.well_power|default:"-" }}</div>
</tr>
<tr>
<td><strong>شماره اشتراک برق:</strong></td>
<td>{{ instance.well.electricity_subscription_number|default:"-" }}</td>
</tr>
<tr>
<td><strong>سریال کنتور:</strong></td>
<td>{{ instance.well.water_meter_serial_number|default:"-" }}</td>
</tr>
<tr>
<td><strong>قدرت چاه:</strong></td>
<td>{{ instance.well.well_power|default:"-" }}</td>
</tr>
</table>
</div> </div>
</div> </div>
<!-- Items Table --> <!-- Items Table -->
<div class="mb-4"> <div class="mb-4">
<table class="table items-table"> <table class="table border-top m-0">
<thead> <thead>
<tr> <tr>
<th style="width: 5%">ردیف</th> <th style="width: 5%">ردیف</th>
<th style="width: 30%">شرح کالا/خدمات</th> <th style="width: 30%">شرح کالا/خدمات</th>
<th style="width: 30%">توضیحات</th> <th style="width: 30%">توضیحات</th>
<th style="width: 10%">تعداد</th> <th style="width: 10%">تعداد</th>
<th style="width: 12.5%">قیمت واحد (تومان)</th> <th style="width: 12.5%">قیمت واحد(تومان)</th>
<th style="width: 12.5%">قیمت کل (تومان)</th> <th style="width: 12.5%">قیمت کل(تومان)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for quote_item in quote.items.all %} {% for quote_item in quote.items.all %}
<tr> <tr>
<td>{{ forloop.counter }}</td> <td>{{ forloop.counter }}</td>
<td class="text-start">{{ quote_item.item.name }}</td> <td class="text-nowrap">{{ quote_item.item.name }}</td>
<td class="text-start">{{ quote_item.item.description|default:"-" }}</td> <td class="text-nowrap">{{ quote_item.item.description|default:"-" }}</td>
<td>{{ quote_item.quantity }}</td> <td>{{ quote_item.quantity }}</td>
<td>{{ quote_item.unit_price|floatformat:0 }}</td> <td>{{ quote_item.unit_price|floatformat:0|intcomma:False }}</td>
<td>{{ quote_item.total_price|floatformat:0 }}</td> <td>{{ quote_item.total_price|floatformat:0|intcomma:False }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="total-section"> <tr class="total-section">
<td colspan="5" class="text-end"><strong>جمع کل:</strong></td> <td colspan="5" class="text-end"><strong>جمع کل(تومان):</strong></td>
<td><strong>{{ quote.total_amount|floatformat:0 }} تومان</strong></td> <td><strong>{{ quote.total_amount|floatformat:0|intcomma:False }}</strong></td>
</tr> </tr>
{% if quote.discount_amount > 0 %} {% if quote.discount_amount > 0 %}
<tr class="total-section"> <tr class="total-section">
<td colspan="5" class="text-end"><strong>تخفیف:</strong></td> <td colspan="5" class="text-end"><strong>تخفیف(تومان):</strong></td>
<td><strong>{{ quote.discount_amount|floatformat:0 }} تومان</strong></td> <td><strong>{{ quote.discount_amount|floatformat:0|intcomma:False }}</strong></td>
</tr> </tr>
{% endif %} {% endif %}
<tr class="total-section border-top border-2"> <tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی:</strong></td> <td colspan="5" class="text-end"><strong>مبلغ نهایی(تومان):</strong></td>
<td><strong>{{ quote.final_amount|floatformat:0 }} تومان</strong></td> <td><strong>{{ quote.final_amount|floatformat:0|intcomma:False }}</strong></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
<!-- Notes --> <!-- Conditions & Payment (matches preview) -->
{% if quote.notes %} <div class="row">
<div class="mb-4"> <div class="col-8">
<h6 class="fw-bold">یادداشت:</h6> <h6 class="fw-bold mb-2">شرایط و ضوابط</h6>
<p>{{ quote.notes }}</p> <ul class="small mb-0">
<li class="mb-1">اعتبار پیش‌فاکتور صادر شده ۴۸ ساعت پس از تاریخ صدور می‌باشد</li>
<li class="mb-1">مبلغ فوق به صورت علی‌الحساب دریافت می‌گردد</li>
<li class="mb-1">این برگه صرفاً جهت اعلام قیمت بوده و ارزش قانونی دیگری ندارد</li>
{% 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> </div>
{% endif %} {% endif %}
<!-- Additional Info -->
<div class="mb-4">
<p><strong>صادر کننده:</strong> {{ quote.created_by.get_full_name }}</p>
<p class="text-muted">این پیش‌فاکتور تا تاریخ {{ quote.valid_until|date:"Y/m/d" }} معتبر است.</p>
</div> </div>
<!-- Signature Section --> <!-- Signature Section (optional, compact) -->
<div class="signature-section"> {% if quote.notes %}
<div class="row"> <div class="mt-3 small text-muted">یادداشت: {{ quote.notes }}</div>
<div class="col-6"> {% endif %}
<div class="text-center">
<p class="mb-2"><strong>امضای مشترک</strong></p>
<div class="signature-box">
امضا و مهر
</div>
<p class="mt-2 small">تاریخ: ____/____/____</p>
</div>
</div>
<div class="col-6">
<div class="text-center">
<p class="mb-2"><strong>امضای شرکت</strong></p>
<div class="signature-box">
امضا و مهر
</div>
<p class="mt-2 small">تاریخ: ____/____/____</p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center mt-4 small text-muted">
این پیش‌فاکتور توسط سیستم مدیریت فرآیندها تولید شده است.
</div>
</div> </div>
<script> <script>
// Auto print on load (optional) window.onload = function() {
// window.onload = function() { window.print(); } window.print();
setTimeout(function(){ window.close(); }, 200);
};
</script> </script>
</body> </body>
</html> </html>

View file

@ -19,6 +19,10 @@
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
@ -26,11 +30,13 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} {% instance_info instance %}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </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>
<div class="bs-stepper wizard-vertical vertical mt-2"> <div class="bs-stepper wizard-vertical vertical mt-2">
@ -50,7 +56,7 @@
<div class="col-12 mb-3"> <div class="col-12 mb-3">
<div class="alert alert-info"> <div class="alert alert-info">
<h6>پیش‌فاکتور موجود</h6> <h6>پیش‌فاکتور موجود</h6>
<span class="mb-1">نام: {{ existing_quote.name }} | </span> <span class="mb-1">{{ existing_quote.name }} | </span>
<span class="mb-1">مبلغ کل: {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان | </span> <span class="mb-1">مبلغ کل: {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان | </span>
<span class="mb-0">وضعیت: {{ existing_quote.get_status_display_with_color|safe }}</span> <span class="mb-0">وضعیت: {{ existing_quote.get_status_display_with_color|safe }}</span>
</div> </div>
@ -143,7 +149,7 @@
{% endif %} {% endif %}
{% else %} {% else %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-label-primary"> <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> <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a> </a>
@ -212,4 +218,6 @@
} }
}); });
</script> </script>
{% endblock %} {% endblock %}

View file

@ -15,9 +15,7 @@ urlpatterns = [
# Quote payments step (step 3) # Quote payments step (step 3)
path('instance/<int:instance_id>/step/<int:step_id>/payments/', views.quote_payment_step, name='quote_payment_step'), path('instance/<int:instance_id>/step/<int:step_id>/payments/', views.quote_payment_step, name='quote_payment_step'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/add/', views.add_quote_payment, name='add_quote_payment'), path('instance/<int:instance_id>/step/<int:step_id>/payments/add/', views.add_quote_payment, name='add_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/update/', views.update_quote_payment, name='update_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/delete/', views.delete_quote_payment, name='delete_quote_payment'), path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/delete/', views.delete_quote_payment, name='delete_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/approve/', views.approve_payments, name='approve_payments'),
# Quote print # Quote print
path('instance/<int:instance_id>/quote/print/', views.quote_print, name='quote_print'), path('instance/<int:instance_id>/quote/print/', views.quote_print, name='quote_print'),
@ -33,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/', views.final_settlement_step, name='final_settlement_step'),
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/add/', views.add_final_payment, name='add_final_payment'), path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/add/', views.add_final_payment, name='add_final_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/<int:payment_id>/delete/', views.delete_final_payment, name='delete_final_payment'), path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/<int:payment_id>/delete/', views.delete_final_payment, name='delete_final_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/approve/', views.approve_final_settlement, name='approve_final_settlement'),
] ]

View file

@ -12,12 +12,17 @@ import json
from processes.models import ProcessInstance, ProcessStep, StepInstance, StepRejection, StepApproval from processes.models import ProcessInstance, ProcessStep, StepInstance, StepRejection, StepApproval
from accounts.models import Role from accounts.models import Role
from common.consts import UserRoles from common.consts import UserRoles
from .models import Item, Quote, QuoteItem, Payment, Invoice from .models import Item, Quote, QuoteItem, Payment, Invoice, InvoiceItem
from installations.models import InstallationReport, InstallationItemChange from installations.models import InstallationReport, InstallationItemChange
from processes.utils import get_scoped_instance_or_404
@login_required @login_required
def quote_step(request, instance_id, step_id): def quote_step(request, instance_id, step_id):
"""مرحله انتخاب اقلام و ساخت پیش‌فاکتور""" """مرحله انتخاب اقلام و ساخت پیش‌فاکتور"""
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
# Enforce scoped access to prevent URL tampering
instance = get_object_or_404( instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'), ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id id=instance_id
@ -62,11 +67,12 @@ def quote_step(request, instance_id, step_id):
'is_broker': is_broker, 'is_broker': is_broker,
}) })
@require_POST @require_POST
@login_required @login_required
def create_quote(request, instance_id, step_id): 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) step = get_object_or_404(instance.process.steps, id=step_id)
# enforce permission: only BROKER can create/update quote # enforce permission: only BROKER can create/update quote
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
@ -90,7 +96,7 @@ def create_quote(request, instance_id, step_id):
except Exception: except Exception:
continue continue
default_item_ids = set(Item.objects.filter(is_default_in_quotes=True, is_deleted=False).values_list('id', flat=True)) default_item_ids = set(Item.objects.filter(is_default_in_quotes=True, is_deleted=False, is_special=False).values_list('id', flat=True))
if default_item_ids: if default_item_ids:
for default_id in default_item_ids: for default_id in default_item_ids:
if default_id not in payload_by_id: if default_id not in payload_by_id:
@ -105,7 +111,7 @@ def create_quote(request, instance_id, step_id):
return JsonResponse({'success': False, 'message': 'هیچ آیتمی انتخاب نشده است'}) return JsonResponse({'success': False, 'message': 'هیچ آیتمی انتخاب نشده است'})
# Create or reuse quote # Create or reuse quote
quote, _ = Quote.objects.get_or_create( quote, created_q = Quote.objects.get_or_create(
process_instance=instance, process_instance=instance,
defaults={ defaults={
'name': f"پیش‌فاکتور {instance.code}", 'name': f"پیش‌فاکتور {instance.code}",
@ -115,6 +121,15 @@ def create_quote(request, instance_id, step_id):
} }
) )
# Track whether this step was already completed before this edit
step_instance_existing = instance.step_instances.filter(step=step).first()
was_already_completed = bool(step_instance_existing and step_instance_existing.status == 'completed')
# Snapshot previous items before overwrite for change detection
previous_items_map = {}
if not created_q:
previous_items_map = {qi.item_id: int(qi.quantity) for qi in quote.items.filter(is_deleted=False).all()}
# Replace quote items with submitted ones # Replace quote items with submitted ones
quote.items.all().delete() quote.items.all().delete()
for entry in items_payload: for entry in items_payload:
@ -138,31 +153,81 @@ def create_quote(request, instance_id, step_id):
quote.calculate_totals() quote.calculate_totals()
# Detect changes versus previous state and mark audit fields if editing after completion
try:
new_items_map = {int(entry.get('id')): int(entry.get('qty') or 1) for entry in items_payload}
except Exception:
new_items_map = {}
next_step = instance.process.steps.filter(order__gt=step.order).first()
if was_already_completed and new_items_map != previous_items_map:
# StepInstance-level generic audit (for reuse across steps)
if step_instance_existing:
step_instance_existing.edited_after_completion = True
step_instance_existing.last_edited_at = timezone.now()
step_instance_existing.last_edited_by = request.user
step_instance_existing.edit_count = (step_instance_existing.edit_count or 0) + 1
step_instance_existing.completed_at = timezone.now()
step_instance_existing.save(update_fields=['edited_after_completion', 'last_edited_at', 'last_edited_by', 'edit_count', 'completed_at'])
if quote.status != 'draft':
quote.status = 'draft'
quote.save(update_fields=['status'])
# Reset ALL subsequent completed steps to in_progress
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
# Set current step to the next step
if next_step:
instance.current_step = next_step
instance.save(update_fields=['current_step'])
# تکمیل مرحله # تکمیل مرحله
step_instance, created = StepInstance.objects.get_or_create( step_instance, created = StepInstance.objects.get_or_create(
process_instance=instance, process_instance=instance,
step=step step=step
) )
if not was_already_completed:
step_instance.status = 'completed' step_instance.status = 'completed'
step_instance.completed_at = timezone.now() step_instance.completed_at = timezone.now()
step_instance.save() step_instance.save(update_fields=['status', 'completed_at'])
# انتقال به مرحله بعدی # انتقال به مرحله بعدی
next_step = instance.process.steps.filter(order__gt=step.order).first()
redirect_url = None redirect_url = None
if next_step: if next_step:
# Only advance current step if we are currently on this step to avoid regressions
if instance.current_step_id == step.id:
instance.current_step = next_step instance.current_step = next_step
instance.save() instance.save(update_fields=['current_step'])
# هدایت مستقیم به مرحله پیش‌نمایش پیش‌فاکتور # هدایت مستقیم به مرحله پیش‌نمایش پیش‌فاکتور
redirect_url = reverse('invoices:quote_preview_step', args=[instance.id, next_step.id]) redirect_url = reverse('invoices:quote_preview_step', args=[instance.id, next_step.id])
return JsonResponse({'success': True, 'quote_id': quote.id, 'redirect': redirect_url}) return JsonResponse({'success': True, 'quote_id': quote.id, 'redirect': redirect_url})
@login_required @login_required
def quote_preview_step(request, instance_id, step_id): def quote_preview_step(request, instance_id, step_id):
"""مرحله صدور پیش‌فاکتور - نمایش و تایید فاکتور""" """مرحله صدور پیش‌فاکتور - نمایش و تایید فاکتور"""
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
instance = get_object_or_404( instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'), ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile', 'broker', 'broker__company', 'broker__affairs', 'broker__affairs__county', 'broker__affairs__county__city'),
id=instance_id id=instance_id
) )
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
@ -199,10 +264,11 @@ def quote_preview_step(request, instance_id, step_id):
'is_broker': is_broker, 'is_broker': is_broker,
}) })
@login_required @login_required
def quote_print(request, instance_id): 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) quote = get_object_or_404(Quote, process_instance=instance)
return render(request, 'invoices/quote_print.html', { return render(request, 'invoices/quote_print.html', {
@ -210,11 +276,12 @@ def quote_print(request, instance_id):
'quote': quote, 'quote': quote,
}) })
@require_POST @require_POST
@login_required @login_required
def approve_quote(request, instance_id, step_id): 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) step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance) quote = get_object_or_404(Quote, process_instance=instance)
# enforce permission: only BROKER can approve # enforce permission: only BROKER can approve
@ -256,6 +323,9 @@ def approve_quote(request, instance_id, step_id):
@login_required @login_required
def quote_payment_step(request, instance_id, step_id): def quote_payment_step(request, instance_id, step_id):
"""مرحله سوم: ثبت فیش‌های واریزی پیش‌فاکتور""" """مرحله سوم: ثبت فیش‌های واریزی پیش‌فاکتور"""
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
instance = get_object_or_404( instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'), ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id id=instance_id
@ -282,6 +352,7 @@ def quote_payment_step(request, instance_id, step_id):
} }
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'}) step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
reqs = list(step.approver_requirements.select_related('role').all()) reqs = list(step.approver_requirements.select_related('role').all())
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None) user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else [] user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
@ -295,6 +366,7 @@ def quote_payment_step(request, instance_id, step_id):
} }
for r in reqs for r in reqs
] ]
# dynamic permission: who can approve/reject this step (based on requirements) # dynamic permission: who can approve/reject this step (based on requirements)
try: try:
req_role_ids = {r.role_id for r in reqs} req_role_ids = {r.role_id for r in reqs}
@ -302,20 +374,7 @@ def quote_payment_step(request, instance_id, step_id):
can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0 can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0
except Exception: except Exception:
can_approve_reject = False can_approve_reject = False
# approver status map for template
reqs = list(step.approver_requirements.select_related('role').all())
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
approvals_list = list(step_instance.approvals.select_related('role').all())
approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [
{
'role': r.role,
'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None),
'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''),
}
for r in reqs
]
# Accountant/Admin approval and rejection via POST (multi-role) # Accountant/Admin approval and rejection via POST (multi-role)
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']: if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
@ -359,6 +418,13 @@ def quote_payment_step(request, instance_id, step_id):
defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason} defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
) )
StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason) StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
messages.success(request, 'مرحله پرداخت‌ها رد شد و برای اصلاح بازگشت.') messages.success(request, 'مرحله پرداخت‌ها رد شد و برای اصلاح بازگشت.')
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id) return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
@ -385,8 +451,6 @@ def quote_payment_step(request, instance_id, step_id):
'approver_statuses': approver_statuses, 'approver_statuses': approver_statuses,
'is_broker': is_broker, 'is_broker': is_broker,
'is_accountant': is_accountant, 'is_accountant': is_accountant,
# dynamic permissions: any role required to approve can also manage payments
'can_manage_payments': can_approve_reject,
'can_approve_reject': can_approve_reject, 'can_approve_reject': can_approve_reject,
}) })
@ -395,7 +459,7 @@ def quote_payment_step(request, instance_id, step_id):
@login_required @login_required
def add_quote_payment(request, instance_id, step_id): def add_quote_payment(request, instance_id, step_id):
"""افزودن فیش واریزی جدید برای پیش‌فاکتور""" """افزودن فیش واریزی جدید برای پیش‌فاکتور"""
instance = get_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) step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance) quote = get_object_or_404(Quote, process_instance=instance)
invoice, _ = Invoice.objects.get_or_create( invoice, _ = Invoice.objects.get_or_create(
@ -409,14 +473,16 @@ def add_quote_payment(request, instance_id, step_id):
} }
) )
# dynamic permission: users whose roles are among required approvers can add payments # who can add payments
profile = getattr(request.user, 'profile', None)
is_broker = False
is_accountant = False
try: try:
req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True)) is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()) is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT))
user_role_ids = set(user_roles_qs.values_list('id', flat=True))
if len(req_role_ids.intersection(user_role_ids)) == 0:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
except Exception: except Exception:
is_broker = False
is_accountant = False
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'}) return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -474,48 +540,31 @@ def add_quote_payment(request, instance_id, step_id):
si.approvals.all().delete() si.approvals.all().delete()
except Exception: except Exception:
pass pass
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@require_POST
@login_required
def update_quote_payment(request, instance_id, step_id, payment_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance)
invoice = Invoice.objects.filter(quote=quote).first()
if not invoice:
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
# Reset ALL subsequent completed steps to in_progress
try: try:
amount = request.POST.get('amount') subsequent_steps = instance.process.steps.filter(order__gt=step.order)
payment_date = request.POST.get('payment_date') or payment.payment_date for subsequent_step in subsequent_steps:
payment_method = request.POST.get('payment_method') or payment.payment_method subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
reference_number = request.POST.get('reference_number') or '' if subsequent_step_instance and subsequent_step_instance.status == 'completed':
notes = request.POST.get('notes') or '' # Bypass validation by using update() instead of save()
receipt_image = request.FILES.get('receipt_image') instance.step_instances.filter(step=subsequent_step).update(
if amount: status='in_progress',
payment.amount = amount completed_at=None
payment.payment_date = payment_date )
payment.payment_method = payment_method # Clear previous approvals if the step requires re-approval
payment.reference_number = reference_number try:
payment.notes = notes subsequent_step_instance.approvals.all().delete()
# اگر نیاز به ذخیره عکس در Payment دارید، فیلد آن اضافه شده است
if receipt_image:
payment.receipt_image = receipt_image
payment.save()
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'خطا در ویرایش فیش'}) pass
except Exception:
pass
# On update, return to awaiting approval # If current step is ahead of this step, reset it back to this step
try: try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) if instance.current_step and instance.current_step.order > step.order:
si.status = 'in_progress' instance.current_step = step
si.completed_at = None instance.save(update_fields=['current_step'])
si.save()
si.approvals.all().delete()
except Exception: except Exception:
pass pass
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id]) redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
@ -525,22 +574,25 @@ def update_quote_payment(request, instance_id, step_id, payment_id):
@require_POST @require_POST
@login_required @login_required
def delete_quote_payment(request, instance_id, step_id, payment_id): def delete_quote_payment(request, instance_id, step_id, payment_id):
instance = get_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) step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance) quote = get_object_or_404(Quote, process_instance=instance)
invoice = Invoice.objects.filter(quote=quote).first() invoice = Invoice.objects.filter(quote=quote).first()
if not invoice: if not invoice:
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'}) return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
# dynamic permission: users whose roles are among required approvers can delete payments
# who can delete payments
profile = getattr(request.user, 'profile', None)
is_broker = False
is_accountant = False
try: try:
req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True)) is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()) is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT))
user_role_ids = set(user_roles_qs.values_list('id', flat=True))
if len(req_role_ids.intersection(user_role_ids)) == 0:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'})
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'}) is_broker = False
is_accountant = False
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
try: try:
# soft delete using project's BaseModel delete override # soft delete using project's BaseModel delete override
@ -556,46 +608,43 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
si.approvals.all().delete() si.approvals.all().delete()
except Exception: except Exception:
pass pass
# Reset ALL subsequent completed steps to in_progress
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance and subsequent_step_instance.status == 'completed':
# Bypass validation by using update() instead of save()
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
except Exception:
pass
except Exception:
pass
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id]) redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url}) return JsonResponse({'success': True, 'redirect': redirect_url})
@require_POST
@login_required
def approve_payments(request, instance_id, step_id):
"""تایید مرحله پرداخت فیش‌ها و انتقال به مرحله بعد"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance)
is_fully_paid = quote.get_remaining_amount() <= 0
# تکمیل مرحله
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()
# حرکت به مرحله بعد
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])
msg = 'پرداخت‌ها تایید شد'
if is_fully_paid:
msg += ' - مبلغ پیش‌فاکتور به طور کامل پرداخت شده است.'
else:
msg += ' - توجه: مبلغ پیش‌فاکتور به طور کامل پرداخت نشده است.'
return JsonResponse({'success': True, 'message': msg, 'redirect': redirect_url, 'is_fully_paid': is_fully_paid})
@login_required @login_required
def final_invoice_step(request, instance_id, step_id): def final_invoice_step(request, instance_id, step_id):
"""تجمیع اقلام پیش‌فاکتور با تغییرات نصب و صدور فاکتور نهایی""" """تجمیع اقلام پیش‌فاکتور با تغییرات نصب و صدور فاکتور نهایی"""
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
instance = get_object_or_404( instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'), ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id id=instance_id
@ -734,7 +783,7 @@ def final_invoice_step(request, instance_id, step_id):
@login_required @login_required
def final_invoice_print(request, instance_id): def final_invoice_print(request, instance_id):
instance = get_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) invoice = get_object_or_404(Invoice, process_instance=instance)
items = invoice.items.select_related('item').filter(is_deleted=False).all() items = invoice.items.select_related('item').filter(is_deleted=False).all()
return render(request, 'invoices/final_invoice_print.html', { return render(request, 'invoices/final_invoice_print.html', {
@ -747,7 +796,7 @@ def final_invoice_print(request, instance_id):
@require_POST @require_POST
@login_required @login_required
def approve_final_invoice(request, instance_id, step_id): def approve_final_invoice(request, instance_id, step_id):
instance = get_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) step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance) invoice = get_object_or_404(Invoice, process_instance=instance)
# only MANAGER can approve # only MANAGER can approve
@ -756,14 +805,7 @@ def approve_final_invoice(request, instance_id, step_id):
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
# Block approval when there is any remaining (positive or negative)
invoice.calculate_totals()
# if invoice.remaining_amount != 0:
# return JsonResponse({
# 'success': False,
# 'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})"
# })
# mark step completed
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
step_instance.status = 'completed' step_instance.status = 'completed'
step_instance.completed_at = timezone.now() step_instance.completed_at = timezone.now()
@ -782,7 +824,7 @@ def approve_final_invoice(request, instance_id, step_id):
@login_required @login_required
def add_special_charge(request, instance_id, step_id): def add_special_charge(request, instance_id, step_id):
"""افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی به‌صورت آیتم جداگانه""" """افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی به‌صورت آیتم جداگانه"""
instance = get_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) invoice = get_object_or_404(Invoice, process_instance=instance)
# only MANAGER can add special charges # only MANAGER can add special charges
try: try:
@ -790,7 +832,7 @@ def add_special_charge(request, instance_id, step_id):
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403)
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403)
# charge_type was removed from UI; we no longer require it
item_id = request.POST.get('item_id') item_id = request.POST.get('item_id')
amount = (request.POST.get('amount') or '').strip() amount = (request.POST.get('amount') or '').strip()
if not item_id: if not item_id:
@ -805,7 +847,7 @@ def add_special_charge(request, instance_id, step_id):
# Fetch existing special item from DB # Fetch existing special item from DB
special_item = get_object_or_404(Item, id=item_id, is_special=True) special_item = get_object_or_404(Item, id=item_id, is_special=True)
from .models import InvoiceItem
InvoiceItem.objects.create( InvoiceItem.objects.create(
invoice=invoice, invoice=invoice,
item=special_item, item=special_item,
@ -819,7 +861,7 @@ def add_special_charge(request, instance_id, step_id):
@require_POST @require_POST
@login_required @login_required
def delete_special_charge(request, instance_id, step_id, item_id): def delete_special_charge(request, instance_id, step_id, item_id):
instance = get_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) invoice = get_object_or_404(Invoice, process_instance=instance)
# only MANAGER can delete special charges # only MANAGER can delete special charges
try: try:
@ -827,7 +869,6 @@ def delete_special_charge(request, instance_id, step_id, item_id):
return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403)
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403)
from .models import InvoiceItem
inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice) inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice)
# allow deletion only for special items # allow deletion only for special items
try: try:
@ -842,8 +883,9 @@ def delete_special_charge(request, instance_id, step_id, item_id):
@login_required @login_required
def final_settlement_step(request, instance_id, step_id): def final_settlement_step(request, instance_id, step_id):
instance = get_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) step = get_object_or_404(instance.process.steps, id=step_id)
if not instance.can_access_step(step): if not instance.can_access_step(step):
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.') messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
return redirect('processes:request_list') return redirect('processes:request_list')
@ -854,6 +896,7 @@ def final_settlement_step(request, instance_id, step_id):
# Ensure step instance exists # Ensure step instance exists
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'}) step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
# Build approver statuses for template # Build approver statuses for template
reqs = list(step.approver_requirements.select_related('role').all()) reqs = list(step.approver_requirements.select_related('role').all())
approvals_map = {a.role_id: a.decision for a in step_instance.approvals.select_related('role').all()} approvals_map = {a.role_id: a.decision for a in step_instance.approvals.select_related('role').all()}
@ -911,6 +954,13 @@ def final_settlement_step(request, instance_id, step_id):
defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason} defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
) )
StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason) StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
# If current step is ahead of this step, reset it back to this step (align behavior with other steps)
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
messages.success(request, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.') messages.success(request, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id) return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
@ -939,7 +989,7 @@ def final_settlement_step(request, instance_id, step_id):
@require_POST @require_POST
@login_required @login_required
def add_final_payment(request, instance_id, step_id): def add_final_payment(request, instance_id, step_id):
instance = get_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) step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance) invoice = get_object_or_404(Invoice, process_instance=instance)
# Only BROKER can add final settlement payments # Only BROKER can add final settlement payments
@ -948,6 +998,7 @@ def add_final_payment(request, instance_id, step_id):
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403)
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403)
amount = (request.POST.get('amount') or '').strip() amount = (request.POST.get('amount') or '').strip()
payment_date = (request.POST.get('payment_date') or '').strip() payment_date = (request.POST.get('payment_date') or '').strip()
payment_method = (request.POST.get('payment_method') or '').strip() payment_method = (request.POST.get('payment_method') or '').strip()
@ -1002,14 +1053,45 @@ def add_final_payment(request, instance_id, step_id):
) )
# After creation, totals auto-updated by model save. Respond with redirect and new totals for UX. # After creation, totals auto-updated by model save. Respond with redirect and new totals for UX.
invoice.refresh_from_db() invoice.refresh_from_db()
# After payment change, set step back to in_progress
# On delete, return to awaiting approval
try: try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress' si.status = 'in_progress'
si.completed_at = None si.completed_at = None
si.save() si.save()
si.approvals.all().delete()
except Exception: except Exception:
pass pass
# 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({ return JsonResponse({
'success': True, 'success': True,
'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]),
@ -1024,7 +1106,7 @@ def add_final_payment(request, instance_id, step_id):
@require_POST @require_POST
@login_required @login_required
def delete_final_payment(request, instance_id, step_id, payment_id): def delete_final_payment(request, instance_id, step_id, payment_id):
instance = get_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) step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance) invoice = get_object_or_404(Invoice, process_instance=instance)
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
@ -1036,44 +1118,47 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
payment.delete() payment.delete()
invoice.refresh_from_db() invoice.refresh_from_db()
# After payment change, set step back to in_progress
# On delete, return to awaiting approval
try: try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress' si.status = 'in_progress'
si.completed_at = None si.completed_at = None
si.save() si.save()
si.approvals.all().delete()
except Exception: except Exception:
pass pass
# Reset ALL subsequent completed steps to in_progress
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance and subsequent_step_instance.status == 'completed':
# Bypass validation by using update() instead of save()
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
except Exception:
pass
except Exception:
pass
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'totals': { return JsonResponse({'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'totals': {
'final_amount': str(invoice.final_amount), 'final_amount': str(invoice.final_amount),
'paid_amount': str(invoice.paid_amount), 'paid_amount': str(invoice.paid_amount),
'remaining_amount': str(invoice.remaining_amount), 'remaining_amount': str(invoice.remaining_amount),
}}) }})
@require_POST
@login_required
def approve_final_settlement(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance)
# Block approval if any remaining exists (positive or negative)
invoice.calculate_totals()
if invoice.remaining_amount != 0:
return JsonResponse({
'success': False,
'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})"
})
# complete step
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
# move next
next_step = instance.process.steps.filter(order__gt=step.order).first()
redirect_url = reverse('processes:request_list')
if next_step:
instance.current_step = next_step
instance.save()
redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
return JsonResponse({'success': True, 'message': 'تسویه حساب نهایی ثبت شد', 'redirect': redirect_url})

View file

@ -29,7 +29,7 @@ class AffairsAdmin(admin.ModelAdmin):
@admin.register(Broker) @admin.register(Broker)
class BrokerAdmin(admin.ModelAdmin): class BrokerAdmin(admin.ModelAdmin):
list_display = ['name', 'affairs', 'slug'] list_display = ['name', 'affairs', 'slug']
list_filter = ['affairs__county__city', 'affairs__county', 'affairs'] list_filter = ['affairs__county__city', 'affairs__county', 'affairs' ]
search_fields = ['name', 'affairs__name', 'affairs__county__name'] search_fields = ['name', 'affairs__name', 'affairs__county__name']
readonly_fields = ['deleted_at'] readonly_fields = ['deleted_at']
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}

View file

@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2025-09-07 10:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
('locations', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='broker',
name='company',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='accounts.company', verbose_name='شرکت'),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 5.2.4 on 2025-09-07 13:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('locations', '0002_broker_company'),
]
operations = [
migrations.RemoveField(
model_name='broker',
name='company',
),
]

View file

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

View file

@ -50,10 +50,12 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
verbose_name_plural = "درخواست‌ها" verbose_name_plural = "درخواست‌ها"
list_display = [ list_display = [
'code', 'code',
'current_step',
'slug', 'slug',
'well_display', 'well_display',
'representative', 'representative',
'requester', 'requester',
'broker',
'process', 'process',
'status_display', 'status_display',
'priority_display', 'priority_display',
@ -65,7 +67,8 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
'status', 'status',
'priority', 'priority',
'created', 'created',
'well__representative' 'well__representative',
'broker'
] ]
search_fields = [ search_fields = [
'code', 'code',
@ -86,6 +89,7 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
'well', 'well',
'representative', 'representative',
'requester', 'requester',
'broker',
'process', 'process',
'current_step' 'current_step'
] ]
@ -99,7 +103,7 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
'fields': ('well', 'representative') 'fields': ('well', 'representative')
}), }),
('اطلاعات درخواست', { ('اطلاعات درخواست', {
'fields': ('requester', 'priority') 'fields': ('requester', 'broker', 'priority')
}), }),
('وضعیت و پیشرفت', { ('وضعیت و پیشرفت', {
'fields': ('status', 'current_step') 'fields': ('status', 'current_step')
@ -139,9 +143,9 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
@admin.register(StepInstance) @admin.register(StepInstance)
class StepInstanceAdmin(SimpleHistoryAdmin): class StepInstanceAdmin(SimpleHistoryAdmin):
list_display = ['process_instance', 'step', 'assigned_to', 'status_display', 'rejection_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'] 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'] readonly_fields = ['started_at', 'completed_at']
ordering = ['process_instance', 'step__order'] ordering = ['process_instance', 'step__order']

View file

@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2025-09-07 13:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('locations', '0003_remove_broker_company'),
('processes', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='processinstance',
name='broker',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='process_instances', to='locations.broker', verbose_name='کارگزار'),
),
]

View file

@ -0,0 +1,56 @@
# Generated by Django 5.2.4 on 2025-09-08 08:18
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('processes', '0002_processinstance_broker'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='historicalstepinstance',
name='edit_count',
field=models.PositiveIntegerField(default=0, verbose_name='تعداد ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='historicalstepinstance',
name='edited_after_completion',
field=models.BooleanField(default=False, verbose_name='ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='historicalstepinstance',
name='last_edited_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='آخرین زمان ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='historicalstepinstance',
name='last_edited_by',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='ویرایش توسط'),
),
migrations.AddField(
model_name='stepinstance',
name='edit_count',
field=models.PositiveIntegerField(default=0, verbose_name='تعداد ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='stepinstance',
name='edited_after_completion',
field=models.BooleanField(default=False, verbose_name='ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='stepinstance',
name='last_edited_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='آخرین زمان ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='stepinstance',
name='last_edited_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='step_instances_edited', to=settings.AUTH_USER_MODEL, verbose_name='ویرایش توسط'),
),
]

View file

@ -5,7 +5,7 @@ from simple_history.models import HistoricalRecords
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils import timezone from django.utils import timezone
from django.conf import settings from django.conf import settings
from accounts.models import Role from accounts.models import Role, Broker
from _helpers.utils import generate_unique_slug from _helpers.utils import generate_unique_slug
import random import random
@ -189,6 +189,15 @@ class ProcessInstance(SluggedModel):
verbose_name="اولویت" verbose_name="اولویت"
) )
broker = models.ForeignKey(
Broker,
on_delete=models.SET_NULL,
verbose_name="کارگزار",
blank=True,
null=True,
related_name='process_instances'
)
completed_at = models.DateTimeField( completed_at = models.DateTimeField(
null=True, null=True,
blank=True, blank=True,
@ -205,13 +214,6 @@ class ProcessInstance(SluggedModel):
return f"{self.process.name} - {self.well.water_subscription_number}" return f"{self.process.name} - {self.well.water_subscription_number}"
return f"{self.process.name} - {self.requester.get_full_name()}" return f"{self.process.name} - {self.requester.get_full_name()}"
def clean(self):
"""اعتبارسنجی مدل"""
if self.well and self.representative and self.well.representative != self.representative:
raise ValidationError("نماینده درخواست باید همان نماینده ثبت شده در چاه باشد")
if self.well and self.representative and self.requester == self.representative:
raise ValidationError("درخواست کننده نمی‌تواند نماینده چاه باشد")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Generate unique 5-digit numeric code if missing # Generate unique 5-digit numeric code if missing
@ -233,6 +235,13 @@ class ProcessInstance(SluggedModel):
if self.status == 'completed' and not self.completed_at: if self.status == 'completed' and not self.completed_at:
self.completed_at = timezone.now() self.completed_at = timezone.now()
# Auto-set broker if not already set
if not self.broker:
if self.well and hasattr(self.well, 'broker') and self.well.broker:
self.broker = self.well.broker
elif self.requester and hasattr(self.requester, 'profile') and self.requester.profile and hasattr(self.requester.profile, 'broker') and self.requester.profile.broker:
self.broker = self.requester.profile.broker
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_status_display_with_color(self): def get_status_display_with_color(self):
@ -318,6 +327,12 @@ class StepInstance(models.Model):
notes = models.TextField(verbose_name="یادداشت‌ها", blank=True) notes = models.TextField(verbose_name="یادداشت‌ها", blank=True)
started_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ شروع") started_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ شروع")
completed_at = models.DateTimeField(null=True, blank=True, verbose_name="تاریخ تکمیل") completed_at = models.DateTimeField(null=True, blank=True, verbose_name="تاریخ تکمیل")
# Generic edit-tracking for post-completion modifications
edited_after_completion = models.BooleanField(default=False, verbose_name="ویرایش پس از تکمیل")
last_edited_at = models.DateTimeField(null=True, blank=True, verbose_name="آخرین زمان ویرایش پس از تکمیل")
last_edited_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='step_instances_edited', verbose_name="ویرایش توسط")
edit_count = models.PositiveIntegerField(default=0, verbose_name="تعداد ویرایش پس از تکمیل")
history = HistoricalRecords() history = HistoricalRecords()
class Meta: class Meta:

View file

@ -0,0 +1,275 @@
{% load common_tags %}
<!-- Modal for Instance Info -->
<div class="modal fade" id="{{ modal_id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">اطلاعات درخواست {{ instance.code }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-4">
<!-- Well Information -->
{% if well %}
<div class="col-12">
<div class="card border-0 bg-light">
<div class="card-header bg-label-primary text-white py-2">
<h6 class="mb-0">
<i class="bx bx-water me-2"></i>اطلاعات چاه
</h6>
</div>
<div class="card-body pt-3">
<div class="row g-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-droplet text-primary me-2"></i>
<strong>شماره اشتراک آب:</strong>
<span class="ms-2">{{ well.water_subscription_number|default:"-" }}</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-bolt text-warning me-2"></i>
<strong>شماره اشتراک برق:</strong>
<span class="ms-2">{{ well.electricity_subscription_number|default:"-" }}</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-barcode text-info me-2"></i>
<strong>سریال کنتور:</strong>
<span class="ms-2">{{ well.water_meter_serial_number|default:"-" }}</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-barcode-reader text-secondary me-2"></i>
<strong>سریال قدیمی:</strong>
<span class="ms-2">{{ well.water_meter_old_serial_number|default:"-" }}</span>
</div>
</div>
{% if well.water_meter_manufacturer %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-factory text-success me-2"></i>
<strong>سازنده کنتور:</strong>
<span class="ms-2">{{ well.water_meter_manufacturer.name }}</span>
</div>
</div>
{% endif %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-tachometer text-danger me-2"></i>
<strong>قدرت چاه:</strong>
<span class="ms-2">{{ well.well_power|default:"-" }}</span>
</div>
</div>
{% if well.utm_x and well.utm_y %}
<div class="col-12">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-map text-info me-2"></i>
<strong>مختصات:</strong>
<span class="ms-2">X: {{ well.utm_x }}, Y: {{ well.utm_y }}</span>
{% if well.utm_zone %}<span class="text-muted ms-2">(Zone: {{ well.utm_zone }})</span>{% endif %}
</div>
</div>
{% endif %}
{% if well.county %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-map-pin text-warning me-2"></i>
<strong>شهرستان:</strong>
<span class="ms-2">{{ well.county }}</span>
</div>
</div>
{% endif %}
{% if well.affairs %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-building text-primary me-2"></i>
<strong>امور:</strong>
<span class="ms-2">{{ well.affairs }}</span>
</div>
</div>
{% endif %}
{% if well.reference_letter_number %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-file text-secondary me-2"></i>
<strong>شماره معرفی نامه:</strong>
<span class="ms-2">{{ well.reference_letter_number }}</span>
{% if well.reference_letter_date %}
<span class="text-muted ms-2">({{ well.reference_letter_date|to_jalali }})</span>
{% endif %}
</div>
</div>
{% endif %}
{% if well.representative_letter_file %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-file text-secondary me-2"></i>
<strong>فایل نامه نمایندگی:</strong>
<a href="{{ well.representative_letter_file.url }}" class="ms-2">دانلود</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<!-- Representative Information -->
{% if representative %}
<div class="col-12">
<div class="card border-0 bg-light">
<div class="card-header bg-label-success text-white py-2">
<h6 class="mb-0">
<i class="bx bx-user me-2"></i>اطلاعات نماینده
</h6>
</div>
<div class="card-body pt-3">
<div class="row g-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-user-circle text-primary me-2"></i>
<strong>نام و نام خانوادگی:</strong>
<span class="ms-2">{{ representative.get_full_name|default:representative.username }}</span>
</div>
</div>
{% if representative.profile.national_code %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-id-card text-info me-2"></i>
<strong>کد ملی:</strong>
<span class="ms-2">{{ representative.profile.national_code }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.phone_number_1 %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-phone text-success me-2"></i>
<strong>تلفن اول:</strong>
<span class="ms-2">{{ representative.profile.phone_number_1 }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.phone_number_2 %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-phone text-success me-2"></i>
<strong>تلفن دوم:</strong>
<span class="ms-2">{{ representative.profile.phone_number_2 }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.bank_name %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-credit-card text-warning me-2"></i>
<strong>بانک:</strong>
<span class="ms-2">{{ representative.profile.get_bank_name_display }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.card_number %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-credit-card-alt text-secondary me-2"></i>
<strong>شماره کارت:</strong>
<span class="ms-2">{{ representative.profile.card_number }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.account_number %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-wallet text-info me-2"></i>
<strong>شماره حساب:</strong>
<span class="ms-2">{{ representative.profile.account_number }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.address %}
<div class="col-md-6">
<div class="d-flex align-items-start mb-2">
<i class="bx bx-map text-danger me-2 mt-1"></i>
<div>
<strong>آدرس:</strong>
<p class="mb-0 ms-2 text-wrap">{{ representative.profile.address }}</p>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<!-- Process Information -->
<div class="col-12">
<div class="card border-0 bg-light">
<div class="card-header bg-label-info text-white py-2">
<h6 class="mb-0">
<i class="bx bx-cog me-2"></i>اطلاعات فرآیند
</h6>
</div>
<div class="card-body pt-3">
<div class="row g-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-list-ul text-primary me-2"></i>
<strong>نوع فرآیند:</strong>
<span class="ms-2">{{ instance.process.name }}</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-calendar text-success me-2"></i>
<strong>تاریخ ایجاد:</strong>
<span class="ms-2">{{ instance.jcreated }}</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-check-circle text-info me-2"></i>
<strong>وضعیت:</strong>
<span class="ms-2 badge bg-label-primary">{{ instance.get_status_display }}</span>
</div>
</div>
{% if instance.current_step %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-step-forward text-primary me-2"></i>
<strong>مرحله فعلی:</strong>
<span class="ms-2 badge bg-label-success">{{ instance.current_step.name }}</span>
</div>
</div>
{% endif %}
{% if instance.description %}
<div class="col-md-6">
<div class="d-flex align-items-start mb-2">
<i class="bx bx-note text-secondary me-2 mt-1"></i>
<div>
<strong>توضیحات:</strong>
<p class="mb-0 ms-2 text-wrap">{{ instance.description }}</p>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">بستن</button>
</div>
</div>
</div>
</div>

View file

@ -1,7 +1,8 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static %} {% load static %}
{% load humanize %} {% load humanize %}
{% load common_tags %} {% load common_tags %}
{% load processes_tags %}
{% block sidebar %} {% block sidebar %}
{% include 'sidebars/admin.html' %} {% include 'sidebars/admin.html' %}
@ -15,6 +16,11 @@
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
@ -22,16 +28,22 @@
<div> <div>
<h4 class="mb-1">گزارش نهایی درخواست {{ instance.code }}</h4> <h4 class="mb-1">گزارش نهایی درخواست {{ instance.code }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} {% instance_info instance %}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if invoice %} {% if invoice %}
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><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 %} {% 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 'certificates:certificate_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a> <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>
</div> </div>

View file

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

View file

@ -1,6 +1,7 @@
from django import template from django import template
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from ..models import ProcessInstance, StepInstance from ..models import ProcessInstance, StepInstance
from ..utils import count_incomplete_instances
register = template.Library() register = template.Library()
@ -50,3 +51,62 @@ def stepper_header(instance, current_step=None):
} }
# moved to _base/common/templatetags/common_tags.py # moved to _base/common/templatetags/common_tags.py
@register.inclusion_tag('processes/includes/instance_info_modal.html')
def instance_info_modal(instance, modal_id=None):
"""
نمایش مدال اطلاعات کامل چاه و نماینده
استفاده:
{% load processes_tags %}
{% instance_info_modal instance %}
یا با modal_id سفارشی:
{% instance_info_modal instance "myCustomModal" %}
"""
if not isinstance(instance, ProcessInstance):
return {}
if not modal_id:
modal_id = f"instanceInfoModal_{instance.id}"
return {
'instance': instance,
'modal_id': modal_id,
'well': instance.well,
'representative': instance.representative,
}
@register.simple_tag
def instance_info(instance, modal_id=None):
"""
آیکون info برای نمایش مدال اطلاعات
استفاده:
{% load processes_tags %}
نام کاربر: {{ user.name }} {% instance_info_icon instance %}
یا با modal_id سفارشی:
{% instance_info_icon instance "myCustomModal" %}
"""
if not isinstance(instance, ProcessInstance):
return ""
if not modal_id:
modal_id = f"instanceInfoModal_{instance.id}"
html = f'''
اشتراک آب: {instance.well.water_subscription_number }
| نماینده: {instance.representative.profile.national_code }
<i class="bx bx-info-circle text-muted ms-1"
style="cursor: pointer; font-size: 14px;"
data-bs-toggle="modal"
data-bs-target="#{modal_id}"
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.views.decorators.http import require_POST, require_GET
from django.db import transaction from django.db import transaction
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .models import Process, ProcessInstance, StepInstance 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 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 accounts.forms import CustomerForm
from wells.forms import WellForm from wells.forms import WellForm
from wells.models import WaterMeterManufacturer from wells.models import WaterMeterManufacturer
from common.consts import UserRoles
@login_required @login_required
def request_list(request): def request_list(request):
"""نمایش لیست درخواست‌ها با جدول و مدال ایجاد""" """نمایش لیست درخواست‌ها با جدول و مدال ایجاد"""
instances = ProcessInstance.objects.select_related('well', 'representative', 'requester').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) processes = Process.objects.filter(is_active=True)
status_choices = list(ProcessInstance.STATUS_CHOICES)
affairs_list = Affairs.objects.all().order_by('name')
brokers_list = Broker.objects.all().order_by('name')
steps_list = ProcessStep.objects.select_related('process').all().order_by('process__name', 'order')
manufacturers = WaterMeterManufacturer.objects.all().order_by('name') manufacturers = WaterMeterManufacturer.objects.all().order_by('name')
# Calculate progress for each instance # Calculate progress for each instance
@ -52,6 +95,16 @@ def request_list(request):
'completed_count': completed_count, 'completed_count': completed_count,
'in_progress_count': in_progress_count, 'in_progress_count': in_progress_count,
'pending_count': pending_count, 'pending_count': pending_count,
# filter context
'status_choices': status_choices,
'affairs_list': affairs_list,
'brokers_list': brokers_list,
'steps_list': steps_list,
'filter_status': status_q,
'filter_affairs': affairs_q,
'filter_broker': broker_q,
'filter_step': step_q,
'access_denied': access_denied,
}) })
@ -125,6 +178,13 @@ def lookup_representative_by_national_code(request):
def create_request_with_entities(request): def create_request_with_entities(request):
"""ایجاد/به‌روزرسانی چاه و نماینده و سپس ایجاد درخواست""" """ایجاد/به‌روزرسانی چاه و نماینده و سپس ایجاد درخواست"""
User = get_user_model() User = get_user_model()
# Only BROKER can create requests
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)):
return JsonResponse({'ok': False, 'error': 'فقط کارگزار مجاز به ایجاد درخواست است'}, status=403)
except Exception:
return JsonResponse({'ok': False, 'error': 'فقط کارگزار مجاز به ایجاد درخواست است'}, status=403)
process_id = request.POST.get('process') process_id = request.POST.get('process')
process = Process.objects.get(id=process_id) process = Process.objects.get(id=process_id)
description = request.POST.get('description', '') description = request.POST.get('description', '')
@ -230,6 +290,14 @@ def create_request_with_entities(request):
well.broker = current_profile.broker well.broker = current_profile.broker
well.save() well.save()
# Ensure no active (non-deleted, non-completed) request exists for this well
try:
active_exists = ProcessInstance.objects.filter(well=well, is_deleted=False).exclude(status='completed').exists()
if active_exists:
return JsonResponse({'ok': False, 'error': 'برای این چاه یک درخواست جاری وجود دارد. ابتدا آن را تکمیل یا حذف کنید.'}, status=400)
except Exception:
return JsonResponse({'ok': False, 'error': 'خطا در بررسی وضعیت درخواست‌های قبلی این چاه'}, status=400)
# Create request instance # Create request instance
instance = ProcessInstance.objects.create( instance = ProcessInstance.objects.create(
process=process, process=process,
@ -237,6 +305,7 @@ def create_request_with_entities(request):
well=well, well=well,
representative=representative_user, representative=representative_user,
requester=request.user, requester=request.user,
broker=request.user.profile.broker if request.user.profile else None,
status='pending', status='pending',
priority='medium', priority='medium',
) )
@ -260,7 +329,17 @@ def create_request_with_entities(request):
@login_required @login_required
def delete_request(request, instance_id): 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 code = instance.code
if instance.status == 'completed': if instance.status == 'completed':
return JsonResponse({ return JsonResponse({
@ -277,10 +356,10 @@ def delete_request(request, instance_id):
@login_required @login_required
def step_detail(request, instance_id, step_id): def step_detail(request, instance_id, step_id):
"""نمایش جزئیات مرحله خاص""" """نمایش جزئیات مرحله خاص"""
instance = get_object_or_404( # Enforce scoped access to prevent URL tampering
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'), instance = get_scoped_instance_or_404(request, instance_id)
id=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) step = get_object_or_404(instance.process.steps, id=step_id)
# If the request is already completed, redirect to read-only summary page # If the request is already completed, redirect to read-only summary page
if instance.status == 'completed': if instance.status == 'completed':
@ -338,7 +417,8 @@ def step_detail(request, instance_id, step_id):
@login_required @login_required
def instance_steps(request, instance_id): def instance_steps(request, instance_id):
"""هدایت به مرحله فعلی instance""" """هدایت به مرحله فعلی instance"""
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: if not instance.current_step:
# اگر مرحله فعلی تعریف نشده، به اولین مرحله برو # اگر مرحله فعلی تعریف نشده، به اولین مرحله برو
@ -360,6 +440,9 @@ def instance_steps(request, instance_id):
@login_required @login_required
def instance_summary(request, instance_id): def instance_summary(request, instance_id):
"""نمای خلاصهٔ فقط‌خواندنی برای درخواست‌های تکمیل‌شده.""" """نمای خلاصهٔ فقط‌خواندنی برای درخواست‌های تکمیل‌شده."""
# Enforce scoped access to prevent URL tampering
instance = get_scoped_instance_or_404(request, instance_id)
instance = get_object_or_404(ProcessInstance.objects.select_related('well', 'representative'), id=instance_id) instance = get_object_or_404(ProcessInstance.objects.select_related('well', 'representative'), id=instance_id)
# Only show for completed requests; otherwise route to steps # Only show for completed requests; otherwise route to steps
if instance.status != 'completed': if instance.status != 'completed':

View file

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

View file

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

View file

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

View file

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

View file

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