Compare commits

..

No commits in common. "bf4047714cef9455118dd0a5b96220f011b71d60" and "0201779fb5637c3ca00631795a9d48ed48672383" have entirely different histories.

49 changed files with 1075 additions and 30953 deletions

View file

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

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-09-07 07:35 # Generated by Django 5.2.4 on 2025-08-14 09:02
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion
@ -17,27 +17,6 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.CreateModel(
name='Company',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')),
('name', models.CharField(max_length=100, verbose_name='نام')),
('logo', models.ImageField(blank=True, null=True, upload_to='companies/logos', verbose_name='لوگوی شرکت')),
('signature', models.ImageField(blank=True, null=True, upload_to='companies/signatures', verbose_name='امضای شرکت')),
('address', models.TextField(blank=True, null=True, verbose_name='آدرس')),
('phone', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس')),
],
options={
'verbose_name': 'شرکت',
'verbose_name_plural': 'شرکت\u200cها',
},
),
migrations.CreateModel( migrations.CreateModel(
name='HistoricalProfile', name='HistoricalProfile',
fields=[ fields=[
@ -51,7 +30,6 @@ class Migration(migrations.Migration):
('address', models.TextField(blank=True, null=True, verbose_name='آدرس')), ('address', models.TextField(blank=True, null=True, verbose_name='آدرس')),
('card_number', models.CharField(blank=True, max_length=16, null=True, validators=[django.core.validators.RegexValidator(code='invalid_card_number', message='شماره کارت باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره کارت')), ('card_number', models.CharField(blank=True, max_length=16, null=True, validators=[django.core.validators.RegexValidator(code='invalid_card_number', message='شماره کارت باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره کارت')),
('account_number', models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب')), ('account_number', models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب')),
('bank_name', 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='نام بانک')),
('phone_number_1', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۱')), ('phone_number_1', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۱')),
('phone_number_2', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۲')), ('phone_number_2', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۲')),
('pic', models.TextField(default='../static/sample_images/profile.jpg', max_length=100, verbose_name='تصویر')), ('pic', models.TextField(default='../static/sample_images/profile.jpg', max_length=100, verbose_name='تصویر')),
@ -106,7 +84,6 @@ class Migration(migrations.Migration):
('address', models.TextField(blank=True, null=True, verbose_name='آدرس')), ('address', models.TextField(blank=True, null=True, verbose_name='آدرس')),
('card_number', models.CharField(blank=True, max_length=16, null=True, validators=[django.core.validators.RegexValidator(code='invalid_card_number', message='شماره کارت باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره کارت')), ('card_number', models.CharField(blank=True, max_length=16, null=True, validators=[django.core.validators.RegexValidator(code='invalid_card_number', message='شماره کارت باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره کارت')),
('account_number', models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب')), ('account_number', models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب')),
('bank_name', 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='نام بانک')),
('phone_number_1', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۱')), ('phone_number_1', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۱')),
('phone_number_2', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۲')), ('phone_number_2', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۲')),
('pic', models.ImageField(default='../static/sample_images/profile.jpg', upload_to='profile_images', verbose_name='تصویر')), ('pic', models.ImageField(default='../static/sample_images/profile.jpg', upload_to='profile_images', verbose_name='تصویر')),

View file

@ -0,0 +1,34 @@
# Generated by Django 5.2.4 on 2025-08-21 06:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Company',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')),
('name', models.CharField(max_length=100, verbose_name='نام')),
('logo', models.ImageField(blank=True, null=True, upload_to='companies/logos', verbose_name='لوگوی شرکت')),
('signature', models.ImageField(blank=True, null=True, upload_to='companies/signatures', verbose_name='امضای شرکت')),
('address', models.TextField(blank=True, null=True, verbose_name='آدرس')),
('phone', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس')),
],
options={
'verbose_name': 'شرکت',
'verbose_name_plural': 'شرکت\u200cها',
},
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-21 07:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_company'),
]
operations = [
migrations.AddField(
model_name='historicalprofile',
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='profile',
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='نام بانک'),
),
]

View file

@ -64,18 +64,21 @@ layout-wide customizer-hide
<span class="app-brand-text demo text-body fw-bold">سامانه شفافیت</span> <span class="app-brand-text demo text-body fw-bold">سامانه شفافیت</span>
</a> </a>
</div> </div>
<!-- /Logo -->
<h4 class="mb-2">Welcome to Sneat! 👋</h4>
<p class="mb-4">Please sign-in to your account and start the adventure</p>
<form id="formAuthentication" class="mb-3 fv-plugins-bootstrap5 fv-plugins-framework" method="post" novalidate="novalidate"> <form id="formAuthentication" class="mb-3 fv-plugins-bootstrap5 fv-plugins-framework" method="post" novalidate="novalidate">
{% 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">Email or Username</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="Enter your email or username" autofocus="">
<div class="fv-plugins-message-container fv-plugins-message-container--enabled invalid-feedback"></div></div> <div class="fv-plugins-message-container fv-plugins-message-container--enabled invalid-feedback"></div></div>
<div class="mb-3 form-password-toggle fv-plugins-icon-container"> <div class="mb-3 form-password-toggle fv-plugins-icon-container">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<label class="form-label" for="password">رمز عبور</label> <label class="form-label" for="password">Password</label>
<a href="auth-forgot-password-basic.html"> <a href="auth-forgot-password-basic.html">
<small>رمز عبور را فراموش کرده اید؟</small> <small>Forgot Password?</small>
</a> </a>
</div> </div>
<div class="input-group input-group-merge has-validation"> <div class="input-group input-group-merge has-validation">
@ -83,12 +86,43 @@ layout-wide customizer-hide
<span class="input-group-text cursor-pointer"><i class="bx bx-hide"></i></span> <span class="input-group-text cursor-pointer"><i class="bx bx-hide"></i></span>
</div><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>
<div class="mb-3"> <div class="mb-3">
<button class="btn btn-primary d-grid w-100" type="submit">ورود</button> <div class="form-check">
<input class="form-check-input" type="checkbox" id="remember-me">
<label class="form-check-label" for="remember-me">
Remember Me
</label>
</div>
</div>
<div class="mb-3">
<button class="btn btn-primary d-grid w-100" type="submit">Sign in</button>
</div> </div>
<input type="hidden"></form> <input type="hidden"></form>
<p class="text-center">
<span>New on our platform?</span>
<a href="auth-register-basic.html">
<span>Create an account</span>
</a>
</p>
<div class="divider my-4">
<div class="divider-text">or</div>
</div>
<div class="d-flex justify-content-center">
<a href="javascript:;" class="btn btn-icon btn-label-facebook me-3">
<i class="tf-icons bx bxl-facebook"></i>
</a>
<a href="javascript:;" class="btn btn-icon btn-label-google-plus me-3">
<i class="tf-icons bx bxl-google-plus"></i>
</a>
<a href="javascript:;" class="btn btn-icon btn-label-twitter">
<i class="tf-icons bx bxl-twitter"></i>
</a>
</div>
</div> </div>
</div> </div>
<!-- /Register --> <!-- /Register -->

View file

@ -1,11 +1,10 @@
from django.urls import path from django.urls import path
from accounts.views import login_view, dashboard, customer_list, add_customer_ajax, edit_customer_ajax, get_customer_data, logout_view from accounts.views import login_view, dashboard, customer_list, add_customer_ajax, edit_customer_ajax, get_customer_data
app_name = "accounts" app_name = "accounts"
urlpatterns = [ urlpatterns = [
path('login/', login_view, name='login'), path('login/', login_view, name='login'),
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'),
path('customers/add/', add_customer_ajax, name='add_customer_ajax'), path('customers/add/', add_customer_ajax, name='add_customer_ajax'),

View file

@ -1,11 +1,11 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login, authenticate, logout from django.contrib.auth import login, authenticate
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.http import JsonResponse 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.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django import forms from django import forms
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 common.consts import UserRoles from common.consts import UserRoles
@ -21,12 +21,15 @@ def login_view(request):
username = request.POST.get("username") username = request.POST.get("username")
password = request.POST.get("password") password = request.POST.get("password")
user = authenticate(request, username=username, password=password) user = authenticate(request, username=username, password=password)
if user is not None: # if user is not None:
login(request, user) # login(request, user)
return redirect("processes:request_list") # if user.profile.has_none_of([UserRoles.MANAGER]):
else: # return redirect("dashboard:dashboard")
messages.error(request, "کاربری با این مشخصات یافت نشد!") # else:
return redirect("accounts:login") # return redirect("dashboard:admin_dashboard")
# else:
# messages.error(request, "کاربری با این مشخصات یافت نشد!")
# return redirect("accounts:login")
return render(request, "accounts/login.html") return render(request, "accounts/login.html")
@ -34,7 +37,7 @@ def dashboard(request):
return render(request, "accounts/dashboard.html") return render(request, "accounts/dashboard.html")
@login_required
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') customers = Profile.objects.filter(roles__slug=UserRoles.CUSTOMER.value, is_deleted=False).select_related('user')
@ -160,9 +163,3 @@ def get_customer_data(request, customer_id):
}, },
'form_html': form_html 'form_html': form_html
}) })
def logout_view(request):
"""Log out current user and redirect to login page."""
logout(request)
return redirect("accounts:login")

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-09-07 07:35 # Generated by Django 5.2.4 on 2025-08-22 09:58
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@ -9,7 +9,6 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('accounts', '0001_initial'),
('processes', '0001_initial'), ('processes', '0001_initial'),
] ]
@ -24,8 +23,10 @@ class Migration(migrations.Migration):
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')), ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('title', models.CharField(max_length=200, verbose_name='عنوان')), ('title', models.CharField(max_length=200, verbose_name='عنوان')),
('body', models.TextField(verbose_name='متن قالب (با جایگزین\u200cها)')), ('body', models.TextField(verbose_name='متن قالب (با جایگزین\u200cها)')),
('company_logo', models.ImageField(blank=True, null=True, upload_to='certificates/logos/%Y/%m/%d/', verbose_name='لوگو')),
('company_name', models.CharField(blank=True, max_length=200, verbose_name='نام شرکت')),
('company_seal_signature', models.ImageField(blank=True, null=True, upload_to='certificates/seals/%Y/%m/%d/', verbose_name='مهر و امضا')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')), ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('company', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.company', verbose_name='شرکت صادر کننده')),
], ],
options={ options={
'verbose_name': 'قالب گواهی', 'verbose_name': 'قالب گواهی',

View file

@ -0,0 +1,32 @@
# Generated by Django 5.2.4 on 2025-08-22 10:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_historicalprofile_bank_name_profile_bank_name'),
('certificates', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='certificatetemplate',
name='company_logo',
),
migrations.RemoveField(
model_name='certificatetemplate',
name='company_name',
),
migrations.RemoveField(
model_name='certificatetemplate',
name='company_seal_signature',
),
migrations.AddField(
model_name='certificatetemplate',
name='company',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.company', verbose_name='شرکت صادر کننده'),
),
]

View file

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

View file

@ -9,7 +9,6 @@ from processes.models import ProcessInstance, StepInstance
from invoices.models import Invoice from invoices.models import Invoice
from installations.models import InstallationReport from installations.models import InstallationReport
from .models import CertificateTemplate, CertificateInstance from .models import CertificateTemplate, CertificateInstance
from common.consts import UserRoles
from _helpers.jalali import Gregorian from _helpers.jalali import Gregorian
@ -79,14 +78,6 @@ def certificate_step(request, instance_id, step_id):
next_step = instance.process.steps.filter(order__gt=instance.current_step.order).first() if instance.current_step else None next_step = instance.process.steps.filter(order__gt=instance.current_step.order).first() if instance.current_step else None
if request.method == 'POST': if request.method == 'POST':
# Only broker can approve and finish certificate step
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)):
messages.error(request, 'شما مجوز تایید این مرحله را ندارید')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
except Exception:
messages.error(request, 'شما مجوز تایید این مرحله را ندارید')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
cert.approved = True cert.approved = True
cert.approved_at = timezone.now() cert.approved_at = timezone.now()
cert.save() cert.save()
@ -98,10 +89,7 @@ def certificate_step(request, instance_id, step_id):
instance.current_step = next_step instance.current_step = next_step
instance.save() instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id) return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
# Mark the whole process instance as completed on the last step return redirect('processes:request_list')
instance.status = 'completed'
instance.save()
return redirect('processes:instance_summary', instance_id=instance.id)
return render(request, 'certificates/step.html', { return render(request, 'certificates/step.html', {
'instance': instance, 'instance': instance,

View file

@ -1,6 +1,7 @@
# Generated by Django 5.2.4 on 2025-09-07 07:35 # Generated by Django 5.2.4 on 2025-08-21 06:00
import django.db.models.deletion import django.db.models.deletion
import simple_history.models
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -10,7 +11,6 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('accounts', '0001_initial'),
('processes', '0001_initial'), ('processes', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@ -28,7 +28,8 @@ class Migration(migrations.Migration):
('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')), ('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')),
('name', models.CharField(max_length=100, verbose_name='نام')), ('name', models.CharField(max_length=100, verbose_name='نام')),
('body', models.TextField(verbose_name='متن قرارداد')), ('body', models.TextField(verbose_name='متن قرارداد')),
('company', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.company', verbose_name='شرکت')), ('company_logo', models.ImageField(blank=True, null=True, upload_to='contracts/logos/%Y/%m/%d/', verbose_name='لوگوی شرکت')),
('company_signature', models.ImageField(blank=True, null=True, upload_to='contracts/signatures/%Y/%m/%d/', verbose_name='امضای شرکت')),
], ],
options={ options={
'verbose_name': 'قالب قرارداد', 'verbose_name': 'قالب قرارداد',
@ -57,4 +58,61 @@ class Migration(migrations.Migration):
'ordering': ['-created'], 'ordering': ['-created'],
}, },
), ),
migrations.CreateModel(
name='HistoricalContractInstance',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('rendered_body', models.TextField(verbose_name='متن نهایی قرارداد')),
('approved', models.BooleanField(default=False, verbose_name='تایید شده')),
('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('created_by', 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='ایجاد کننده')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('process_instance', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='processes.processinstance', verbose_name='نمونه فرآیند')),
('template', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='contracts.contracttemplate', verbose_name='قالب مورد استفاده')),
],
options={
'verbose_name': 'historical قرارداد',
'verbose_name_plural': 'historical قراردادها',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalContractTemplate',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('slug', models.SlugField(max_length=100, verbose_name='اسلاگ')),
('name', models.CharField(max_length=100, verbose_name='نام')),
('body', models.TextField(verbose_name='متن قرارداد')),
('company_logo', models.TextField(blank=True, max_length=100, null=True, verbose_name='لوگوی شرکت')),
('company_signature', models.TextField(blank=True, max_length=100, null=True, verbose_name='امضای شرکت')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical قالب قرارداد',
'verbose_name_plural': 'historical قالب\u200cهای قرارداد',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
] ]

View file

@ -0,0 +1,38 @@
# Generated by Django 5.2.4 on 2025-08-21 06:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_company'),
('contracts', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='historicalcontracttemplate',
name='history_user',
),
migrations.RemoveField(
model_name='contracttemplate',
name='company_logo',
),
migrations.RemoveField(
model_name='contracttemplate',
name='company_signature',
),
migrations.AddField(
model_name='contracttemplate',
name='company',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.company', verbose_name='شرکت'),
),
migrations.DeleteModel(
name='HistoricalContractInstance',
),
migrations.DeleteModel(
name='HistoricalContractTemplate',
),
]

View file

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

View file

@ -4,7 +4,6 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.template import Template, Context from django.template import Template, Context
from processes.models import ProcessInstance, StepInstance from processes.models import ProcessInstance, StepInstance
from common.consts import UserRoles
from .models import ContractTemplate, ContractInstance from .models import ContractTemplate, ContractInstance
from _helpers.utils import jalali_converter2 from _helpers.utils import jalali_converter2
@ -35,20 +34,6 @@ def contract_step(request, instance_id, step_id):
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
previous_step = instance.process.steps.filter(order__lt=step.order).last() previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first() next_step = instance.process.steps.filter(order__gt=step.order).first()
# Access control:
# - INSTALLER: can open step but cannot view contract body (show inline message)
# - Others: can view
# - Only BROKER can submit/complete this step
profile = getattr(request.user, 'profile', None)
is_broker = False
can_view_contract_body = True
try:
is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
if profile and profile.has_role(UserRoles.INSTALLER):
can_view_contract_body = False
except Exception:
pass
template_obj = ContractTemplate.objects.first() template_obj = ContractTemplate.objects.first()
if not template_obj: if not template_obj:
return render(request, 'contracts/contract_missing.html', {'instance': instance}) return render(request, 'contracts/contract_missing.html', {'instance': instance})
@ -69,11 +54,8 @@ def contract_step(request, instance_id, step_id):
contract.rendered_body = rendered contract.rendered_body = rendered
contract.save() contract.save()
# If user submits to go next, only broker can complete and go to next # If user submits to go next, mark this step completed and go to next
if request.method == 'POST': if request.method == 'POST':
if not is_broker:
from django.http import JsonResponse
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
StepInstance.objects.update_or_create( StepInstance.objects.update_or_create(
process_instance=instance, process_instance=instance,
step=step, step=step,
@ -92,8 +74,6 @@ def contract_step(request, instance_id, step_id):
'template': template_obj, 'template': template_obj,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'is_broker': is_broker,
'can_view_contract_body': can_view_contract_body,
}) })

Binary file not shown.

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-09-07 07:35 # Generated by Django 5.2.4 on 2025-08-21 08:25
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
@ -10,7 +10,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('invoices', '0001_initial'), ('invoices', '0002_historicalpayment_receipt_image_and_more'),
('processes', '0001_initial'), ('processes', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@ -53,8 +53,6 @@ class Migration(migrations.Migration):
('utm_x', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='UTM X')), ('utm_x', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='UTM X')),
('utm_y', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='UTM Y')), ('utm_y', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='UTM Y')),
('description', models.TextField(blank=True, verbose_name='توضیحات')), ('description', models.TextField(blank=True, verbose_name='توضیحات')),
('approved', models.BooleanField(default=False, verbose_name='تایید شده')),
('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید')),
('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='installations.installationassignment', verbose_name='اختصاص')), ('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='installations.installationassignment', verbose_name='اختصاص')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='ایجادکننده')), ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='ایجادکننده')),
], ],

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-21 09:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='installationreport',
name='approved',
field=models.BooleanField(default=False, verbose_name='تایید شده'),
),
migrations.AddField(
model_name='installationreport',
name='approved_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید'),
),
]

View file

@ -1,7 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static %} {% load static %}
{% load processes_tags %} {% load processes_tags %}
{% load common_tags %}
{% load humanize %} {% load humanize %}
{% block sidebar %} {% block sidebar %}
@ -42,15 +41,12 @@
<div class="bs-stepper-content"> <div class="bs-stepper-content">
{% if show_denied_msg %}
<div class="alert alert-warning mb-3">شما اجازه تعیین نصاب را ندارید.</div>
{% endif %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">نصاب</label> <label class="form-label">نصاب</label>
<select name="installer_id" class="form-select" {% if read_only %}disabled{% endif %} required> <select name="installer_id" class="form-select" required>
<option value="">انتخاب کنید...</option> <option value="">انتخاب کنید...</option>
{% for p in installers %} {% for p in installers %}
<option value="{{ p.user.id }}" {% if assignment.installer and p.user.id == assignment.installer.id %}selected{% endif %}>{{ p.user.get_full_name }} ({{ p.user.username }})</option> <option value="{{ p.user.id }}" {% if assignment.installer and p.user.id == assignment.installer.id %}selected{% endif %}>{{ p.user.get_full_name }} ({{ p.user.username }})</option>
@ -59,39 +55,17 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">تاریخ مراجعه نصاب</label> <label class="form-label">تاریخ مراجعه نصاب</label>
<input type="text" id="id_scheduled_date_display" class="form-control" placeholder="انتخاب تاریخ" {% if read_only %}disabled{% endif %} readonly required value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y/m/d' }}{% endif %}"> <input type="text" id="id_scheduled_date_display" class="form-control" placeholder="انتخاب تاریخ" readonly required value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y/m/d' }}{% endif %}">
<input type="hidden" id="id_scheduled_date" name="scheduled_date" value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y-m-d' }}{% endif %}"> <input type="hidden" id="id_scheduled_date" name="scheduled_date" value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y-m-d' }}{% endif %}">
</div> </div>
</div> </div>
{% if assignment.assigned_by or assignment.installer %}
<div class="mt-3 border rounded p-3 bg-light">
<div class="row g-2">
{% if assignment.assigned_by %}
<div class="col-12 col-md-6">
<div class="small text-muted">تعیین‌کننده نصاب</div>
<div>{{ assignment.assigned_by.get_full_name|default:assignment.assigned_by.username }} <span class="text-muted">({{ assignment.assigned_by.username }})</span></div>
</div>
{% endif %}
{% if assignment.updated %}
<div class="col-12 col-md-6">
<div class="small text-muted">تاریخ ثبت/ویرایش</div>
<div>{{ assignment.updated|to_jalali }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="d-flex justify-content-between mt-4"> <div class="d-flex justify-content-between mt-4">
{% if previous_step %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a> <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if is_manager %}
<button class="btn btn-primary" type="submit">ثبت و ادامه</button> <button class="btn btn-primary" type="submit">ثبت و ادامه</button>
{% else %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
{% endif %}
</div> </div>
</form> </form>
</div> </div>

View file

@ -2,7 +2,6 @@
{% load static %} {% load static %}
{% load processes_tags %} {% load processes_tags %}
{% load common_tags %} {% load common_tags %}
{% load accounts_tags %}
{% load humanize %} {% load humanize %}
{% block sidebar %} {% block sidebar %}
@ -20,18 +19,6 @@
<!-- 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>
/* Red styling for removal checkboxes when checked */
.removal-checkbox:checked {
background-color: #dc3545 !important;
border-color: #dc3545 !important;
}
.removal-checkbox:checked:focus {
box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25) !important;
}
</style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -54,27 +41,13 @@
{% stepper_header instance step %} {% stepper_header instance step %}
<div class="bs-stepper-content"> <div class="bs-stepper-content">
{% if report and not edit_mode %} {% if report and not edit_mode %}
<div class="card mb-3 border"> <div class="card mb-3 border">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-end">
<div class="d-flex gap-2">
{% if request.user|is_installer %}
<a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a> <a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a>
{% else %}
<button type="button" class="btn btn-primary" disabled>ویرایش گزارش نصب</button>
{% endif %}
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if step_instance and step_instance.status == 'rejected' and step_instance.get_latest_rejection %}
<div class="alert alert-danger d-flex align-items-start" role="alert">
<i class="bx bx-error-circle me-2"></i>
<div>
<div><strong>این گزارش رد شده است.</strong></div>
<div class="mt-1 small">علت رد: {{ step_instance.get_latest_rejection.reason }}</div>
</div>
</div>
{% endif %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}</p>
@ -94,9 +67,6 @@
</div> </div>
{% endif %} {% endif %}
<hr> <hr>
{% if request.user|is_manager or request.user|is_admin %}
<hr>
{% endif %}
<h6>عکس‌ها</h6> <h6>عکس‌ها</h6>
<div class="row"> <div class="row">
{% for p in report.photos.all %} {% for p in report.photos.all %}
@ -145,42 +115,6 @@
</div> </div>
</div> </div>
</div> </div>
{% if approver_statuses %}
<div class="card border mt-2">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">وضعیت تاییدها</h6>
{% if user_can_approve %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveModal" {% if step_instance and step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectModal">رد</button>
</div>
{% endif %}
</div>
<div class="card-body py-3">
<div class="row g-2">
{% for st in approver_statuses %}
<div class="col-12 col-md-6 col-lg-4">
<div class="d-flex flex-column border rounded px-2 py-1">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark">{{ st.role.name }}</span>
{% if st.status == 'approved' %}
<span class="badge bg-success">تایید شد</span>
{% elif st.status == 'rejected' %}
<span class="badge bg-danger">رد شد</span>
{% else %}
<span class="badge bg-warning text-dark">در انتظار</span>
{% endif %}
</div>
{% if st.status == 'rejected' and st.reason %}
<div class="mt-1 small text-danger">علت: {{ st.reason }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Persistent nav in edit mode (outside cards) --> <!-- Persistent nav in edit mode (outside cards) -->
<div class="d-flex justify-content-between mt-3"> <div class="d-flex justify-content-between mt-3">
{% if previous_step %} {% if previous_step %}
@ -193,9 +127,6 @@
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
{% if not request.user|is_installer %}
<div class="alert alert-warning">شما مجوز ثبت/ویرایش گزارش نصب را ندارید. اطلاعات به صورت فقط خواندنی نمایش داده می‌شود.</div>
{% endif %}
<form method="post" enctype="multipart/form-data" id="installation-report-form"> <form method="post" enctype="multipart/form-data" id="installation-report-form">
{% csrf_token %} {% csrf_token %}
<div class="mb-3"> <div class="mb-3">
@ -203,42 +134,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="انتخاب تاریخ" 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">
</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">
</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">
<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="0.000001" class="form-control" name="utm_x" value="{% if instance.well.utm_x %}{{ instance.well.utm_x }}{% 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="0.000001" class="form-control" name="utm_y" value="{% if instance.well.utm_y %}{{ instance.well.utm_y }}{% 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"></textarea>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<label class="form-label mb-0">عکس‌ها</label> <label class="form-label mb-0">عکس‌ها</label>
{% if request.user|is_installer %}
<button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button> <button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button>
{% endif %}
</div> </div>
{% if report %} {% if report %}
<div class="row mt-2"> <div class="row mt-2">
@ -246,9 +175,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 %} <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 %}
<input type="hidden" name="del_photo_{{ p.id }}" id="del-photo-{{ p.id }}" value="0"> <input type="hidden" name="del_photo_{{ p.id }}" id="del-photo-{{ p.id }}" value="0">
</div> </div>
</div> </div>
@ -284,7 +211,7 @@
{% for qi in quote_items %} {% for qi in quote_items %}
<tr> <tr>
<td> <td>
<input type="checkbox" class="form-check-input removal-checkbox" name="rem_{{ qi.item.id }}_type" value="remove" title="حذف در نصب" {% if removed_qty|get_item:qi.item.id %}checked{% endif %}> <input type="checkbox" class="form-check-input" name="rem_{{ qi.item.id }}_type" value="remove" title="حذف در نصب" {% if removed_qty|get_item:qi.item.id %}checked{% endif %}>
<input type="hidden" name="rem_{{ qi.item.id }}_qty" value="{% if removed_qty|get_item:qi.item.id %}{{ removed_qty|get_item:qi.item.id }}{% else %}{{ qi.quantity }}{% endif %}"> <input type="hidden" name="rem_{{ qi.item.id }}_qty" value="{% if removed_qty|get_item:qi.item.id %}{{ removed_qty|get_item:qi.item.id }}{% else %}{{ qi.quantity }}{% endif %}">
</td> </td>
<td> <td>
@ -358,11 +285,7 @@
<span></span> <span></span>
{% endif %} {% endif %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if request.user|is_installer %}
<button type="submit" class="btn btn-primary" form="installation-report-form">ثبت گزارش</button> <button type="submit" class="btn btn-primary" form="installation-report-form">ثبت گزارش</button>
{% else %}
<button type="button" class="btn btn-primary" disabled>ثبت گزارش</button>
{% endif %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-success">بعدی</a> <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-success">بعدی</a>
{% endif %} {% endif %}
@ -375,58 +298,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Approve Modal -->
<div class="modal fade" id="approveModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<div class="modal-header">
<h5 class="modal-title">تایید گزارش نصب</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
آیا از تایید این گزارش اطمینان دارید؟
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-success">تایید</button>
</div>
</form>
</div>
</div>
</div>
<!-- Reject Modal -->
<div class="modal fade" id="rejectModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="reject">
<div class="modal-header">
<h5 class="modal-title">رد گزارش نصب</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label">علت رد</label>
<textarea class="form-control" name="reject_reason" rows="3" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-danger">ثبت رد</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
@ -574,3 +445,4 @@
</script> </script>
{% endblock %} {% endblock %}

View file

@ -5,8 +5,7 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from accounts.models import Profile from accounts.models import Profile
from common.consts import UserRoles from common.consts import UserRoles
from processes.models import ProcessInstance, StepInstance, StepRejection, StepApproval from processes.models import ProcessInstance, StepInstance
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
@ -22,18 +21,7 @@ def installation_assign_step(request, instance_id, step_id):
installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value).select_related('user').all() installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value).select_related('user').all()
assignment, _ = InstallationAssignment.objects.get_or_create(process_instance=instance) assignment, _ = InstallationAssignment.objects.get_or_create(process_instance=instance)
# Role flags
profile = getattr(request.user, 'profile', None)
is_manager = False
try:
is_manager = bool(profile and profile.has_role(UserRoles.MANAGER))
except Exception:
is_manager = False
if request.method == 'POST': if request.method == 'POST':
if not is_manager:
messages.error(request, 'شما اجازه تعیین نصاب را ندارید')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
installer_id = request.POST.get('installer_id') installer_id = request.POST.get('installer_id')
scheduled_date = (request.POST.get('scheduled_date') or '').strip() scheduled_date = (request.POST.get('scheduled_date') or '').strip()
assignment.installer_id = installer_id or None assignment.installer_id = installer_id or None
@ -55,10 +43,6 @@ def installation_assign_step(request, instance_id, step_id):
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id) return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:request_list') return redirect('processes:request_list')
# Read-only logic for non-managers
read_only = not is_manager
show_denied_msg = (not is_manager) and (assignment.installer_id is None)
return render(request, 'installations/installation_assign_step.html', { return render(request, 'installations/installation_assign_step.html', {
'instance': instance, 'instance': instance,
'step': step, 'step': step,
@ -66,9 +50,6 @@ def installation_assign_step(request, instance_id, step_id):
'installers': installers, 'installers': installers,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'is_manager': is_manager,
'read_only': read_only,
'show_denied_msg': show_denied_msg,
}) })
@ -80,94 +61,15 @@ def installation_report_step(request, instance_id, step_id):
next_step = instance.process.steps.filter(order__gt=step.order).first() next_step = instance.process.steps.filter(order__gt=step.order).first()
assignment = InstallationAssignment.objects.filter(process_instance=instance).first() assignment = InstallationAssignment.objects.filter(process_instance=instance).first()
existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first() existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first()
# Only installers can enter edit mode edit_mode = True if request.GET.get('edit') == '1' else False
user_is_installer = hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.INSTALLER) print("edit_mode", edit_mode)
edit_mode = True if (request.GET.get('edit') == '1' and user_is_installer) else False
# current quote items baseline # current quote items baseline
quote = Quote.objects.filter(process_instance=instance).first() quote = Quote.objects.filter(process_instance=instance).first()
quote_items = list(quote.items.select_related('item').all()) if quote else [] quote_items = list(quote.items.select_related('item').all()) if quote else []
quote_price_map = {qi.item_id: qi.unit_price for qi in quote_items} quote_price_map = {qi.item_id: qi.unit_price for qi in quote_items}
items = Item.objects.filter(is_active=True, is_special=False, is_deleted=False).order_by('name') items = Item.objects.all().order_by('name')
# Ensure a StepInstance exists for this step
step_instance, _ = StepInstance.objects.get_or_create(
process_instance=instance,
step=step,
defaults={'status': 'in_progress'}
)
# Build approver requirements/status for UI
reqs = list(step.approver_requirements.select_related('role').all())
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
user_can_approve = any(r.role in user_roles for r in reqs)
approvals_list = list(step_instance.approvals.select_related('role').all())
approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [
{
'role': r.role,
'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None),
'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''),
}
for r in reqs
]
# Manager approval/rejection actions
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
action = request.POST.get('action')
# find a matching approver role based on step requirements
req_roles = [req.role for req in step.approver_requirements.select_related('role').all()]
user_roles = list(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).all())
matching_role = next((r for r in user_roles if r in req_roles), None)
if matching_role is None:
messages.error(request, 'شما دسترسی لازم برای این عملیات را ندارید.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
if not existing_report:
messages.error(request, 'گزارش برای تایید/رد وجود ندارد.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
if action == 'approve':
existing_report.approved = True
existing_report.save()
StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''}
)
if step_instance.is_fully_approved():
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
if next_step:
instance.current_step = next_step
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:request_list')
messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقش‌ها.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
if action == 'reject':
reason = (request.POST.get('reject_reason') or '').strip()
if not reason:
messages.error(request, 'لطفاً علت رد شدن را وارد کنید.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
)
StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
existing_report.approved = False
existing_report.save()
messages.success(request, 'گزارش رد شد و برای اصلاح به نصاب بازگشت.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
if request.method == 'POST': if request.method == 'POST':
# Only installers can submit or edit reports (non-approval actions)
if request.POST.get('action') not in ['approve', 'reject'] and not user_is_installer:
messages.error(request, 'شما مجوز ثبت/ویرایش گزارش نصب را ندارید')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
description = (request.POST.get('description') or '').strip() description = (request.POST.get('description') or '').strip()
visited_date = (request.POST.get('visited_date') or '').strip() visited_date = (request.POST.get('visited_date') or '').strip()
if '/' in visited_date: if '/' in visited_date:
@ -232,7 +134,6 @@ def installation_report_step(request, instance_id, step_id):
report.is_meter_suspicious = is_suspicious report.is_meter_suspicious = is_suspicious
report.utm_x = utm_x report.utm_x = utm_x
report.utm_y = utm_y report.utm_y = utm_y
report.approved = False # back to awaiting approval after edits
report.save() report.save()
# delete selected existing photos # delete selected existing photos
for key, val in request.POST.items(): for key, val in request.POST.items():
@ -310,17 +211,18 @@ def installation_report_step(request, instance_id, step_id):
total_price=total, total_price=total,
) )
# After installer submits/edits, set step back to in_progress and clear approvals # complete step
step_instance.status = 'in_progress' StepInstance.objects.update_or_create(
step_instance.completed_at = None process_instance=instance,
step_instance.save() step=step,
try: defaults={'status': 'completed', 'completed_at': timezone.now()}
step_instance.approvals.all().delete() )
except Exception:
pass
messages.success(request, 'گزارش ثبت شد و در انتظار تایید است.') if next_step:
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) instance.current_step = next_step
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:request_list')
# Build prefill maps from existing report changes # Build prefill maps from existing report changes
removed_ids = set() removed_ids = set()
@ -348,9 +250,6 @@ def installation_report_step(request, instance_id, step_id):
'added_map': added_map, 'added_map': added_map,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'step_instance': step_instance,
'approver_statuses': approver_statuses,
'user_can_approve': user_can_approve,
}) })

View file

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

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-09-07 07:35 # Generated by Django 5.2.4 on 2025-08-14 09:02
import django.db.models.deletion import django.db.models.deletion
import simple_history.models import simple_history.models
@ -29,7 +29,6 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=100, verbose_name='نام')), ('name', models.CharField(max_length=100, verbose_name='نام')),
('description', models.TextField(blank=True, verbose_name='توضیحات')), ('description', models.TextField(blank=True, verbose_name='توضیحات')),
('unit_price', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='قیمت واحد')), ('unit_price', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='قیمت واحد')),
('is_special', models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی')),
('default_quantity', models.PositiveIntegerField(default=1, verbose_name='تعداد پیش\u200cفرض')), ('default_quantity', models.PositiveIntegerField(default=1, verbose_name='تعداد پیش\u200cفرض')),
('is_default_in_quotes', models.BooleanField(default=False, help_text='این آیتم به صورت پیش\u200cفرض در همه پیش\u200cفاکتورها قرار می\u200cگیرد', verbose_name='پیش\u200cفرض در پیش\u200cفاکتورها')), ('is_default_in_quotes', models.BooleanField(default=False, help_text='این آیتم به صورت پیش\u200cفرض در همه پیش\u200cفاکتورها قرار می\u200cگیرد', verbose_name='پیش\u200cفرض در پیش\u200cفاکتورها')),
('history_id', models.AutoField(primary_key=True, serialize=False)), ('history_id', models.AutoField(primary_key=True, serialize=False)),
@ -122,12 +121,10 @@ class Migration(migrations.Migration):
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')), ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')), ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='مبلغ پرداخت')), ('amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='مبلغ پرداخت')),
('direction', models.CharField(choices=[('in', 'دریافتی'), ('out', 'پرداختی')], default='in', max_length=3, verbose_name='نوع تراکنش')),
('payment_method', models.CharField(choices=[('cash', 'نقدی'), ('bank_transfer', 'انتقال بانکی'), ('check', 'چک'), ('card', 'کارت بانکی'), ('other', 'سایر')], default='cash', max_length=20, verbose_name='روش پرداخت')), ('payment_method', models.CharField(choices=[('cash', 'نقدی'), ('bank_transfer', 'انتقال بانکی'), ('check', 'چک'), ('card', 'کارت بانکی'), ('other', 'سایر')], default='cash', max_length=20, verbose_name='روش پرداخت')),
('reference_number', models.CharField(blank=True, db_index=True, max_length=100, verbose_name='شماره مرجع')), ('reference_number', models.CharField(blank=True, max_length=100, verbose_name='شماره مرجع')),
('payment_date', models.DateField(verbose_name='تاریخ پرداخت')), ('payment_date', models.DateField(verbose_name='تاریخ پرداخت')),
('notes', models.TextField(blank=True, verbose_name='یادداشت\u200cها')), ('notes', models.TextField(blank=True, verbose_name='یادداشت\u200cها')),
('receipt_image', models.TextField(blank=True, max_length=100, null=True, verbose_name='تصویر فیش')),
('history_id', models.AutoField(primary_key=True, serialize=False)), ('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)), ('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)), ('history_change_reason', models.CharField(max_length=100, null=True)),
@ -157,7 +154,6 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=100, verbose_name='نام')), ('name', models.CharField(max_length=100, verbose_name='نام')),
('description', models.TextField(blank=True, verbose_name='توضیحات')), ('description', models.TextField(blank=True, verbose_name='توضیحات')),
('unit_price', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='قیمت واحد')), ('unit_price', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='قیمت واحد')),
('is_special', models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی')),
('default_quantity', models.PositiveIntegerField(default=1, verbose_name='تعداد پیش\u200cفرض')), ('default_quantity', models.PositiveIntegerField(default=1, verbose_name='تعداد پیش\u200cفرض')),
('is_default_in_quotes', models.BooleanField(default=False, help_text='این آیتم به صورت پیش\u200cفرض در همه پیش\u200cفاکتورها قرار می\u200cگیرد', verbose_name='پیش\u200cفرض در پیش\u200cفاکتورها')), ('is_default_in_quotes', models.BooleanField(default=False, help_text='این آیتم به صورت پیش\u200cفرض در همه پیش\u200cفاکتورها قرار می\u200cگیرد', verbose_name='پیش\u200cفرض در پیش\u200cفاکتورها')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='ایجاد کننده')), ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='ایجاد کننده')),
@ -229,12 +225,10 @@ class Migration(migrations.Migration):
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')), ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')), ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='مبلغ پرداخت')), ('amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='مبلغ پرداخت')),
('direction', models.CharField(choices=[('in', 'دریافتی'), ('out', 'پرداختی')], default='in', max_length=3, verbose_name='نوع تراکنش')),
('payment_method', models.CharField(choices=[('cash', 'نقدی'), ('bank_transfer', 'انتقال بانکی'), ('check', 'چک'), ('card', 'کارت بانکی'), ('other', 'سایر')], default='cash', max_length=20, verbose_name='روش پرداخت')), ('payment_method', models.CharField(choices=[('cash', 'نقدی'), ('bank_transfer', 'انتقال بانکی'), ('check', 'چک'), ('card', 'کارت بانکی'), ('other', 'سایر')], default='cash', max_length=20, verbose_name='روش پرداخت')),
('reference_number', models.CharField(blank=True, max_length=100, unique=True, verbose_name='شماره مرجع')), ('reference_number', models.CharField(blank=True, max_length=100, verbose_name='شماره مرجع')),
('payment_date', models.DateField(verbose_name='تاریخ پرداخت')), ('payment_date', models.DateField(verbose_name='تاریخ پرداخت')),
('notes', models.TextField(blank=True, verbose_name='یادداشت\u200cها')), ('notes', models.TextField(blank=True, verbose_name='یادداشت\u200cها')),
('receipt_image', models.ImageField(blank=True, null=True, upload_to='payments/%Y/%m/%d/', verbose_name='تصویر فیش')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='ثبت کننده')), ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='ثبت کننده')),
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='invoices.invoice', verbose_name='فاکتور')), ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='invoices.invoice', verbose_name='فاکتور')),
], ],

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-16 04:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoices', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='historicalpayment',
name='receipt_image',
field=models.TextField(blank=True, max_length=100, null=True, verbose_name='تصویر فیش'),
),
migrations.AddField(
model_name='payment',
name='receipt_image',
field=models.ImageField(blank=True, null=True, upload_to='payments/%Y/%m/%d/', verbose_name='تصویر فیش'),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-21 18:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoices', '0002_historicalpayment_receipt_image_and_more'),
]
operations = [
migrations.AlterField(
model_name='historicalpayment',
name='reference_number',
field=models.CharField(blank=True, db_index=True, max_length=100, verbose_name='شماره مرجع'),
),
migrations.AlterField(
model_name='payment',
name='reference_number',
field=models.CharField(blank=True, max_length=100, unique=True, verbose_name='شماره مرجع'),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-22 08:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoices', '0003_alter_historicalpayment_reference_number_and_more'),
]
operations = [
migrations.AddField(
model_name='historicalpayment',
name='direction',
field=models.CharField(choices=[('in', 'دریافتی'), ('out', 'پرداختی')], default='in', max_length=3, verbose_name='نوع تراکنش'),
),
migrations.AddField(
model_name='payment',
name='direction',
field=models.CharField(choices=[('in', 'دریافتی'), ('out', 'پرداختی')], default='in', max_length=3, verbose_name='نوع تراکنش'),
),
]

View file

@ -0,0 +1,33 @@
# Generated by Django 5.2.4 on 2025-08-22 08:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoices', '0004_historicalpayment_direction_payment_direction'),
]
operations = [
migrations.AddField(
model_name='historicalitem',
name='is_special',
field=models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی'),
),
migrations.AddField(
model_name='historicalitem',
name='special_kind',
field=models.CharField(blank=True, choices=[('repair', 'تعمیر'), ('replace', 'تعویض')], max_length=10, verbose_name='نوع ویژه'),
),
migrations.AddField(
model_name='item',
name='is_special',
field=models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی'),
),
migrations.AddField(
model_name='item',
name='special_kind',
field=models.CharField(blank=True, choices=[('repair', 'تعمیر'), ('replace', 'تعویض')], max_length=10, verbose_name='نوع ویژه'),
),
]

View file

@ -0,0 +1,21 @@
# Generated by Django 5.2.4 on 2025-08-22 08:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('invoices', '0005_historicalitem_is_special_and_more'),
]
operations = [
migrations.RemoveField(
model_name='historicalitem',
name='special_kind',
),
migrations.RemoveField(
model_name='item',
name='special_kind',
),
]

View file

@ -50,9 +50,7 @@
<div class="card border"> <div class="card border">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">فاکتور نهایی</h5> <h5 class="mb-0">فاکتور نهایی</h5>
{% if is_manager %}
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSpecialChargeModal()"><i class="bx bx-plus"></i> افزودن هزینه تعمیر/تعویض</button> <button type="button" class="btn btn-sm btn-outline-primary" onclick="openSpecialChargeModal()"><i class="bx bx-plus"></i> افزودن هزینه تعمیر/تعویض</button>
{% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
@ -129,9 +127,7 @@
<td class="text-end">{{ si.unit_price|floatformat:0|intcomma:False }}</td> <td class="text-end">{{ si.unit_price|floatformat:0|intcomma:False }}</td>
<td class="text-end"> <td class="text-end">
{{ si.total_price|floatformat:0|intcomma:False }} {{ si.total_price|floatformat:0|intcomma:False }}
{% if is_manager %}
<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="deleteSpecial('{{ si.id }}')" title="حذف"><i class="bx bx-trash"></i></button> <button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="deleteSpecial('{{ si.id }}')" title="حذف"><i class="bx bx-trash"></i></button>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -168,11 +164,7 @@
<span></span> <span></span>
{% endif %} {% endif %}
{% if next_step %} {% if next_step %}
{% if is_manager %}
<button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button> <button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button>
{% else %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -2,7 +2,6 @@
{% load static %} {% load static %}
{% load processes_tags %} {% load processes_tags %}
{% load common_tags %} {% load common_tags %}
{% load accounts_tags %}
{% load humanize %} {% load humanize %}
{% block sidebar %} {% block sidebar %}
@ -47,7 +46,6 @@
<div class="bs-stepper-content"> <div class="bs-stepper-content">
<div class="row g-3"> <div class="row g-3">
{% if is_broker %}
<div class="col-12 col-lg-5"> <div class="col-12 col-lg-5">
<div class="card border h-100"> <div class="card border h-100">
<div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div> <div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div>
@ -80,11 +78,11 @@
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">شماره مرجع/چک</label> <label class="form-label">شماره مرجع</label>
<input type="text" class="form-control" name="reference_number" id="id_reference_number" required> <input type="text" class="form-control" name="reference_number" id="id_reference_number" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">تصویر فیش/چک</label> <label class="form-label">تصویر فیش</label>
<input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required> <input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@ -94,39 +92,23 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %} <div class="col-12 col-lg-7">
<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"><h5 class="mb-0">وضعیت فاکتور</h5></div>
<h5 class="mb-0">وضعیت فاکتور</h5>
</div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-6 col-md-4"> <div class="col-6">
<div class="border rounded p-3 h-100"> <div class="border rounded p-3">
<div class="small text-muted">مبلغ نهایی</div> <div class="small text-muted">مبلغ نهایی</div>
<div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div> <div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
</div> </div>
</div> </div>
<div class="col-6 col-md-4"> <div class="col-6">
<div class="border rounded p-3 h-100"> <div class="border rounded p-3">
<div class="small text-muted">پرداختی‌ها</div>
<div class="h5 mt-1 text-success">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-muted">مانده</div> <div class="small text-muted">مانده</div>
<div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div> <div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div>
</div> </div>
</div> </div>
<div class="col-6 d-flex align-items-center">
{% if invoice.remaining_amount <= 0 %}
<span class="badge bg-success">تسویه کامل</span>
{% else %}
<span class="badge bg-warning text-dark">باقی‌مانده دارد</span>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -141,8 +123,8 @@
<th>مبلغ</th> <th>مبلغ</th>
<th>تاریخ</th> <th>تاریخ</th>
<th>روش</th> <th>روش</th>
<th class="text-nowrap">شماره مرجع/چک</th> <th>شماره مرجع</th>
<th>عملیات</th> <th style="width:150px">عملیات</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -150,7 +132,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.payment_date|to_jalali }}</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>
@ -160,9 +142,7 @@
<i class="bx bx-show"></i> <i class="bx bx-show"></i>
</a> </a>
{% endif %} {% endif %}
{% if is_broker %} <button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteFinalPayment({{ p.id }})" title="حذف" aria-label="حذف"><i class="bx bx-trash"></i></button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف"><i class="bx bx-trash"></i></button>
{% endif %}
</div> </div>
</td> </td>
</tr> </tr>
@ -172,141 +152,20 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="card-footer d-flex justify-content-between">
</div>
</div>
</div>
{% if approver_statuses %}
<div class="card border mt-2">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">وضعیت تاییدها</h6>
{% if can_approve_reject %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveFinalSettleModal" {% if step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
</div>
{% endif %}
</div>
<div class="card-body py-3">
<div class="row g-2">
{% for st in approver_statuses %}
<div class="col-12 col-md-6 col-lg-4">
<div class="d-flex flex-column border rounded px-2 py-1">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark">{{ st.role.name }}</span>
{% if st.status == 'approved' %}
<span class="badge bg-success">تایید شد</span>
{% elif st.status == 'rejected' %}
<span class="badge bg-danger">رد شد</span>
{% else %}
<span class="badge bg-warning text-dark">در انتظار</span>
{% endif %}
</div>
{% if st.status == 'rejected' and st.reason %}
<div class="mt-1 small text-danger">علت: {{ st.reason }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="col-12 d-flex justify-content-between mt-3">
{% if previous_step %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a> <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if step_instance.status == 'completed' %} <button type="button" id="btnApproveFinalSettlement" class="btn btn-primary">تایید و ادامه</button>
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
{% else %}
<a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Delete Confirmation Modal (final settlement payments) -->
<div class="modal fade" id="deletePaymentModal" tabindex="-1" aria-labelledby="deletePaymentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deletePaymentModalLabel">تایید حذف تراکنش</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
آیا از حذف این تراکنش مطمئن هستید؟ این عمل قابل بازگشت نیست.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-danger" onclick="confirmDeletePayment()" data-bs-dismiss="modal">حذف</button>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Approve Final Settlement Modal -->
<div class="modal fade" id="approveFinalSettleModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<div class="modal-header">
<h5 class="modal-title">تایید تسویه نهایی</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if invoice.remaining_amount != 0 %}
<div class="alert alert-warning" role="alert">
مانده فاکتور صفر نیست: <strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br>
تا صفر نشود امکان تایید نیست.
</div>
{% else %}
آیا از تایید این مرحله اطمینان دارید؟
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-success" {% if invoice.remaining_amount != 0 %}disabled{% endif %}>تایید</button>
</div>
</form>
</div>
</div>
</div>
<!-- Reject Final Settlement Modal -->
<div class="modal fade" id="rejectFinalSettleModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="reject">
<div class="modal-header">
<h5 class="modal-title">رد تسویه نهایی</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label">علت رد</label>
<textarea class="form-control" name="reject_reason" rows="3" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-danger">ثبت رد</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
@ -332,10 +191,7 @@
if (g) { fd.set('payment_date', g); } if (g) { fd.set('payment_date', g); }
return fd; return fd;
} }
(function(){ document.getElementById('btnAddFinalPayment').addEventListener('click', function(){
const btn = document.getElementById('btnAddFinalPayment');
if (!btn) return;
btn.addEventListener('click', function(){
const fd = buildForm(); const fd = buildForm();
// Frontend validation // Frontend validation
const amount = document.getElementById('id_amount').value.trim(); const amount = document.getElementById('id_amount').value.trim();
@ -358,19 +214,11 @@
} }
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger')); }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
}); });
})();
let deleteTargetId = null; function deleteFinalPayment(id){
function openDeleteModal(id){
deleteTargetId = id;
const modal = new bootstrap.Modal(document.getElementById('deletePaymentModal'));
modal.show();
}
function confirmDeletePayment(){
if (!deleteTargetId) return;
const fd = new FormData(); const fd = new FormData();
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value); fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
fetch(`{% url "invoices:delete_final_payment" instance.id step.id 0 %}`.replace('/0/', `/${deleteTargetId}/`), { method:'POST', body: fd }) fetch(`{% url "invoices:delete_final_payment" instance.id step.id 0 %}`.replace('/0/', `/${id}/`), { method:'POST', body: fd })
.then(r=>r.json()).then(resp=>{ .then(r=>r.json()).then(resp=>{
if (resp.success) { if (resp.success) {
showToast('حذف شد', 'success'); showToast('حذف شد', 'success');
@ -381,7 +229,20 @@
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger')); }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
} }
// Legacy approve button removed; using modal forms below document.getElementById('btnApproveFinalSettlement').addEventListener('click', function(){
const fd = new FormData();
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
fetch('{% url "invoices:approve_final_settlement" instance.id step.id %}', { method:'POST', body: fd })
.then(r=>r.json()).then(resp=>{
if (resp.success) {
showToast(resp.message || 'تایید شد', 'success');
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 600);
} else {
showToast(resp.message || 'خطا در تایید', 'danger');
}
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -1,7 +1,6 @@
{% extends '_base.html' %} {% extends '_base.html' %}
{% load static %} {% load static %}
{% load processes_tags %} {% load processes_tags %}
{% load accounts_tags %}
{% load humanize %} {% load humanize %}
{% block sidebar %} {% block sidebar %}
@ -56,15 +55,14 @@
<div class="content active dstepper-block"> <div class="content active dstepper-block">
<div class="content-header mb-3"> <div class="content-header mb-3">
<h6 class="mb-0">{{ step.name }}</h6> <h6 class="mb-0">{{ step.name }}</h6>
<small>ثبت فیش‌ها/چک‌های واریزی برای پیش‌فاکتور</small> <small>ثبت فیش‌های واریزی برای پیش‌فاکتور</small>
</div> </div>
<div class="row g-3"> <div class="row g-3">
{% if can_manage_payments %}
<div class="col-12 col-lg-5"> <div class="col-12 col-lg-5">
<div class="card h-100 border"> <div class="card h-100 border">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0">ثبت فیش/چک جدید</h5> <h5 class="card-title mb-0">ثبت فیش جدید</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
@ -86,11 +84,11 @@
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">شماره مرجع/چک</label> <label class="form-label">شماره مرجع</label>
<input type="text" class="form-control" name="reference_number" id="id_reference_number" placeholder="..." required> <input type="text" class="form-control" name="reference_number" id="id_reference_number" placeholder="..." required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">تصویر فیش/چک</label> <label class="form-label">تصویر فیش</label>
<input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required> <input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -98,16 +96,16 @@
<textarea class="form-control" rows="2" name="notes" id="id_notes"></textarea> <textarea class="form-control" rows="2" name="notes" id="id_notes"></textarea>
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<button type="button" id="btnAddPayment" class="btn btn-primary">افزودن فیش/چک</button> <button type="button" id="btnAddPayment" class="btn btn-primary">افزودن فیش</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endif %} <div class="col-12 col-lg-7">
<div class="col-12 {% if can_manage_payments %}col-lg-7{% else %}col-lg-12{% endif %}">
<div class="card mb-3 border"> <div class="card mb-3 border">
<div class="card-header d-flex justify-content-between"> <div class="card-header d-flex justify-content-between">
<h5 class="card-title mb-0">وضعیت پیش‌فاکتور</h5> <h5 class="card-title mb-0">وضعیت پیش‌فاکتور</h5>
<a href="{% url 'invoices:quote_preview_step' instance.id step.id|add:'-1' %}" class="btn btn-sm btn-label-secondary">مشاهده پیش‌فاکتور</a>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
@ -141,10 +139,8 @@
</div> </div>
<div class="card border"> <div class="card border">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header">
<div> <h5 class="card-title mb-0">فیش‌های ثبت شده</h5>
<h5 class="card-title mb-0">فیش‌ها/چک‌های ثبت شده</h5>
</div>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped mb-0"> <table class="table table-striped mb-0">
@ -153,8 +149,9 @@
<th>مبلغ</th> <th>مبلغ</th>
<th>تاریخ</th> <th>تاریخ</th>
<th>روش</th> <th>روش</th>
<th>شماره مرجع/چک</th> <th>شماره مرجع</th>
<th>عملیات</th> <th>تصویر</th>
<th style="width:120px">عملیات</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -165,23 +162,28 @@
<td>{{ p.get_payment_method_display }}</td> <td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td> <td>{{ p.reference_number|default:'-' }}</td>
<td> <td>
<div class="btn-group">
{% if p.receipt_image %} {% if p.receipt_image %}
<a href="{{ p.receipt_image.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="مشاهده" aria-label="مشاهده"> <a href="{{ p.receipt_image.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="مشاهده" aria-label="مشاهده">
<i class="bx bx-show"></i> <i class="bx bx-show"></i>
</a> </a>
{% else %}
-
{% endif %} {% endif %}
{% if can_manage_payments %} </td>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف"> <td>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="editPayment({{ p.id }})" title="ویرایش" aria-label="ویرایش">
<i class="bx bx-edit"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal({{ p.id }})" title="حذف" aria-label="حذف">
<i class="bx bx-trash"></i> <i class="bx bx-trash"></i>
</button> </button>
{% endif %}
</div> </div>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="6" class="text-center text-muted">تا کنون فیش/چکی ثبت نشده است</td> <td colspan="6" class="text-center text-muted">تا کنون فیشی ثبت نشده است</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -189,42 +191,6 @@
</div> </div>
</div> </div>
</div> </div>
{% if approver_statuses %}
<div class="card border mt-2">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">وضعیت تاییدها</h6>
{% if can_approve_reject %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2" {% if step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
</div>
{% endif %}
</div>
<div class="card-body py-3">
<div class="row g-2">
{% for st in approver_statuses %}
<div class="col-12 col-md-6 col-lg-4">
<div class="d-flex flex-column border rounded px-2 py-1">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark">{{ st.role.name }}</span>
{% if st.status == 'approved' %}
<span class="badge bg-success">تایید شد</span>
{% elif st.status == 'rejected' %}
<span class="badge bg-danger">رد شد</span>
{% else %}
<span class="badge bg-warning text-dark">در انتظار</span>
{% endif %}
</div>
{% if st.status == 'rejected' and st.reason %}
<div class="mt-1 small text-danger">علت: {{ st.reason }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="col-12 d-flex justify-content-between mt-3"> <div class="col-12 d-flex justify-content-between mt-3">
{% if previous_step %} {% if previous_step %}
@ -235,114 +201,30 @@
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if step_instance.status == 'completed' %} <button type="button" id="btnApprovePayments" class="btn btn-primary">
{% if next_step %} تایید پرداخت‌ها
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary"> <i class="bx bx-chevron-left bx-sm ms-sm-2"></i>
<span class="align-middle d-sm-inline-block d-none me-sm-1">بعدی</span> </button>
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% else %}
<a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deletePaymentModal" tabindex="-1" aria-labelledby="deletePaymentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deletePaymentModalLabel">تایید حذف فیش</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
آیا از حذف این فیش مطمئن هستید؟ این عمل قابل بازگشت نیست.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-danger" onclick="confirmDeletePayment()" data-bs-dismiss="modal">حذف</button>
</div>
</div>
</div>
</div>
<!-- Removed legacy approvePaymentsModal; using approvePaymentsModal2 with form POST -->
<!-- Approve Modal 2 (direct approve button in header) -->
<div class="modal fade" id="approvePaymentsModal2" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<div class="modal-header">
<h5 class="modal-title">تایید پرداخت‌ها</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if not totals.is_fully_paid %}
<div class="alert alert-warning" role="alert">
مبلغی از پیش‌فاکتور هنوز پرداخت نشده است.
<div class="mt-1">مانده: <strong>{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان</strong></div>
</div>
آیا مطمئن هستید که می‌خواهید مرحله را تایید کنید؟
{% else %}
آیا از تایید این مرحله اطمینان دارید؟
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-success">تایید</button>
</div>
</form>
</div>
</div>
</div>
<!-- Reject Modal for payments step -->
<div class="modal fade" id="rejectPaymentsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="reject">
<div class="modal-header">
<h5 class="modal-title">رد مرحله پرداخت‌ها</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label">علت رد</label>
<textarea class="form-control" name="reject_reason" rows="3" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-danger">ثبت رد</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
// Removed legacy isFullyPaid-driven approve flow; approval now via modal submit const isFullyPaid = {{ totals.is_fully_paid|yesno:'true,false' }};
function buildFormData(form) { function buildFormData(form) {
const fd = new FormData(form); const fd = new FormData(form);
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value); fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
return fd; return fd;
} }
const btnAddPayment = document.getElementById('btnAddPayment'); document.getElementById('btnAddPayment').addEventListener('click', function() {
if (btnAddPayment) btnAddPayment.addEventListener('click', function() {
// Front-end validation // Front-end validation
const amount = document.getElementById('id_amount').value.trim(); const amount = document.getElementById('id_amount').value.trim();
const payDate = document.getElementById('id_payment_date').value.trim(); const payDate = document.getElementById('id_payment_date').value.trim();
@ -401,7 +283,51 @@
alert('ویرایش فیش را بعدا با مدال تکمیل می‌کنیم. فعلا حذف و افزودن مجدد انجام دهید.'); alert('ویرایش فیش را بعدا با مدال تکمیل می‌کنیم. فعلا حذف و افزودن مجدد انجام دهید.');
} }
// Legacy approve JS removed; approval handled by modal forms in header function performApprovePayments() {
const fd = new FormData();
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
fetch('{% url "invoices:approve_payments" instance.id step.id %}', {
method: 'POST',
body: fd
}).then(r => r.json()).then(resp => {
if (resp.success) {
showToast(resp.message || 'پرداخت‌ها تایید شد', 'success');
if (resp.redirect) {
setTimeout(() => { window.location.href = resp.redirect; }, 600);
}
} else {
showToast(resp.message || resp.error || 'خطا در تایید پرداخت‌ها', 'danger');
}
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
}
function openApproveModal() {
const el = document.getElementById('approvePaymentsModal');
const remEl = document.getElementById('remainingAmountText');
if (remEl) {
remEl.textContent = '{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان';
}
// Prefer jQuery plugin if available to avoid namespace issues
if (window.$ && typeof $(el).modal === 'function') {
$(el).modal('show');
} else if (window.bootstrap && window.bootstrap.Modal) {
const modal = new window.bootstrap.Modal(el);
modal.show();
} else {
// fallback: force display
el.classList.add('show');
el.style.display = 'block';
el.removeAttribute('aria-hidden');
}
}
document.getElementById('btnApprovePayments').addEventListener('click', function() {
if (isFullyPaid) {
performApprovePayments();
} else {
openApproveModal();
}
});
</script> </script>
<!-- Persian Date Picker JS --> <!-- Persian Date Picker JS -->
@ -439,4 +365,42 @@
})(); })();
</script> </script>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deletePaymentModal" tabindex="-1" aria-labelledby="deletePaymentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deletePaymentModalLabel">تایید حذف فیش</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
آیا از حذف این فیش مطمئن هستید؟ این عمل قابل بازگشت نیست.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-danger" onclick="confirmDeletePayment()" data-bs-dismiss="modal">حذف</button>
</div>
</div>
</div>
</div>
<!-- Approve Confirmation Modal (shown when remaining amount > 0) -->
<div class="modal fade" id="approvePaymentsModal" tabindex="-1" aria-labelledby="approvePaymentsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="approvePaymentsModalLabel">تایید نهایی پرداخت‌ها</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
مبلغی از پیش‌فاکتور هنوز پرداخت نشده است.
<div class="mt-2">مانده: <strong id="remainingAmountText"></strong></div>
آیا مطمئن هستید که می‌خواهید مرحله را تایید و به مرحله بعد بروید؟
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="performApprovePayments()">بله، تایید</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -221,7 +221,6 @@
<span></span> <span></span>
{% endif %} {% endif %}
{% if is_broker %}
{% if step_instance.status == 'completed' %} {% if step_instance.status == 'completed' %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" <a href="{% url 'processes:step_detail' instance.id next_step.id %}"
@ -237,17 +236,6 @@
تایید پیش‌فاکتور تایید پیش‌فاکتور
</button> </button>
{% endif %} {% endif %}
{% else %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}"
class="btn btn-label-primary">
مرحله بعد
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% else %}
<a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View file

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

View file

@ -9,9 +9,7 @@ from django.urls import reverse
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
import json import json
from processes.models import ProcessInstance, ProcessStep, StepInstance, StepRejection, StepApproval from processes.models import ProcessInstance, ProcessStep, StepInstance
from accounts.models import Role
from common.consts import UserRoles
from .models import Item, Quote, QuoteItem, Payment, Invoice from .models import Item, Quote, QuoteItem, Payment, Invoice
from installations.models import InstallationReport, InstallationItemChange from installations.models import InstallationReport, InstallationItemChange
@ -30,7 +28,7 @@ def quote_step(request, instance_id, step_id):
return redirect('processes:request_list') return redirect('processes:request_list')
# دریافت آیتم‌ها # دریافت آیتم‌ها
items = Item.objects.filter(is_active=True, is_special=False, is_deleted=False).order_by('name') items = Item.objects.all().order_by('name')
existing_quote = Quote.objects.filter(process_instance=instance).first() existing_quote = Quote.objects.filter(process_instance=instance).first()
existing_quote_items = {} existing_quote_items = {}
if existing_quote: if existing_quote:
@ -42,14 +40,6 @@ def quote_step(request, instance_id, step_id):
previous_step = instance.process.steps.filter(order__lt=step.order).last() previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first() next_step = instance.process.steps.filter(order__gt=step.order).first()
# determine if current user is broker
profile = getattr(request.user, 'profile', None)
is_broker = False
try:
is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
except Exception:
is_broker = False
return render(request, 'invoices/quote_step.html', { return render(request, 'invoices/quote_step.html', {
'instance': instance, 'instance': instance,
'step': step, 'step': step,
@ -59,7 +49,6 @@ def quote_step(request, instance_id, step_id):
'existing_quote': existing_quote, 'existing_quote': existing_quote,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'is_broker': is_broker,
}) })
@require_POST @require_POST
@ -68,13 +57,6 @@ def create_quote(request, instance_id, step_id):
"""ساخت/بروزرسانی پیش‌فاکتور از اقلام انتخابی""" """ساخت/بروزرسانی پیش‌فاکتور از اقلام انتخابی"""
instance = get_object_or_404(ProcessInstance, id=instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
# enforce permission: only BROKER can create/update quote
profile = getattr(request.user, 'profile', None)
try:
if not (profile and profile.has_role(UserRoles.BROKER)):
return JsonResponse({'success': False, 'message': 'شما مجوز ثبت/ویرایش پیش‌فاکتور را ندارید'})
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز ثبت/ویرایش پیش‌فاکتور را ندارید'})
try: try:
items_payload = json.loads(request.POST.get('items') or '[]') items_payload = json.loads(request.POST.get('items') or '[]')
@ -90,7 +72,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).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:
@ -181,14 +163,6 @@ def quote_preview_step(request, instance_id, step_id):
previous_step = instance.process.steps.filter(order__lt=step.order).last() previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first() next_step = instance.process.steps.filter(order__gt=step.order).first()
# determine if current user is broker for UI controls
profile = getattr(request.user, 'profile', None)
is_broker = False
try:
is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
except Exception:
is_broker = False
return render(request, 'invoices/quote_preview_step.html', { return render(request, 'invoices/quote_preview_step.html', {
'instance': instance, 'instance': instance,
'step': step, 'step': step,
@ -196,7 +170,6 @@ def quote_preview_step(request, instance_id, step_id):
'quote': quote, 'quote': quote,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'is_broker': is_broker,
}) })
@login_required @login_required
@ -217,13 +190,6 @@ def approve_quote(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance) quote = get_object_or_404(Quote, process_instance=instance)
# enforce permission: only BROKER can approve
profile = getattr(request.user, 'profile', None)
try:
if not (profile and profile.has_role(UserRoles.BROKER)):
return JsonResponse({'success': False, 'message': 'شما مجوز تایید پیش‌فاکتور را ندارید'})
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز تایید پیش‌فاکتور را ندارید'})
# تایید پیش‌فاکتور # تایید پیش‌فاکتور
quote.status = 'sent' quote.status = 'sent'
@ -281,97 +247,7 @@ def quote_payment_step(request, instance_id, step_id):
'is_fully_paid': quote.get_remaining_amount() <= 0, 'is_fully_paid': quote.get_remaining_amount() <= 0,
} }
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'}) step_instance = instance.step_instances.filter(step=step).first()
reqs = list(step.approver_requirements.select_related('role').all())
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
approvals_list = list(step_instance.approvals.select_related('role').all())
approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [
{
'role': r.role,
'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None),
'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''),
}
for r in reqs
]
# dynamic permission: who can approve/reject this step (based on requirements)
try:
req_role_ids = {r.role_id for r in reqs}
user_role_ids = {ur.id for ur in user_roles}
can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0
except Exception:
can_approve_reject = False
# approver status map for template
reqs = list(step.approver_requirements.select_related('role').all())
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
approvals_list = list(step_instance.approvals.select_related('role').all())
approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [
{
'role': r.role,
'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None),
'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''),
}
for r in reqs
]
# Accountant/Admin approval and rejection via POST (multi-role)
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
# match user's role against step required approver roles
req_roles = [req.role for req in step.approver_requirements.select_related('role').all()]
user_roles = list(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).all())
matching_role = next((r for r in user_roles if r in req_roles), None)
if matching_role is None:
messages.error(request, 'شما دسترسی لازم برای تایید/رد این مرحله را ندارید.')
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
action = request.POST.get('action')
if action == 'approve':
StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''}
)
if step_instance.is_fully_approved():
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
# move to next step
redirect_url = 'processes:request_list'
if next_step:
instance.current_step = next_step
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect(redirect_url)
messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقش‌ها.')
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
if action == 'reject':
reason = (request.POST.get('reject_reason') or '').strip()
if not reason:
messages.error(request, 'علت رد شدن را وارد کنید')
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
)
StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
messages.success(request, 'مرحله پرداخت‌ها رد شد و برای اصلاح بازگشت.')
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
# role flags for permissions (legacy flags kept for compatibility)
profile = getattr(request.user, 'profile', None)
is_broker = False
is_accountant = False
try:
is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT))
except Exception:
is_broker = False
is_accountant = False
return render(request, 'invoices/quote_payment_step.html', { return render(request, 'invoices/quote_payment_step.html', {
'instance': instance, 'instance': instance,
@ -382,12 +258,6 @@ def quote_payment_step(request, instance_id, step_id):
'totals': totals, 'totals': totals,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'approver_statuses': approver_statuses,
'is_broker': is_broker,
'is_accountant': is_accountant,
# dynamic permissions: any role required to approve can also manage payments
'can_manage_payments': can_approve_reject,
'can_approve_reject': can_approve_reject,
}) })
@ -409,16 +279,6 @@ def add_quote_payment(request, instance_id, step_id):
} }
) )
# dynamic permission: users whose roles are among required approvers can add payments
try:
req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True))
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none())
user_role_ids = set(user_roles_qs.values_list('id', flat=True))
if len(req_role_ids.intersection(user_role_ids)) == 0:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
amount = (request.POST.get('amount') or '').strip() amount = (request.POST.get('amount') or '').strip()
@ -465,15 +325,6 @@ def add_quote_payment(request, instance_id, step_id):
logger.exception('Error adding quote payment (instance=%s, step=%s)', instance_id, step_id) logger.exception('Error adding quote payment (instance=%s, step=%s)', instance_id, step_id)
return JsonResponse({'success': False, 'message': 'خطا در ثبت فیش', 'error': str(e)}) return JsonResponse({'success': False, 'message': 'خطا در ثبت فیش', 'error': str(e)})
# After modifying payments, set step back to in_progress (awaiting approval)
try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
except Exception:
pass
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id]) redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url}) return JsonResponse({'success': True, 'redirect': redirect_url})
@ -509,15 +360,6 @@ def update_quote_payment(request, instance_id, step_id, payment_id):
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'خطا در ویرایش فیش'}) return JsonResponse({'success': False, 'message': 'خطا در ویرایش فیش'})
# On update, return to awaiting approval
try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
except Exception:
pass
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id]) redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url}) return JsonResponse({'success': True, 'redirect': redirect_url})
@ -532,30 +374,11 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
if not invoice: if not invoice:
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'}) return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
# dynamic permission: users whose roles are among required approvers can delete payments
try:
req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True))
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none())
user_role_ids = set(user_roles_qs.values_list('id', flat=True))
if len(req_role_ids.intersection(user_role_ids)) == 0:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'})
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'})
try: try:
# soft delete using project's BaseModel delete override # soft delete using project's BaseModel delete override
payment.delete() payment.delete()
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'خطا در حذف فیش'}) return JsonResponse({'success': False, 'message': 'خطا در حذف فیش'})
# On delete, return to awaiting approval
try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
except Exception:
pass
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id]) redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url}) return JsonResponse({'success': True, 'redirect': redirect_url})
@ -711,14 +534,6 @@ def final_invoice_step(request, instance_id, step_id):
# Choices for special items from DB # Choices for special items from DB
special_choices = list(Item.objects.filter(is_special=True).values('id', 'name')) special_choices = list(Item.objects.filter(is_special=True).values('id', 'name'))
# role flag for manager-only actions
profile = getattr(request.user, 'profile', None)
is_manager = False
try:
is_manager = bool(profile and profile.has_role(UserRoles.MANAGER))
except Exception:
is_manager = False
return render(request, 'invoices/final_invoice_step.html', { return render(request, 'invoices/final_invoice_step.html', {
'instance': instance, 'instance': instance,
'step': step, 'step': step,
@ -728,7 +543,6 @@ def final_invoice_step(request, instance_id, step_id):
'invoice_specials': invoice.items.select_related('item').filter(item__is_special=True, is_deleted=False).all(), 'invoice_specials': invoice.items.select_related('item').filter(item__is_special=True, is_deleted=False).all(),
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'is_manager': is_manager,
}) })
@ -750,12 +564,6 @@ def approve_final_invoice(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance) invoice = get_object_or_404(Invoice, process_instance=instance)
# only MANAGER can approve
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
# Block approval when there is any remaining (positive or negative) # Block approval when there is any remaining (positive or negative)
invoice.calculate_totals() invoice.calculate_totals()
# if invoice.remaining_amount != 0: # if invoice.remaining_amount != 0:
@ -784,12 +592,6 @@ def add_special_charge(request, instance_id, step_id):
"""افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی به‌صورت آیتم جداگانه""" """افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی به‌صورت آیتم جداگانه"""
instance = get_object_or_404(ProcessInstance, id=instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
invoice = get_object_or_404(Invoice, process_instance=instance) invoice = get_object_or_404(Invoice, process_instance=instance)
# only MANAGER can add special charges
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403)
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403)
# charge_type was removed from UI; we no longer require it # charge_type was removed from UI; we no longer require it
item_id = request.POST.get('item_id') item_id = request.POST.get('item_id')
amount = (request.POST.get('amount') or '').strip() amount = (request.POST.get('amount') or '').strip()
@ -821,12 +623,6 @@ def add_special_charge(request, instance_id, step_id):
def delete_special_charge(request, instance_id, step_id, item_id): def delete_special_charge(request, instance_id, step_id, item_id):
instance = get_object_or_404(ProcessInstance, id=instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
invoice = get_object_or_404(Invoice, process_instance=instance) invoice = get_object_or_404(Invoice, process_instance=instance)
# only MANAGER can delete special charges
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403)
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403)
from .models import InvoiceItem from .models import InvoiceItem
inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice) inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice)
# allow deletion only for special items # allow deletion only for special items
@ -852,87 +648,13 @@ def final_settlement_step(request, instance_id, step_id):
previous_step = instance.process.steps.filter(order__lt=step.order).last() previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first() next_step = instance.process.steps.filter(order__gt=step.order).first()
# Ensure step instance exists
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
# Build approver statuses for template
reqs = list(step.approver_requirements.select_related('role').all())
approvals_map = {a.role_id: a.decision for a in step_instance.approvals.select_related('role').all()}
approver_statuses = [{'role': r.role, 'status': approvals_map.get(r.role_id)} for r in reqs]
# dynamic permission to control approve/reject UI
try:
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none())
user_role_ids = set(user_roles_qs.values_list('id', flat=True))
req_role_ids = {r.role_id for r in reqs}
can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0
except Exception:
can_approve_reject = False
# Accountant/Admin approval and rejection (multi-role)
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
req_roles = [req.role for req in step.approver_requirements.select_related('role').all()]
user_roles = list(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).all())
matching_role = next((r for r in user_roles if r in req_roles), None)
if matching_role is None:
messages.error(request, 'شما دسترسی لازم برای تایید/رد این مرحله را ندارید.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
action = request.POST.get('action')
if action == 'approve':
# enforce zero remaining
invoice.calculate_totals()
if invoice.remaining_amount != 0:
messages.error(request, f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})")
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''}
)
if step_instance.is_fully_approved():
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
if next_step:
instance.current_step = next_step
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:request_list')
messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقش‌ها.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
if action == 'reject':
reason = (request.POST.get('reject_reason') or '').strip()
if not reason:
messages.error(request, 'علت رد شدن را وارد کنید')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
)
StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
messages.success(request, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
# broker flag for payment management permission
profile = getattr(request.user, 'profile', None)
is_broker = False
try:
is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
except Exception:
is_broker = False
return render(request, 'invoices/final_settlement_step.html', { return render(request, 'invoices/final_settlement_step.html', {
'instance': instance, 'instance': instance,
'step': step, 'step': step,
'invoice': invoice, 'invoice': invoice,
'payments': invoice.payments.filter(is_deleted=False).all(), 'payments': invoice.payments.filter(is_deleted=False).all(),
'step_instance': step_instance,
'previous_step': previous_step, 'previous_step': previous_step,
'next_step': next_step, 'next_step': next_step,
'approver_statuses': approver_statuses,
'can_approve_reject': can_approve_reject,
'is_broker': is_broker,
}) })
@ -940,14 +662,7 @@ def final_settlement_step(request, instance_id, step_id):
@login_required @login_required
def add_final_payment(request, instance_id, step_id): def add_final_payment(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance) invoice = get_object_or_404(Invoice, process_instance=instance)
# Only BROKER can add final settlement payments
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)):
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403)
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403)
amount = (request.POST.get('amount') or '').strip() amount = (request.POST.get('amount') or '').strip()
payment_date = (request.POST.get('payment_date') or '').strip() payment_date = (request.POST.get('payment_date') or '').strip()
payment_method = (request.POST.get('payment_method') or '').strip() payment_method = (request.POST.get('payment_method') or '').strip()
@ -1002,14 +717,6 @@ def add_final_payment(request, instance_id, step_id):
) )
# After creation, totals auto-updated by model save. Respond with redirect and new totals for UX. # After creation, totals auto-updated by model save. Respond with redirect and new totals for UX.
invoice.refresh_from_db() invoice.refresh_from_db()
# After payment change, set step back to in_progress
try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress'
si.completed_at = None
si.save()
except Exception:
pass
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]),
@ -1025,25 +732,10 @@ def add_final_payment(request, instance_id, step_id):
@login_required @login_required
def delete_final_payment(request, instance_id, step_id, payment_id): def delete_final_payment(request, instance_id, step_id, payment_id):
instance = get_object_or_404(ProcessInstance, id=instance_id) instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance) invoice = get_object_or_404(Invoice, process_instance=instance)
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
# Only BROKER can delete final settlement payments
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)):
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
payment.delete() payment.delete()
invoice.refresh_from_db() invoice.refresh_from_db()
# After payment change, set step back to in_progress
try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress'
si.completed_at = None
si.save()
except Exception:
pass
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'totals': { return JsonResponse({'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'totals': {
'final_amount': str(invoice.final_amount), 'final_amount': str(invoice.final_amount),
'paid_amount': str(invoice.paid_amount), 'paid_amount': str(invoice.paid_amount),

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-09-07 07:35 # Generated by Django 5.2.4 on 2025-08-14 09:02
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View file

@ -2,7 +2,7 @@ from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin from simple_history.admin import SimpleHistoryAdmin
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from .models import Process, ProcessStep, ProcessInstance, StepInstance, StepDependency, StepRejection, StepApproverRequirement, StepApproval from .models import Process, ProcessStep, ProcessInstance, StepInstance, StepDependency, StepRejection, StepRevision
@admin.register(Process) @admin.register(Process)
class ProcessAdmin(SimpleHistoryAdmin): class ProcessAdmin(SimpleHistoryAdmin):
@ -168,16 +168,14 @@ class StepRejectionAdmin(SimpleHistoryAdmin):
return obj.reason[:50] + "..." if len(obj.reason) > 50 else obj.reason return obj.reason[:50] + "..." if len(obj.reason) > 50 else obj.reason
reason_short.short_description = "دلیل رد شدن" reason_short.short_description = "دلیل رد شدن"
@admin.register(StepRevision)
class StepRevisionAdmin(SimpleHistoryAdmin):
list_display = ['step_instance', 'rejection', 'revised_by', 'changes_short', 'created_at']
list_filter = ['revised_by', 'created_at', 'step_instance__step__process']
search_fields = ['step_instance__step__name', 'revised_by__username', 'changes_description']
readonly_fields = ['created_at']
ordering = ['-created_at']
@admin.register(StepApproverRequirement) def changes_short(self, obj):
class StepApproverRequirementAdmin(admin.ModelAdmin): return obj.changes_description[:50] + "..." if len(obj.changes_description) > 50 else obj.changes_description
list_display = ("step", "role", "required_count") changes_short.short_description = "تغییرات"
list_filter = ("step__process", "role")
search_fields = ("step__name", "role__name")
@admin.register(StepApproval)
class StepApprovalAdmin(admin.ModelAdmin):
list_display = ("step_instance", "role", "decision", "approved_by", "created_at")
list_filter = ("decision", "role", "step_instance__step__process")
search_fields = ("step_instance__process_instance__code", "role__name", "approved_by__username")

View file

@ -0,0 +1,26 @@
from django import forms
from .models import ProcessInstance, StepInstance
class ProcessInstanceForm(forms.ModelForm):
class Meta:
model = ProcessInstance
fields = ['description', 'process', 'well', 'representative', 'requester', 'priority', 'status', 'current_step']
widgets = {
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'process': forms.Select(attrs={'class': 'form-control'}),
'well': forms.Select(attrs={'class': 'form-control'}),
'representative': forms.Select(attrs={'class': 'form-control'}),
'requester': forms.Select(attrs={'class': 'form-control'}),
'priority': forms.Select(attrs={'class': 'form-control'}),
'status': forms.Select(attrs={'class': 'form-control'}),
'current_step': forms.Select(attrs={'class': 'form-control'}),
}
class StepInstanceForm(forms.ModelForm):
class Meta:
model = StepInstance
fields = ['status', 'notes']
widgets = {
'status': forms.Select(attrs={'class': 'form-control'}),
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3})
}

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-09-07 07:35 # Generated by Django 5.2.4 on 2025-08-14 09:02
import django.db.models.deletion import django.db.models.deletion
import simple_history.models import simple_history.models
@ -11,7 +11,6 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('accounts', '0001_initial'),
('wells', '0001_initial'), ('wells', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@ -232,17 +231,42 @@ class Migration(migrations.Migration):
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='StepApproverRequirement', name='HistoricalStepRevision',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('required_count', models.PositiveIntegerField(default=1, verbose_name='تعداد موردنیاز')), ('changes_description', models.TextField(help_text='توضیح تغییراتی که برای اصلاح انجام شده', verbose_name='توضیح تغییرات')),
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.role', verbose_name='نقش تاییدکننده')), ('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ اصلاح')),
('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approver_requirements', to='processes.processstep', verbose_name='مرحله')), ('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('revised_by', 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='اصلاح کننده')),
('step_instance', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='processes.stepinstance', verbose_name='نمونه مرحله')),
('rejection', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='processes.steprejection', verbose_name='رد شدن مربوطه')),
], ],
options={ options={
'verbose_name': 'نیازمندی تایید نقش', 'verbose_name': 'historical بازبینی مرحله',
'verbose_name_plural': 'نیازمندی\u200cهای تایید نقش', 'verbose_name_plural': 'historical بازبینی\u200cهای مراحل',
'unique_together': {('step', 'role')}, 'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='StepRevision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('changes_description', models.TextField(help_text='توضیح تغییراتی که برای اصلاح انجام شده', verbose_name='توضیح تغییرات')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ اصلاح')),
('rejection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='processes.steprejection', verbose_name='رد شدن مربوطه')),
('revised_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='step_revisions', to=settings.AUTH_USER_MODEL, verbose_name='اصلاح کننده')),
('step_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='processes.stepinstance', verbose_name='نمونه مرحله')),
],
options={
'verbose_name': 'بازبینی مرحله',
'verbose_name_plural': 'بازبینی\u200cهای مراحل',
'ordering': ['-created_at'],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
@ -260,21 +284,4 @@ class Migration(migrations.Migration):
'unique_together': {('dependent_step', 'dependency_step')}, 'unique_together': {('dependent_step', 'dependency_step')},
}, },
), ),
migrations.CreateModel(
name='StepApproval',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('decision', models.CharField(choices=[('approved', 'تایید'), ('rejected', 'رد')], max_length=8, verbose_name='نتیجه')),
('reason', models.TextField(blank=True, verbose_name='علت (برای رد)')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ')),
('approved_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='تاییدکننده')),
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.role', verbose_name='نقش')),
('step_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to='processes.stepinstance', verbose_name='نمونه مرحله')),
],
options={
'verbose_name': 'تایید مرحله',
'verbose_name_plural': 'تاییدهای مرحله',
'unique_together': {('step_instance', 'role')},
},
),
] ]

View file

@ -4,8 +4,6 @@ from common.models import NameSlugModel, SluggedModel
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils import timezone from django.utils import timezone
from django.conf import settings
from accounts.models import Role
from _helpers.utils import generate_unique_slug from _helpers.utils import generate_unique_slug
import random import random
@ -48,9 +46,6 @@ class ProcessStep(NameSlugModel):
) )
history = HistoricalRecords() history = HistoricalRecords()
# Note: approver requirements are defined via StepApproverRequirement through model
# See StepApproverRequirement below
class Meta: class Meta:
verbose_name = "مرحله فرآیند" verbose_name = "مرحله فرآیند"
verbose_name_plural = "مراحل فرآیند" verbose_name_plural = "مراحل فرآیند"
@ -68,7 +63,6 @@ class ProcessStep(NameSlugModel):
"""دریافت مراحلی که به این مرحله وابسته هستند""" """دریافت مراحلی که به این مرحله وابسته هستند"""
return StepDependency.objects.filter(dependency_step=self).values_list('dependent_step', flat=True) return StepDependency.objects.filter(dependency_step=self).values_list('dependent_step', flat=True)
class StepDependency(models.Model): class StepDependency(models.Model):
"""مدل وابستگی بین مراحل""" """مدل وابستگی بین مراحل"""
dependent_step = models.ForeignKey( dependent_step = models.ForeignKey(
@ -245,7 +239,7 @@ class ProcessInstance(SluggedModel):
'cancelled': 'warning', 'cancelled': 'warning',
} }
color = status_colors.get(self.status, 'secondary') color = status_colors.get(self.status, 'secondary')
return '<span class="badge bg-label-{}">{}</span>'.format(color, self.get_status_display()) return '<span class="badge bg-{}">{}</span>'.format(color, self.get_status_display())
def get_priority_display_with_color(self): def get_priority_display_with_color(self):
"""نمایش اولویت با رنگ""" """نمایش اولویت با رنگ"""
@ -296,7 +290,6 @@ class ProcessInstance(SluggedModel):
return False return False
return True return True
class StepInstance(models.Model): class StepInstance(models.Model):
"""مدل نمونه مرحله (برای هر مرحله در هر درخواست)""" """مدل نمونه مرحله (برای هر مرحله در هر درخواست)"""
process_instance = models.ForeignKey(ProcessInstance, on_delete=models.CASCADE, related_name='step_instances', verbose_name="نمونه فرآیند") process_instance = models.ForeignKey(ProcessInstance, on_delete=models.CASCADE, related_name='step_instances', verbose_name="نمونه فرآیند")
@ -360,27 +353,6 @@ class StepInstance(models.Model):
"""دریافت آخرین رد شدن""" """دریافت آخرین رد شدن"""
return self.rejections.order_by('-created_at').first() return self.rejections.order_by('-created_at').first()
# -------- Multi-role approval helpers --------
def required_roles(self):
return [req.role for req in self.step.approver_requirements.select_related('role').all()]
def approvals_by_role(self):
decisions = {}
for a in self.approvals.select_related('role').order_by('created_at'):
decisions[a.role_id] = a.decision
return decisions
def is_fully_approved(self) -> bool:
req_roles = self.required_roles()
if not req_roles:
return True
role_to_decision = self.approvals_by_role()
for r in req_roles:
if role_to_decision.get(r.id) != 'approved':
return False
return True
class StepRejection(models.Model): class StepRejection(models.Model):
"""مدل رد شدن مرحله""" """مدل رد شدن مرحله"""
step_instance = models.ForeignKey( step_instance = models.ForeignKey(
@ -418,35 +390,37 @@ class StepRejection(models.Model):
self.step_instance.save() self.step_instance.save()
super().save(*args, **kwargs) super().save(*args, **kwargs)
class StepRevision(models.Model):
class StepApproverRequirement(models.Model): """مدل بازبینی و اصلاح مرحله"""
"""Required approver roles for a step.""" step_instance = models.ForeignKey(
step = models.ForeignKey(ProcessStep, on_delete=models.CASCADE, related_name='approver_requirements', verbose_name="مرحله") StepInstance,
role = models.ForeignKey(Role, on_delete=models.CASCADE, verbose_name="نقش تاییدکننده") on_delete=models.CASCADE,
required_count = models.PositiveIntegerField(default=1, verbose_name="تعداد موردنیاز") related_name='revisions',
verbose_name="نمونه مرحله"
)
rejection = models.ForeignKey(
StepRejection,
on_delete=models.CASCADE,
related_name='revisions',
verbose_name="رد شدن مربوطه"
)
revised_by = models.ForeignKey(
User,
on_delete=models.CASCADE,
verbose_name="اصلاح کننده",
related_name='step_revisions'
)
changes_description = models.TextField(
verbose_name="توضیح تغییرات",
help_text="توضیح تغییراتی که برای اصلاح انجام شده"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ اصلاح")
history = HistoricalRecords()
class Meta: class Meta:
unique_together = ('step', 'role') verbose_name = "بازبینی مرحله"
verbose_name = "نیازمندی تایید نقش" verbose_name_plural = "بازبینی‌های مراحل"
verbose_name_plural = "نیازمندی‌های تایید نقش" ordering = ['-created_at']
def __str__(self): def __str__(self):
return f"{self.step}{self.role} (x{self.required_count})" return f"بازبینی {self.step_instance} توسط {self.revised_by.get_full_name()}"
class StepApproval(models.Model):
"""Approvals per role for a concrete step instance."""
step_instance = models.ForeignKey(StepInstance, on_delete=models.CASCADE, related_name='approvals', verbose_name="نمونه مرحله")
role = models.ForeignKey(Role, on_delete=models.CASCADE, verbose_name="نقش")
approved_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="تاییدکننده")
decision = models.CharField(max_length=8, choices=[('approved', 'تایید'), ('rejected', 'رد')], verbose_name='نتیجه')
reason = models.TextField(blank=True, verbose_name='علت (برای رد)')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ')
class Meta:
unique_together = ('step_instance', 'role')
verbose_name = 'تایید مرحله'
verbose_name_plural = 'تاییدهای مرحله'
def __str__(self):
return f"{self.step_instance} - {self.role} - {self.decision}"

View file

@ -6,7 +6,6 @@
<div class="step <div class="step
{% if not can_access %}disabled {% if not can_access %}disabled
{% elif status == 'completed' %}completed {% elif status == 'completed' %}completed
{% elif status == 'rejected' %}rejected
{% elif is_todo %}active {% elif is_todo %}active
{% endif %} {% endif %}
{% if is_selected %} selected{% endif %}" {% if is_selected %} selected{% endif %}"
@ -20,9 +19,9 @@
<span class="step-trigger"> <span class="step-trigger">
{% endif %} {% endif %}
<span class="bs-stepper-circle {% if status == 'rejected' %}bg-danger text-white{% endif %}">{{ forloop.counter }}</span> <span class="bs-stepper-circle">{{ forloop.counter }}</span>
<span class="bs-stepper-label mt-1"> <span class="bs-stepper-label mt-1">
<span class="bs-stepper-title {% if status == 'rejected' %}text-danger{% endif %}">{{ step.name }}</span> <span class="bs-stepper-title">{{ step.name }}</span>
<span class="bs-stepper-subtitle">{{ step.description|default:' ' }}</span> <span class="bs-stepper-subtitle">{{ step.description|default:' ' }}</span>
</span> </span>

View file

@ -1,168 +0,0 @@
{% extends '_base.html' %}
{% load static %}
{% load humanize %}
{% load common_tags %}
{% block sidebar %}
{% include 'sidebars/admin.html' %}
{% endblock sidebar %}
{% block navbar %}
{% include 'navbars/admin.html' %}
{% endblock navbar %}
{% block title %}گزارش نهایی - درخواست {{ instance.code }}{% endblock %}
{% block content %}
{% include '_toasts.html' %}
<div class="container-xxl flex-grow-1 container-p-y">
<div class="row">
<div class="col-12 mb-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-1">گزارش نهایی درخواست {{ instance.code }}</h4>
<small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small>
</div>
<div class="d-flex gap-2">
{% if invoice %}
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت فاکتور</a>
{% endif %}
<a href="{% url 'certificates:certificate_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت گواهی</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<div class="card border">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">فاکتور نهایی</h6>
</div>
<div class="card-body">
{% if invoice %}
<div class="row g-3 mb-3">
<div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">مبلغ نهایی</div><div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div></div></div>
<div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">پرداختی‌ها</div><div class="h5 mt-1 text-success">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</div></div></div>
<div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">مانده</div><div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div></div></div>
</div>
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>آیتم</th>
<th class="text-center">تعداد</th>
<th class="text-end">قیمت واحد</th>
<th class="text-end">قیمت کل</th>
</tr>
</thead>
<tbody>
{% for it in rows %}
<tr>
<td>{{ it.item.name }}</td>
<td class="text-center">{{ it.quantity }}</td>
<td class="text-end">{{ it.unit_price|floatformat:0|intcomma:False }}</td>
<td class="text-end">{{ it.total_price|floatformat:0|intcomma:False }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="text-center text-muted">اطلاعاتی ندارد</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted">فاکتور نهایی ثبت نشده است.</div>
{% endif %}
</div>
</div>
</div>
<div class="col-12">
<div class="card border">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">گزارش نصب</h6>
{% if latest_report and latest_report.assignment and latest_report.assignment.installer %}
<span class="small text-muted">نصاب: {{ latest_report.assignment.installer.get_full_name|default:latest_report.assignment.installer.username }}</span>
{% endif %}
</div>
<div class="card-body">
{% if latest_report %}
<div class="row g-3">
<div class="col-12 col-md-6">
<p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ latest_report.visited_date|to_jalali|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-purchase-tag bx-sm me-2"></i>سریال کنتور جدید: {{ latest_report.new_water_meter_serial|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-lock-alt bx-sm me-2"></i>شماره پلمپ: {{ latest_report.seal_number|default:'-' }}</p>
</div>
<div class="col-12 col-md-6">
<p class="text-nowrap mb-2"><i class="bx bx-help-circle bx-sm me-2"></i>کنتور مشکوک: {{ latest_report.is_meter_suspicious|yesno:'بله,خیر' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-map bx-sm me-2"></i>UTM X: {{ latest_report.utm_x|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-map-pin bx-sm me-2"></i>UTM Y: {{ latest_report.utm_y|default:'-' }}</p>
</div>
</div>
{% if latest_report.description %}
<div class="mt-2">
<p class="mb-0"><i class="bx bx-text bx-sm me-2"></i><strong>توضیحات:</strong></p>
<div class="text-muted">{{ latest_report.description }}</div>
</div>
{% endif %}
<hr>
<h6>عکس‌ها</h6>
<div class="row">
{% for p in latest_report.photos.all %}
<div class="col-6 col-md-3 mb-2"><img class="img-fluid rounded border" src="{{ p.image.url }}" alt="photo"></div>
{% empty %}
<div class="text-muted">بدون عکس</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted">گزارش نصب ثبت نشده است.</div>
{% endif %}
</div>
</div>
</div>
<div class="col-12">
<div class="card border">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">تراکنش‌ها</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>نوع</th>
<th>مبلغ</th>
<th>تاریخ</th>
<th>روش</th>
<th>شماره مرجع/چک</th>
</tr>
</thead>
<tbody>
{% for p in payments %}
<tr>
<td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td>
<td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
<td>{{ p.payment_date|date:'Y/m/d' }}</td>
<td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="text-center text-muted">بدون تراکنش</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -28,113 +28,17 @@
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row py-3 mb-4 card-header flex-column flex-md-row pb-0"> <div class="d-flex align-items-center justify-content-between mb-3">
<div class="d-md-flex justify-content-between align-items-center dt-layout-start col-md-auto me-auto mt-0"> <h4 class="mb-0">درخواست‌ها</h4>
<h5 class="card-title mb-0 text-md-start text-center fw-bold">لیست درخواست‌ها</h5>
</div>
<div class="d-md-flex justify-content-between align-items-center dt-layout-end col-md-auto ms-auto mt-0">
<div class="dt-buttons btn-group flex-wrap mb-0">
<div class="btn-group">
<button class="btn buttons-collection btn-label-primary dropdown-toggle me-4 d-none" type="button">
<span>
<span class="d-flex align-items-center gap-2">
<i class="icon-base bx bx-export me-sm-1"></i>
<span class="d-none d-sm-inline-block">خروجی</span>
</span>
</span>
</button>
<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"></i>
درخواست جدید درخواست جدید
</button> </button>
</div> </div>
</div>
</div>
</div>
<!-- Summary Cards -->
<div class="row g-4 mb-4">
<div class="col-sm-6 col-xl-3">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between">
<div class="content-left">
<span>کل درخواست‌ها</span>
<div class="d-flex align-items-end mt-2">
<h4 class="mb-0 me-2">{{ total_count }}</h4>
</div>
</div>
<div class="avatar">
<span class="avatar-initial rounded bg-label-primary">
<i class="bx bx-list-ul bx-sm"></i>
</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between">
<div class="content-left">
<span>تکمیل‌شده</span>
<div class="d-flex align-items-end mt-2">
<h4 class="mb-0 me-2">{{ completed_count }}</h4>
</div>
</div>
<div class="avatar">
<span class="avatar-initial rounded bg-label-success">
<i class="bx bx-badge-check bx-sm"></i>
</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between">
<div class="content-left">
<span>در حال انجام</span>
<div class="d-flex align-items-end mt-2">
<h4 class="mb-0 me-2">{{ in_progress_count }}</h4>
</div>
</div>
<div class="avatar">
<span class="avatar-initial rounded bg-label-info">
<i class="bx bx-loader-circle bx-sm"></i>
</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between">
<div class="content-left">
<span>در انتظار</span>
<div class="d-flex align-items-end mt-2">
<h4 class="mb-0 me-2">{{ pending_count }}</h4>
</div>
</div>
<div class="avatar">
<span class="avatar-initial rounded bg-label-warning">
<i class="bx bx-time bx-sm"></i>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card"> <div class="card">
<div class="card-datatable table-responsive"> <div class="table-responsive">
<table id="requests-table" class="datatables-basic table border-top"> <table id="requestsTable" class="table table-striped">
<thead> <thead>
<tr> <tr>
<th>شناسه</th> <th>شناسه</th>
@ -142,43 +46,25 @@
<th>مرحله فعلی</th> <th>مرحله فعلی</th>
<th>شماره اشتراک آب</th> <th>شماره اشتراک آب</th>
<th>نماینده</th> <th>نماینده</th>
<th>استان</th> <th>درخواست‌کننده</th>
<th>امور</th> <th>اولویت</th>
<th>پیشرفت</th>
<th>وضعیت</th> <th>وضعیت</th>
<th>تاریخ ایجاد</th> <th>تاریخ ایجاد</th>
<th>عملیات</th> <th>عملیات</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for item in instances_with_progress %} {% for inst in instances %}
<tr> <tr>
<td>{{ item.instance.code }}</td> <td>{{ inst.code }}</td>
<td>{{ item.instance.process.name }}</td> <td>{{ inst.process.name }}</td>
<td class="text-primary"> <td class="text-primary">{{ inst.current_step.name|default:"--" }}</td>
{% if item.instance.status == 'completed' %} <td>{{ inst.well.water_subscription_number }}</td>
<a href="{% url 'processes:instance_summary' item.instance.id %}" class="text-primary">{{ item.instance.current_step.name|default:"--" }}</a> <td>{% if inst.representative %}{{ inst.representative.get_full_name }}{% else %}-{% endif %}</td>
{% elif item.instance.current_step %} <td>{% if inst.requester %}{{ inst.requester.get_full_name }}{% else %}-{% endif %}</td>
<a href="{% url 'processes:instance_steps' item.instance.id %}" class="text-primary">{{ item.instance.current_step.name }}</a> <td>{{ inst.get_priority_display }}</td>
{% else %} <td>{{ inst.get_status_display }}</td>
-- <td>{{ inst.jcreated }}</td>
{% endif %}
</td>
<td>{{ item.instance.well.water_subscription_number }}</td>
<td>{% if item.instance.representative %}{{ item.instance.representative.get_full_name }}{% else %}-{% endif %}</td>
<td>{% if item.instance.well and item.instance.well.county %}{{ item.instance.well.county }}{% else %}-{% endif %}</td>
<td>{% if item.instance.well and item.instance.well.affairs %}{{ item.instance.well.affairs }}{% else %}-{% endif %}</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="width: 80px; height: 6px;">
<div class="progress-bar {% if item.progress_percentage == 100 %}bg-success{% elif item.progress_percentage >= 70 %}bg-info{% elif item.progress_percentage >= 40 %}bg-warning{% else %}bg-secondary{% endif %}" role="progressbar" style="width: {{ item.progress_percentage }}%;" aria-valuenow="{{ item.progress_percentage }}" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
<small class="text-muted">{{ item.progress_percentage }}%</small>
</div>
</td>
<td>{{ item.instance.get_status_display_with_color|safe }}</td>
<td>{{ item.instance.jcreated }}</td>
<td> <td>
<div class="d-inline-block"> <div class="d-inline-block">
<a href="javascript:;" class="btn btn-icon dropdown-toggle hide-arrow" data-bs-toggle="dropdown"> <a href="javascript:;" class="btn btn-icon dropdown-toggle hide-arrow" data-bs-toggle="dropdown">
@ -186,19 +72,13 @@
</a> </a>
<ul class="dropdown-menu dropdown-menu-end m-0"> <ul class="dropdown-menu dropdown-menu-end m-0">
<li> <li>
{% if item.instance.status == 'completed' %} <a href="{% url 'processes:instance_steps' inst.id %}" class="dropdown-item">
<a href="{% url 'processes:instance_summary' item.instance.id %}" class="dropdown-item">
<i class="bx bx-show me-1"></i>مشاهده گزارش
</a>
{% else %}
<a href="{% url 'processes:instance_steps' item.instance.id %}" class="dropdown-item">
<i class="bx bx-show me-1"></i>مشاهده جزئیات <i class="bx bx-show me-1"></i>مشاهده جزئیات
</a> </a>
{% endif %}
</li> </li>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<li> <li>
<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="{{ inst.id }}" data-instance-code="{{ inst.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>
@ -208,7 +88,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="11" class="text-center text-muted">موردی ثبت نشده است</td> <td colspan="9" class="text-center text-muted">موردی ثبت نشده است</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -240,7 +120,7 @@
<div class="col-sm-12"> <div class="col-sm-12">
<label class="form-label">شماره اشتراک آب</label> <label class="form-label">شماره اشتراک آب</label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" id="req_water_sub" name="water_subscription_number" data-field="water_subscription_number" placeholder="مثال: 12345" required> <input type="text" class="form-control" id="req_water_sub" placeholder="مثال: 12345" required>
<button class="btn btn-outline-secondary" type="button" id="btnLookupWell"> <button class="btn btn-outline-secondary" type="button" id="btnLookupWell">
بررسی/افزودن چاه بررسی/افزودن چاه
</button> </button>
@ -331,7 +211,7 @@
<div class="col-sm-12"> <div class="col-sm-12">
<label class="form-label">کد ملی نماینده</label> <label class="form-label">کد ملی نماینده</label>
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" id="rep_national_code" data-field="national_code" placeholder="مثال: 0012345678" maxlength="10" inputmode="numeric" pattern="\d*"> <input type="text" class="form-control" id="rep_national_code" placeholder="مثال: 0012345678">
<button class="btn btn-outline-secondary" type="button" id="btnLookupRep"> <button class="btn btn-outline-secondary" type="button" id="btnLookupRep">
بررسی/افزودن نماینده بررسی/افزودن نماینده
</button> </button>
@ -382,7 +262,7 @@
<hr class="mt-3 border border-dashed"> <hr class="mt-3 border border-dashed">
<div class="col-sm-12"> <div class="col-sm-12">
<label class="form-label">توضیحات</label> <label class="form-label">توضیحات</label>
<textarea class="form-control" rows="3" id="req_description" name="description"></textarea> <textarea class="form-control" rows="3" id="req_description"></textarea>
</div> </div>
</div> </div>
</form> </form>
@ -475,13 +355,19 @@
$(function() { $(function() {
// Initialize DataTable similar to customer_list // if ($.fn.DataTable) {
$('#requests-table').DataTable({ // try {
pageLength: 10, // $('#requestsTable').DataTable({
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, "همه"]], // pageLength: 10,
order: [[0, 'desc']], // order: [[0, 'desc']]
responsive: true, // });
}); // } catch (e) {
// console.error('DataTable init failed', e);
// }
// } else {
// console.warn('DataTables library not loaded');
// }
let currentWellId = null; let currentWellId = null;
let currentRepId = null; let currentRepId = null;
let wellChecked = false; let wellChecked = false;
@ -507,7 +393,7 @@
if (!$el.length) return false; if (!$el.length) return false;
$el.addClass('is-invalid'); $el.addClass('is-invalid');
const $feedback = $('<div class="invalid-feedback inline-error"></div>').text(message); const $feedback = $('<div class="invalid-feedback inline-error"></div>').text(message);
const $grp = $el.closest('.input-group, .form-group, .mb-3'); const $grp = $el.closest('.input-group');
if ($grp.length) { if ($grp.length) {
$feedback.insertAfter($grp); $feedback.insertAfter($grp);
} else { } else {
@ -516,49 +402,60 @@
return true; return true;
} }
// Generic field resolution with small exception map function mapWellFieldToSelector(field) {
const exceptionMap = { switch (field) {
water_subscription_number: '#req_water_sub', case 'water_subscription_number': return '#req_water_sub';
national_code: '#rep_national_code', case 'electricity_subscription_number': return '#id_electricity_subscription_number';
representative: '#rep_national_code' case 'water_meter_serial_number': return '#id_water_meter_serial_number';
}; case 'water_meter_old_serial_number': return '#id_water_meter_old_serial_number';
case 'water_meter_manufacturer': return '#id_water_meter_manufacturer';
case 'new_manufacturer': return '#id_new_manufacturer';
case 'utm_x': return '#id_utm_x';
case 'utm_y': return '#id_utm_y';
case 'utm_zone': return '#id_utm_zone';
case 'utm_hemisphere': return '#id_utm_hemisphere';
case 'well_power': return '#id_well_power';
case 'reference_letter_number': return '#id_reference_letter_number';
case 'reference_letter_date': return '#id_reference_letter_date';
case 'representative_letter_file': return '#id_representative_letter_file';
case 'representative': return '#rep_national_code';
default: return '#id_' + field;
}
}
function findFieldSelector(field, context) { function mapCustomerFieldToSelector(field) {
const $ctx = context ? $(context) : $('#requestModal'); switch (field) {
let $el = $ctx.find(`#id_${field}`).first(); case 'national_code': return $('#id_national_code').length ? '#id_national_code' : '#rep_national_code';
if ($el.length) return $el; case 'first_name': return '#id_first_name';
$el = $ctx.find(`[name="${field}"]`).first(); case 'last_name': return '#id_last_name';
if ($el.length) return $el; case 'phone_number_1': return '#id_phone_number_1';
$el = $ctx.find(`[data-field="${field}"]`).first(); case 'phone_number_2': return '#id_phone_number_2';
if ($el.length) return $el; case 'card_number': return '#id_card_number';
const ex = exceptionMap[field]; case 'account_number': return '#id_account_number';
return ex ? $(ex) : $(); case 'bank_name': return '#id_bank_name';
case 'address': return '#id_address';
default: return '#id_' + field;
}
} }
function showInlineErrors(errors) { function showInlineErrors(errors) {
if (!errors) return; if (!errors) return;
let nonFieldWell = ''; let nonFieldWell = '';
let nonFieldCustomer = ''; let nonFieldCustomer = '';
// Request-level errors (e.g., process)
if (errors.request) {
for (const key in errors.request) {
const msgs = Array.isArray(errors.request[key]) ? errors.request[key] : [errors.request[key]];
if (key === '__all__' || key === 'non_field_errors') { continue; }
applyErrorTo(findFieldSelector(key, '#requestForm'), msgs[0]);
}
}
if (errors.well) { if (errors.well) {
for (const key in errors.well) { for (const key in errors.well) {
const msgs = Array.isArray(errors.well[key]) ? errors.well[key] : [errors.well[key]]; const msgs = Array.isArray(errors.well[key]) ? errors.well[key] : [errors.well[key]];
if (key === '__all__' || key === 'non_field_errors') { nonFieldWell = msgs.join('، '); continue; } if (key === '__all__' || key === 'non_field_errors') { nonFieldWell = msgs.join('، '); continue; }
applyErrorTo(findFieldSelector(key, '#wellFormBlock'), msgs[0]); const sel = mapWellFieldToSelector(key);
applyErrorTo(sel, msgs[0]);
} }
} }
if (errors.customer) { if (errors.customer) {
for (const key in errors.customer) { for (const key in errors.customer) {
const msgs = Array.isArray(errors.customer[key]) ? errors.customer[key] : [errors.customer[key]]; const msgs = Array.isArray(errors.customer[key]) ? errors.customer[key] : [errors.customer[key]];
if (key === '__all__' || key === 'non_field_errors') { nonFieldCustomer = msgs.join('، '); continue; } if (key === '__all__' || key === 'non_field_errors') { nonFieldCustomer = msgs.join('، '); continue; }
applyErrorTo(findFieldSelector(key, '#repNewFields'), msgs[0]); const sel = mapCustomerFieldToSelector(key);
applyErrorTo(sel, msgs[0]);
} }
} }
if (nonFieldWell) setStatus('#wellStatus', nonFieldWell, 'danger'); if (nonFieldWell) setStatus('#wellStatus', nonFieldWell, 'danger');
@ -633,7 +530,7 @@
$('#remove-file').val('false'); $('#remove-file').val('false');
// Initialize Persian Date Picker after well form is shown // Initialize Persian Date Picker after well form is shown
setTimeout(initPersianDatePicker, 100); setTimeout(initPersianDatePicker, 100);
setStatus('#wellStatus', 'چاه یافت نشد. اطلاعات چاه را وارد کنید.', 'danger'); setStatus('#wellStatus', 'چاه یافت نشد. با ذخیره، ایجاد خواهد شد.', 'danger');
} }
}) })
.fail(function(){ setStatus('#wellStatus', 'خطا در بررسی چاه', 'danger'); }); .fail(function(){ setStatus('#wellStatus', 'خطا در بررسی چاه', 'danger'); });
@ -691,26 +588,46 @@
}); });
$('#btnSaveRequest').on('click', function(){ $('#btnSaveRequest').on('click', function(){
clearInlineErrors(); const formData = new FormData();
// Use form's native FormData - much cleaner! formData.append('csrfmiddlewaretoken', $('input[name=csrfmiddlewaretoken]').val());
const formData = new FormData(document.getElementById('requestForm')); formData.append('process', $('#req_process').val());
formData.append('description', $('#req_description').val());
// Add custom fields that aren't in the form formData.append('water_subscription_number', $('#req_water_sub').val().trim());
if (currentWellId) formData.append('well_id', currentWellId); if (currentWellId) formData.append('well_id', currentWellId);
if (currentRepId) formData.append('representative_id', currentRepId); if (currentRepId) formData.append('representative_id', currentRepId);
// Send fields using CustomerForm names if visible
// Handle special national_code logic (prefer visible field) const ncField = $('#id_national_code').length ? $('#id_national_code').val() : '';
const ncField = $('#id_national_code').val(); formData.append('national_code', (ncField || $('#rep_national_code').val().trim()));
if (ncField) { formData.append('first_name', $('#id_first_name').val() || '');
formData.set('national_code', ncField); formData.append('last_name', $('#id_last_name').val() || '');
} else { formData.append('phone_number_1', $('#id_phone_number_1').val() || '');
formData.set('national_code', $('#rep_national_code').val().trim()); formData.append('phone_number_2', $('#id_phone_number_2').val() || '');
} formData.append('card_number', $('#id_card_number').val() || '');
formData.append('account_number', $('#id_account_number').val() || '');
// Handle Persian date conversion formData.append('address', $('#id_address').val() || '');
formData.append('bank_name', $('#id_bank_name').val() || '');
// Include WellForm fields so edits are saved
if ($('#wellFormBlock').is(':visible')) {
formData.append('electricity_subscription_number', $('#id_electricity_subscription_number').val() || '');
formData.append('water_meter_serial_number', $('#id_water_meter_serial_number').val() || '');
formData.append('water_meter_old_serial_number', $('#id_water_meter_old_serial_number').val() || '');
formData.append('water_meter_manufacturer', $('#id_water_meter_manufacturer').is(':visible') ? ($('#id_water_meter_manufacturer').val() || '') : '');
formData.append('new_manufacturer', $('#id_new_manufacturer').is(':visible') ? ($('#id_new_manufacturer').val() || '') : '');
formData.append('utm_x', $('#id_utm_x').val() || '');
formData.append('utm_y', $('#id_utm_y').val() || '');
formData.append('utm_zone', $('#id_utm_zone').val() || '');
formData.append('utm_hemisphere', $('#id_utm_hemisphere').val() || '');
formData.append('well_power', $('#id_well_power').val() || '');
formData.append('reference_letter_number', $('#id_reference_letter_number').val() || '');
// Use gregorian date if available, otherwise use the field value
const gregorianDate = $('#id_reference_letter_date').attr('data-gregorian'); const gregorianDate = $('#id_reference_letter_date').attr('data-gregorian');
if (gregorianDate) { formData.append('reference_letter_date', gregorianDate || $('#id_reference_letter_date').val() || '');
formData.set('reference_letter_date', gregorianDate); // Remove flag
formData.append('remove_file', $('#remove-file').val() || 'false');
const repFile = document.getElementById('id_representative_letter_file');
if (repFile && repFile.files && repFile.files[0]) {
formData.append('representative_letter_file', repFile.files[0]);
}
} }
const $btn = $(this).prop('disabled', true).text('در حال ذخیره...'); const $btn = $(this).prop('disabled', true).text('در حال ذخیره...');
@ -729,10 +646,6 @@
setTimeout(function(){ location.reload(); }, 1200); setTimeout(function(){ location.reload(); }, 1200);
} }
} else { } else {
clearInlineErrors();
if (resp.errors) {
showInlineErrors(resp.errors);
}
const msg = buildErrorMessage(resp); const msg = buildErrorMessage(resp);
showToast(msg, 'danger'); showToast(msg, 'danger');
} }
@ -740,10 +653,6 @@
let msg = 'خطا در ذخیره'; let msg = 'خطا در ذخیره';
try { try {
const resp = JSON.parse(xhr.responseText); const resp = JSON.parse(xhr.responseText);
clearInlineErrors();
if (resp && resp.errors) {
showInlineErrors(resp.errors);
}
msg = buildErrorMessage(resp) || msg; msg = buildErrorMessage(resp) || msg;
} catch(e) {} } catch(e) {}
showToast(msg, 'danger'); showToast(msg, 'danger');
@ -800,14 +709,6 @@
} }
}); });
// Enforce digit-only and max length for national code input
$('#rep_national_code').on('input', function() {
const cleaned = (this.value || '').replace(/\D/g, '').slice(0, 10);
if (this.value !== cleaned) {
this.value = cleaned;
}
});
$('#requestModal').on('hidden.bs.modal', function(){ $('#requestModal').on('hidden.bs.modal', function(){
$('#requestForm')[0].reset(); $('#requestForm')[0].reset();
$('#wellFormBlock').hide(); $('#wellFormBlock').hide();

View file

@ -26,10 +26,8 @@ def stepper_header(instance, current_step=None):
step_instance = next((si for si in step_instances if si.step_id == step.id), None) step_instance = next((si for si in step_instances if si.step_id == step.id), None)
status = step_id_to_status.get(step.id, 'pending') status = step_id_to_status.get(step.id, 'pending')
# بررسی دسترسی به مرحله (UI navigation constraints): # بررسی دسترسی به مرحله
# can_access = instance.can_access_step(step) can_access = instance.can_access_step(step)
# فقط مراحل تکمیل‌شده یا مرحله جاری قابل کلیک هستند
can_access = (step_id_to_status.get(step.id) == 'completed') or (instance.current_step and step.id == instance.current_step.id)
# مرحله انتخاب‌شده (نمایش فعلی) # مرحله انتخاب‌شده (نمایش فعلی)
is_selected = bool(current_step and step.id == current_step.id) is_selected = bool(current_step and step.id == current_step.id)
# مرحله‌ای که باید انجام شود (مرحله جاری در instance) # مرحله‌ای که باید انجام شود (مرحله جاری در instance)

View file

@ -14,6 +14,11 @@ urlpatterns = [
# New step-based architecture # New step-based architecture
path('instance/<int:instance_id>/steps/', views.instance_steps, name='instance_steps'), path('instance/<int:instance_id>/steps/', views.instance_steps, name='instance_steps'),
path('instance/<int:instance_id>/step/<int:step_id>/', views.step_detail, name='step_detail'), path('instance/<int:instance_id>/step/<int:step_id>/', views.step_detail, name='step_detail'),
path('instance/<int:instance_id>/summary/', views.instance_summary, name='instance_summary'),
# Legacy process views
path('', views.process_list, name='process_list'),
path('<int:process_id>/', views.process_detail, name='process_detail'),
path('<int:process_id>/start/', views.start_process, name='start_process'),
path('instance/<int:instance_id>/', views.instance_detail, name='instance_detail'),
path('my-processes/', views.my_processes, name='my_processes'),
] ]

View file

@ -1,6 +1,7 @@
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
import json
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib import messages from django.contrib import messages
from django.http import JsonResponse from django.http import JsonResponse
@ -10,48 +11,41 @@ from django.contrib.auth import get_user_model
from .models import Process, ProcessInstance, StepInstance from .models import Process, ProcessInstance, StepInstance
from wells.models import Well from wells.models import Well
from accounts.models import Profile from accounts.models import Profile
from .forms import ProcessInstanceForm
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
@login_required @login_required
def request_list(request): def process_list(request):
"""نمایش لیست درخواست‌ها با جدول و مدال ایجاد""" """نمایش لیست فرآیندهای فعال"""
instances = ProcessInstance.objects.select_related('well', 'representative', 'requester').prefetch_related('step_instances__step').filter(is_deleted=False).order_by('-created')
processes = Process.objects.filter(is_active=True) processes = Process.objects.filter(is_active=True)
manufacturers = WaterMeterManufacturer.objects.all().order_by('name') return render(request, 'processes/process_list.html', {
'processes': processes
# Calculate progress for each instance
instances_with_progress = []
for instance in instances:
total_steps = instance.process.steps.count()
completed_steps = instance.step_instances.filter(status='completed').count()
progress_percentage = (completed_steps / total_steps * 100) if total_steps > 0 else 0
instances_with_progress.append({
'instance': instance,
'progress_percentage': round(progress_percentage),
'completed_steps': completed_steps,
'total_steps': total_steps,
}) })
# Summary stats for header cards @login_required
total_count = instances.count() def process_detail(request, process_id):
completed_count = instances.filter(status='completed').count() """نمایش جزئیات فرآیند"""
in_progress_count = instances.filter(status='in_progress').count() process = get_object_or_404(Process, id=process_id, is_active=True)
pending_count = instances.filter(status='pending').count() return render(request, 'processes/process_detail.html', {
'process': process
})
@login_required
def request_list(request):
"""نمایش لیست درخواست‌ها با جدول و مدال ایجاد"""
instances = ProcessInstance.objects.select_related('well', 'representative', 'requester').filter(is_deleted=False).order_by('-created')
processes = Process.objects.filter(is_active=True)
manufacturers = WaterMeterManufacturer.objects.all().order_by('name')
return render(request, 'processes/request_list.html', { return render(request, 'processes/request_list.html', {
'instances_with_progress': instances_with_progress, 'instances': instances,
'customer_form': CustomerForm(), 'customer_form': CustomerForm(),
'well_form': WellForm(), 'well_form': WellForm(),
'processes': processes, 'processes': processes,
'manufacturers': manufacturers, 'manufacturers': manufacturers
'total_count': total_count,
'completed_count': completed_count,
'in_progress_count': in_progress_count,
'pending_count': pending_count,
}) })
@ -133,12 +127,23 @@ def create_request_with_entities(request):
well_id = request.POST.get('well_id') # optional if existing well_id = request.POST.get('well_id') # optional if existing
# Representative fields # Representative fields
representative_id = request.POST.get('representative_id') representative_id = request.POST.get('representative_id')
# Prefer plain CustomerForm keys; fallback to representative_* keys
representative_national_code = request.POST.get('national_code') or request.POST.get('representative_national_code')
representative_first_name = request.POST.get('first_name') or request.POST.get('representative_first_name')
representative_last_name = request.POST.get('last_name') or request.POST.get('representative_last_name')
representative_username = request.POST.get('username') or request.POST.get('representative_username')
representative_phone_number_1 = request.POST.get('phone_number_1') or request.POST.get('representative_phone_number_1')
representative_phone_number_2 = request.POST.get('phone_number_2') or request.POST.get('representative_phone_number_2')
representative_card_number = request.POST.get('card_number') or request.POST.get('representative_card_number')
representative_account_number = request.POST.get('account_number') or request.POST.get('representative_account_number')
representative_bank_name = request.POST.get('bank_name') or request.POST.get('representative_bank_name')
representative_address = request.POST.get('address') or request.POST.get('representative_address')
if not process_id: if not process_id:
return JsonResponse({'ok': False, 'errors': {'request': {'process': ['فرآیند الزامی است']}}}, status=400) return JsonResponse({'ok': False, 'errors': {'request': {'process': ['فرآیند الزامی است']}}}, status=400)
if not water_subscription_number: if not water_subscription_number:
return JsonResponse({'ok': False, 'errors': {'well': {'water_subscription_number': ['شماره اشتراک آب الزامی است']}}}, status=400) return JsonResponse({'ok': False, 'errors': {'well': {'water_subscription_number': ['شماره اشتراک آب الزامی است']}}}, status=400)
if not representative_id and not request.POST.get('national_code'): if not representative_id and not representative_national_code:
return JsonResponse({'ok': False, 'errors': {'customer': {'national_code': ['کد ملی نماینده را وارد کنید یا دکمه بررسی/افزودن نماینده را بزنید']}}}, status=400) return JsonResponse({'ok': False, 'errors': {'customer': {'national_code': ['کد ملی نماینده را وارد کنید یا دکمه بررسی/افزودن نماینده را بزنید']}}}, status=400)
representative_user = None representative_user = None
@ -147,20 +152,52 @@ def create_request_with_entities(request):
representative_profile = Profile.objects.select_related('user').filter(user_id=representative_id).first() representative_profile = Profile.objects.select_related('user').filter(user_id=representative_id).first()
if not representative_profile: if not representative_profile:
return JsonResponse({'ok': False, 'errors': {'customer': {'__all__': ['نماینده انتخاب‌شده یافت نشد']}}}, status=400) return JsonResponse({'ok': False, 'errors': {'customer': {'__all__': ['نماینده انتخاب‌شده یافت نشد']}}}, status=400)
# Use CustomerForm with request.POST data, merging with existing values
customer_form = CustomerForm(request.POST, instance=representative_profile)
customer_form.request = request
if not customer_form.is_valid():
return JsonResponse({'ok': False, 'errors': {'customer': customer_form.errors}}, status=400)
representative_profile = customer_form.save()
representative_user = representative_profile.user representative_user = representative_profile.user
# Optionally update if fields provided
changed = False
if representative_first_name:
representative_user.first_name = representative_first_name
changed = True
if representative_last_name:
representative_user.last_name = representative_last_name
changed = True
if representative_username:
representative_user.username = representative_username
changed = True
if changed:
representative_user.save()
if representative_national_code:
representative_profile.national_code = representative_national_code
if representative_phone_number_1 is not None:
representative_profile.phone_number_1 = representative_phone_number_1
if representative_phone_number_2 is not None:
representative_profile.phone_number_2 = representative_phone_number_2
if representative_card_number is not None:
representative_profile.card_number = representative_card_number
if representative_account_number is not None:
representative_profile.account_number = representative_account_number
if representative_bank_name is not None:
representative_profile.bank_name = representative_bank_name
if representative_address is not None:
representative_profile.address = representative_address
representative_profile.save()
else: else:
# Use CustomerForm to validate/create/update representative profile by national code # Use CustomerForm to validate/create/update representative profile by national code
profile_instance = None profile_instance = None
national_code = request.POST.get('national_code') if representative_national_code:
if national_code: profile_instance = Profile.objects.filter(national_code=representative_national_code).first()
profile_instance = Profile.objects.filter(national_code=national_code).first() customer_data = {
customer_form = CustomerForm(request.POST, instance=profile_instance) 'first_name': representative_first_name or '',
'last_name': representative_last_name or '',
'phone_number_1': representative_phone_number_1 or '',
'phone_number_2': representative_phone_number_2 or '',
'national_code': representative_national_code or '',
'address': representative_address or '',
'card_number': representative_card_number or '',
'account_number': representative_account_number or '',
'bank_name': representative_bank_name or '',
}
customer_form = CustomerForm(customer_data, instance=profile_instance)
customer_form.request = request customer_form.request = request
if not customer_form.is_valid(): if not customer_form.is_valid():
return JsonResponse({'ok': False, 'errors': {'customer': customer_form.errors}}, status=400) return JsonResponse({'ok': False, 'errors': {'customer': customer_form.errors}}, status=400)
@ -255,24 +292,62 @@ def create_request_with_entities(request):
redirect_url = reverse('processes:instance_steps', args=[instance.id]) redirect_url = reverse('processes:instance_steps', args=[instance.id])
return JsonResponse({'ok': True, 'instance_id': instance.id, 'redirect': redirect_url}) return JsonResponse({'ok': True, 'instance_id': instance.id, 'redirect': redirect_url})
@require_POST @require_POST
@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_object_or_404(ProcessInstance, id=instance_id)
code = instance.code code = instance.code
if instance.status == 'completed':
return JsonResponse({
'success': False,
'message': 'درخواست تکمیل شده نمی‌تواند حذف شود'
})
instance.delete() instance.delete()
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
'message': f'درخواست {code} با موفقیت حذف شد' 'message': f'درخواست {code} با موفقیت حذف شد'
}) })
@login_required
def start_process(request, process_id):
"""شروع فرآیند جدید"""
process = get_object_or_404(Process, id=process_id, is_active=True)
if request.method == 'POST':
form = ProcessInstanceForm(request.POST)
if form.is_valid():
instance = form.save(commit=False)
instance.process = process
instance.requester = request.user
instance.save()
# ایجاد نمونه‌های مرحله
for step in process.steps.all():
StepInstance.objects.create(
process_instance=instance,
step=step
)
# تنظیم مرحله اول به عنوان مرحله فعلی
first_step = process.steps.first()
if first_step:
instance.current_step = first_step
instance.status = 'in_progress'
instance.save()
messages.success(request, f'فرآیند {process.name} با موفقیت شروع شد.')
return redirect('processes:instance_detail', instance_id=instance.id)
else:
form = ProcessInstanceForm()
return render(request, 'processes/start_process.html', {
'process': process,
'form': form
})
@login_required
def instance_detail(request, instance_id):
"""نمایش جزئیات نمونه فرآیند"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
return render(request, 'processes/instance_detail.html', {
'instance': instance
})
@login_required @login_required
def step_detail(request, instance_id, step_id): def step_detail(request, instance_id, step_id):
@ -282,17 +357,6 @@ def step_detail(request, instance_id, step_id):
id=instance_id id=instance_id
) )
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
# If the request is already completed, redirect to read-only summary page
if instance.status == 'completed':
return redirect('processes:instance_summary', instance_id=instance.id)
# جلوگیری از پرش به مراحل آینده: فقط اجازه نمایش مرحله جاری یا مراحل تکمیل‌شده
try:
if instance.current_step and step.order > instance.current_step.order:
messages.error(request, 'ابتدا مراحل قبلی را تکمیل کنید.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=instance.current_step.id)
except Exception:
pass
# بررسی دسترسی به مرحله # بررسی دسترسی به مرحله
if not instance.can_access_step(step): if not instance.can_access_step(step):
@ -334,7 +398,6 @@ def step_detail(request, instance_id, step_id):
'next_step': next_step, 'next_step': next_step,
}) })
@login_required @login_required
def instance_steps(request, instance_id): def instance_steps(request, instance_id):
"""هدایت به مرحله فعلی instance""" """هدایت به مرحله فعلی instance"""
@ -351,41 +414,16 @@ def instance_steps(request, instance_id):
messages.error(request, 'هیچ مرحله‌ای برای این فرآیند تعریف نشده است.') messages.error(request, 'هیچ مرحله‌ای برای این فرآیند تعریف نشده است.')
return redirect('processes:request_list') return redirect('processes:request_list')
# If completed, go to summary instead of steps
if instance.status == 'completed':
return redirect('processes:instance_summary', instance_id=instance.id)
return redirect('processes:step_detail', instance_id=instance.id, step_id=instance.current_step.id) return redirect('processes:step_detail', instance_id=instance.id, step_id=instance.current_step.id)
@login_required @login_required
def instance_summary(request, instance_id): def my_processes(request):
"""نمای خلاصهٔ فقط‌خواندنی برای درخواست‌های تکمیل‌شده.""" """نمایش فرآیندهای کاربر"""
instance = get_object_or_404(ProcessInstance.objects.select_related('well', 'representative'), id=instance_id) my_instances = ProcessInstance.objects.filter(requester=request.user)
# Only show for completed requests; otherwise route to steps assigned_steps = StepInstance.objects.filter(assigned_to=request.user, status='in_progress')
if instance.status != 'completed':
return redirect('processes:instance_steps', instance_id=instance.id)
# Collect final invoice, payments, and certificate if any return render(request, 'processes/my_processes.html', {
from invoices.models import Invoice 'my_instances': my_instances,
from installations.models import InstallationReport 'assigned_steps': assigned_steps
from certificates.models import CertificateInstance
invoice = Invoice.objects.filter(process_instance=instance).first()
payments = invoice.payments.filter(is_deleted=False).all() if invoice else []
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
certificate = CertificateInstance.objects.filter(process_instance=instance).order_by('-created').first()
# Build rows like final invoice step
rows = []
if invoice:
items_qs = invoice.items.select_related('item').filter(is_deleted=False).all()
rows = list(items_qs)
return render(request, 'processes/instance_summary.html', {
'instance': instance,
'invoice': invoice,
'payments': payments,
'rows': rows,
'latest_report': latest_report,
'certificate': certificate,
}) })

File diff suppressed because one or more lines are too long

View file

@ -35,7 +35,7 @@ id="layout-navbar">
<!-- /Language --> <!-- /Language -->
<!-- Quick links --> <!-- Quick links -->
<li class="nav-item dropdown-shortcuts navbar-dropdown dropdown me-2 me-xl-0 d-none"> <li class="nav-item dropdown-shortcuts navbar-dropdown dropdown me-2 me-xl-0">
<a class="nav-link dropdown-toggle hide-arrow" href="#" data-bs-toggle="dropdown" <a class="nav-link dropdown-toggle hide-arrow" href="#" data-bs-toggle="dropdown"
data-bs-auto-close="outside" aria-expanded="false"> data-bs-auto-close="outside" aria-expanded="false">
<i class='bx bx-grid-alt bx-sm'></i> <i class='bx bx-grid-alt bx-sm'></i>
@ -127,17 +127,17 @@ id="layout-navbar">
<ul class="dropdown-menu dropdown-menu-end dropdown-styles"> <ul class="dropdown-menu dropdown-menu-end dropdown-styles">
<li> <li>
<a class="dropdown-item" href="#" data-theme="light"> <a class="dropdown-item" href="#" data-theme="light">
<span class="align-middle"><i class='bx bx-sun me-2'></i>روشن</span> <span class="align-middle"><i class='bx bx-sun me-2'></i>Light</span>
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="#" data-theme="dark"> <a class="dropdown-item" href="#" data-theme="dark">
<span class="align-middle"><i class="bx bx-moon me-2"></i>تاریک</span> <span class="align-middle"><i class="bx bx-moon me-2"></i>Dark</span>
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="#" data-theme="system"> <a class="dropdown-item" href="#" data-theme="system">
<span class="align-middle"><i class="bx bx-desktop me-2"></i>سیستم</span> <span class="align-middle"><i class="bx bx-desktop me-2"></i>System</span>
</a> </a>
</li> </li>
</ul> </ul>
@ -146,7 +146,7 @@ id="layout-navbar">
<!-- Notification --> <!-- Notification -->
<li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-1 d-none"> <li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-1">
<a class="nav-link dropdown-toggle hide-arrow" href="#" data-bs-toggle="dropdown" <a class="nav-link dropdown-toggle hide-arrow" href="#" data-bs-toggle="dropdown"
data-bs-auto-close="outside" aria-expanded="false"> data-bs-auto-close="outside" aria-expanded="false">
<i class="bx bx-bell bx-sm"></i> <i class="bx bx-bell bx-sm"></i>
@ -306,6 +306,24 @@ id="layout-navbar">
</div> </div>
</div> </div>
</li> </li>
<li class="list-group-item list-group-item-action dropdown-notifications-item marked-as-read">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar">
<span class="avatar-initial rounded-circle bg-label-warning"><i class="bx bx-error"></i></span>
</div>
</div>
<div class="flex-grow-1">
<h6 class="mb-1">CPU is running high</h6>
<p class="mb-0">CPU Utilization Percent is currently at 88.63%,</p>
<small class="text-muted">5 days ago</small>
</div>
<div class="flex-shrink-0 dropdown-notifications-actions">
<a href="javascript:void(0)" class="dropdown-notifications-read"><span class="badge badge-dot"></span></a>
<a href="javascript:void(0)" class="dropdown-notifications-archive"><span class="bx bx-x"></span></a>
</div>
</div>
</li>
</ul> </ul>
</li> </li>
<li class="dropdown-menu-footer border-top p-3"> <li class="dropdown-menu-footer border-top p-3">
@ -314,36 +332,25 @@ id="layout-navbar">
</ul> </ul>
</li> </li>
<!--/ Notification --> <!--/ Notification -->
<!-- User --> <!-- User -->
<li class="nav-item navbar-dropdown dropdown-user dropdown"> <li class="nav-item navbar-dropdown dropdown-user dropdown">
<a class="nav-link dropdown-toggle hide-arrow" href="#" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle hide-arrow" href="#" data-bs-toggle="dropdown">
<div class="avatar avatar-online"> <div class="avatar avatar-online">
{% if request.user.is_authenticated and request.user.profile and request.user.profile.pic %}
<img src="{{ request.user.profile.pic.url }}" alt class="w-px-40 h-auto rounded-circle">
{% else %}
<img src="{% static 'assets/img/avatars/1.png' %}" alt class="w-px-40 h-auto rounded-circle"> <img src="{% static 'assets/img/avatars/1.png' %}" alt class="w-px-40 h-auto rounded-circle">
{% endif %}
</div> </div>
</a> </a>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <li>
<a class="dropdown-item" href="#"> <a class="dropdown-item" href="pages-account-settings-account.html">
<div class="d-flex"> <div class="d-flex">
<div class="flex-shrink-0 me-3"> <div class="flex-shrink-0 me-3">
<div class="avatar avatar-online"> <div class="avatar avatar-online">
{% if request.user.is_authenticated and request.user.profile and request.user.profile.pic %}
<img src="{{ request.user.profile.pic.url }}" alt class="w-px-40 h-auto rounded-circle">
{% else %}
<img src="{% static 'assets/img/avatars/1.png' %}" alt class="w-px-40 h-auto rounded-circle"> <img src="{% static 'assets/img/avatars/1.png' %}" alt class="w-px-40 h-auto rounded-circle">
{% endif %}
</div> </div>
</div> </div>
<div class="flex-grow-1"> <div class="flex-grow-1">
<span class="fw-medium d-block">{{ request.user.get_full_name|default:request.user.username }}</span> <span class="fw-medium d-block">John Doe</span>
{% if request.user.profile %} <small class="text-muted">Admin</small>
<small class="text-muted">{{ request.user.profile.roles_str }}</small>
{% endif %}
</div> </div>
</div> </div>
</a> </a>
@ -352,24 +359,48 @@ id="layout-navbar">
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
</li> </li>
<li> <li>
<a class="dropdown-item" href="#"> <a class="dropdown-item" href="pages-profile-user.html">
<i class="bx bx-user me-2"></i> <i class="bx bx-user me-2"></i>
<span class="align-middle">پروفایل</span> <span class="align-middle">My Profile</span>
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="#"> <a class="dropdown-item" href="pages-account-settings-account.html">
<i class="bx bx-cog me-2"></i> <i class="bx bx-cog me-2"></i>
<span class="align-middle">تنظیمات</span> <span class="align-middle">Settings</span>
</a>
</li>
<li>
<a class="dropdown-item" href="pages-account-settings-billing.html">
<span class="d-flex align-items-center align-middle">
<i class="flex-shrink-0 bx bx-credit-card me-2"></i>
<span class="flex-grow-1 align-middle">Billing</span>
<span class="flex-shrink-0 badge badge-center rounded-pill bg-danger w-px-20 h-px-20">4</span>
</span>
</a> </a>
</li> </li>
<li> <li>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'accounts:logout' %}"> <a class="dropdown-item" href="pages-faq.html">
<i class="bx bx-help-circle me-2"></i>
<span class="align-middle">FAQ</span>
</a>
</li>
<li>
<a class="dropdown-item" href="pages-pricing.html">
<i class="bx bx-dollar me-2"></i>
<span class="align-middle">Pricing</span>
</a>
</li>
<li>
<div class="dropdown-divider"></div>
</li>
<li>
<a class="dropdown-item" href="auth-login-cover.html" target="_blank">
<i class="bx bx-power-off me-2"></i> <i class="bx bx-power-off me-2"></i>
<span class="align-middle">خروج</span> <span class="align-middle">Log Out</span>
</a> </a>
</li> </li>
</ul> </ul>

View file

@ -63,7 +63,7 @@
<ul class="menu-inner py-1"> <ul class="menu-inner py-1">
<!-- Dashboards --> <!-- Dashboards -->
<li class="menu-item d-none"> <li class="menu-item">
<a href="#" class="menu-link menu-toggle"> <a href="#" class="menu-link menu-toggle">
<i class="menu-icon tf-icons bx bx-home-circle"></i> <i class="menu-icon tf-icons bx bx-home-circle"></i>
<div class="text-truncate">داشبورد</div> <div class="text-truncate">داشبورد</div>

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-09-07 07:35 # Generated by Django 5.2.4 on 2025-08-14 09:02
import django.db.models.deletion import django.db.models.deletion
import simple_history.models import simple_history.models