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

This commit is contained in:
Hadi 2025-09-07 13:24:10 +03:30
commit bf4047714c
49 changed files with 30954 additions and 1076 deletions

View file

@ -167,7 +167,7 @@ JAZZMIN_SETTINGS = {
# Copyright on the footer
"copyright": "سامانه شفافیت",
# 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)
"custom_css": "../static/admin/css/custom_rtl.css",
"custom_js": None,

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-08-14 09:02
# Generated by Django 5.2.4 on 2025-09-07 07:35
import django.core.validators
import django.db.models.deletion
@ -17,6 +17,27 @@ class Migration(migrations.Migration):
]
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(
name='HistoricalProfile',
fields=[
@ -30,6 +51,7 @@ class Migration(migrations.Migration):
('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='شماره کارت')),
('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_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='تصویر')),
@ -84,6 +106,7 @@ class Migration(migrations.Migration):
('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='شماره کارت')),
('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_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='تصویر')),

View file

@ -1,34 +0,0 @@
# 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

@ -1,23 +0,0 @@
# 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,21 +64,18 @@ layout-wide customizer-hide
<span class="app-brand-text demo text-body fw-bold">سامانه شفافیت</span>
</a>
</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">
{% csrf_token %}
<div class="mb-3 fv-plugins-icon-container">
<label for="email" class="form-label">Email or Username</label>
<label for="email" class="form-label">نام کاربری</label>
<input type="text" class="form-control" id="email" name="username" placeholder="Enter your email or username" autofocus="">
<div class="fv-plugins-message-container fv-plugins-message-container--enabled invalid-feedback"></div></div>
<div class="mb-3 form-password-toggle fv-plugins-icon-container">
<div class="d-flex justify-content-between">
<label class="form-label" for="password">Password</label>
<label class="form-label" for="password">رمز عبور</label>
<a href="auth-forgot-password-basic.html">
<small>Forgot Password?</small>
<small>رمز عبور را فراموش کرده اید؟</small>
</a>
</div>
<div class="input-group input-group-merge has-validation">
@ -86,43 +83,12 @@ layout-wide customizer-hide
<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="mb-3">
<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>
<button class="btn btn-primary d-grid w-100" type="submit">ورود</button>
</div>
<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>
<!-- /Register -->

View file

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

View file

@ -1,11 +1,11 @@
from django.contrib import messages
from django.contrib.auth import login, authenticate
from django.contrib.auth import login, authenticate, logout
from django.shortcuts import render, redirect, get_object_or_404
from django.http import JsonResponse
from django.views.decorators.http import require_POST, require_GET
from django.views.decorators.csrf import csrf_exempt
from django import forms
from django.contrib.auth.decorators import login_required
from accounts.models import Profile
from accounts.forms import CustomerForm
from common.consts import UserRoles
@ -21,15 +21,12 @@ def login_view(request):
username = request.POST.get("username")
password = request.POST.get("password")
user = authenticate(request, username=username, password=password)
# if user is not None:
# login(request, user)
# if user.profile.has_none_of([UserRoles.MANAGER]):
# return redirect("dashboard:dashboard")
# else:
# return redirect("dashboard:admin_dashboard")
# else:
# messages.error(request, "کاربری با این مشخصات یافت نشد!")
# return redirect("accounts:login")
if user is not None:
login(request, user)
return redirect("processes:request_list")
else:
messages.error(request, "کاربری با این مشخصات یافت نشد!")
return redirect("accounts:login")
return render(request, "accounts/login.html")
@ -37,7 +34,7 @@ def dashboard(request):
return render(request, "accounts/dashboard.html")
@login_required
def customer_list(request):
# Get all profiles that have customer role
customers = Profile.objects.filter(roles__slug=UserRoles.CUSTOMER.value, is_deleted=False).select_related('user')
@ -163,3 +160,9 @@ def get_customer_data(request, customer_id):
},
'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-08-22 09:58
# Generated by Django 5.2.4 on 2025-09-07 07:35
import django.db.models.deletion
from django.db import migrations, models
@ -9,6 +9,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts', '0001_initial'),
('processes', '0001_initial'),
]
@ -23,10 +24,8 @@ class Migration(migrations.Migration):
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('title', models.CharField(max_length=200, verbose_name='عنوان')),
('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='فعال')),
('company', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.company', verbose_name='شرکت صادر کننده')),
],
options={
'verbose_name': 'قالب گواهی',

View file

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

View file

@ -9,6 +9,7 @@ from processes.models import ProcessInstance, StepInstance
from invoices.models import Invoice
from installations.models import InstallationReport
from .models import CertificateTemplate, CertificateInstance
from common.consts import UserRoles
from _helpers.jalali import Gregorian
@ -78,6 +79,14 @@ 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
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_at = timezone.now()
cert.save()
@ -89,7 +98,10 @@ def certificate_step(request, instance_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')
# Mark the whole process instance as completed on the last step
instance.status = 'completed'
instance.save()
return redirect('processes:instance_summary', instance_id=instance.id)
return render(request, 'certificates/step.html', {
'instance': instance,

View file

@ -1,7 +1,6 @@
# Generated by Django 5.2.4 on 2025-08-21 06:00
# Generated by Django 5.2.4 on 2025-09-07 07:35
import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models
@ -11,6 +10,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts', '0001_initial'),
('processes', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@ -28,8 +28,7 @@ class Migration(migrations.Migration):
('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')),
('name', models.CharField(max_length=100, verbose_name='نام')),
('body', models.TextField(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='امضای شرکت')),
('company', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.company', verbose_name='شرکت')),
],
options={
'verbose_name': 'قالب قرارداد',
@ -58,61 +57,4 @@ class Migration(migrations.Migration):
'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

@ -1,38 +0,0 @@
# 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,32 +41,36 @@
<div class="bs-stepper-content">
<div class="card border">
<div class="card-body">
{% if template.company.logo %}
<div class="text-center mb-3">
<img src="{{ template.company.logo.url }}" alt="لوگوی شرکت" style="max-height:80px;">
<h4 class="text-muted">{{ contract.template.company.name }}</h4>
<h5 class="text-muted">{{ contract.template.name }}</h5>
</div>
{% endif %}
{% if can_view_contract_body %}
{% if template.company.logo %}
<div class="text-center mb-3">
<img src="{{ template.company.logo.url }}" alt="لوگوی شرکت" style="max-height:80px;">
<h4 class="text-muted">{{ contract.template.company.name }}</h4>
<h5 class="text-muted">{{ contract.template.name }}</h5>
</div>
{% endif %}
<div class="small text-muted mb-2">تاریخ: {{ contract.jcreated }}</div>
<hr>
<div class="contract-body" style="white-space: pre-line; line-height:1.9;">{{ contract.rendered_body|safe }}</div>
<hr>
<div class="row mt-4">
<div class="col-6 text-center">
<div>امضای مشترک</div>
<div style="height:90px;border:1px dashed #ccc; margin-top:10px;"></div>
</div>
<div class="col-6 text-center">
<div>امضای شرکت</div>
<div style="height:90px;border:1px dashed #ccc; margin-top:10px;">
{% if template.company.signature %}
<img src="{{ template.company.signature.url }}" alt="امضای شرکت" style="max-height:80px;">
{% endif %}
<div class="small text-muted mb-2">تاریخ: {{ contract.jcreated }}</div>
<hr>
<div class="contract-body" style="white-space: pre-line; line-height:1.9;">{{ contract.rendered_body|safe }}</div>
<hr>
<div class="row mt-4">
<div class="col-6 text-center">
<div>امضای مشترک</div>
<div style="height:90px;border:1px dashed #ccc; margin-top:10px;"></div>
</div>
<div class="col-6 text-center">
<div>امضای شرکت</div>
<div style="height:90px;border:1px dashed #ccc; margin-top:10px;">
{% if template.company.signature %}
<img src="{{ template.company.signature.url }}" alt="امضای شرکت" style="max-height:80px;">
{% endif %}
</div>
</div>
</div>
</div>
{% else %}
<div class="alert alert-warning mb-0">شما دسترسی به مشاهده متن قرارداد را ندارید.</div>
{% endif %}
</div>
</div>
<form method="post" class="d-flex justify-content-between mt-3">
@ -77,9 +81,17 @@
<span></span>
{% endif %}
{% if next_step %}
<button type="submit" class="btn btn-primary">بعدی</button>
{% if is_broker %}
<button type="submit" class="btn btn-primary">تایید و بعدی</button>
{% else %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
{% endif %}
{% else %}
<button class="btn btn-success" type="button">اتمام</button>
{% if is_broker %}
<button class="btn btn-success" type="submit">اتمام</button>
{% else %}
<button class="btn btn-success" type="button" disabled>اتمام</button>
{% endif %}
{% endif %}
</form>
</div>

View file

@ -4,6 +4,7 @@ from django.urls import reverse
from django.utils import timezone
from django.template import Template, Context
from processes.models import ProcessInstance, StepInstance
from common.consts import UserRoles
from .models import ContractTemplate, ContractInstance
from _helpers.utils import jalali_converter2
@ -34,6 +35,20 @@ def contract_step(request, instance_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()
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()
if not template_obj:
return render(request, 'contracts/contract_missing.html', {'instance': instance})
@ -54,8 +69,11 @@ def contract_step(request, instance_id, step_id):
contract.rendered_body = rendered
contract.save()
# If user submits to go next, mark this step completed and go to next
# If user submits to go next, only broker can complete and go to next
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(
process_instance=instance,
step=step,
@ -74,6 +92,8 @@ def contract_step(request, instance_id, step_id):
'template': template_obj,
'previous_step': previous_step,
'next_step': next_step,
'is_broker': is_broker,
'can_view_contract_body': can_view_contract_body,
})

BIN
db.sqlite3 Normal file

Binary file not shown.

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-08-21 08:25
# Generated by Django 5.2.4 on 2025-09-07 07:35
import django.db.models.deletion
from django.conf import settings
@ -10,7 +10,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('invoices', '0002_historicalpayment_receipt_image_and_more'),
('invoices', '0001_initial'),
('processes', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@ -53,6 +53,8 @@ class Migration(migrations.Migration):
('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')),
('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='اختصاص')),
('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

@ -1,23 +0,0 @@
# 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,6 +1,7 @@
{% extends '_base.html' %}
{% load static %}
{% load processes_tags %}
{% load common_tags %}
{% load humanize %}
{% block sidebar %}
@ -41,12 +42,15 @@
<div class="bs-stepper-content">
{% if show_denied_msg %}
<div class="alert alert-warning mb-3">شما اجازه تعیین نصاب را ندارید.</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">نصاب</label>
<select name="installer_id" class="form-select" required>
<select name="installer_id" class="form-select" {% if read_only %}disabled{% endif %} required>
<option value="">انتخاب کنید...</option>
{% 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>
@ -55,17 +59,39 @@
</div>
<div class="col-md-6">
<label class="form-label">تاریخ مراجعه نصاب</label>
<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="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="hidden" id="id_scheduled_date" name="scheduled_date" value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y-m-d' }}{% endif %}">
</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">
{% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %}
<span></span>
{% endif %}
<button class="btn btn-primary" type="submit">ثبت و ادامه</button>
{% if is_manager %}
<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>
</form>
</div>

View file

@ -2,6 +2,7 @@
{% load static %}
{% load processes_tags %}
{% load common_tags %}
{% load accounts_tags %}
{% load humanize %}
{% block sidebar %}
@ -19,6 +20,18 @@
<!-- Persian Date Picker 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 %}
{% block content %}
@ -41,13 +54,27 @@
{% stepper_header instance step %}
<div class="bs-stepper-content">
{% if report and not edit_mode %}
<div class="card mb-3 border">
<div class="card-header d-flex justify-content-end">
<a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a>
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex gap-2">
{% if request.user|is_installer %}
<a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a>
{% else %}
<button type="button" class="btn btn-primary" disabled>ویرایش گزارش نصب</button>
{% endif %}
</div>
</div>
<div class="card-body">
{% if step_instance and step_instance.status == 'rejected' and step_instance.get_latest_rejection %}
<div class="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="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>
@ -67,6 +94,9 @@
</div>
{% endif %}
<hr>
{% if request.user|is_manager or request.user|is_admin %}
<hr>
{% endif %}
<h6>عکس‌ها</h6>
<div class="row">
{% for p in report.photos.all %}
@ -115,6 +145,42 @@
</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) -->
<div class="d-flex justify-content-between mt-3">
{% if previous_step %}
@ -127,6 +193,9 @@
{% endif %}
</div>
{% 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">
{% csrf_token %}
<div class="mb-3">
@ -134,40 +203,42 @@
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">تاریخ مراجعه</label>
<input type="text" id="id_visited_date_display" class="form-control" placeholder="انتخاب تاریخ" readonly required value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y/m/d' }}{% endif %}">
<input type="text" id="id_visited_date_display" class="form-control" placeholder="انتخاب تاریخ" {% if not request.user|is_installer %}disabled{% endif %} readonly required value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y/m/d' }}{% endif %}">
<input type="hidden" id="id_visited_date" name="visited_date" value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y-m-d' }}{% endif %}">
</div>
<div class="col-md-3">
<label class="form-label">سریال کنتور جدید</label>
<input type="text" class="form-control" name="new_water_meter_serial">
<input type="text" class="form-control" name="new_water_meter_serial" value="{% if report and edit_mode %}{{ report.new_water_meter_serial|default_if_none:'' }}{% endif %}" {% if not request.user|is_installer %}readonly{% endif %}>
</div>
<div class="col-md-3">
<label class="form-label">شماره پلمپ</label>
<input type="text" class="form-control" name="seal_number">
<input type="text" class="form-control" name="seal_number" value="{% if report and edit_mode %}{{ report.seal_number|default_if_none:'' }}{% endif %}" {% if not request.user|is_installer %}readonly{% endif %}>
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_meter_suspicious" id="id_is_meter_suspicious">
<input class="form-check-input" type="checkbox" name="is_meter_suspicious" id="id_is_meter_suspicious" {% if not request.user|is_installer %}disabled{% endif %} {% if report and edit_mode and report.is_meter_suspicious %}checked{% endif %}>
<label class="form-check-label" for="id_is_meter_suspicious">کنتور مشکوک است</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">UTM X</label>
<input type="number" step="0.000001" class="form-control" name="utm_x" value="{% if instance.well.utm_x %}{{ instance.well.utm_x }}{% endif %}">
<input type="number" step="0.000001" class="form-control" name="utm_x" value="{% if report and edit_mode and report.utm_x %}{{ report.utm_x }}{% elif instance.well.utm_x %}{{ instance.well.utm_x }}{% endif %}" {% if not request.user|is_installer %}readonly{% endif %}>
</div>
<div class="col-md-3">
<label class="form-label">UTM Y</label>
<input type="number" step="0.000001" class="form-control" name="utm_y" value="{% if instance.well.utm_y %}{{ instance.well.utm_y }}{% endif %}">
<input type="number" step="0.000001" class="form-control" name="utm_y" value="{% if report and edit_mode and report.utm_y %}{{ report.utm_y }}{% elif instance.well.utm_y %}{{ instance.well.utm_y }}{% endif %}" {% if not request.user|is_installer %}readonly{% endif %}>
</div>
</div>
<div class="my-3">
<label class="form-label">توضیحات (اختیاری)</label>
<textarea class="form-control" rows="3" name="description"></textarea>
<textarea class="form-control" rows="3" name="description" {% if not request.user|is_installer %}readonly{% endif %}>{% if report and edit_mode %}{{ report.description|default_if_none:'' }}{% endif %}</textarea>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<label class="form-label mb-0">عکس‌ها</label>
<button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button>
{% if request.user|is_installer %}
<button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button>
{% endif %}
</div>
{% if report %}
<div class="row mt-2">
@ -175,7 +246,9 @@
<div class="col-6 col-md-3 mb-2" id="existing-photo-{{ p.id }}">
<div class="position-relative border rounded p-1">
<img class="img-fluid rounded" src="{{ p.image.url }}" alt="photo">
<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>
{% 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>
{% endif %}
<input type="hidden" name="del_photo_{{ p.id }}" id="del-photo-{{ p.id }}" value="0">
</div>
</div>
@ -211,7 +284,7 @@
{% for qi in quote_items %}
<tr>
<td>
<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="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="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>
@ -285,7 +358,11 @@
<span></span>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" form="installation-report-form">ثبت گزارش</button>
{% if request.user|is_installer %}
<button type="submit" class="btn btn-primary" form="installation-report-form">ثبت گزارش</button>
{% else %}
<button type="button" class="btn btn-primary" disabled>ثبت گزارش</button>
{% endif %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-success">بعدی</a>
{% endif %}
@ -298,6 +375,58 @@
</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 %}
{% block script %}
@ -445,4 +574,3 @@
</script>
{% endblock %}

View file

@ -5,7 +5,8 @@ from django.urls import reverse
from django.utils import timezone
from accounts.models import Profile
from common.consts import UserRoles
from processes.models import ProcessInstance, StepInstance
from processes.models import ProcessInstance, StepInstance, StepRejection, StepApproval
from accounts.models import Role
from invoices.models import Item, Quote, QuoteItem
from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange
from decimal import Decimal, InvalidOperation
@ -21,7 +22,18 @@ def installation_assign_step(request, instance_id, step_id):
installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value).select_related('user').all()
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 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')
scheduled_date = (request.POST.get('scheduled_date') or '').strip()
assignment.installer_id = installer_id or None
@ -43,6 +55,10 @@ 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: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', {
'instance': instance,
'step': step,
@ -50,6 +66,9 @@ def installation_assign_step(request, instance_id, step_id):
'installers': installers,
'previous_step': previous_step,
'next_step': next_step,
'is_manager': is_manager,
'read_only': read_only,
'show_denied_msg': show_denied_msg,
})
@ -61,15 +80,94 @@ def installation_report_step(request, instance_id, step_id):
next_step = instance.process.steps.filter(order__gt=step.order).first()
assignment = InstallationAssignment.objects.filter(process_instance=instance).first()
existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first()
edit_mode = True if request.GET.get('edit') == '1' else False
print("edit_mode", edit_mode)
# Only installers can enter edit mode
user_is_installer = hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.INSTALLER)
edit_mode = True if (request.GET.get('edit') == '1' and user_is_installer) else False
# current quote items baseline
quote = Quote.objects.filter(process_instance=instance).first()
quote_items = list(quote.items.select_related('item').all()) if quote else []
quote_price_map = {qi.item_id: qi.unit_price for qi in quote_items}
items = Item.objects.all().order_by('name')
items = Item.objects.filter(is_active=True, is_special=False, is_deleted=False).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':
# 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()
visited_date = (request.POST.get('visited_date') or '').strip()
if '/' in visited_date:
@ -134,6 +232,7 @@ def installation_report_step(request, instance_id, step_id):
report.is_meter_suspicious = is_suspicious
report.utm_x = utm_x
report.utm_y = utm_y
report.approved = False # back to awaiting approval after edits
report.save()
# delete selected existing photos
for key, val in request.POST.items():
@ -211,18 +310,17 @@ def installation_report_step(request, instance_id, step_id):
total_price=total,
)
# complete step
StepInstance.objects.update_or_create(
process_instance=instance,
step=step,
defaults={'status': 'completed', 'completed_at': timezone.now()}
)
# After installer submits/edits, set step back to in_progress and clear approvals
step_instance.status = 'in_progress'
step_instance.completed_at = None
step_instance.save()
try:
step_instance.approvals.all().delete()
except Exception:
pass
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)
# Build prefill maps from existing report changes
removed_ids = set()
@ -250,6 +348,9 @@ def installation_report_step(request, instance_id, step_id):
'added_map': added_map,
'previous_step': previous_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)
class ItemAdmin(SimpleHistoryAdmin):
list_display = ['name', 'unit_price', 'default_quantity', 'is_default_in_quotes', 'is_active', 'created_by']
list_filter = ['is_default_in_quotes', 'is_active', 'created_by']
list_display = ['name', 'unit_price', 'default_quantity', 'is_default_in_quotes', 'is_special', 'is_active', 'created_by']
list_filter = ['is_default_in_quotes', 'is_special', 'is_active', 'created_by']
search_fields = ['name', 'description']
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ['deleted_at', 'created', 'updated']

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-08-14 09:02
# Generated by Django 5.2.4 on 2025-09-07 07:35
import django.db.models.deletion
import simple_history.models
@ -29,6 +29,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=100, verbose_name='نام')),
('description', models.TextField(blank=True, 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فرض')),
('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)),
@ -121,10 +122,12 @@ class Migration(migrations.Migration):
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, 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='روش پرداخت')),
('reference_number', models.CharField(blank=True, max_length=100, verbose_name='شماره مرجع')),
('reference_number', models.CharField(blank=True, db_index=True, max_length=100, verbose_name='شماره مرجع')),
('payment_date', models.DateField(verbose_name='تاریخ پرداخت')),
('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_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
@ -154,6 +157,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=100, verbose_name='نام')),
('description', models.TextField(blank=True, 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فرض')),
('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='ایجاد کننده')),
@ -225,10 +229,12 @@ class Migration(migrations.Migration):
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, 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='روش پرداخت')),
('reference_number', models.CharField(blank=True, max_length=100, verbose_name='شماره مرجع')),
('reference_number', models.CharField(blank=True, max_length=100, unique=True, verbose_name='شماره مرجع')),
('payment_date', models.DateField(verbose_name='تاریخ پرداخت')),
('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='ثبت کننده')),
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='invoices.invoice', verbose_name='فاکتور')),
],

View file

@ -1,23 +0,0 @@
# 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

@ -1,23 +0,0 @@
# 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

@ -1,23 +0,0 @@
# 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

@ -1,33 +0,0 @@
# 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

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

View file

@ -2,6 +2,7 @@
{% load static %}
{% load processes_tags %}
{% load common_tags %}
{% load accounts_tags %}
{% load humanize %}
{% block sidebar %}
@ -46,6 +47,7 @@
<div class="bs-stepper-content">
<div class="row g-3">
{% if is_broker %}
<div class="col-12 col-lg-5">
<div class="card border h-100">
<div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div>
@ -78,11 +80,11 @@
</select>
</div>
<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>
</div>
<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>
</div>
<div class="d-flex justify-content-end">
@ -92,23 +94,39 @@
</div>
</div>
</div>
<div class="col-12 col-lg-7">
{% endif %}
<div class="col-12 {% if is_broker %}col-lg-7{% else %}col-lg-12{% endif %}">
<div class="card mb-3 border">
<div class="card-header"><h5 class="mb-0">وضعیت فاکتور</h5></div>
<div class="card-header d-flex justify-content-between">
<h5 class="mb-0">وضعیت فاکتور</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<div class="border rounded p-3">
<div class="col-6 col-md-4">
<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">
<div class="border rounded p-3">
<div class="col-6 col-md-4">
<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-4">
<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 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>
@ -123,8 +141,8 @@
<th>مبلغ</th>
<th>تاریخ</th>
<th>روش</th>
<th>شماره مرجع</th>
<th style="width:150px">عملیات</th>
<th class="text-nowrap">شماره مرجع/چک</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
@ -132,7 +150,7 @@
<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|to_jalali }}</td>
<td>{{ p.payment_date|date:'Y/m/d' }}</td>
<td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td>
<td>
@ -142,7 +160,9 @@
<i class="bx bx-show"></i>
</a>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteFinalPayment({{ p.id }})" title="حذف" aria-label="حذف"><i class="bx bx-trash"></i></button>
{% if is_broker %}
<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>
</td>
</tr>
@ -152,20 +172,141 @@
</tbody>
</table>
</div>
<div class="card-footer d-flex justify-content-between">
{% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %}
<span></span>
{% endif %}
<button type="button" id="btnApproveFinalSettlement" class="btn btn-primary">تایید و ادامه</button>
</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="#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 %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %}
<span></span>
{% endif %}
{% if step_instance.status == 'completed' %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
{% else %}
<a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
{% endif %}
{% endif %}
</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>
<!-- 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 %}
{% block script %}
@ -191,8 +332,11 @@
if (g) { fd.set('payment_date', g); }
return fd;
}
document.getElementById('btnAddFinalPayment').addEventListener('click', function(){
const fd = buildForm();
(function(){
const btn = document.getElementById('btnAddFinalPayment');
if (!btn) return;
btn.addEventListener('click', function(){
const fd = buildForm();
// Frontend validation
const amount = document.getElementById('id_amount').value.trim();
const payDate = document.getElementById('id_payment_date').value.trim();
@ -204,7 +348,7 @@
showToast('همه فیلدها الزامی است', 'danger');
return;
}
fetch('{% url "invoices:add_final_payment" instance.id step.id %}', { method:'POST', body: fd })
fetch('{% url "invoices:add_final_payment" instance.id step.id %}', { method:'POST', body: fd })
.then(r=>r.json()).then(resp=>{
if (resp.success) {
showToast('تراکنش ثبت شد', 'success');
@ -213,12 +357,20 @@
showToast(resp.message || 'خطا در ثبت تراکنش', 'danger');
}
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
});
});
})();
function deleteFinalPayment(id){
let deleteTargetId = null;
function openDeleteModal(id){
deleteTargetId = id;
const modal = new bootstrap.Modal(document.getElementById('deletePaymentModal'));
modal.show();
}
function confirmDeletePayment(){
if (!deleteTargetId) return;
const fd = new FormData();
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
fetch(`{% url "invoices:delete_final_payment" instance.id step.id 0 %}`.replace('/0/', `/${id}/`), { method:'POST', body: fd })
fetch(`{% url "invoices:delete_final_payment" instance.id step.id 0 %}`.replace('/0/', `/${deleteTargetId}/`), { method:'POST', body: fd })
.then(r=>r.json()).then(resp=>{
if (resp.success) {
showToast('حذف شد', 'success');
@ -229,20 +381,7 @@
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
}
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'));
});
// Legacy approve button removed; using modal forms below
</script>
{% endblock %}

View file

@ -1,6 +1,7 @@
{% extends '_base.html' %}
{% load static %}
{% load processes_tags %}
{% load accounts_tags %}
{% load humanize %}
{% block sidebar %}
@ -55,14 +56,15 @@
<div class="content active dstepper-block">
<div class="content-header mb-3">
<h6 class="mb-0">{{ step.name }}</h6>
<small>ثبت فیش‌های واریزی برای پیش‌فاکتور</small>
<small>ثبت فیش‌ها/چک‌های واریزی برای پیش‌فاکتور</small>
</div>
<div class="row g-3">
{% if can_manage_payments %}
<div class="col-12 col-lg-5">
<div class="card h-100 border">
<div class="card-header">
<h5 class="card-title mb-0">ثبت فیش جدید</h5>
<h5 class="card-title mb-0">ثبت فیش/چک جدید</h5>
</div>
<div class="card-body">
<div class="mb-3">
@ -84,11 +86,11 @@
</select>
</div>
<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>
</div>
<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>
</div>
<div class="mb-3">
@ -96,16 +98,16 @@
<textarea class="form-control" rows="2" name="notes" id="id_notes"></textarea>
</div>
<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 class="col-12 col-lg-7">
{% endif %}
<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-header d-flex justify-content-between">
<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>
<h5 class="card-title mb-0">وضعیت پیش‌فاکتور</h5>
</div>
<div class="card-body">
<div class="row g-3">
@ -139,8 +141,10 @@
</div>
<div class="card border">
<div class="card-header">
<h5 class="card-title mb-0">فیش‌های ثبت شده</h5>
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title mb-0">فیش‌ها/چک‌های ثبت شده</h5>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped mb-0">
@ -149,9 +153,8 @@
<th>مبلغ</th>
<th>تاریخ</th>
<th>روش</th>
<th>شماره مرجع</th>
<th>تصویر</th>
<th style="width:120px">عملیات</th>
<th>شماره مرجع/چک</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
@ -162,28 +165,23 @@
<td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td>
<td>
{% if p.receipt_image %}
<div class="btn-group">
{% if p.receipt_image %}
<a href="{{ p.receipt_image.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="مشاهده" aria-label="مشاهده">
<i class="bx bx-show"></i>
</a>
{% else %}
-
{% endif %}
</td>
<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="حذف">
{% endif %}
{% if can_manage_payments %}
<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>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center text-muted">تا کنون فیشی ثبت نشده است</td>
<td colspan="6" class="text-center text-muted">تا کنون فیش/چکی ثبت نشده است</td>
</tr>
{% endfor %}
</tbody>
@ -191,6 +189,42 @@
</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">
{% if previous_step %}
@ -201,30 +235,114 @@
{% else %}
<span></span>
{% endif %}
<button type="button" id="btnApprovePayments" class="btn btn-primary">
تایید پرداخت‌ها
<i class="bx bx-chevron-left bx-sm ms-sm-2"></i>
</button>
{% if step_instance.status == 'completed' %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
<span class="align-middle d-sm-inline-block d-none me-sm-1">بعدی</span>
<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>
</form>
</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 %}
{% block script %}
<script>
const isFullyPaid = {{ totals.is_fully_paid|yesno:'true,false' }};
// Removed legacy isFullyPaid-driven approve flow; approval now via modal submit
function buildFormData(form) {
const fd = new FormData(form);
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
return fd;
}
document.getElementById('btnAddPayment').addEventListener('click', function() {
const btnAddPayment = document.getElementById('btnAddPayment');
if (btnAddPayment) btnAddPayment.addEventListener('click', function() {
// Front-end validation
const amount = document.getElementById('id_amount').value.trim();
const payDate = document.getElementById('id_payment_date').value.trim();
@ -283,51 +401,7 @@
alert('ویرایش فیش را بعدا با مدال تکمیل می‌کنیم. فعلا حذف و افزودن مجدد انجام دهید.');
}
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();
}
});
// Legacy approve JS removed; approval handled by modal forms in header
</script>
<!-- Persian Date Picker JS -->
@ -365,42 +439,4 @@
})();
</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 %}

View file

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

View file

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

View file

@ -9,7 +9,9 @@ from django.urls import reverse
from decimal import Decimal, InvalidOperation
import json
from processes.models import ProcessInstance, ProcessStep, StepInstance
from processes.models import ProcessInstance, ProcessStep, StepInstance, StepRejection, StepApproval
from accounts.models import Role
from common.consts import UserRoles
from .models import Item, Quote, QuoteItem, Payment, Invoice
from installations.models import InstallationReport, InstallationItemChange
@ -28,7 +30,7 @@ def quote_step(request, instance_id, step_id):
return redirect('processes:request_list')
# دریافت آیتم‌ها
items = Item.objects.all().order_by('name')
items = Item.objects.filter(is_active=True, is_special=False, is_deleted=False).order_by('name')
existing_quote = Quote.objects.filter(process_instance=instance).first()
existing_quote_items = {}
if existing_quote:
@ -40,6 +42,14 @@ def quote_step(request, instance_id, step_id):
previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first()
# 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', {
'instance': instance,
'step': step,
@ -49,6 +59,7 @@ def quote_step(request, instance_id, step_id):
'existing_quote': existing_quote,
'previous_step': previous_step,
'next_step': next_step,
'is_broker': is_broker,
})
@require_POST
@ -57,6 +68,13 @@ def create_quote(request, instance_id, step_id):
"""ساخت/بروزرسانی پیش‌فاکتور از اقلام انتخابی"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
# 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:
items_payload = json.loads(request.POST.get('items') or '[]')
@ -72,7 +90,7 @@ def create_quote(request, instance_id, step_id):
except Exception:
continue
default_item_ids = set(Item.objects.filter(is_default_in_quotes=True).values_list('id', flat=True))
default_item_ids = set(Item.objects.filter(is_default_in_quotes=True, is_deleted=False).values_list('id', flat=True))
if default_item_ids:
for default_id in default_item_ids:
if default_id not in payload_by_id:
@ -163,6 +181,14 @@ def quote_preview_step(request, instance_id, step_id):
previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first()
# 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', {
'instance': instance,
'step': step,
@ -170,6 +196,7 @@ def quote_preview_step(request, instance_id, step_id):
'quote': quote,
'previous_step': previous_step,
'next_step': next_step,
'is_broker': is_broker,
})
@login_required
@ -190,6 +217,13 @@ def approve_quote(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance)
# 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'
@ -247,7 +281,97 @@ def quote_payment_step(request, instance_id, step_id):
'is_fully_paid': quote.get_remaining_amount() <= 0,
}
step_instance = instance.step_instances.filter(step=step).first()
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
reqs = list(step.approver_requirements.select_related('role').all())
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', {
'instance': instance,
@ -258,6 +382,12 @@ def quote_payment_step(request, instance_id, step_id):
'totals': totals,
'previous_step': previous_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,
})
@ -279,6 +409,16 @@ 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__)
try:
amount = (request.POST.get('amount') or '').strip()
@ -325,6 +465,15 @@ def add_quote_payment(request, 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)})
# 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])
return JsonResponse({'success': True, 'redirect': redirect_url})
@ -360,6 +509,15 @@ def update_quote_payment(request, instance_id, step_id, payment_id):
except Exception:
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])
return JsonResponse({'success': True, 'redirect': redirect_url})
@ -374,11 +532,30 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
if not invoice:
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
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:
# soft delete using project's BaseModel delete override
payment.delete()
except Exception:
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])
return JsonResponse({'success': True, 'redirect': redirect_url})
@ -534,6 +711,14 @@ def final_invoice_step(request, instance_id, step_id):
# Choices for special items from DB
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', {
'instance': instance,
'step': step,
@ -543,6 +728,7 @@ 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(),
'previous_step': previous_step,
'next_step': next_step,
'is_manager': is_manager,
})
@ -564,6 +750,12 @@ def approve_final_invoice(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance)
# 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)
invoice.calculate_totals()
# if invoice.remaining_amount != 0:
@ -592,6 +784,12 @@ def add_special_charge(request, instance_id, step_id):
"""افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی به‌صورت آیتم جداگانه"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
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
item_id = request.POST.get('item_id')
amount = (request.POST.get('amount') or '').strip()
@ -623,6 +821,12 @@ def add_special_charge(request, instance_id, step_id):
def delete_special_charge(request, instance_id, step_id, item_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
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
inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice)
# allow deletion only for special items
@ -648,13 +852,87 @@ def final_settlement_step(request, instance_id, step_id):
previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first()
# 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', {
'instance': instance,
'step': step,
'invoice': invoice,
'payments': invoice.payments.filter(is_deleted=False).all(),
'step_instance': step_instance,
'previous_step': previous_step,
'next_step': next_step,
'approver_statuses': approver_statuses,
'can_approve_reject': can_approve_reject,
'is_broker': is_broker,
})
@ -662,7 +940,14 @@ def final_settlement_step(request, instance_id, step_id):
@login_required
def add_final_payment(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance)
# 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()
payment_date = (request.POST.get('payment_date') or '').strip()
payment_method = (request.POST.get('payment_method') or '').strip()
@ -717,6 +1002,14 @@ def add_final_payment(request, instance_id, step_id):
)
# After creation, totals auto-updated by model save. Respond with redirect and new totals for UX.
invoice.refresh_from_db()
# After payment change, set step back to in_progress
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]),
@ -732,10 +1025,25 @@ def add_final_payment(request, instance_id, step_id):
@login_required
def delete_final_payment(request, instance_id, step_id, payment_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance)
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()
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': {
'final_amount': str(invoice.final_amount),
'paid_amount': str(invoice.paid_amount),

View file

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

View file

@ -2,7 +2,7 @@ from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from .models import Process, ProcessStep, ProcessInstance, StepInstance, StepDependency, StepRejection, StepRevision
from .models import Process, ProcessStep, ProcessInstance, StepInstance, StepDependency, StepRejection, StepApproverRequirement, StepApproval
@admin.register(Process)
class ProcessAdmin(SimpleHistoryAdmin):
@ -168,14 +168,16 @@ class StepRejectionAdmin(SimpleHistoryAdmin):
return obj.reason[:50] + "..." if len(obj.reason) > 50 else obj.reason
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']
def changes_short(self, obj):
return obj.changes_description[:50] + "..." if len(obj.changes_description) > 50 else obj.changes_description
changes_short.short_description = "تغییرات"
@admin.register(StepApproverRequirement)
class StepApproverRequirementAdmin(admin.ModelAdmin):
list_display = ("step", "role", "required_count")
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

@ -1,26 +0,0 @@
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-08-14 09:02
# Generated by Django 5.2.4 on 2025-09-07 07:35
import django.db.models.deletion
import simple_history.models
@ -11,6 +11,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('accounts', '0001_initial'),
('wells', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@ -231,42 +232,17 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name='HistoricalStepRevision',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('changes_description', models.TextField(help_text='توضیح تغییراتی که برای اصلاح انجام شده', verbose_name='توضیح تغییرات')),
('created_at', models.DateTimeField(blank=True, editable=False, 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={
'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),
),
migrations.CreateModel(
name='StepRevision',
name='StepApproverRequirement',
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='نمونه مرحله')),
('required_count', models.PositiveIntegerField(default=1, verbose_name='تعداد موردنیاز')),
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.role', verbose_name='نقش تاییدکننده')),
('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approver_requirements', to='processes.processstep', verbose_name='مرحله')),
],
options={
'verbose_name': 'بازبینی مرحله',
'verbose_name_plural': 'بازبینی\u200cهای مراحل',
'ordering': ['-created_at'],
'verbose_name': 'نیازمندی تایید نقش',
'verbose_name_plural': 'نیازمندی\u200cهای تایید نقش',
'unique_together': {('step', 'role')},
},
),
migrations.CreateModel(
@ -284,4 +260,21 @@ class Migration(migrations.Migration):
'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,6 +4,8 @@ from common.models import NameSlugModel, SluggedModel
from simple_history.models import HistoricalRecords
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.conf import settings
from accounts.models import Role
from _helpers.utils import generate_unique_slug
import random
@ -46,6 +48,9 @@ class ProcessStep(NameSlugModel):
)
history = HistoricalRecords()
# Note: approver requirements are defined via StepApproverRequirement through model
# See StepApproverRequirement below
class Meta:
verbose_name = "مرحله فرآیند"
verbose_name_plural = "مراحل فرآیند"
@ -63,6 +68,7 @@ class ProcessStep(NameSlugModel):
"""دریافت مراحلی که به این مرحله وابسته هستند"""
return StepDependency.objects.filter(dependency_step=self).values_list('dependent_step', flat=True)
class StepDependency(models.Model):
"""مدل وابستگی بین مراحل"""
dependent_step = models.ForeignKey(
@ -239,7 +245,7 @@ class ProcessInstance(SluggedModel):
'cancelled': 'warning',
}
color = status_colors.get(self.status, 'secondary')
return '<span class="badge bg-{}">{}</span>'.format(color, self.get_status_display())
return '<span class="badge bg-label-{}">{}</span>'.format(color, self.get_status_display())
def get_priority_display_with_color(self):
"""نمایش اولویت با رنگ"""
@ -290,6 +296,7 @@ class ProcessInstance(SluggedModel):
return False
return True
class StepInstance(models.Model):
"""مدل نمونه مرحله (برای هر مرحله در هر درخواست)"""
process_instance = models.ForeignKey(ProcessInstance, on_delete=models.CASCADE, related_name='step_instances', verbose_name="نمونه فرآیند")
@ -353,6 +360,27 @@ class StepInstance(models.Model):
"""دریافت آخرین رد شدن"""
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):
"""مدل رد شدن مرحله"""
step_instance = models.ForeignKey(
@ -390,37 +418,35 @@ class StepRejection(models.Model):
self.step_instance.save()
super().save(*args, **kwargs)
class StepRevision(models.Model):
"""مدل بازبینی و اصلاح مرحله"""
step_instance = models.ForeignKey(
StepInstance,
on_delete=models.CASCADE,
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 StepApproverRequirement(models.Model):
"""Required approver roles for a step."""
step = models.ForeignKey(ProcessStep, on_delete=models.CASCADE, related_name='approver_requirements', verbose_name="مرحله")
role = models.ForeignKey(Role, on_delete=models.CASCADE, verbose_name="نقش تاییدکننده")
required_count = models.PositiveIntegerField(default=1, verbose_name="تعداد موردنیاز")
class Meta:
verbose_name = "بازبینی مرحله"
verbose_name_plural = "بازبینی‌های مراحل"
ordering = ['-created_at']
unique_together = ('step', 'role')
verbose_name = "نیازمندی تایید نقش"
verbose_name_plural = "نیازمندی‌های تایید نقش"
def __str__(self):
return f"بازبینی {self.step_instance} توسط {self.revised_by.get_full_name()}"
return f"{self.step}{self.role} (x{self.required_count})"
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,6 +6,7 @@
<div class="step
{% if not can_access %}disabled
{% elif status == 'completed' %}completed
{% elif status == 'rejected' %}rejected
{% elif is_todo %}active
{% endif %}
{% if is_selected %} selected{% endif %}"
@ -19,9 +20,9 @@
<span class="step-trigger">
{% endif %}
<span class="bs-stepper-circle">{{ forloop.counter }}</span>
<span class="bs-stepper-circle {% if status == 'rejected' %}bg-danger text-white{% endif %}">{{ forloop.counter }}</span>
<span class="bs-stepper-label mt-1">
<span class="bs-stepper-title">{{ step.name }}</span>
<span class="bs-stepper-title {% if status == 'rejected' %}text-danger{% endif %}">{{ step.name }}</span>
<span class="bs-stepper-subtitle">{{ step.description|default:' ' }}</span>
</span>

View file

@ -0,0 +1,168 @@
{% 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,17 +28,113 @@
<div class="container-xxl flex-grow-1 container-p-y">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">درخواست‌ها</h4>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#requestModal">
<i class="bx bx-plus"></i>
درخواست جدید
</button>
<div class="row py-3 mb-4 card-header flex-column flex-md-row pb-0">
<div class="d-md-flex justify-content-between align-items-center dt-layout-start col-md-auto me-auto mt-0">
<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">
<i class="bx bx-plus me-1"></i>
درخواست جدید
</button>
</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="table-responsive">
<table id="requestsTable" class="table table-striped">
<div class="card-datatable table-responsive">
<table id="requests-table" class="datatables-basic table border-top">
<thead>
<tr>
<th>شناسه</th>
@ -46,25 +142,43 @@
<th>مرحله فعلی</th>
<th>شماره اشتراک آب</th>
<th>نماینده</th>
<th>درخواست‌کننده</th>
<th>اولویت</th>
<th>استان</th>
<th>امور</th>
<th>پیشرفت</th>
<th>وضعیت</th>
<th>تاریخ ایجاد</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
{% for inst in instances %}
{% for item in instances_with_progress %}
<tr>
<td>{{ inst.code }}</td>
<td>{{ inst.process.name }}</td>
<td class="text-primary">{{ inst.current_step.name|default:"--" }}</td>
<td>{{ inst.well.water_subscription_number }}</td>
<td>{% if inst.representative %}{{ inst.representative.get_full_name }}{% else %}-{% endif %}</td>
<td>{% if inst.requester %}{{ inst.requester.get_full_name }}{% else %}-{% endif %}</td>
<td>{{ inst.get_priority_display }}</td>
<td>{{ inst.get_status_display }}</td>
<td>{{ inst.jcreated }}</td>
<td>{{ item.instance.code }}</td>
<td>{{ item.instance.process.name }}</td>
<td class="text-primary">
{% if item.instance.status == 'completed' %}
<a href="{% url 'processes:instance_summary' item.instance.id %}" class="text-primary">{{ item.instance.current_step.name|default:"--" }}</a>
{% elif item.instance.current_step %}
<a href="{% url 'processes:instance_steps' item.instance.id %}" class="text-primary">{{ item.instance.current_step.name }}</a>
{% else %}
--
{% 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>
<div class="d-inline-block">
<a href="javascript:;" class="btn btn-icon dropdown-toggle hide-arrow" data-bs-toggle="dropdown">
@ -72,13 +186,19 @@
</a>
<ul class="dropdown-menu dropdown-menu-end m-0">
<li>
<a href="{% url 'processes:instance_steps' inst.id %}" class="dropdown-item">
<i class="bx bx-show me-1"></i>مشاهده جزئیات
</a>
{% if item.instance.status == 'completed' %}
<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>مشاهده جزئیات
</a>
{% endif %}
</li>
<div class="dropdown-divider"></div>
<li>
<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'))">
<a href="#" class="dropdown-item text-danger" data-instance-id="{{ item.instance.id }}" data-instance-code="{{ item.instance.code }}" onclick="deleteRequest(this.getAttribute('data-instance-id'), this.getAttribute('data-instance-code'))">
<i class="bx bx-trash me-1"></i>حذف
</a>
</li>
@ -88,7 +208,7 @@
</tr>
{% empty %}
<tr>
<td colspan="9" class="text-center text-muted">موردی ثبت نشده است</td>
<td colspan="11" class="text-center text-muted">موردی ثبت نشده است</td>
</tr>
{% endfor %}
</tbody>
@ -120,7 +240,7 @@
<div class="col-sm-12">
<label class="form-label">شماره اشتراک آب</label>
<div class="input-group">
<input type="text" class="form-control" id="req_water_sub" placeholder="مثال: 12345" required>
<input type="text" class="form-control" id="req_water_sub" name="water_subscription_number" data-field="water_subscription_number" placeholder="مثال: 12345" required>
<button class="btn btn-outline-secondary" type="button" id="btnLookupWell">
بررسی/افزودن چاه
</button>
@ -211,7 +331,7 @@
<div class="col-sm-12">
<label class="form-label">کد ملی نماینده</label>
<div class="input-group">
<input type="text" class="form-control" id="rep_national_code" placeholder="مثال: 0012345678">
<input type="text" class="form-control" id="rep_national_code" data-field="national_code" placeholder="مثال: 0012345678" maxlength="10" inputmode="numeric" pattern="\d*">
<button class="btn btn-outline-secondary" type="button" id="btnLookupRep">
بررسی/افزودن نماینده
</button>
@ -262,7 +382,7 @@
<hr class="mt-3 border border-dashed">
<div class="col-sm-12">
<label class="form-label">توضیحات</label>
<textarea class="form-control" rows="3" id="req_description"></textarea>
<textarea class="form-control" rows="3" id="req_description" name="description"></textarea>
</div>
</div>
</form>
@ -355,19 +475,13 @@
$(function() {
// if ($.fn.DataTable) {
// try {
// $('#requestsTable').DataTable({
// pageLength: 10,
// order: [[0, 'desc']]
// });
// } catch (e) {
// console.error('DataTable init failed', e);
// }
// } else {
// console.warn('DataTables library not loaded');
// }
// Initialize DataTable similar to customer_list
$('#requests-table').DataTable({
pageLength: 10,
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, "همه"]],
order: [[0, 'desc']],
responsive: true,
});
let currentWellId = null;
let currentRepId = null;
let wellChecked = false;
@ -393,7 +507,7 @@
if (!$el.length) return false;
$el.addClass('is-invalid');
const $feedback = $('<div class="invalid-feedback inline-error"></div>').text(message);
const $grp = $el.closest('.input-group');
const $grp = $el.closest('.input-group, .form-group, .mb-3');
if ($grp.length) {
$feedback.insertAfter($grp);
} else {
@ -402,60 +516,49 @@
return true;
}
function mapWellFieldToSelector(field) {
switch (field) {
case 'water_subscription_number': return '#req_water_sub';
case 'electricity_subscription_number': return '#id_electricity_subscription_number';
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;
}
}
// Generic field resolution with small exception map
const exceptionMap = {
water_subscription_number: '#req_water_sub',
national_code: '#rep_national_code',
representative: '#rep_national_code'
};
function mapCustomerFieldToSelector(field) {
switch (field) {
case 'national_code': return $('#id_national_code').length ? '#id_national_code' : '#rep_national_code';
case 'first_name': return '#id_first_name';
case 'last_name': return '#id_last_name';
case 'phone_number_1': return '#id_phone_number_1';
case 'phone_number_2': return '#id_phone_number_2';
case 'card_number': return '#id_card_number';
case 'account_number': return '#id_account_number';
case 'bank_name': return '#id_bank_name';
case 'address': return '#id_address';
default: return '#id_' + field;
}
function findFieldSelector(field, context) {
const $ctx = context ? $(context) : $('#requestModal');
let $el = $ctx.find(`#id_${field}`).first();
if ($el.length) return $el;
$el = $ctx.find(`[name="${field}"]`).first();
if ($el.length) return $el;
$el = $ctx.find(`[data-field="${field}"]`).first();
if ($el.length) return $el;
const ex = exceptionMap[field];
return ex ? $(ex) : $();
}
function showInlineErrors(errors) {
if (!errors) return;
let nonFieldWell = '';
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) {
for (const key in errors.well) {
const msgs = Array.isArray(errors.well[key]) ? errors.well[key] : [errors.well[key]];
if (key === '__all__' || key === 'non_field_errors') { nonFieldWell = msgs.join('، '); continue; }
const sel = mapWellFieldToSelector(key);
applyErrorTo(sel, msgs[0]);
applyErrorTo(findFieldSelector(key, '#wellFormBlock'), msgs[0]);
}
}
if (errors.customer) {
for (const key in errors.customer) {
const msgs = Array.isArray(errors.customer[key]) ? errors.customer[key] : [errors.customer[key]];
if (key === '__all__' || key === 'non_field_errors') { nonFieldCustomer = msgs.join('، '); continue; }
const sel = mapCustomerFieldToSelector(key);
applyErrorTo(sel, msgs[0]);
applyErrorTo(findFieldSelector(key, '#repNewFields'), msgs[0]);
}
}
if (nonFieldWell) setStatus('#wellStatus', nonFieldWell, 'danger');
@ -530,7 +633,7 @@
$('#remove-file').val('false');
// Initialize Persian Date Picker after well form is shown
setTimeout(initPersianDatePicker, 100);
setStatus('#wellStatus', 'چاه یافت نشد. با ذخیره، ایجاد خواهد شد.', 'danger');
setStatus('#wellStatus', 'چاه یافت نشد. اطلاعات چاه را وارد کنید.', 'danger');
}
})
.fail(function(){ setStatus('#wellStatus', 'خطا در بررسی چاه', 'danger'); });
@ -588,46 +691,26 @@
});
$('#btnSaveRequest').on('click', function(){
const formData = new FormData();
formData.append('csrfmiddlewaretoken', $('input[name=csrfmiddlewaretoken]').val());
formData.append('process', $('#req_process').val());
formData.append('description', $('#req_description').val());
formData.append('water_subscription_number', $('#req_water_sub').val().trim());
clearInlineErrors();
// Use form's native FormData - much cleaner!
const formData = new FormData(document.getElementById('requestForm'));
// Add custom fields that aren't in the form
if (currentWellId) formData.append('well_id', currentWellId);
if (currentRepId) formData.append('representative_id', currentRepId);
// Send fields using CustomerForm names if visible
const ncField = $('#id_national_code').length ? $('#id_national_code').val() : '';
formData.append('national_code', (ncField || $('#rep_national_code').val().trim()));
formData.append('first_name', $('#id_first_name').val() || '');
formData.append('last_name', $('#id_last_name').val() || '');
formData.append('phone_number_1', $('#id_phone_number_1').val() || '');
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() || '');
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');
formData.append('reference_letter_date', gregorianDate || $('#id_reference_letter_date').val() || '');
// 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]);
}
// Handle special national_code logic (prefer visible field)
const ncField = $('#id_national_code').val();
if (ncField) {
formData.set('national_code', ncField);
} else {
formData.set('national_code', $('#rep_national_code').val().trim());
}
// Handle Persian date conversion
const gregorianDate = $('#id_reference_letter_date').attr('data-gregorian');
if (gregorianDate) {
formData.set('reference_letter_date', gregorianDate);
}
const $btn = $(this).prop('disabled', true).text('در حال ذخیره...');
@ -646,6 +729,10 @@
setTimeout(function(){ location.reload(); }, 1200);
}
} else {
clearInlineErrors();
if (resp.errors) {
showInlineErrors(resp.errors);
}
const msg = buildErrorMessage(resp);
showToast(msg, 'danger');
}
@ -653,6 +740,10 @@
let msg = 'خطا در ذخیره';
try {
const resp = JSON.parse(xhr.responseText);
clearInlineErrors();
if (resp && resp.errors) {
showInlineErrors(resp.errors);
}
msg = buildErrorMessage(resp) || msg;
} catch(e) {}
showToast(msg, 'danger');
@ -709,6 +800,14 @@
}
});
// 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(){
$('#requestForm')[0].reset();
$('#wellFormBlock').hide();

View file

@ -26,8 +26,10 @@ def stepper_header(instance, current_step=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')
# بررسی دسترسی به مرحله
can_access = instance.can_access_step(step)
# بررسی دسترسی به مرحله (UI navigation constraints):
# 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)
# مرحله‌ای که باید انجام شود (مرحله جاری در instance)

View file

@ -14,11 +14,6 @@ urlpatterns = [
# New step-based architecture
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>/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,7 +1,6 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
import json
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import JsonResponse
@ -11,41 +10,48 @@ from django.contrib.auth import get_user_model
from .models import Process, ProcessInstance, StepInstance
from wells.models import Well
from accounts.models import Profile
from .forms import ProcessInstanceForm
from accounts.forms import CustomerForm
from wells.forms import WellForm
from wells.models import WaterMeterManufacturer
@login_required
def process_list(request):
"""نمایش لیست فرآیندهای فعال"""
processes = Process.objects.filter(is_active=True)
return render(request, 'processes/process_list.html', {
'processes': processes
})
@login_required
def process_detail(request, process_id):
"""نمایش جزئیات فرآیند"""
process = get_object_or_404(Process, id=process_id, is_active=True)
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')
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)
manufacturers = WaterMeterManufacturer.objects.all().order_by('name')
# 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
total_count = instances.count()
completed_count = instances.filter(status='completed').count()
in_progress_count = instances.filter(status='in_progress').count()
pending_count = instances.filter(status='pending').count()
return render(request, 'processes/request_list.html', {
'instances': instances,
'instances_with_progress': instances_with_progress,
'customer_form': CustomerForm(),
'well_form': WellForm(),
'processes': processes,
'manufacturers': manufacturers
'manufacturers': manufacturers,
'total_count': total_count,
'completed_count': completed_count,
'in_progress_count': in_progress_count,
'pending_count': pending_count,
})
@ -127,23 +133,12 @@ def create_request_with_entities(request):
well_id = request.POST.get('well_id') # optional if existing
# Representative fields
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:
return JsonResponse({'ok': False, 'errors': {'request': {'process': ['فرآیند الزامی است']}}}, status=400)
if not water_subscription_number:
return JsonResponse({'ok': False, 'errors': {'well': {'water_subscription_number': ['شماره اشتراک آب الزامی است']}}}, status=400)
if not representative_id and not representative_national_code:
if not representative_id and not request.POST.get('national_code'):
return JsonResponse({'ok': False, 'errors': {'customer': {'national_code': ['کد ملی نماینده را وارد کنید یا دکمه بررسی/افزودن نماینده را بزنید']}}}, status=400)
representative_user = None
@ -152,52 +147,20 @@ def create_request_with_entities(request):
representative_profile = Profile.objects.select_related('user').filter(user_id=representative_id).first()
if not representative_profile:
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
# 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:
# Use CustomerForm to validate/create/update representative profile by national code
profile_instance = None
if representative_national_code:
profile_instance = Profile.objects.filter(national_code=representative_national_code).first()
customer_data = {
'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)
national_code = request.POST.get('national_code')
if national_code:
profile_instance = Profile.objects.filter(national_code=national_code).first()
customer_form = CustomerForm(request.POST, instance=profile_instance)
customer_form.request = request
if not customer_form.is_valid():
return JsonResponse({'ok': False, 'errors': {'customer': customer_form.errors}}, status=400)
@ -292,62 +255,24 @@ def create_request_with_entities(request):
redirect_url = reverse('processes:instance_steps', args=[instance.id])
return JsonResponse({'ok': True, 'instance_id': instance.id, 'redirect': redirect_url})
@require_POST
@login_required
def delete_request(request, instance_id):
"""حذف درخواست"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
code = instance.code
if instance.status == 'completed':
return JsonResponse({
'success': False,
'message': 'درخواست تکمیل شده نمی‌تواند حذف شود'
})
instance.delete()
return JsonResponse({
'success': True,
'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
def step_detail(request, instance_id, step_id):
@ -357,7 +282,18 @@ def step_detail(request, instance_id, step_id):
id=instance_id
)
step = get_object_or_404(instance.process.steps, id=step_id)
# If the request is already completed, redirect to read-only summary page
if instance.status == 'completed':
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):
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
@ -398,6 +334,7 @@ def step_detail(request, instance_id, step_id):
'next_step': next_step,
})
@login_required
def instance_steps(request, instance_id):
"""هدایت به مرحله فعلی instance"""
@ -414,16 +351,41 @@ def instance_steps(request, instance_id):
messages.error(request, 'هیچ مرحله‌ای برای این فرآیند تعریف نشده است.')
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)
@login_required
def my_processes(request):
"""نمایش فرآیندهای کاربر"""
my_instances = ProcessInstance.objects.filter(requester=request.user)
assigned_steps = StepInstance.objects.filter(assigned_to=request.user, status='in_progress')
return render(request, 'processes/my_processes.html', {
'my_instances': my_instances,
'assigned_steps': assigned_steps
})
@login_required
def instance_summary(request, instance_id):
"""نمای خلاصهٔ فقط‌خواندنی برای درخواست‌های تکمیل‌شده."""
instance = get_object_or_404(ProcessInstance.objects.select_related('well', 'representative'), id=instance_id)
# Only show for completed requests; otherwise route to steps
if instance.status != 'completed':
return redirect('processes:instance_steps', instance_id=instance.id)
# Collect final invoice, payments, and certificate if any
from invoices.models import Invoice
from installations.models import InstallationReport
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 -->
<!-- Quick links -->
<li class="nav-item dropdown-shortcuts navbar-dropdown dropdown me-2 me-xl-0">
<li class="nav-item dropdown-shortcuts navbar-dropdown dropdown me-2 me-xl-0 d-none">
<a class="nav-link dropdown-toggle hide-arrow" href="#" data-bs-toggle="dropdown"
data-bs-auto-close="outside" aria-expanded="false">
<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">
<li>
<a class="dropdown-item" href="#" data-theme="light">
<span class="align-middle"><i class='bx bx-sun me-2'></i>Light</span>
<span class="align-middle"><i class='bx bx-sun me-2'></i>روشن</span>
</a>
</li>
<li>
<a class="dropdown-item" href="#" data-theme="dark">
<span class="align-middle"><i class="bx bx-moon me-2"></i>Dark</span>
<span class="align-middle"><i class="bx bx-moon me-2"></i>تاریک</span>
</a>
</li>
<li>
<a class="dropdown-item" href="#" data-theme="system">
<span class="align-middle"><i class="bx bx-desktop me-2"></i>System</span>
<span class="align-middle"><i class="bx bx-desktop me-2"></i>سیستم</span>
</a>
</li>
</ul>
@ -146,7 +146,7 @@ id="layout-navbar">
<!-- Notification -->
<li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-1">
<li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-1 d-none">
<a class="nav-link dropdown-toggle hide-arrow" href="#" data-bs-toggle="dropdown"
data-bs-auto-close="outside" aria-expanded="false">
<i class="bx bx-bell bx-sm"></i>
@ -306,24 +306,6 @@ id="layout-navbar">
</div>
</div>
</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>
</li>
<li class="dropdown-menu-footer border-top p-3">
@ -332,25 +314,36 @@ id="layout-navbar">
</ul>
</li>
<!--/ Notification -->
<!-- User -->
<li class="nav-item navbar-dropdown dropdown-user dropdown">
<a class="nav-link dropdown-toggle hide-arrow" href="#" data-bs-toggle="dropdown">
<div class="avatar avatar-online">
<img src="{% static 'assets/img/avatars/1.png' %}" alt class="w-px-40 h-auto rounded-circle">
{% 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">
{% endif %}
</div>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="pages-account-settings-account.html">
<a class="dropdown-item" href="#">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar avatar-online">
<img src="{% static 'assets/img/avatars/1.png' %}" alt class="w-px-40 h-auto rounded-circle">
{% 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">
{% endif %}
</div>
</div>
<div class="flex-grow-1">
<span class="fw-medium d-block">John Doe</span>
<small class="text-muted">Admin</small>
<span class="fw-medium d-block">{{ request.user.get_full_name|default:request.user.username }}</span>
{% if request.user.profile %}
<small class="text-muted">{{ request.user.profile.roles_str }}</small>
{% endif %}
</div>
</div>
</a>
@ -359,48 +352,24 @@ id="layout-navbar">
<div class="dropdown-divider"></div>
</li>
<li>
<a class="dropdown-item" href="pages-profile-user.html">
<a class="dropdown-item" href="#">
<i class="bx bx-user me-2"></i>
<span class="align-middle">My Profile</span>
<span class="align-middle">پروفایل</span>
</a>
</li>
<li>
<a class="dropdown-item" href="pages-account-settings-account.html">
<a class="dropdown-item" href="#">
<i class="bx bx-cog me-2"></i>
<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>
<span class="align-middle">تنظیمات</span>
</a>
</li>
<li>
<div class="dropdown-divider"></div>
</li>
<li>
<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">
<a class="dropdown-item" href="{% url 'accounts:logout' %}">
<i class="bx bx-power-off me-2"></i>
<span class="align-middle">Log Out</span>
<span class="align-middle">خروج</span>
</a>
</li>
</ul>

View file

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

View file

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