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

This commit is contained in:
Haydo 2025-10-09 20:33:12 +03:30
commit e05fb4c95f
84 changed files with 4988 additions and 803 deletions

4
.gitignore vendored
View file

@ -9,8 +9,8 @@
*.pyc
__pycache__/
local_settings.py
# *.sqlite3
# db.sqlite3
#*.sqlite3
#db.sqlite3
db.sqlite3-journal
media
#static

View file

@ -157,15 +157,15 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
JAZZMIN_SETTINGS = {
# title of the window (Will default to current_admin_site.site_title if absent or None)
"site_title": "سامانه شفافیت",
"site_title": "کنتور پلاس",
# Title on the login screen (19 chars max) (defaults to current_admin_site.site_header if absent or None)
"site_header": "سامانه شفافیت",
"site_header": "کنتور پلاس",
# Title on the brand (19 chars max) (defaults to current_admin_site.site_header if absent or None)
"site_brand": "سامانه شفافیت",
"site_brand": "کنتور پلاس",
# Welcome text on the login screen
"welcome_sign": "به سامانه شفافیت خوش آمدید",
"welcome_sign": "به کنتور پلاس خوش آمدید",
# Copyright on the footer
"copyright": "سامانه شفافیت",
"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",
# Relative paths to custom CSS/JS scripts (must be present in static files)
@ -173,3 +173,6 @@ JAZZMIN_SETTINGS = {
"custom_js": None,
}
# VAT / Value Added Tax percent (e.g., 0.09 for 9%)
VAT_RATE = 0.1

View file

@ -144,7 +144,7 @@ def persian_converter2(time):
def persian_converter3(time):
time = time + datetime.timedelta(days=1)
time = time
time_to_str = "{},{},{}".format(time.year, time.month, time.day)
time_to_tuple = jalali.Gregorian(time_to_str).persian_tuple()
time_to_list = list(time_to_tuple)
@ -192,4 +192,122 @@ def normalize_size(size: int) -> str:
return f"{int(size_mb)} MB" if size_mb.is_integer() else f"{size_mb:.1f} MB"
else:
size_gb = size / (1024 * 1024 * 1024)
return f"{int(size_gb)} GB" if size_gb.is_integer() else f"{size_gb:.1f} GB"
return f"{int(size_gb)} GB" if size_gb.is_integer() else f"{size_gb:.1f} GB"
def number_to_persian_words(number):
"""
تبدیل عدد به حروف فارسی
مثال: 12345 -> دوازده هزار و سیصد و چهل و پنج
"""
try:
# تبدیل به عدد صحیح (در صورت نیاز)
from decimal import Decimal
if isinstance(number, Decimal):
number = int(number)
elif isinstance(number, float):
number = int(number)
elif isinstance(number, str):
number = int(float(number.replace(',', '')))
if number == 0:
return "صفر"
if number < 0:
return "منفی " + number_to_persian_words(abs(number))
# اعداد یک رقمی
ones = [
"", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"
]
# اعداد ده تا نوزده
teens = [
"ده", "یازده", "دوازده", "سیزده", "چهارده", "پانزده",
"شانزده", "هفده", "هجده", "نوزده"
]
# اعداد بیست تا نود
tens = [
"", "", "بیست", "سی", "چهل", "پنجاه", "شصت", "هفتاد", "هشتاد", "نود"
]
# اعداد صد تا نهصد
hundreds = [
"", "یکصد", "دویست", "سیصد", "چهارصد", "پانصد",
"ششصد", "هفتصد", "هشتصد", "نهصد"
]
# مراتب بزرگتر
scale = [
"", "هزار", "میلیون", "میلیارد", "بیلیون", "بیلیارد"
]
def convert_group(num):
"""تبدیل گروه سه رقمی به حروف"""
if num == 0:
return ""
result = []
# صدها
h = num // 100
if h > 0:
result.append(hundreds[h])
# دهگان و یکان
remainder = num % 100
if remainder >= 10 and remainder < 20:
# اعداد 10 تا 19
result.append(teens[remainder - 10])
else:
# دهگان
t = remainder // 10
if t > 0:
result.append(tens[t])
# یکان
o = remainder % 10
if o > 0:
result.append(ones[o])
return " و ".join(result)
# تقسیم عدد به گروه‌های سه رقمی
groups = []
scale_index = 0
while number > 0:
group = number % 1000
if group != 0:
group_text = convert_group(group)
if scale_index > 0:
group_text += " " + scale[scale_index]
groups.append(group_text)
number //= 1000
scale_index += 1
# معکوس کردن و ترکیب گروه‌ها
groups.reverse()
result = " و ".join(groups)
return result
except Exception:
return ""
def amount_to_persian_words(amount):
"""
تبدیل مبلغ به حروف فارسی با واحد ریال
مثال: 12345 -> دوازده هزار و سیصد و چهل و پنج ریال
"""
try:
words = number_to_persian_words(amount)
if words:
return words + " ریال"
return ""
except Exception:
return ""

View file

@ -16,6 +16,8 @@ class ProfileAdmin(admin.ModelAdmin):
list_display = [
"user",
"fullname",
"user_type_display",
"company_name",
"pic_tag",
"roles_str",
"affairs",
@ -25,8 +27,52 @@ class ProfileAdmin(admin.ModelAdmin):
"is_active",
"jcreated",
]
search_fields = ['user__username', 'user__first_name', 'user__last_name', 'user__phone_number']
list_filter = ['user', 'roles', 'affairs', 'county', 'broker']
search_fields = [
'user__username',
'user__first_name',
'user__last_name',
'user__phone_number',
'company_name',
'company_national_id',
'national_code'
]
list_filter = [
'user_type',
'user',
'roles',
'affairs',
'county',
'broker',
'is_completed',
'is_active'
]
fieldsets = (
('اطلاعات کاربری', {
'fields': ('user', 'user_type', 'pic', 'roles')
}),
('اطلاعات شخصی - حقیقی', {
'fields': ('national_code', 'address', 'phone_number_1', 'phone_number_2'),
'classes': ('collapse',),
}),
('اطلاعات شرکت - حقوقی', {
'fields': ('company_name', 'company_national_id'),
'classes': ('collapse',),
}),
('اطلاعات بانکی', {
'fields': ('card_number', 'account_number', 'bank_name'),
'classes': ('collapse',),
}),
('اطلاعات سازمانی', {
'fields': ('affairs', 'county', 'broker', 'owner'),
}),
('وضعیت', {
'fields': ('is_completed', 'is_active'),
}),
('تاریخ‌ها', {
'fields': ('created', 'updated'),
'classes': ('collapse',),
}),
)
date_hierarchy = 'created'
ordering = ['-created']
readonly_fields = ['created', 'updated']

View file

@ -2,7 +2,7 @@ from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UserCreationForm
from .models import Profile, Role
from common.consts import UserRoles
from common.consts import UserRoles, USER_TYPE_CHOICES
User = get_user_model()
@ -28,13 +28,19 @@ class CustomerForm(forms.ModelForm):
class Meta:
model = Profile
fields = [
'phone_number_1', 'phone_number_2', 'national_code',
'user_type', 'phone_number_1', 'phone_number_2', 'national_code',
'company_name', 'company_national_id',
'address', 'card_number', 'account_number', 'bank_name'
]
widgets = {
'user_type': forms.Select(attrs={
'class': 'form-control',
'id': 'user-type-select'
}),
'phone_number_1': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '09123456789'
'placeholder': '09123456789',
'required': True
}),
'phone_number_2': forms.TextInput(attrs={
'class': 'form-control',
@ -46,30 +52,46 @@ class CustomerForm(forms.ModelForm):
'maxlength': '10',
'required': 'required'
}),
'company_name': forms.TextInput(attrs={
'class': 'form-control company-field',
'placeholder': 'نام شرکت'
}),
'company_national_id': forms.TextInput(attrs={
'class': 'form-control company-field',
'placeholder': 'شناسه ملی شرکت',
'maxlength': '11'
}),
'address': forms.Textarea(attrs={
'class': 'form-control',
'placeholder': 'آدرس کامل',
'rows': '3'
'rows': '3',
'required': True
}),
'card_number': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'شماره کارت بانکی',
'maxlength': '16'
'maxlength': '16',
'required': True
}),
'account_number': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'شماره حساب بانکی',
'maxlength': '20'
'maxlength': '20',
'required': True
}),
'bank_name': forms.Select(attrs={
'class': 'form-control',
'placeholder': 'نام بانک',
'required': True
}),
}
labels = {
'user_type': 'نوع کاربر',
'phone_number_1': 'تلفن ۱',
'phone_number_2': 'تلفن ۲',
'national_code': 'کد ملی',
'company_name': 'نام شرکت',
'company_national_id': 'شناسه ملی شرکت',
'address': 'آدرس',
'card_number': 'شماره کارت',
'account_number': 'شماره حساب',
@ -89,6 +111,21 @@ class CustomerForm(forms.ModelForm):
raise forms.ValidationError('این کد ملی قبلاً استفاده شده است.')
return national_code
def clean(self):
cleaned_data = super().clean()
user_type = cleaned_data.get('user_type')
company_name = cleaned_data.get('company_name')
company_national_id = cleaned_data.get('company_national_id')
# If user type is legal, company fields are required
if user_type == 'legal':
if not company_name:
self.add_error('company_name', 'برای کاربران حقوقی نام شرکت الزامی است.')
if not company_national_id:
self.add_error('company_national_id', 'برای کاربران حقوقی شناسه ملی شرکت الزامی است.')
return cleaned_data
def save(self, commit=True):
def _compute_completed(cleaned):
try:
@ -100,7 +137,15 @@ class CustomerForm(forms.ModelForm):
bank_ok = bool(cleaned.get('bank_name'))
card_ok = bool((cleaned.get('card_number') or '').strip())
acc_ok = bool((cleaned.get('account_number') or '').strip())
return all([first_ok, last_ok, nc_ok, phone_ok, addr_ok, bank_ok, card_ok, acc_ok])
# Check user type specific requirements
user_type = cleaned.get('user_type', 'individual')
if user_type == 'legal':
company_name_ok = bool((cleaned.get('company_name') or '').strip())
company_id_ok = bool((cleaned.get('company_national_id') or '').strip())
return all([first_ok, last_ok, nc_ok, phone_ok, addr_ok, bank_ok, card_ok, acc_ok, company_name_ok, company_id_ok])
else:
return all([first_ok, last_ok, nc_ok, phone_ok, addr_ok, bank_ok, card_ok, acc_ok])
except Exception:
return False
# Check if this is an update (instance exists)
@ -125,9 +170,12 @@ class CustomerForm(forms.ModelForm):
profile.is_completed = _compute_completed({
'first_name': user.first_name,
'last_name': user.last_name,
'user_type': self.cleaned_data.get('user_type'),
'national_code': self.cleaned_data.get('national_code'),
'phone_number_1': self.cleaned_data.get('phone_number_1'),
'phone_number_2': self.cleaned_data.get('phone_number_2'),
'company_name': self.cleaned_data.get('company_name'),
'company_national_id': self.cleaned_data.get('company_national_id'),
'address': self.cleaned_data.get('address'),
'bank_name': self.cleaned_data.get('bank_name'),
'card_number': self.cleaned_data.get('card_number'),
@ -171,9 +219,12 @@ class CustomerForm(forms.ModelForm):
profile.is_completed = _compute_completed({
'first_name': user.first_name,
'last_name': user.last_name,
'user_type': self.cleaned_data.get('user_type'),
'national_code': self.cleaned_data.get('national_code'),
'phone_number_1': self.cleaned_data.get('phone_number_1'),
'phone_number_2': self.cleaned_data.get('phone_number_2'),
'company_name': self.cleaned_data.get('company_name'),
'company_national_id': self.cleaned_data.get('company_national_id'),
'address': self.cleaned_data.get('address'),
'bank_name': self.cleaned_data.get('bank_name'),
'card_number': self.cleaned_data.get('card_number'),

View file

@ -0,0 +1,44 @@
# Generated by Django 5.2.4 on 2025-09-21 07:37
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_company_card_holder_name'),
]
operations = [
migrations.AddField(
model_name='historicalprofile',
name='company_name',
field=models.CharField(blank=True, help_text='فقط برای کاربران حقوقی الزامی است', max_length=255, null=True, verbose_name='نام شرکت'),
),
migrations.AddField(
model_name='historicalprofile',
name='company_national_id',
field=models.CharField(blank=True, help_text='فقط برای کاربران حقوقی الزامی است', max_length=11, null=True, validators=[django.core.validators.RegexValidator(code='invalid_company_national_id', message='شناسه ملی باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شناسه ملی شرکت'),
),
migrations.AddField(
model_name='historicalprofile',
name='user_type',
field=models.CharField(choices=[('individual', 'حقیقی'), ('legal', 'حقوقی')], default='individual', max_length=20, verbose_name='نوع کاربر'),
),
migrations.AddField(
model_name='profile',
name='company_name',
field=models.CharField(blank=True, help_text='فقط برای کاربران حقوقی الزامی است', max_length=255, null=True, verbose_name='نام شرکت'),
),
migrations.AddField(
model_name='profile',
name='company_national_id',
field=models.CharField(blank=True, help_text='فقط برای کاربران حقوقی الزامی است', max_length=11, null=True, validators=[django.core.validators.RegexValidator(code='invalid_company_national_id', message='شناسه ملی باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شناسه ملی شرکت'),
),
migrations.AddField(
model_name='profile',
name='user_type',
field=models.CharField(choices=[('individual', 'حقیقی'), ('legal', 'حقوقی')], default='individual', max_length=20, verbose_name='نوع کاربر'),
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 5.2.4 on 2025-10-02 09:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0007_historicalprofile_company_name_and_more'),
]
operations = [
migrations.AlterField(
model_name='historicalprofile',
name='phone_number_1',
field=models.CharField(default=1, max_length=11, verbose_name='شماره تماس ۱'),
preserve_default=False,
),
migrations.AlterField(
model_name='profile',
name='phone_number_1',
field=models.CharField(default=1, max_length=11, verbose_name='شماره تماس ۱'),
preserve_default=False,
),
]

View file

@ -0,0 +1,74 @@
# Generated by Django 5.2.4 on 2025-10-04 10:36
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_alter_historicalprofile_phone_number_1_and_more'),
]
operations = [
migrations.AlterField(
model_name='historicalprofile',
name='account_number',
field=models.CharField(default=1, max_length=20, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب'),
preserve_default=False,
),
migrations.AlterField(
model_name='historicalprofile',
name='address',
field=models.TextField(default=1, verbose_name='آدرس'),
preserve_default=False,
),
migrations.AlterField(
model_name='historicalprofile',
name='bank_name',
field=models.CharField(choices=[('mellat', 'بانک ملت'), ('saman', 'بانک سامان'), ('parsian', 'بانک پارسیان'), ('sina', 'بانک سینا'), ('tejarat', 'بانک تجارت'), ('tosee', 'بانک توسعه'), ('iran_zamin', 'بانک ایران زمین'), ('meli', 'بانک ملی'), ('saderat', 'بانک توسعه صادرات'), ('iran_zamin', 'بانک ایران زمین'), ('refah', 'بانک رفاه'), ('eghtesad_novin', 'بانک اقتصاد نوین'), ('pasargad', 'بانک پاسارگاد'), ('other', 'سایر')], default=1, max_length=255, verbose_name='نام بانک'),
preserve_default=False,
),
migrations.AlterField(
model_name='historicalprofile',
name='card_number',
field=models.CharField(default=1, max_length=16, validators=[django.core.validators.RegexValidator(code='invalid_card_number', message='شماره کارت باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره کارت'),
preserve_default=False,
),
migrations.AlterField(
model_name='historicalprofile',
name='national_code',
field=models.CharField(default=1, max_length=10, validators=[django.core.validators.RegexValidator(code='invalid_national_code', message='کد ملی باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='کد ملی'),
preserve_default=False,
),
migrations.AlterField(
model_name='profile',
name='account_number',
field=models.CharField(default=1, max_length=20, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب'),
preserve_default=False,
),
migrations.AlterField(
model_name='profile',
name='address',
field=models.TextField(default=1, verbose_name='آدرس'),
preserve_default=False,
),
migrations.AlterField(
model_name='profile',
name='bank_name',
field=models.CharField(choices=[('mellat', 'بانک ملت'), ('saman', 'بانک سامان'), ('parsian', 'بانک پارسیان'), ('sina', 'بانک سینا'), ('tejarat', 'بانک تجارت'), ('tosee', 'بانک توسعه'), ('iran_zamin', 'بانک ایران زمین'), ('meli', 'بانک ملی'), ('saderat', 'بانک توسعه صادرات'), ('iran_zamin', 'بانک ایران زمین'), ('refah', 'بانک رفاه'), ('eghtesad_novin', 'بانک اقتصاد نوین'), ('pasargad', 'بانک پاسارگاد'), ('other', 'سایر')], default=1, max_length=255, verbose_name='نام بانک'),
preserve_default=False,
),
migrations.AlterField(
model_name='profile',
name='card_number',
field=models.CharField(default=1, max_length=16, validators=[django.core.validators.RegexValidator(code='invalid_card_number', message='شماره کارت باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره کارت'),
preserve_default=False,
),
migrations.AlterField(
model_name='profile',
name='national_code',
field=models.CharField(default=1, max_length=10, validators=[django.core.validators.RegexValidator(code='invalid_national_code', message='کد ملی باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='کد ملی'),
preserve_default=False,
),
]

View file

@ -4,7 +4,7 @@ from django.utils.html import format_html
from django.core.validators import RegexValidator
from simple_history.models import HistoricalRecords
from common.models import TagModel, BaseModel, NameSlugModel
from common.consts import UserRoles, BANK_CHOICES
from common.consts import UserRoles, BANK_CHOICES, USER_TYPE_CHOICES
from locations.models import Affairs, Broker, County
@ -27,9 +27,7 @@ class Profile(BaseModel):
)
national_code = models.CharField(
max_length=10,
null=True,
verbose_name="کد ملی",
blank=True,
validators=[
RegexValidator(
regex=r'^\d+$',
@ -39,15 +37,11 @@ class Profile(BaseModel):
]
)
address = models.TextField(
null=True,
verbose_name="آدرس",
blank=True
)
card_number = models.CharField(
max_length=16,
null=True,
verbose_name="شماره کارت",
blank=True,
validators=[
RegexValidator(
regex=r'^\d+$',
@ -58,9 +52,7 @@ class Profile(BaseModel):
)
account_number = models.CharField(
max_length=20,
null=True,
verbose_name="شماره حساب",
blank=True,
validators=[
RegexValidator(
regex=r'^\d+$',
@ -72,15 +64,11 @@ class Profile(BaseModel):
bank_name = models.CharField(
max_length=255,
choices=BANK_CHOICES,
null=True,
verbose_name="نام بانک",
blank=True
)
phone_number_1 = models.CharField(
max_length=11,
null=True,
verbose_name="شماره تماس ۱",
blank=True
)
phone_number_2 = models.CharField(
max_length=11,
@ -88,6 +76,33 @@ class Profile(BaseModel):
verbose_name="شماره تماس ۲",
blank=True
)
user_type = models.CharField(
max_length=20,
choices=USER_TYPE_CHOICES,
default='individual',
verbose_name="نوع کاربر"
)
company_national_id = models.CharField(
max_length=11,
null=True,
verbose_name="شناسه ملی شرکت",
blank=True,
validators=[
RegexValidator(
regex=r'^\d+$',
message='شناسه ملی باید فقط شامل اعداد باشد.',
code='invalid_company_national_id'
)
],
help_text="فقط برای کاربران حقوقی الزامی است"
)
company_name = models.CharField(
max_length=255,
null=True,
verbose_name="نام شرکت",
blank=True,
help_text="فقط برای کاربران حقوقی الزامی است"
)
pic = models.ImageField(
upload_to="profile_images",
@ -179,6 +194,23 @@ class Profile(BaseModel):
pic_tag.short_description = "تصویر"
def is_legal_entity(self):
return self.user_type == 'legal'
def is_individual(self):
return self.user_type == 'individual'
def get_display_name(self):
"""Returns appropriate display name based on user type"""
if self.is_legal_entity() and self.company_name:
return self.company_name
return self.user.get_full_name() or str(self.user)
def user_type_display(self):
return dict(USER_TYPE_CHOICES).get(self.user_type, self.user_type)
user_type_display.short_description = "نوع کاربر"
class Company(NameSlugModel):
logo = models.ImageField(

View file

@ -61,6 +61,7 @@
<tr>
<th>ردیف</th>
<th>کاربر</th>
<th>نوع کاربر</th>
<th>کد ملی</th>
<th>تلفن</th>
<th>آدرس</th>
@ -100,6 +101,27 @@
</div>
</div>
</td>
<td>
{% if customer.user_type == 'legal' %}
<span class="badge bg-label-info">
<i class="bx bx-buildings me-1"></i>حقوقی
</span>
<div class="mt-1">
{% if customer.company_name %}
<small class="text-muted d-block">{{ customer.company_name|truncatechars:25 }}</small>
{% endif %}
{% if customer.company_national_id %}
<small class="text-muted d-block">
<i class="bx bx-id-card me-1"></i>{{ customer.company_national_id }}
</small>
{% endif %}
</div>
{% else %}
<span class="badge bg-label-primary">
<i class="bx bx-user me-1"></i>حقیقی
</span>
{% endif %}
</td>
<td>{{ customer.national_code|default:"کد ملی ثبت نشده" }}</td>
<td>
<div class="d-flex flex-column">
@ -205,6 +227,16 @@
<input type="hidden" id="customer-id" name="customer_id" value="">
<!-- User Information -->
<div class="col-sm-12">
<label class="form-label fw-bold" for="{{ form.user_type.id_for_label }}">{{ form.user_type.label }}</label>
<div class="input-group input-group-merge">
<span class="input-group-text"><i class="bx bx-user-circle"></i></span>
{{ form.user_type }}
</div>
{% if form.user_type.errors %}
<div class="invalid-feedback d-block">{{ form.user_type.errors.0 }}</div>
{% endif %}
</div>
<div class="col-sm-6">
<label class="form-label fw-bold" for="{{ form.first_name.id_for_label }}">{{ form.first_name.label }}</label>
@ -261,6 +293,29 @@
{% endif %}
</div>
<!-- Company Information (for legal entities) -->
<div class="col-sm-12 company-fields" style="display: none;">
<label class="form-label fw-bold" for="{{ form.company_name.id_for_label }}">{{ form.company_name.label }}</label>
<div class="input-group input-group-merge">
<span class="input-group-text"><i class="bx bx-buildings"></i></span>
{{ form.company_name }}
</div>
{% if form.company_name.errors %}
<div class="invalid-feedback d-block">{{ form.company_name.errors.0 }}</div>
{% endif %}
</div>
<div class="col-sm-12 company-fields" style="display: none;">
<label class="form-label fw-bold" for="{{ form.company_national_id.id_for_label }}">{{ form.company_national_id.label }}</label>
<div class="input-group input-group-merge">
<span class="input-group-text"><i class="bx bx-id-card"></i></span>
{{ form.company_national_id }}
</div>
{% if form.company_national_id.errors %}
<div class="invalid-feedback d-block">{{ form.company_national_id.errors.0 }}</div>
{% endif %}
</div>
<div class="col-sm-12">
<label class="form-label fw-bold" for="{{ form.bank_name.id_for_label }}">{{ form.bank_name.label }}</label>
<div class="input-group input-group-merge">
@ -313,6 +368,165 @@
</div>
</div>
<!-- Customer Details Modal -->
<div class="modal fade" id="customerDetailsModal" tabindex="-1" aria-labelledby="customerDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="customerDetailsModalLabel">جزئیات مشترک</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="customer-details-loading" class="text-center py-4" style="display:none;">
<div class="spinner-border" role="status"></div>
<div class="mt-2">در حال بارگذاری...</div>
</div>
<div id="customer-details-content" style="display:none;">
<div class="card mb-4">
<div class="card-body">
<h6 class="fw-bold mb-3 text-primary">مشخصات مشترک</h6>
<div class="row">
<div class="col-md-6">
<table class="table table-borderless table-sm mb-0">
<tbody>
<tr>
<td class="text-muted" style="width: 40%;"><i class="bx bx-user me-1"></i>نام کاربری</td>
<td><strong id="cd-username">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-id-card me-1"></i>نام و نام خانوادگی</td>
<td><strong id="cd-fullname">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-fingerprint me-1"></i>کد ملی</td>
<td><strong id="cd-national-code">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-user-circle me-1"></i>نوع کاربر</td>
<td><strong id="cd-user-type">-</strong></td>
</tr>
<tr id="cd-company-name-row" style="display: none;">
<td class="text-muted"><i class="bx bx-buildings me-1"></i>نام شرکت</td>
<td><strong id="cd-company-name">-</strong></td>
</tr>
<tr id="cd-company-id-row" style="display: none;">
<td class="text-muted"><i class="bx bx-id-card me-1"></i>شناسه ملی شرکت</td>
<td><strong id="cd-company-id">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-phone me-1"></i>شماره تلفن اول</td>
<td><strong id="cd-phone1">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-phone me-1"></i>شماره تلفن دوم</td>
<td><strong id="cd-phone2">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-envelope me-1"></i>ایمیل</td>
<td><strong id="cd-email">-</strong></td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-6">
<table class="table table-borderless table-sm mb-0">
<tbody>
<tr>
<td class="text-muted" style="width: 40%;"><i class="bx bx-credit-card me-1"></i>شماره کارت</td>
<td><strong id="cd-card">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-wallet me-1"></i>شماره حساب</td>
<td><strong id="cd-account">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-bank me-1"></i>نام بانک</td>
<td><strong id="cd-bank">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-map me-1"></i>آدرس</td>
<td><strong id="cd-address">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-calendar me-1"></i>تاریخ عضویت</td>
<td><strong id="cd-joined">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-check-circle me-1"></i>وضعیت</td>
<td><span id="cd-status" class="badge">-</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Wells Section -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-bold">چاه‌های مشترک
<span class="badge bg-label-primary" id="cd-wells-count">0</span>
</h6>
</div>
<div class="card-body p-0">
<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 id="cd-wells-body">
<tr><td class="text-center py-3" colspan="5"><span class="text-muted">رکوردی یافت نشد</span></td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Requests Section -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-bold">درخواست‌های مشترک
<span class="badge bg-label-primary" id="cd-requests-count">0</span>
</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>کد</th>
<th>فرآیند</th>
<th>چاه</th>
<th>مرحله فعلی</th>
<th>وضعیت</th>
<th>تاریخ ایجاد</th>
<th></th>
</tr>
</thead>
<tbody id="cd-requests-body">
<tr><td class="text-center py-3" colspan="7"><span class="text-muted">رکوردی یافت نشد</span></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">بستن</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-labelledby="deleteConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog">
@ -348,6 +562,9 @@
lengthMenu: [[10, 25, 50, -1], [10, 25, 50, "همه"]],
order: [[0, 'asc']],
responsive: true,
columnDefs: [
{ targets: [8], orderable: false } // عملیات column غیرقابل مرتب‌سازی
]
});
// Handle form submission
@ -436,8 +653,111 @@
// Customer functions
function viewCustomer(id) {
// Implement view functionality
console.log('View customer:', id);
const modalEl = document.getElementById('customerDetailsModal');
const modal = new bootstrap.Modal(modalEl);
// reset content
$('#customer-details-content').hide();
$('#customer-details-loading').show();
$('#cd-wells-body').html('<tr><td class="text-center py-3" colspan="5"><span class="text-muted">در حال بارگذاری...</span></td></tr>');
$('#cd-requests-body').html('<tr><td class="text-center py-3" colspan="7"><span class="text-muted">در حال بارگذاری...</span></td></tr>');
$('#cd-wells-count').text('0');
$('#cd-requests-count').text('0');
modal.show();
// Fetch customer details
$.get('{% url "accounts:get_customer_details" 0 %}'.replace('0', id))
.done(function(resp){
if (!resp.success) { showToast('خطا در دریافت جزئیات مشترک', 'danger'); return; }
const c = resp.customer;
$('#customerDetailsModalLabel').text('جزئیات مشترک ' + (c.user.full_name || c.user.username));
$('#cd-username').text(c.user.username || '-');
$('#cd-fullname').text(c.user.full_name || '-');
$('#cd-national-code').text(c.national_code || '-');
// User type and company information
const userTypeDisplay = c.user_type === 'legal' ? 'حقوقی' : 'حقیقی';
$('#cd-user-type').text(userTypeDisplay);
if (c.user_type === 'legal') {
$('#cd-company-name').text(c.company_name || '-');
$('#cd-company-id').text(c.company_national_id || '-');
$('#cd-company-name-row').show();
$('#cd-company-id-row').show();
} else {
$('#cd-company-name-row').hide();
$('#cd-company-id-row').hide();
}
$('#cd-phone1').text(c.phone_number_1 || '-');
$('#cd-phone2').text(c.phone_number_2 || '-');
$('#cd-email').text(c.user.email || '-');
$('#cd-card').text(c.card_number || '-');
$('#cd-account').text(c.account_number || '-');
$('#cd-bank').text(c.bank_name || '-');
$('#cd-address').text(c.address || '-');
$('#cd-joined').text(c.user.date_joined || '-');
// Status badge
if (c.is_completed) {
$('#cd-status').removeClass().addClass('badge bg-success').text('تکمیل شده');
} else {
$('#cd-status').removeClass().addClass('badge bg-warning').text('ناقص');
}
$('#cd-wells-count').text(resp.total_wells || '0');
$('#cd-requests-count').text(resp.total_requests || '0');
$('#customer-details-loading').hide();
$('#customer-details-content').show();
})
.fail(function(){ showToast('خطا در ارتباط با سرور', 'danger'); $('#customer-details-loading').hide(); });
// Fetch wells
$.get('{% url "accounts:get_customer_wells" 0 %}'.replace('0', id))
.done(function(resp){
if (!resp.success) { $('#cd-wells-body').html('<tr><td class="text-center py-3" colspan="5"><span class="text-danger">خطا در بارگذاری چاه‌ها</span></td></tr>'); return; }
const rows = (resp.wells || []).map(function(w){
return '<tr>'+
'<td>'+ (w.water_subscription_number || '-') +'</td>'+
'<td>'+ (w.electricity_subscription_number || '-') +'</td>'+
'<td>'+ (w.water_meter_serial_number || '-') +'</td>'+
'<td>'+ (w.water_meter_manufacturer || '-') +'</td>'+
'<td>'+ (w.created || '-') +'</td>'+
'</tr>';
});
if (!rows.length) {
$('#cd-wells-body').html('<tr><td class="text-center py-3" colspan="5"><span class="text-muted">رکوردی یافت نشد</span></td></tr>');
} else {
$('#cd-wells-body').html(rows.join(''));
}
})
.fail(function(){ $('#cd-wells-body').html('<tr><td class="text-center py-3" colspan="5"><span class="text-danger">خطا در بارگذاری چاه‌ها</span></td></tr>'); });
// Fetch requests
$.get('{% url "accounts:get_customer_requests" 0 %}'.replace('0', id))
.done(function(resp){
if (!resp.success) { $('#cd-requests-body').html('<tr><td class="text-center py-3" colspan="7"><span class="text-danger">خطا در بارگذاری درخواست‌ها</span></td></tr>'); return; }
const rows = (resp.requests || []).map(function(r){
const status = r.status_display || r.status;
const step = r.current_step || '-';
const href = r.url || '#';
const well = r.well_subscription || '-';
return '<tr>'+
'<td>'+ (r.code || '-') +'</td>'+
'<td>'+ (r.process || '-') +'</td>'+
'<td>'+ well +'</td>'+
'<td>'+ step +'</td>'+
'<td>'+ status +'</td>'+
'<td>'+ (r.created || '-') +'</td>'+
'<td><a class="btn btn-sm btn-outline-primary" href="'+ href +'" target="_blank">جزئیات</a></td>'+
'</tr>';
});
if (!rows.length) {
$('#cd-requests-body').html('<tr><td class="text-center py-3" colspan="7"><span class="text-muted">رکوردی یافت نشد</span></td></tr>');
} else {
$('#cd-requests-body').html(rows.join(''));
}
})
.fail(function(){ $('#cd-requests-body').html('<tr><td class="text-center py-3" colspan="7"><span class="text-danger">خطا در بارگذاری درخواست‌ها</span></td></tr>'); });
}
function editCustomer(id) {
@ -454,12 +774,16 @@
'customer-id': customer.id,
'id_first_name': customer.first_name,
'id_last_name': customer.last_name,
'user-type-select': customer.user_type,
'id_phone_number_1': customer.phone_number_1,
'id_phone_number_2': customer.phone_number_2,
'id_national_code': customer.national_code,
'id_company_name': customer.company_name,
'id_company_national_id': customer.company_national_id,
'id_card_number': customer.card_number,
'id_account_number': customer.account_number,
'id_address': customer.address
'id_address': customer.address,
'id_bank_name': customer.bank_name
};
// Loop through fields for easier maintenance
@ -471,6 +795,19 @@
$('#exampleModalLabel').text('ویرایش کاربر');
$('.data-submit').text('ویرایش');
// Ensure select value is applied (for some browsers/plugins)
if (customer.bank_name !== undefined && customer.bank_name !== null) {
$('#id_bank_name').val(customer.bank_name);
}
// Ensure user type is applied and toggle company fields
if (customer.user_type !== undefined && customer.user_type !== null) {
$('#user-type-select').val(customer.user_type);
}
// Toggle company fields based on user type
toggleCompanyFields();
// Open modal
$('#add-new-record').offcanvas('show');
} else {
@ -512,8 +849,39 @@
$('.is-invalid').removeClass('is-invalid');
$('.invalid-feedback').remove();
// Reset user type to individual and hide company fields
$('#user-type-select').val('individual');
toggleCompanyFields();
// Open modal
$('#add-new-record').offcanvas('show');
}
function toggleCompanyFields() {
const userType = $('#user-type-select').val();
const companyFields = $('.company-fields');
if (userType === 'legal') {
companyFields.show();
// Make company fields required
$('input[name="company_name"]').attr('required', true);
$('input[name="company_national_id"]').attr('required', true);
} else {
companyFields.hide();
// Remove required attribute from company fields
$('input[name="company_name"]').removeAttr('required').val('');
$('input[name="company_national_id"]').removeAttr('required').val('');
// Clear any validation errors for company fields
$('.company-fields .is-invalid').removeClass('is-invalid');
$('.company-fields .invalid-feedback').remove();
}
}
// Initialize user type toggle functionality
$(document).ready(function() {
$('#user-type-select').on('change', toggleCompanyFields);
// Initialize on page load
toggleCompanyFields();
});
</script>
{% endblock %}

View file

@ -64,7 +64,7 @@ layout-wide customizer-hide
</svg>
</span>
<span class="app-brand-text demo text-body fw-bold">سامانه شفافیت</span>
<span class="app-brand-text demo text-body fw-bold">کنتور پلاس</span>
</a>
</div>

View file

@ -1,6 +1,9 @@
from django.urls import path
from accounts.views import login_view, dashboard, customer_list, add_customer_ajax, edit_customer_ajax, get_customer_data, logout_view
from accounts.views import (
login_view, dashboard, customer_list, add_customer_ajax, edit_customer_ajax,
get_customer_data, get_customer_details, get_customer_wells, get_customer_requests, logout_view
)
app_name = "accounts"
urlpatterns = [
@ -11,4 +14,7 @@ urlpatterns = [
path('customers/add/', add_customer_ajax, name='add_customer_ajax'),
path('customers/<int:customer_id>/data/', get_customer_data, name='get_customer_data'),
path('customers/<int:customer_id>/edit/', edit_customer_ajax, name='edit_customer_ajax'),
path('customers/<int:customer_id>/details/', get_customer_details, name='get_customer_details'),
path('customers/<int:customer_id>/wells/', get_customer_wells, name='get_customer_wells'),
path('customers/<int:customer_id>/requests/', get_customer_requests, name='get_customer_requests'),
]

View file

@ -6,6 +6,7 @@ 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 django.urls import reverse
from accounts.models import Profile
from accounts.forms import CustomerForm
from processes.utils import scope_customers_queryset
@ -40,7 +41,7 @@ def dashboard(request):
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def customer_list(request):
# Get all profiles that have customer role
base = Profile.objects.filter(roles__slug=UserRoles.CUSTOMER.value, is_deleted=False).select_related('user')
@ -55,7 +56,7 @@ def customer_list(request):
@require_POST
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def add_customer_ajax(request):
"""AJAX endpoint for adding customers"""
form = CustomerForm(request.POST, request.FILES)
@ -95,7 +96,7 @@ def add_customer_ajax(request):
@require_POST
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def edit_customer_ajax(request, customer_id):
customer = get_object_or_404(Profile, id=customer_id)
form = CustomerForm(request.POST, request.FILES, instance=customer)
@ -147,9 +148,12 @@ def get_customer_data(request, customer_id):
form_html = {
'first_name': str(form['first_name']),
'last_name': str(form['last_name']),
'user_type': str(form['user_type']),
'phone_number_1': str(form['phone_number_1']),
'phone_number_2': str(form['phone_number_2']),
'national_code': str(form['national_code']),
'company_name': str(form['company_name']),
'company_national_id': str(form['company_national_id']),
'card_number': str(form['card_number']),
'account_number': str(form['account_number']),
'address': str(form['address']),
@ -162,9 +166,12 @@ def get_customer_data(request, customer_id):
'id': customer.id,
'first_name': customer.user.first_name,
'last_name': customer.user.last_name,
'user_type': customer.user_type or 'individual',
'phone_number_1': customer.phone_number_1 or '',
'phone_number_2': customer.phone_number_2 or '',
'national_code': customer.national_code or '',
'company_name': customer.company_name or '',
'company_national_id': customer.company_national_id or '',
'card_number': customer.card_number or '',
'account_number': customer.account_number or '',
'address': customer.address or '',
@ -174,6 +181,131 @@ def get_customer_data(request, customer_id):
})
@require_GET
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def get_customer_details(request, customer_id):
"""جزئیات کامل مشترک برای نمایش در مدال"""
customer = get_object_or_404(
Profile.objects.select_related('user', 'affairs', 'county', 'broker'),
id=customer_id
)
data = {
'id': customer.id,
'user': {
'username': customer.user.username,
'first_name': customer.user.first_name or '',
'last_name': customer.user.last_name or '',
'full_name': customer.user.get_full_name() or customer.user.username,
'email': customer.user.email or '',
'date_joined': customer.jcreated_date() if customer.user.date_joined else '',
},
'national_code': customer.national_code or '',
'user_type': customer.user_type or 'individual',
'company_name': customer.company_name or '',
'company_national_id': customer.company_national_id or '',
'phone_number_1': customer.phone_number_1 or '',
'phone_number_2': customer.phone_number_2 or '',
'card_number': customer.card_number or '',
'account_number': customer.account_number or '',
'bank_name': customer.get_bank_name_display() or '',
'address': customer.address or '',
'pic_url': customer.pic.url if customer.pic else '',
'affairs': str(customer.affairs) if customer.affairs else '',
'county': str(customer.county) if customer.county else '',
'broker': str(customer.broker) if customer.broker else '',
'is_completed': customer.is_completed,
}
# تعداد چاه‌ها و درخواست‌ها برای نمایش سریع
try:
from wells.models import Well
from processes.models import ProcessInstance
total_wells = Well.objects.filter(representative=customer.user, is_deleted=False).count()
total_requests = ProcessInstance.objects.filter(representative=customer.user, is_deleted=False).count()
except Exception:
total_wells = 0
total_requests = 0
return JsonResponse({
'success': True,
'customer': data,
'total_wells': total_wells,
'total_requests': total_requests
})
@require_GET
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def get_customer_wells(request, customer_id):
"""چاه‌های مرتبط با یک مشترک"""
customer = get_object_or_404(Profile, id=customer_id)
try:
from wells.models import Well
qs = Well.objects.select_related(
'water_meter_manufacturer', 'affairs', 'county', 'broker'
).filter(representative=customer.user, is_deleted=False).order_by('-created')
items = []
for well in qs[:100]: # محدودسازی برای عملکرد
items.append({
'id': well.id,
'water_subscription_number': well.water_subscription_number,
'electricity_subscription_number': well.electricity_subscription_number or '',
'water_meter_serial_number': well.water_meter_serial_number or '',
'water_meter_manufacturer': str(well.water_meter_manufacturer) if well.water_meter_manufacturer else '',
'well_power': well.well_power or '',
'affairs': str(well.affairs) if well.affairs else '',
'county': str(well.county) if well.county else '',
'broker': str(well.broker) if well.broker else '',
'created': well.jcreated_date() if hasattr(well, 'created') and well.created else '',
})
except Exception:
items = []
return JsonResponse({'success': True, 'wells': items})
@require_GET
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def get_customer_requests(request, customer_id):
"""درخواست‌های مرتبط با یک مشترک"""
customer = get_object_or_404(Profile, id=customer_id)
try:
from processes.models import ProcessInstance
qs = ProcessInstance.objects.select_related(
'process', 'current_step', 'requester', 'well'
).filter(representative=customer.user, is_deleted=False).order_by('-created')
items = []
for inst in qs[:100]: # محدودسازی برای عملکرد
try:
url = reverse('processes:instance_summary', args=[inst.id]) if inst.status == 'completed' else reverse('processes:instance_steps', args=[inst.id])
except Exception:
url = ''
items.append({
'id': inst.id,
'code': inst.code,
'process': inst.process.name if inst.process else '',
'status': inst.status,
'status_display': inst.get_status_display(),
'current_step': inst.current_step.name if inst.current_step else '',
'requester': inst.requester.get_full_name() if inst.requester else '',
'well_subscription': inst.well.water_subscription_number if inst.well else '',
'created': inst.jcreated_date() if hasattr(inst, 'created') and inst.created else '',
'url': url,
})
except Exception:
items = []
return JsonResponse({'success': True, 'requests': items})
@login_required
def logout_view(request):
"""Log out current user and redirect to login page."""

View file

@ -12,9 +12,7 @@ class CertificateTemplateAdmin(admin.ModelAdmin):
@admin.register(CertificateInstance)
class CertificateInstanceAdmin(admin.ModelAdmin):
list_display = ('process_instance', 'rendered_title', 'issued_at', 'approved')
list_display = ('process_instance', 'rendered_title', 'hologram_code', 'issued_at', 'approved')
list_filter = ('approved', 'issued_at')
search_fields = ('process_instance__code', 'rendered_title')
search_fields = ('process_instance__code', 'rendered_title', 'hologram_code')
autocomplete_fields = ('process_instance', 'template')

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-27 15:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('certificates', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='certificateinstance',
name='hologram_code',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='کد یکتا هولوگرام'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-10-09 08:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('certificates', '0002_certificateinstance_hologram_code'),
]
operations = [
migrations.AlterField(
model_name='certificateinstance',
name='hologram_code',
field=models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='کد یکتا هولوگرام'),
),
]

View file

@ -28,6 +28,7 @@ class CertificateInstance(BaseModel):
issued_at = models.DateField(auto_now_add=True, verbose_name='تاریخ صدور')
approved = models.BooleanField(default=False, verbose_name='تایید شده')
approved_at = models.DateTimeField(null=True, blank=True, verbose_name='تاریخ تایید')
hologram_code = models.CharField(max_length=50, null=True, blank=True, verbose_name='کد یکتا هولوگرام', unique=True)
class Meta:
verbose_name = 'گواهی'

View file

@ -18,19 +18,20 @@
<link rel="stylesheet" href="{% static 'assets/css/persian-fonts.css' %}">
<style>
@page { size: A4; margin: 1cm; }
@page { size: A4 landscape; margin: 1cm; }
@media print { body { print-color-adjust: exact; } .no-print { display: none !important; } }
.header { border-bottom: 1px solid #dee2e6; padding-bottom: 16px; margin-bottom: 24px; }
.header { border-bottom: 0px solid #dee2e6; padding-bottom: 10px; margin-bottom: 10px; }
.company-name { font-weight: 600; }
.body-text { white-space: pre-line; line-height: 1.9; }
.signature-section { margin-top: 40px; border-top: 1px solid #dee2e6; padding-top: 24px; }
.signature-section { margin-top: 40px; border-top: 0px solid #dee2e6; padding-top: 24px; }
</style>
</head>
<body>
<div class="container-fluid py-3">
<!-- Top-left request info -->
<div class="d-flex mb-2">
<div class="d-flex">
<div class="ms-auto text-end">
<div class="">کد یکتا هولوگرام: {{ cert.hologram_code|default:'-' }}</div>
<div class="">شماره درخواست: {{ instance.code }}</div>
<div class="">تاریخ: {{ cert.jissued_at }}</div>
</div>
@ -38,10 +39,7 @@
<!-- Header with logo and company -->
<div class="header text-center">
{% if template.company and template.company.logo %}
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:90px">
{% endif %}
<h4 class="mt-2">{{ cert.rendered_title }}</h4>
<h4 class="">{{ cert.rendered_title }}</h4>
{% if template.company %}
<div class="text-muted company-name">{{ template.company.name }}</div>
{% endif %}
@ -51,17 +49,41 @@
<div class="body-text">
{{ cert.rendered_body|safe }}
</div>
<!-- Signature -->
<div class="signature-section d-flex justify-content-end">
<div class="text-center">
<div>مهر و امضای تایید کننده</div>
<div class="text-muted">{{ template.company.name }}</div>
{% if template.company and template.company.signature %}
<img src="{{ template.company.signature.url }}" alt="seal" style="max-height:200px">
{% endif %}
<h6 class="my-2">مشخصات چاه و کنتور هوشمند</h6>
<div class="row" style="font-size: 14px;">
<div class="col-4">
<div>موقعیت مکانی (UTM): {{ latest_report.utm_x|default:'-' }} , {{ latest_report.utm_y|default:'-' }}</div>
<div>نیرو محرکه چاه: {{ latest_report.driving_force|default:'-' }}</div>
<div>نوع کنتور: {{ latest_report.get_meter_type_display|default:'-' }}</div>
<div>قطر لوله آبده (اینچ): {{ latest_report.discharge_pipe_diameter|default:'-' }}</div>
<div>نوع مصرف: {{ latest_report.get_usage_type_display|default:'-' }}</div>
<div>شماره سیم‌کارت: {{ latest_report.sim_number|default:'-' }}</div>
</div>
<div class="col-4">
<div>سایز کنتور: {{ latest_report.meter_size|default:'-' }}</div>
<div>شماره پروانه بهره‌برداری چاه: {{ latest_report.exploitation_license_number|default:'-' }}</div>
<div>قدرت موتور: {{ latest_report.motor_power|default:'-' }}</div>
<div>دبی قبل از کالیبراسیون: {{ latest_report.pre_calibration_flow_rate|default:'-' }}</div>
<div>دبی بعد از کالیبراسیون: {{ latest_report.post_calibration_flow_rate|default:'-' }}</div>
<div>نام شرکت کنتورساز: {{ latest_report.water_meter_manufacturer.name|default:'-' }}</div>
<div>شماره سریال کنتور: {{ instance.well.water_meter_serial_number|default:'-' }}</div>
</div>
<div class="col-4">
<!-- Signature -->
<div class="signature-section d-flex justify-content-end">
<div class="text-center">
<div>مهر و امضای تایید کننده</div>
<div class="text-muted">{{ template.company.name }}</div>
{% if template.company and template.company.signature %}
<img src="{{ template.company.signature.url }}" alt="seal" style="max-height:200px">
{% endif %}
</div>
</div>
</div>
</div>
</div>
<script>

View file

@ -38,9 +38,11 @@
</small>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" target="_blank" href="{% url 'certificates:certificate_print' instance.id %}">
{% if request.user|is_broker or request.user|is_manager %}
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#printHologramModal">
<i class="bx bx-printer me-2"></i> پرینت
</a>
</button>
{% endif %}
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
@ -52,7 +54,7 @@
<div class="bs-stepper wizard-vertical vertical mt-2">
{% stepper_header instance step %}
<div class="bs-stepper-content">
{% if request.user|is_broker or request.user|is_manager or request.user|is_water_resource_manager %}
<div class="card">
<div class="card-body">
<div class="d-flex mb-2">
@ -61,16 +63,33 @@
<div>تاریخ: {{ cert.jissued_at }}</div>
</div>
</div>
<div class="text-center mb-3">
{% if template.company and template.company.logo %}
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:80px">
{% endif %}
<div class="text-center">
<h5 class="mt-2">{{ cert.rendered_title }}</h5>
{% if template.company %}<div class="text-muted">{{ template.company.name }}</div>{% endif %}
</div>
<div class="mt-3" style="white-space:pre-line; line-height:1.9;">
<div class="mb-3" style="white-space:pre-line; line-height:1.9;">
{{ cert.rendered_body|safe }}
</div>
<h6 class="mb-2">مشخصات چاه و کنتور هوشمند</h6>
<div class="row g-2 small">
<div class="col-12 col-md-6">
<div class="d-flex gap-2"><span class="text-muted">موقعیت مکانی (UTM):</span><span class="fw-medium">{{ latest_report.utm_x|default:'-' }} , {{ latest_report.utm_y|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">نیرو محرکه چاه:</span><span class="fw-medium">{{ latest_report.driving_force|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">نوع کنتور:</span><span class="fw-medium">{{ latest_report.get_meter_type_display|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">قطر لوله آبده (اینچ):</span><span class="fw-medium">{{ latest_report.discharge_pipe_diameter|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">نوع مصرف:</span><span class="fw-medium">{{ latest_report.get_usage_type_display|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">شماره سیم‌کارت:</span><span class="fw-medium">{{ latest_report.sim_number|default:'-' }}</span></div>
</div>
<div class="col-12 col-md-6">
<div class="d-flex gap-2"><span class="text-muted">سایز کنتور:</span><span class="fw-medium">{{ latest_report.meter_size|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">شماره پروانه بهره‌برداری چاه:</span><span class="fw-medium">{{ latest_report.exploitation_license_number|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">قدرت موتور:</span><span class="fw-medium">{{ latest_report.motor_power|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">دبی قبل از کالیبراسیون:</span><span class="fw-medium">{{ latest_report.pre_calibration_flow_rate|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">دبی بعد از کالیبراسیون:</span><span class="fw-medium">{{ latest_report.post_calibration_flow_rate|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">نام شرکت کنتورساز:</span><span class="fw-medium">{{ latest_report.water_meter_manufacturer.name|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">شماره سریال کنتور:</span><span class="fw-medium">{{ instance.well.water_meter_serial_number|default:'-' }}</span></div>
</div>
</div>
<div class="signature-section d-flex justify-content-end">
<div class="text-center">
<div>مهر و امضای تایید کننده</div>
@ -98,11 +117,49 @@
</form>
</div>
</div>
{% else %}
<div class="card">
<div class="card-body">
<div class="text-center py-5">
<div class="mb-4">
<i class="bx bx-lock-alt text-warning" style="font-size: 80px;"></i>
</div>
<h4 class="mb-3">دسترسی محدود</h4>
<p class="text-muted mb-4">
متأسفانه شما دسترسی لازم برای مشاهده این صفحه را ندارید.<br>
</p>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Print Hologram Modal -->
<div class="modal fade" id="printHologramModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'certificates:certificate_print' instance.id %}" target="_blank">
{% csrf_token %}
<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">
<label class="form-label">کد هولوگرام</label>
<input type="text" class="form-control" name="hologram_code" value="{{ cert.hologram_code|default:'' }}" placeholder="مثال: 123456" required>
<div class="form-text">این کد باید با کد هولوگرام روی گواهی یکسان باشد.</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-primary">ثبت و پرینت</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -4,13 +4,16 @@ from django.contrib import messages
from django.http import JsonResponse
from django.urls import reverse
from django.utils import timezone
from django.template import Template, Context
from django.utils.safestring import mark_safe
from django.db import IntegrityError
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 common.decorators import allowed_roles
from _helpers.jalali import Gregorian
from processes.utils import get_scoped_instance_or_404
@ -28,20 +31,33 @@ def _render_template(template: CertificateTemplate, instance: ProcessInstance):
well = instance.well
rep = instance.representative
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
individual = True if rep.profile and rep.profile.user_type == 'individual' else False
customer_company_name = rep.profile.company_name if rep.profile and rep.profile.user_type == 'legal' else None
city = template.company.broker.affairs.county.city.name if template.company and template.company.broker and template.company.broker.affairs and template.company.broker.affairs.county and template.company.broker.affairs.county.city else None
county = template.company.broker.affairs.county.name if template.company and template.company.broker and template.company.broker.affairs and template.company.broker.affairs.county else None
ctx = {
'today_jalali': _to_jalali(timezone.now().date()),
'request_code': instance.code,
'company_name': (template.company.name if template.company else '') or '',
'customer_full_name': rep.get_full_name() if rep else '',
'water_subscription_number': getattr(well, 'water_subscription_number', '') or '',
'address': getattr(well, 'county', '') or '',
'visit_date_jalali': _to_jalali(getattr(latest_report, 'visited_date', None)) if latest_report else '',
'today_jalali': mark_safe(f"<span class=\"fw-bold\">{_to_jalali(timezone.now().date())}</span>"),
'request_code': mark_safe(f"<span class=\"fw-bold\">{instance.code}</span>"),
'company_name': mark_safe(f"<span class=\"fw-bold\">{(template.company.name if template.company else '') or ''}</span>"),
'customer_full_name': mark_safe(f"<span class=\"fw-bold\">{rep.get_full_name() if rep else ''}</span>"),
'water_subscription_number': mark_safe(f"<span class=\"fw-bold\">{getattr(well, 'water_subscription_number', '') or ''}</span>"),
'address': mark_safe(f"<span class=\"fw-bold\">{getattr(well, 'county', '') or ''}</span>"),
'visit_date_jalali': mark_safe(f"<span class=\"fw-bold\">{_to_jalali(getattr(latest_report, 'visited_date', None)) if latest_report else ''}</span>"),
'city': mark_safe(f"<span class=\"fw-bold\">{city or ''}</span>"),
'county': mark_safe(f"<span class=\"fw-bold\">{county or ''}</span>"),
'customer_company_name': mark_safe(f"<span class=\"fw-bold\">{customer_company_name or ''}</span>"),
'individual': individual,
}
title = (template.title or '').format(**ctx)
body = (template.body or '')
# Render body placeholders with bold values
for k, v in ctx.items():
body = body.replace(f"{{{{ {k} }}}}", f"<strong>{str(v)}</strong>")
# Render title using Django template engine
title_template = Template(template.title or '')
title = title_template.render(Context(ctx))
# Render body using Django template engine
body_template = Template(template.body or '')
body = body_template.render(Context(ctx))
return title, body
@ -52,16 +68,20 @@ def certificate_step(request, instance_id, step_id):
# Ensure all previous steps are completed and invoice settled
prior_steps = instance.process.steps.filter(order__lt=instance.current_step.order if instance.current_step else 9999)
incomplete = StepInstance.objects.filter(process_instance=instance, step__in=prior_steps).exclude(status='completed').exists()
previous_step = instance.process.steps.filter(order__lt=instance.current_step.order).last() if instance.current_step else None
prev_si = StepInstance.objects.filter(process_instance=instance, step=previous_step).first() if previous_step else None
if incomplete:
if incomplete and not prev_si.status == 'approved':
messages.error(request, 'ابتدا همه مراحل قبلی را تکمیل کنید')
return redirect('processes:request_list')
inv = Invoice.objects.filter(process_instance=instance).first()
if inv:
inv.calculate_totals()
if inv.remaining_amount != 0:
messages.error(request, 'مانده فاکتور باید صفر باشد')
return redirect('processes:request_list')
if prev_si and not prev_si.status == 'approved':
inv.calculate_totals()
if inv.get_remaining_amount() != 0:
messages.error(request, 'مانده فاکتور باید صفر باشد')
return redirect('processes:request_list')
template = CertificateTemplate.objects.filter(is_active=True).order_by('-created').first()
if not template:
@ -117,6 +137,8 @@ def certificate_step(request, instance_id, step_id):
instance.save()
return redirect('processes:instance_summary', instance_id=instance.id)
# latest installation report for details
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
return render(request, 'certificates/step.html', {
'instance': instance,
'template': template,
@ -124,18 +146,82 @@ def certificate_step(request, instance_id, step_id):
'previous_step': previous_step,
'next_step': next_step,
'step': step,
'latest_report': latest_report,
})
@login_required
@allowed_roles([UserRoles.BROKER, UserRoles.MANAGER])
def certificate_print(request, instance_id):
instance = get_scoped_instance_or_404(request, instance_id)
cert = CertificateInstance.objects.filter(process_instance=instance).order_by('-created').first()
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
if request.method == 'POST':
# Save/update hologram code then print
code = (request.POST.get('hologram_code') or '').strip()
if not code:
messages.error(request, 'کد یکتای هولوگرام الزامی است')
# Find certificate step to redirect back
certificate_step = instance.process.steps.filter(order=9).first()
if certificate_step and instance.current_step:
return redirect('processes:step_detail', instance_id=instance.id, step_id=certificate_step.id)
return redirect('processes:instance_summary', instance_id=instance.id)
try:
if cert:
# Check if hologram code is already used by another certificate
if CertificateInstance.objects.filter(hologram_code=code).exclude(id=cert.id).exists():
messages.error(request, 'این کد هولوگرام قبلاً استفاده شده است. لطفاً کد دیگری وارد کنید')
# Find certificate step to redirect back
certificate_step = instance.process.steps.filter(order=9).first()
if certificate_step and instance.current_step:
return redirect('processes:step_detail', instance_id=instance.id, step_id=certificate_step.id)
return redirect('processes:instance_summary', instance_id=instance.id)
cert.hologram_code = code
cert.save(update_fields=['hologram_code'])
else:
# Check if hologram code is already used
if CertificateInstance.objects.filter(hologram_code=code).exists():
messages.error(request, 'این کد هولوگرام قبلاً استفاده شده است. لطفاً کد دیگری وارد کنید')
# Find certificate step to redirect back
certificate_step = instance.process.steps.filter(order=9).first()
if certificate_step and instance.current_step:
return redirect('processes:step_detail', instance_id=instance.id, step_id=certificate_step.id)
return redirect('processes:instance_summary', instance_id=instance.id)
template = CertificateTemplate.objects.filter(is_active=True).order_by('-created').first()
if template:
title, body = _render_template(template, instance)
cert = CertificateInstance.objects.create(
process_instance=instance,
template=template,
rendered_title=title,
rendered_body=body,
hologram_code=code
)
except IntegrityError:
messages.error(request, 'این کد هولوگرام قبلاً استفاده شده است. لطفاً کد دیگری وارد کنید')
# Find certificate step to redirect back
certificate_step = instance.process.steps.filter(order=9).first()
if certificate_step and instance.current_step:
return redirect('processes:step_detail', instance_id=instance.id, step_id=certificate_step.id)
return redirect('processes:instance_summary', instance_id=instance.id)
# proceed to rendering page after saving code
return render(request, 'certificates/print.html', {
'instance': instance,
'cert': cert,
'template': cert.template if cert else None,
'latest_report': latest_report,
})
template = cert.template if cert else None
return render(request, 'certificates/print.html', {
'instance': instance,
'cert': cert,
'template': template,
'latest_report': latest_report,
})

View file

@ -13,6 +13,11 @@ class UserRoles(Enum):
HEADQUARTER = "hdq" # ستاد آب منطقه‌ای
USER_TYPE_CHOICES = [
('individual', 'حقیقی'),
('legal', 'حقوقی'),
]
BANK_CHOICES = [
('mellat', 'بانک ملت'),
('saman', 'بانک سامان'),

View file

@ -1,7 +1,23 @@
from django import template
from _helpers.utils import jalali_converter2
from _helpers.utils import jalali_converter2, amount_to_persian_words
register = template.Library()
@register.filter(name='to_jalali')
def to_jalali(value):
return jalali_converter2(value)
return jalali_converter2(value)
@register.filter(name='amount_to_words')
def amount_to_words(value):
"""تبدیل مبلغ به حروف فارسی"""
try:
if value is None or value == '':
return ""
# تبدیل Decimal به int
from decimal import Decimal
if isinstance(value, Decimal):
value = int(value)
result = amount_to_persian_words(value)
return result if result else "صفر ریال"
except Exception as e:
return f"خطا: {str(e)}"

View file

@ -96,9 +96,8 @@
<span></span>
{% endif %}
{% if next_step %}
{% if is_broker %}
<button type="submit" class="btn btn-primary">تایید و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
{% if is_broker and step_instance.status != 'completed' %}
<button type="submit" class="btn btn-primary">تایید
</button>
{% else %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">

View file

@ -1,3 +1,4 @@
from django.db.models.query import FlatValuesListIterable
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.urls import reverse
@ -28,6 +29,9 @@ def build_contract_context(instance: ProcessInstance) -> dict:
except Exception:
latest_payment_date = None
individual = True if profile and profile.user_type == 'individual' else False
company_national_id = profile.company_national_id if profile and profile.user_type == 'legal' else None
company_name = profile.company_name if profile and profile.user_type == 'legal' else None
return {
'customer_full_name': mark_safe(f"<span class=\"fw-bold\">{representative.get_full_name() if representative else ''}</span>"),
'registration_number': mark_safe(f"<span class=\"fw-bold\">{instance.broker.company.registration_number if instance.broker and instance.broker.company else ''}</span>"),
@ -48,6 +52,11 @@ def build_contract_context(instance: ProcessInstance) -> dict:
'bank_name': mark_safe(f"<span class=\"fw-bold\">{instance.representative.profile.get_bank_name_display() if instance.representative else ''}</span>"),
'prepayment_amount': mark_safe(f"<span class=\"fw-bold\">{int(total_paid):,}</span>"),
'prepayment_date': mark_safe(f"<span class=\"fw-bold\">{jalali_converter2(latest_payment_date)}</span>") if latest_payment_date else '',
'user_type': mark_safe(f"<span>{profile.get_user_type_display() if profile else ''}</span>"),
'individual': individual,
'company_national_id': mark_safe(f"<span class=\"fw-bold\">{company_national_id if company_national_id else ''}</span>"),
'company_name': mark_safe(f"<span class=\"fw-bold\">{company_name if company_name else ''}</span>"),
}
@ -59,6 +68,8 @@ def contract_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()
step_instance = StepInstance.objects.filter(process_instance=instance, step=step).first()
profile = getattr(request.user, 'profile', None)
is_broker = False
can_view_contract_body = True
@ -93,15 +104,16 @@ def contract_step(request, instance_id, step_id):
if request.method == 'POST':
if not is_broker:
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
StepInstance.objects.update_or_create(
step_instance, _ = StepInstance.objects.update_or_create(
process_instance=instance,
step=step,
defaults={'status': 'completed', 'completed_at': timezone.now()}
)
if next_step:
instance.current_step = 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:step_detail', instance_id=instance.id, step_id=step.id)
# return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:request_list')
return render(request, 'contracts/contract_step.html', {
@ -113,6 +125,7 @@ def contract_step(request, instance_id, step_id):
'next_step': next_step,
'is_broker': is_broker,
'can_view_contract_body': can_view_contract_body,
'step_instance': step_instance,
})

Binary file not shown.

View file

@ -21,10 +21,40 @@ class InstallationItemChangeInline(admin.TabularInline):
@admin.register(InstallationReport)
class InstallationReportAdmin(admin.ModelAdmin):
list_display = ('assignment', 'visited_date', 'new_water_meter_serial', 'seal_number', 'is_meter_suspicious', 'approved', 'created')
list_filter = ('is_meter_suspicious', 'approved', 'visited_date')
search_fields = ('assignment__process_instance__code', 'new_water_meter_serial', 'seal_number')
list_display = (
'assignment', 'visited_date', 'meter_type', 'meter_size', 'water_meter_manufacturer',
'discharge_pipe_diameter', 'usage_type', 'exploitation_license_number',
'motor_power', 'pre_calibration_flow_rate', 'post_calibration_flow_rate',
'new_water_meter_serial', 'seal_number', 'sim_number',
'is_meter_suspicious', 'approved', 'created'
)
list_filter = ('is_meter_suspicious', 'approved', 'visited_date', 'meter_type', 'usage_type', 'water_meter_manufacturer')
search_fields = (
'assignment__process_instance__code', 'new_water_meter_serial', 'seal_number', 'exploitation_license_number', 'sim_number'
)
inlines = [InstallationPhotoInline, InstallationItemChangeInline]
fieldsets = (
('زمان و تایید', {
'fields': ('visited_date', 'approved', 'approved_at')
}),
('کنتور و سازنده', {
'fields': (
'meter_type', 'meter_size', 'water_meter_manufacturer', 'new_water_meter_serial', 'seal_number', 'is_meter_suspicious', 'sim_number'
)
}),
('مشخصات هیدرولیکی', {
'fields': ('discharge_pipe_diameter', 'pre_calibration_flow_rate', 'post_calibration_flow_rate')
}),
('کاربری و مجوز', {
'fields': ('usage_type', 'exploitation_license_number')
}),
('توان و محرکه', {
'fields': ('driving_force', 'motor_power')
}),
('توضیحات', {
'fields': ('description',)
}),
)
@admin.register(InstallationPhoto)

224
installations/forms.py Normal file
View file

@ -0,0 +1,224 @@
from django import forms
from django.core.exceptions import ValidationError
from .models import InstallationReport
from wells.models import WaterMeterManufacturer
class InstallationReportForm(forms.ModelForm):
# Additional fields for manufacturer handling
new_manufacturer = forms.CharField(
max_length=100,
required=False,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'شرکت سازنده جدید',
'style': 'display:none;'
})
)
class Meta:
model = InstallationReport
fields = [
'visited_date', 'new_water_meter_serial', 'seal_number',
'utm_x', 'utm_y', 'meter_type', 'meter_size', 'meter_model',
'discharge_pipe_diameter', 'usage_type', 'exploitation_license_number',
'motor_power', 'pre_calibration_flow_rate', 'post_calibration_flow_rate',
'water_meter_manufacturer', 'sim_number', 'driving_force',
'is_meter_suspicious', 'description'
]
widgets = {
'visited_date': forms.DateInput(attrs={
'type': 'date',
'class': 'form-control',
'required': True
}),
'new_water_meter_serial': forms.TextInput(attrs={
'class': 'form-control',
'required': True
}),
'seal_number': forms.TextInput(attrs={
'class': 'form-control',
'required': True
}),
'utm_x': forms.NumberInput(attrs={
'class': 'form-control',
'step': '1',
'required': True
}),
'utm_y': forms.NumberInput(attrs={
'class': 'form-control',
'step': '1',
'required': True
}),
'meter_type': forms.Select(attrs={
'class': 'form-select',
'required': True
}, choices=[
('', 'انتخاب کنید'),
('smart', 'هوشمند (آب و برق)'),
('volumetric', 'حجمی')
]),
'meter_size': forms.TextInput(attrs={
'class': 'form-control'
}),
'meter_model': forms.Select(attrs={
'class': 'form-select'
}, choices=[
('', 'انتخاب کنید'),
('direct', 'مستقیم'),
('indirect', 'غیرمستقیم')
]),
'discharge_pipe_diameter': forms.NumberInput(attrs={
'class': 'form-control',
'required': True
}),
'usage_type': forms.Select(attrs={
'class': 'form-select',
'required': True
}, choices=[
('', 'انتخاب کنید'),
('domestic', 'شرب و خدمات'),
('agriculture', 'کشاورزی'),
('industrial', 'صنعتی')
]),
'exploitation_license_number': forms.TextInput(attrs={
'class': 'form-control',
'required': True
}),
'motor_power': forms.NumberInput(attrs={
'class': 'form-control',
'required': True
}),
'pre_calibration_flow_rate': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0',
'step': '0.0001',
'required': True
}),
'post_calibration_flow_rate': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0',
'step': '0.0001',
'required': True
}),
'water_meter_manufacturer': forms.Select(attrs={
'class': 'form-select',
'id': 'id_water_meter_manufacturer',
'required': True
}),
'sim_number': forms.TextInput(attrs={
'class': 'form-control',
'required': True
}),
'driving_force': forms.TextInput(attrs={
'class': 'form-control',
'required': True
}),
'is_meter_suspicious': forms.CheckboxInput(attrs={
'class': 'form-check-input',
'id': 'id_is_meter_suspicious',
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3
})
}
labels = {
'visited_date': 'تاریخ مراجعه',
'new_water_meter_serial': 'سریال کنتور جدید',
'seal_number': 'شماره پلمپ',
'utm_x': 'UTM X',
'utm_y': 'UTM Y',
'meter_type': 'نوع کنتور',
'meter_size': 'سایز کنتور',
'discharge_pipe_diameter': 'قطر لوله آبده (اینچ)',
'usage_type': 'نوع مصرف',
'exploitation_license_number': 'شماره پروانه بهره‌برداری',
'motor_power': 'قدرت موتور (کیلووات ساعت)',
'pre_calibration_flow_rate': 'دبی قبل از کالیبراسیون (لیتر بر ثانیه)',
'post_calibration_flow_rate': 'دبی بعد از کالیبراسیون (لیتر بر ثانیه)',
'water_meter_manufacturer': 'شرکت سازنده کنتور',
'sim_number': 'شماره سیمکارت',
'driving_force': 'نیرو محرکه چاه',
'is_meter_suspicious': 'کنتور مشکوک است',
'description': 'توضیحات'
}
def __init__(self, *args, **kwargs):
self.user_is_installer = kwargs.pop('user_is_installer', False)
self.instance_well = kwargs.pop('instance_well', None)
super().__init__(*args, **kwargs)
# Set manufacturer choices
manufacturers = WaterMeterManufacturer.objects.filter(is_deleted=False)
manufacturer_choices = [('', 'انتخاب شرکت سازنده')]
manufacturer_choices.extend([(m.id, m.name) for m in manufacturers])
self.fields['water_meter_manufacturer'].choices = manufacturer_choices
# Pre-fill UTM from well if available and no existing report data
if self.instance_well and not self.instance.pk:
if self.instance_well.utm_x:
self.initial['utm_x'] = self.instance_well.utm_x
if self.instance_well.utm_y:
self.initial['utm_y'] = self.instance_well.utm_y
# Disable fields for non-installers
if not self.user_is_installer:
for field_name, field in self.fields.items():
if field_name != 'new_manufacturer': # Keep this always disabled via CSS
field.widget.attrs['readonly'] = True
if isinstance(field.widget, (forms.Select, forms.CheckboxInput)):
field.widget.attrs['disabled'] = True
def clean(self):
cleaned_data = super().clean()
# Handle new manufacturer creation
new_manufacturer = cleaned_data.get('new_manufacturer')
water_meter_manufacturer = cleaned_data.get('water_meter_manufacturer')
if new_manufacturer and not water_meter_manufacturer:
# Create new manufacturer
manufacturer, created = WaterMeterManufacturer.objects.get_or_create(
name=new_manufacturer,
defaults={'is_deleted': False}
)
cleaned_data['water_meter_manufacturer'] = manufacturer
return cleaned_data
def clean_visited_date(self):
visited_date = self.cleaned_data.get('visited_date')
if not visited_date:
raise ValidationError('تاریخ مراجعه الزامی است.')
return visited_date
def clean_exploitation_license_number(self):
license_number = self.cleaned_data.get('exploitation_license_number')
if not license_number or not license_number.strip():
raise ValidationError('شماره پروانه بهره‌برداری الزامی است.')
return license_number.strip()
def validate_photos(self, request_files, existing_photos, deleted_photo_ids):
"""
Validate that at least one photo is present (either existing or newly uploaded)
This method should be called from the view after form.is_valid()
"""
# Count existing photos that are not deleted
kept_existing = 0
if existing_photos:
for photo in existing_photos:
if str(photo.id) not in deleted_photo_ids:
kept_existing += 1
# Count new photos
new_photos = len(request_files.getlist('photos')) if request_files else 0
total_photos = kept_existing + new_photos
if total_photos <= 0:
raise ValidationError('بارگذاری حداقل یک عکس الزامی است.')
return True

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-09-21 07:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='utm_x',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='UTM X'),
),
migrations.AlterField(
model_name='installationreport',
name='utm_y',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='UTM Y'),
),
]

View file

@ -0,0 +1,70 @@
# Generated by Django 5.2.4 on 2025-09-24 11:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0002_alter_installationreport_utm_x_and_more'),
('wells', '0004_remove_historicalwell_discharge_pipe_diameter_and_more'),
]
operations = [
migrations.AddField(
model_name='installationreport',
name='discharge_pipe_diameter',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='قطر لوله آبده (اینچ)'),
),
migrations.AddField(
model_name='installationreport',
name='driving_force',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='نیرو محرکه چاه'),
),
migrations.AddField(
model_name='installationreport',
name='exploitation_license_number',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='شماره پروانه بهره\u200cبرداری چاه'),
),
migrations.AddField(
model_name='installationreport',
name='meter_size',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='سایز کنتور'),
),
migrations.AddField(
model_name='installationreport',
name='meter_type',
field=models.CharField(blank=True, choices=[('smart', 'هوشمند (آبی/برق)'), ('volumetric', 'حجمی')], max_length=20, null=True, verbose_name='نوع کنتور'),
),
migrations.AddField(
model_name='installationreport',
name='motor_power',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='(کیلووات ساعت)قدرت موتور'),
),
migrations.AddField(
model_name='installationreport',
name='post_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی بعد از کالیبراسیون'),
),
migrations.AddField(
model_name='installationreport',
name='pre_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
),
migrations.AddField(
model_name='installationreport',
name='sim_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='شماره سیمکارت'),
),
migrations.AddField(
model_name='installationreport',
name='usage_type',
field=models.CharField(blank=True, choices=[('domestic', 'شرب و خدمات'), ('agriculture', 'کشاورزی'), ('industrial', 'صنعتی')], max_length=20, null=True, verbose_name='نوع مصرف'),
),
migrations.AddField(
model_name='installationreport',
name='water_meter_manufacturer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='wells.watermetermanufacturer', verbose_name='شرکت سازنده کنتور آب'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-27 06:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0003_installationreport_discharge_pipe_diameter_and_more'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='motor_power',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='(کیلووات ساعت) قدرت موتور'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-29 10:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0004_alter_installationreport_motor_power'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='usage_type',
field=models.CharField(choices=[('domestic', 'شرب و خدمات'), ('agriculture', 'کشاورزی'), ('industrial', 'صنعتی')], max_length=20, null=True, verbose_name='نوع مصرف'),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 5.2.4 on 2025-09-29 10:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0005_alter_installationreport_usage_type'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='exploitation_license_number',
field=models.CharField(default=1, max_length=50, verbose_name='شماره پروانه بهره\u200cبرداری چاه'),
preserve_default=False,
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-10-07 04:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0006_alter_installationreport_exploitation_license_number'),
]
operations = [
migrations.AddField(
model_name='installationreport',
name='meter_model',
field=models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B')], max_length=20, null=True, verbose_name='مدل کنتور'),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-10-09 08:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0007_installationreport_meter_model'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='post_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی بعد از کالیبراسیون'),
),
migrations.AlterField(
model_name='installationreport',
name='pre_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
),
]

View file

@ -0,0 +1,30 @@
# Generated by Django 5.2.4 on 2025-10-09 12:28
import django.core.validators
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0008_alter_installationreport_post_calibration_flow_rate_and_more'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='meter_model',
field=models.CharField(blank=True, choices=[('direct', 'مستقیم'), ('indirect', 'غیرمستقیم')], max_length=20, null=True, verbose_name='مدل کنتور'),
),
migrations.AlterField(
model_name='installationreport',
name='post_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))], verbose_name='(لیتر بر ثانیه)دبی بعد از کالیبراسیون'),
),
migrations.AlterField(
model_name='installationreport',
name='pre_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.0001'))], verbose_name='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2025-10-09 12:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0009_alter_installationreport_meter_model_and_more'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='motor_power',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, verbose_name='(کیلووات ساعت) قدرت موتور'),
),
migrations.AlterField(
model_name='installationreport',
name='post_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی بعد از کالیبراسیون'),
),
migrations.AlterField(
model_name='installationreport',
name='pre_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-10-09 12:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0010_alter_installationreport_motor_power_and_more'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='discharge_pipe_diameter',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True, verbose_name='قطر لوله آبده (اینچ)'),
),
]

View file

@ -1,6 +1,8 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.core.validators import MinValueValidator
from decimal import Decimal
from common.models import BaseModel
User = get_user_model()
@ -42,6 +44,31 @@ class InstallationReport(BaseModel):
new_water_meter_serial = models.CharField(max_length=50, null=True, blank=True, verbose_name='سریال کنتور جدید')
seal_number = models.CharField(max_length=50, null=True, blank=True, verbose_name='شماره پلمپ')
is_meter_suspicious = models.BooleanField(default=False, verbose_name='کنتور مشکوک است؟')
METER_TYPE_CHOICES = [
('smart', 'هوشمند (آبی/برق)'),
('volumetric', 'حجمی'),
]
meter_type = models.CharField(max_length=20, choices=METER_TYPE_CHOICES, null=True, blank=True, verbose_name='نوع کنتور')
METER_MODEL_CHOICES = [
('direct', 'مستقیم'),
('indirect', 'غیرمستقیم'),
]
meter_model = models.CharField(max_length=20, choices=METER_MODEL_CHOICES, null=True, blank=True, verbose_name='مدل کنتور')
meter_size = models.CharField(max_length=50, null=True, blank=True, verbose_name='سایز کنتور')
discharge_pipe_diameter = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True, verbose_name='قطر لوله آبده (اینچ)')
USAGE_TYPE_CHOICES = [
('domestic', 'شرب و خدمات'),
('agriculture', 'کشاورزی'),
('industrial', 'صنعتی'),
]
usage_type = models.CharField(max_length=20, choices=USAGE_TYPE_CHOICES, null=True, verbose_name='نوع مصرف')
exploitation_license_number = models.CharField(max_length=50, verbose_name='شماره پروانه بهره‌برداری چاه')
motor_power = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True, verbose_name='(کیلووات ساعت) قدرت موتور')
pre_calibration_flow_rate = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True, verbose_name='(لیتر بر ثانیه)دبی قبل از کالیبراسیون')
post_calibration_flow_rate = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True, verbose_name='(لیتر بر ثانیه)دبی بعد از کالیبراسیون')
water_meter_manufacturer = models.ForeignKey('wells.WaterMeterManufacturer', on_delete=models.SET_NULL, null=True, blank=True, verbose_name='شرکت سازنده کنتور آب')
sim_number = models.CharField(max_length=20, null=True, blank=True, verbose_name='شماره سیمکارت')
driving_force = models.CharField(max_length=50, null=True, blank=True, verbose_name='نیرو محرکه چاه')
utm_x = models.DecimalField(max_digits=10, decimal_places=0, null=True, blank=True, verbose_name='UTM X')
utm_y = models.DecimalField(max_digits=10, decimal_places=0, null=True, blank=True, verbose_name='UTM Y')
description = models.TextField(blank=True, verbose_name='توضیحات')

View file

@ -31,6 +31,60 @@
.removal-checkbox:checked:focus {
box-shadow: 0 0 0 0.25rem rgba(220, 53, 69, 0.25) !important;
}
/* Upload Loader Overlay */
#uploadLoader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 9999;
display: none;
justify-content: center;
align-items: center;
}
#uploadLoader.active {
display: flex;
}
.loader-content {
background: white;
padding: 2rem;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 300px;
}
.loader-spinner {
width: 50px;
height: 50px;
border: 5px solid #f3f3f3;
border-top: 5px solid #696cff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loader-text {
font-size: 1.1rem;
font-weight: 500;
color: #333;
margin-bottom: 0.5rem;
}
.loader-subtext {
font-size: 0.9rem;
color: #666;
}
</style>
{% endblock %}
@ -38,6 +92,15 @@
{% include '_toasts.html' %}
<!-- Upload Loader Overlay -->
<div id="uploadLoader">
<div class="loader-content">
<div class="loader-spinner"></div>
<div class="loader-text">در حال آپلود...</div>
<div class="loader-subtext">لطفا تا بارگذاری کامل گزارش منتظر بمانید.</div>
</div>
</div>
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
@ -63,7 +126,7 @@
<div class="bs-stepper-content">
{% if report and not edit_mode %}
<div class="mb-3 text-end">
{% if user_is_installer %}
{% if user_is_installer and not report.approved %}
<a href="?edit=1" class="btn btn-primary">
<i class="bx bx-edit bx-sm me-2"></i>
ویرایش گزارش نصب
@ -75,7 +138,6 @@
<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 %}
@ -86,11 +148,27 @@
<p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-purchase-tag bx-sm me-2"></i>سریال جدید: {{ report.new_water_meter_serial|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-lock-alt bx-sm me-2"></i>شماره پلمپ: {{ report.seal_number|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-chip bx-sm me-2"></i>نوع کنتور: {{ report.get_meter_type_display|default:'-' }}</p>
{% if report.meter_type == 'smart' %}
<p class="text-nowrap mb-2"><i class="bx bx-chip bx-sm me-2"></i>مدل کنتور: {{ report.get_meter_model_display|default:'-' }}</p>
{% else %}
<p class="text-nowrap mb-2"><i class="bx bx-ruler bx-sm me-2"></i>سایز کنتور: {{ report.meter_size|default:'-' }}</p>
{% endif %}
<p class="text-nowrap mb-2"><i class="bx bx-tachometer bx-sm me-2"></i>قطر لوله آبده (اینچ): {{ report.discharge_pipe_diameter|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-building bx-sm me-2"></i>سازنده کنتور: {{ report.water_meter_manufacturer|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-sim-card bx-sm me-2"></i>شماره سیمکارت: {{ report.sim_number|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-cog bx-sm me-2"></i>نیرو محرکه چاه: {{ report.driving_force|default:'-' }}</p>
</div>
<div class="col-md-6">
<p class="text-nowrap mb-2"><i class="bx bx-help-circle bx-sm me-2"></i>کنتور مشکوک: {{ report.is_meter_suspicious|yesno:'بله,خیر' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-map bx-sm me-2"></i>UTM X: {{ report.utm_x|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-map-pin bx-sm me-2"></i>UTM Y: {{ report.utm_y|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-category bx-sm me-2"></i>نوع مصرف: {{ report.get_usage_type_display|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-id-card bx-sm me-2"></i>شماره پروانه بهره‌برداری: {{ report.exploitation_license_number|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-bolt-circle bx-sm me-2"></i>قدرت موتور(کیلووات ساعت): {{ report.motor_power|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-water bx-sm me-2"></i>دبی قبل کالیبراسیون(لیتر/ثانیه): {{ report.pre_calibration_flow_rate|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-water bx-sm me-2"></i>دبی بعد کالیبراسیون(لیتر/ثانیه): {{ report.post_calibration_flow_rate|default:'-' }}</p>
</div>
</div>
{% if report.description %}
@ -155,11 +233,18 @@
<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">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectModal">رد</button>
</div>
{% if can_approve_reject %}
{% if current_user_has_decided %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" disabled>تایید</button>
<button type="button" class="btn btn-danger btn-sm" disabled>رد</button>
</div>
{% else %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveModal">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectModal">رد</button>
</div>
{% endif %}
{% endif %}
</div>
<div class="card-body py-3">
@ -213,41 +298,156 @@
{% if user_is_installer %}
<!-- Installation Report Form -->
<form method="post" enctype="multipart/form-data" id="installation-report-form">
{% csrf_token %}
{% csrf_token %}
<div class="mb-3">
<div class="">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">تاریخ مراجعه</label>
<input type="text" id="id_visited_date_display" class="form-control" placeholder="انتخاب تاریخ" {% if not 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 %}">
{{ form.visited_date.label_tag }}
<!-- Custom date picker handling -->
<input type="text" id="id_visited_date_display" class="form-control{% if form.visited_date.errors %} is-invalid{% endif %}" placeholder="انتخاب تاریخ" {% if not user_is_installer %}disabled{% endif %} readonly required value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y/m/d' }}{% elif form.visited_date.value %}{{ form.visited_date.value|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' }}{% elif form.visited_date.value %}{{ form.visited_date.value }}{% endif %}">
{% if form.visited_date.errors %}
<div class="invalid-feedback">{{ form.visited_date.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label class="form-label">سریال کنتور جدید</label>
<input type="text" class="form-control" name="new_water_meter_serial" value="{% if report and edit_mode %}{{ report.new_water_meter_serial|default_if_none:'' }}{% endif %}" {% if not user_is_installer %}readonly{% endif %}>
{{ form.new_water_meter_serial.label_tag }}
{{ form.new_water_meter_serial }}
{% if form.new_water_meter_serial.errors %}
<div class="invalid-feedback">{{ form.new_water_meter_serial.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label class="form-label">شماره پلمپ</label>
<input type="text" class="form-control" name="seal_number" value="{% if report and edit_mode %}{{ report.seal_number|default_if_none:'' }}{% endif %}" {% if not user_is_installer %}readonly{% endif %}>
{{ form.seal_number.label_tag }}
{{ form.seal_number }}
{% if form.seal_number.errors %}
<div class="invalid-feedback">{{ form.seal_number.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.utm_x.label_tag }}
{{ form.utm_x }}
{% if form.utm_x.errors %}
<div class="invalid-feedback">{{ form.utm_x.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.utm_y.label_tag }}
{{ form.utm_y }}
{% if form.utm_y.errors %}
<div class="invalid-feedback">{{ form.utm_y.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.meter_type.label_tag }}
{{ form.meter_type }}
{% if form.meter_type.errors %}
<div class="invalid-feedback">{{ form.meter_type.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3" id="meter_size_wrapper">
{{ form.meter_size.label_tag }}
{{ form.meter_size }}
{% if form.meter_size.errors %}
<div class="invalid-feedback">{{ form.meter_size.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3" id="meter_model_wrapper">
{{ form.meter_model.label_tag }}
{{ form.meter_model }}
{% if form.meter_model.errors %}
<div class="invalid-feedback">{{ form.meter_size.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.discharge_pipe_diameter.label_tag }}
{{ form.discharge_pipe_diameter }}
{% if form.discharge_pipe_diameter.errors %}
<div class="invalid-feedback">{{ form.discharge_pipe_diameter.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.usage_type.label_tag }}
{{ form.usage_type }}
{% if form.usage_type.errors %}
<div class="invalid-feedback">{{ form.usage_type.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.exploitation_license_number.label_tag }}
{{ form.exploitation_license_number }}
{% if form.exploitation_license_number.errors %}
<div class="invalid-feedback">{{ form.exploitation_license_number.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.motor_power.label_tag }}
{{ form.motor_power }}
{% if form.motor_power.errors %}
<div class="invalid-feedback">{{ form.motor_power.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.pre_calibration_flow_rate.label_tag }}
{{ form.pre_calibration_flow_rate }}
{% if form.pre_calibration_flow_rate.errors %}
<div class="invalid-feedback">{{ form.pre_calibration_flow_rate.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.post_calibration_flow_rate.label_tag }}
{{ form.post_calibration_flow_rate }}
{% if form.post_calibration_flow_rate.errors %}
<div class="invalid-feedback">{{ form.post_calibration_flow_rate.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.water_meter_manufacturer.label_tag }}حجمی
<div class="input-group">
{{ form.water_meter_manufacturer }}
{{ form.new_manufacturer }}
{% if user_is_installer %}
<button class="btn btn-outline-primary" type="button" id="btnToggleManufacturer"><i class="bx bx-plus"></i></button>
{% endif %}
</div>
{% if form.water_meter_manufacturer.errors %}
<div class="invalid-feedback">{{ form.water_meter_manufacturer.errors.0 }}</div>
{% endif %}
{% if form.new_manufacturer.errors %}
<div class="invalid-feedback">{{ form.new_manufacturer.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.sim_number.label_tag }}
{{ form.sim_number }}
{% if form.sim_number.errors %}
<div class="invalid-feedback">{{ form.sim_number.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.driving_force.label_tag }}
{{ form.driving_force }}
{% if form.driving_force.errors %}
<div class="invalid-feedback">{{ form.driving_force.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_meter_suspicious" id="id_is_meter_suspicious" {% if not 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>
{{ form.is_meter_suspicious }}
{{ form.is_meter_suspicious.label_tag }}
</div>
</div>
<div class="col-md-3">
<label class="form-label">UTM X</label>
<input type="number" step="1" class="form-control" name="utm_x" value="{% if report and edit_mode and report.utm_x %}{{ report.utm_x }}{% elif instance.well.utm_x %}{{ instance.well.utm_x }}{% endif %}" {% if not user_is_installer %}readonly{% endif %}>
</div>
<div class="col-md-3">
<label class="form-label">UTM Y</label>
<input type="number" step="1" class="form-control" name="utm_y" value="{% if report and edit_mode and report.utm_y %}{{ report.utm_y }}{% elif instance.well.utm_y %}{{ instance.well.utm_y }}{% endif %}" {% if not user_is_installer %}readonly{% endif %}>
{% if form.is_meter_suspicious.errors %}
<div class="invalid-feedback">{{ form.is_meter_suspicious.errors.0 }}</div>
{% endif %}
</div>
</div>
<div class="my-3">
<label class="form-label">توضیحات (اختیاری)</label>
<textarea class="form-control" rows="3" name="description" {% if not user_is_installer %}readonly{% endif %}>{% if report and edit_mode %}{{ report.description|default_if_none:'' }}{% endif %}</textarea>
{{ form.description.label_tag }}
{{ form.description }}
{% if form.description.errors %}
<div class="invalid-feedback">{{ form.description.errors.0 }}</div>
{% endif %}
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center">
@ -284,7 +484,7 @@
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 mb-4">
<div class="col-12 mb-4 d-none">
<h6 class="mb-2">اقلام انتخاب‌شده قبلی <small class="text-muted">(برای حذف در نصب تیک بزنید)</small></h6>
<div class="table-responsive">
<table class="table table-sm align-middle">
@ -309,7 +509,7 @@
{% if qi.item.description %}<small class="text-muted">{{ qi.item.description }}</small>{% endif %}
</div>
</td>
<td>{{ qi.unit_price|floatformat:0|intcomma:False }} تومان</td>
<td>{{ qi.unit_price|floatformat:0|intcomma:False }} ریال</td>
<td>
<span class="text-muted">{% if removed_qty|get_item:qi.item.id %}{{ removed_qty|get_item:qi.item.id }}{% else %}{{ qi.quantity }}{% endif %}</span>
</td>
@ -321,7 +521,6 @@
</table>
</div>
</div>
<hr>
<div class="col-12">
<h6 class="mb-2">افزودن اقلام جدید</h6>
<div class="table-responsive">
@ -349,7 +548,7 @@
{% if it.description %}<small class="text-muted">{{ it.description }}</small>{% endif %}
</div>
</td>
<td>{{ it.unit_price|floatformat:0|intcomma:False }} تومان</td>
<td>{{ it.unit_price|floatformat:0|intcomma:False }} ریال</td>
<td>
{% with add_entry=added_map|get_item:it.id %}
<input class="form-control form-control-sm" type="number" min="1" name="add_{{ it.id }}_qty" value="{% if add_entry %}{{ add_entry.qty }}{% endif %}">
@ -380,7 +579,7 @@
{% if user_is_installer %}
<button type="submit" class="btn btn-success" form="installation-report-form">ثبت گزارش</button>
{% endif %}
{% if next_step %}
{% if next_step and not edit_mode and report %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
@ -502,6 +701,9 @@
// Require date and show success toast on submit (persist across redirect)
(function(){
const form = document.querySelector('form[enctype]') || document.querySelector('form');
const loader = document.getElementById('uploadLoader');
const submitButton = document.querySelector('button[type="submit"][form="installation-report-form"]');
if (!form) return;
form.addEventListener('submit', function(ev){
const display = document.getElementById('id_visited_date_display');
@ -513,8 +715,46 @@
display.scrollIntoView({behavior:'smooth', block:'center'});
return false;
}
// Require at least one photo: either existing (not marked for deletion) or newly added
try {
var keptExisting = 0;
document.querySelectorAll('input[id^="del-photo-"]').forEach(function(inp){
if (String(inp.value) !== '1') keptExisting += 1;
});
var newFiles = document.querySelectorAll('#photoInputs input[type="file"]').length;
if ((keptExisting + newFiles) <= 0) {
ev.preventDefault(); ev.stopPropagation();
showToast('بارگذاری حداقل یک عکس الزامی است', 'danger');
(document.getElementById('btnAddPhoto') || form).scrollIntoView({behavior:'smooth', block:'center'});
return false;
}
} catch(_) {}
// Show loader overlay when form is valid and submitting
if (loader) {
loader.classList.add('active');
}
// Disable submit button to prevent double submission
if (submitButton) {
submitButton.disabled = true;
submitButton.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>در حال ارسال...';
}
try { sessionStorage.setItem('install_report_saved', '1'); } catch(_) {}
}, false);
// Hide loader on back navigation or page show (in case of errors)
window.addEventListener('pageshow', function(event) {
if (loader) {
loader.classList.remove('active');
}
if (submitButton) {
submitButton.disabled = false;
submitButton.innerHTML = 'ثبت گزارش';
}
});
// on load, if saved flag exists, show toast
try {
if (sessionStorage.getItem('install_report_saved') === '1') {
@ -568,6 +808,36 @@
if (btnAddPhoto) btnAddPhoto.addEventListener('click', createPhotoInput);
})();
// Toggle manufacturer select/input (like request_list)
(function(){
const $select = $('#id_water_meter_manufacturer');
const $input = $('#id_new_manufacturer');
const $btn = $('#btnToggleManufacturer');
if (!$select.length || !$btn.length) return;
$btn.on('click', function(){
if ($select.is(':visible')) {
$select.hide();
$input.show().focus();
$btn.html('<i class="bx bx-check"></i>');
} else {
// When switching back, if input has value, append it as selected option
const val = ($input.val() || '').trim();
if (val) {
// Add a temporary option with value prefixed 'new:' to be handled server-side
const exists = $select.find('option').filter(function(){ return $(this).text().trim() === val; }).length > 0;
if (!exists) {
const opt = $('<option></option>').val('').text(val);
$select.append(opt);
}
$select.val('');
}
$input.hide();
$select.show();
$btn.html('<i class="bx bx-plus"></i>');
}
});
})();
// Mark delete for existing photos
function markDeletePhoto(id){
const hidden = document.getElementById('del-photo-' + id);
@ -590,6 +860,47 @@
}
}
}
// Dynamic meter field visibility based on meter type
(function() {
const meterTypeSelect = document.getElementById('{{ form.meter_type.id_for_label }}');
const meterSizeWrapper = document.getElementById('meter_size_wrapper');
const meterModelWrapper = document.getElementById('meter_model_wrapper');
function updateMeterFields() {
if (!meterTypeSelect) return;
const selectedType = meterTypeSelect.value;
if (selectedType === 'smart') {
// Show meter_model, hide meter_size
meterModelWrapper.style.display = '';
meterSizeWrapper.style.display = 'none';
// Clear meter_size value when hidden
const meterSizeInput = meterSizeWrapper.querySelector('input, select');
if (meterSizeInput) meterSizeInput.value = '';
} else if (selectedType === 'volumetric') {
// Show meter_size, hide meter_model
meterSizeWrapper.style.display = '';
meterModelWrapper.style.display = 'none';
// Clear meter_model value when hidden
const meterModelSelect = meterModelWrapper.querySelector('select');
if (meterModelSelect) meterModelSelect.value = '';
} else {
// No selection: hide both
meterSizeWrapper.style.display = 'none';
meterModelWrapper.style.display = 'none';
}
}
// Initial update on page load
updateMeterFields();
// Update on change
if (meterTypeSelect) {
meterTypeSelect.addEventListener('change', updateMeterFields);
}
})();
</script>
{% endblock %}

View file

@ -3,12 +3,15 @@ from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.urls import reverse
from django.utils import timezone
from django.core.exceptions import ValidationError
from accounts.models import Profile
from common.consts import UserRoles
from processes.models import ProcessInstance, StepInstance, StepRejection, StepApproval
from accounts.models import Role
from invoices.models import Item, Quote, QuoteItem
from wells.models import WaterMeterManufacturer
from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange
from .forms import InstallationReportForm
from decimal import Decimal, InvalidOperation
from processes.utils import get_scoped_instance_or_404
@ -122,12 +125,9 @@ def installation_report_step(request, instance_id, step_id):
is_assigned_installer = bool(assignment and assignment.installer_id == request.user.id)
user_is_installer = bool(has_installer_role and is_assigned_installer)
edit_mode = True if (request.GET.get('edit') == '1' and user_is_installer) else False
# current quote items baseline
quote = Quote.objects.filter(process_instance=instance).first()
quote_items = list(quote.items.select_related('item').all()) if quote else []
quote_price_map = {qi.item_id: qi.unit_price for qi in quote_items}
items = Item.objects.filter(is_active=True, is_special=False, is_deleted=False).order_by('name')
# Prevent edit mode if an approved report exists
if existing_report and existing_report.approved:
edit_mode = False
# Ensure a StepInstance exists for this step
step_instance, _ = StepInstance.objects.get_or_create(
@ -136,6 +136,177 @@ def installation_report_step(request, instance_id, step_id):
defaults={'status': 'in_progress'}
)
# 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.filter(is_active=True, is_special=False, is_deleted=False).order_by('name')
manufacturers = WaterMeterManufacturer.objects.all().order_by('name')
# Initialize the form
form = None
if request.method == 'POST' and request.POST.get('action') not in ['approve', 'reject']:
# Handle form submission for report creation/editing
if not user_is_installer:
messages.error(request, 'شما مجوز ثبت/ویرایش گزارش نصب را ندارید')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
# Block editing approved reports
if existing_report and existing_report.approved:
messages.error(request, 'این گزارش قبلا تایید شده و قابل ویرایش نیست')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
form = InstallationReportForm(
request.POST,
instance=existing_report if edit_mode else None,
user_is_installer=user_is_installer,
instance_well=instance.well
)
if form.is_valid():
# Validate photos
photo_validation_passed = False
try:
deleted_photo_ids = []
for key, val in request.POST.items():
if key.startswith('del_photo_') and val == '1':
try:
pid = key.split('_')[-1]
deleted_photo_ids.append(pid)
except Exception:
continue
existing_photos = existing_report.photos.all() if existing_report else None
form.validate_photos(request.FILES, existing_photos, deleted_photo_ids)
photo_validation_passed = True
except ValidationError as e:
form.add_error(None, str(e))
# Re-render form with photo validation error
photo_validation_passed = False
# Always clear approvals/rejections when form is submitted (even if photo validation fails)
# Reset step status and clear approvals/rejections
step_instance.status = 'in_progress'
step_instance.completed_at = None
step_instance.save()
try:
for appr in list(step_instance.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(step_instance.rejections.all()):
rej.delete()
except Exception:
pass
# Reopen subsequent steps
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance and subsequent_step_instance.status == 'completed':
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
try:
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
pass
# Reset current step if needed
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
# Only save the report if photo validation passed
if photo_validation_passed:
# Save the form
report = form.save(commit=False)
if not existing_report:
report.assignment = assignment
report.created_by = request.user
report.approved = False # Reset approval status
report.save()
# Handle photo uploads and deletions
if existing_report and edit_mode:
# Delete selected existing photos
for key, val in request.POST.items():
if key.startswith('del_photo_') and val == '1':
try:
pid = int(key.split('_')[-1])
InstallationPhoto.objects.filter(id=pid, report=report).delete()
except Exception:
continue
# Add new photos
for f in request.FILES.getlist('photos'):
InstallationPhoto.objects.create(report=report, image=f)
# Handle item changes (this logic remains the same)
remove_map = {}
add_map = {}
for key in request.POST.keys():
if key.startswith('rem_') and key.endswith('_type'):
try:
item_id = int(key.split('_')[1])
except Exception:
continue
if request.POST.get(key) != 'remove':
continue
qty_val = request.POST.get(f'rem_{item_id}_qty') or '1'
try:
qty = int(qty_val)
except Exception:
qty = 1
remove_map[item_id] = qty
if key.startswith('add_') and key.endswith('_type'):
try:
item_id = int(key.split('_')[1])
except Exception:
continue
if request.POST.get(key) != 'add':
continue
qty_val = request.POST.get(f'add_{item_id}_qty') or '1'
price_val = request.POST.get(f'add_{item_id}_price')
try:
qty = int(qty_val)
except Exception:
qty = 1
# resolve unit price
unit_price = None
if price_val:
try:
unit_price = Decimal(price_val)
except InvalidOperation:
unit_price = None
if unit_price is None:
item_obj = Item.objects.filter(id=item_id).first()
unit_price = item_obj.unit_price if item_obj else None
add_map[item_id] = {'qty': qty, 'price': unit_price}
# Replace item changes with new submission
if existing_report and edit_mode:
report.item_changes.all().delete()
create_item_changes_for_report(report, remove_map, add_map, quote_price_map)
messages.success(request, 'گزارش ثبت شد و در انتظار تایید است.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
else:
# GET request or approval/rejection actions - initialize form for display
form = InstallationReportForm(
instance=existing_report if existing_report else None,
user_is_installer=user_is_installer,
instance_well=instance.well
)
# 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)
@ -148,16 +319,39 @@ def installation_report_step(request, instance_id, step_id):
except Exception:
can_approve_reject = False
user_can_approve = can_approve_reject
approvals_list = list(step_instance.approvals.select_related('role').all())
approvals_list = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False))
rejections_list = list(step_instance.rejections.select_related('role', 'rejected_by').filter(is_deleted=False))
approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [
{
rejections_by_role = {r.role_id: r for r in rejections_list}
approver_statuses = []
for r in reqs:
appr = approvals_by_role.get(r.role_id)
rejection = rejections_by_role.get(r.role_id)
if appr:
status = 'approved'
reason = appr.reason
elif rejection:
status = 'rejected'
reason = rejection.reason
else:
status = None
reason = ''
approver_statuses.append({
'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
]
'status': status,
'reason': reason,
})
# Determine if current user has already approved/rejected (to disable buttons)
current_user_has_decided = False
try:
user_has_approval = step_instance.approvals.filter(approved_by=request.user, is_deleted=False).exists()
user_has_rejection = step_instance.rejections.filter(rejected_by=request.user, is_deleted=False).exists()
current_user_has_decided = bool(user_has_approval or user_has_rejection)
except Exception:
current_user_has_decided = False
# Manager approval/rejection actions
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
@ -175,14 +369,17 @@ def installation_report_step(request, instance_id, step_id):
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(
# Record this user's approval for their role
StepApproval.objects.create(
step_instance=step_instance,
role=matching_role,
defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''}
approved_by=request.user,
reason=''
)
# Only mark report approved when ALL required roles have approved
if step_instance.is_fully_approved():
existing_report.approved = True
existing_report.save()
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
@ -191,6 +388,11 @@ def installation_report_step(request, instance_id, step_id):
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:request_list')
else:
# Not fully approved yet; keep report as not approved
if existing_report.approved:
existing_report.approved = False
existing_report.save(update_fields=['approved'])
messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقش‌ها.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
@ -199,12 +401,8 @@ def installation_report_step(request, instance_id, step_id):
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)
# Only create StepRejection for rejections, not StepApproval
StepRejection.objects.create(step_instance=step_instance, role=matching_role, rejected_by=request.user, reason=reason)
existing_report.approved = False
existing_report.save()
# If current step moved ahead of this step, reset it back for correction (align with invoices)
@ -217,160 +415,6 @@ def installation_report_step(request, instance_id, step_id):
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:
visited_date = visited_date.replace('/', '-')
new_serial = (request.POST.get('new_water_meter_serial') or '').strip()
seal_number = (request.POST.get('seal_number') or '').strip()
is_suspicious = True if request.POST.get('is_meter_suspicious') == 'on' else False
utm_x = request.POST.get('utm_x') or None
utm_y = request.POST.get('utm_y') or None
# Normalize UTM to integer meters
if utm_x is not None and utm_x != '':
try:
utm_x = int(Decimal(str(utm_x)))
except InvalidOperation:
utm_x = None
else:
utm_x = None
if utm_y is not None and utm_y != '':
try:
utm_y = int(Decimal(str(utm_y)))
except InvalidOperation:
utm_y = None
else:
utm_y = None
# Build maps from form fields: remove and add
remove_map = {}
add_map = {}
for key in request.POST.keys():
if key.startswith('rem_') and key.endswith('_type'):
# rem_{id}_type = 'remove'
try:
item_id = int(key.split('_')[1])
except Exception:
continue
if request.POST.get(key) != 'remove':
continue
qty_val = request.POST.get(f'rem_{item_id}_qty') or '1'
try:
qty = int(qty_val)
except Exception:
qty = 1
remove_map[item_id] = qty
if key.startswith('add_') and key.endswith('_type'):
try:
item_id = int(key.split('_')[1])
except Exception:
continue
if request.POST.get(key) != 'add':
continue
qty_val = request.POST.get(f'add_{item_id}_qty') or '1'
price_val = request.POST.get(f'add_{item_id}_price')
try:
qty = int(qty_val)
except Exception:
qty = 1
# resolve unit price
unit_price = None
if price_val:
try:
unit_price = Decimal(price_val)
except InvalidOperation:
unit_price = None
if unit_price is None:
item_obj = Item.objects.filter(id=item_id).first()
unit_price = item_obj.unit_price if item_obj else None
add_map[item_id] = {'qty': qty, 'price': unit_price}
if existing_report and edit_mode:
report = existing_report
report.description = description
report.visited_date = visited_date or None
report.new_water_meter_serial = new_serial or None
report.seal_number = seal_number or None
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():
if key.startswith('del_photo_') and val == '1':
try:
pid = int(key.split('_')[-1])
InstallationPhoto.objects.filter(id=pid, report=report).delete()
except Exception:
continue
# append new photos
for f in request.FILES.getlist('photos'):
InstallationPhoto.objects.create(report=report, image=f)
# replace item changes with new submission
report.item_changes.all().delete()
create_item_changes_for_report(report, remove_map, add_map, quote_price_map)
else:
report = InstallationReport.objects.create(
assignment=assignment,
description=description,
visited_date=visited_date or None,
new_water_meter_serial=new_serial or None,
seal_number=seal_number or None,
is_meter_suspicious=is_suspicious,
utm_x=utm_x,
utm_y=utm_y,
created_by=request.user,
)
# photos
for f in request.FILES.getlist('photos'):
InstallationPhoto.objects.create(report=report, image=f)
# item changes
create_item_changes_for_report(report, remove_map, add_map, quote_price_map)
# After installer submits/edits, set step back to in_progress and clear approvals
step_instance.status = 'in_progress'
step_instance.completed_at = None
step_instance.save()
try:
step_instance.approvals.all().delete()
except Exception:
pass
# If the report was edited, ensure downstream steps reopen like invoices flow
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance and subsequent_step_instance.status == 'completed':
# Reopen the step
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
# Clear previous approvals if any
try:
subsequent_step_instance.approvals.all().delete()
except Exception:
pass
except Exception:
pass
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
messages.success(request, 'گزارش ثبت شد و در انتظار تایید است.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
# Build prefill maps from existing report changes
removed_ids = set()
@ -389,11 +433,13 @@ def installation_report_step(request, instance_id, step_id):
'step': step,
'assignment': assignment,
'report': existing_report,
'form': form,
'edit_mode': edit_mode,
'user_is_installer': user_is_installer,
'quote': quote,
'quote_items': quote_items,
'all_items': items,
'manufacturers': manufacturers,
'removed_ids': removed_ids,
'removed_qty': removed_qty,
'added_map': added_map,
@ -403,6 +449,7 @@ def installation_report_step(request, instance_id, step_id):
'approver_statuses': approver_statuses,
'user_can_approve': user_can_approve,
'can_approve_reject': can_approve_reject,
'current_user_has_decided': current_user_has_decided,
})

View file

@ -44,11 +44,11 @@ class PaymentInline(admin.TabularInline):
@admin.register(Invoice)
class InvoiceAdmin(SimpleHistoryAdmin):
list_display = ['name', 'process_instance', 'customer', 'status_display', 'final_amount', 'paid_amount', 'remaining_amount', 'due_date']
list_display = ['name', 'process_instance', 'customer', 'status_display', 'final_amount', 'paid_amount_display', 'remaining_amount_display', 'due_date']
list_filter = ['status', 'created', 'due_date', 'process_instance__process']
search_fields = ['name', 'customer__username', 'customer__first_name', 'customer__last_name', 'notes']
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ['deleted_at', 'created', 'updated', 'total_amount', 'discount_amount', 'final_amount', 'paid_amount', 'remaining_amount']
readonly_fields = ['deleted_at', 'created', 'updated', 'total_amount', 'discount_amount', 'final_amount', 'paid_amount_display', 'remaining_amount_display']
inlines = [InvoiceItemInline, PaymentInline]
ordering = ['-created']
@ -56,6 +56,16 @@ class InvoiceAdmin(SimpleHistoryAdmin):
return mark_safe(obj.get_status_display_with_color())
status_display.short_description = "وضعیت"
def paid_amount_display(self, obj):
return f"{obj.get_paid_amount():,.0f} ریال"
paid_amount_display.short_description = "مبلغ پرداخت شده"
def remaining_amount_display(self, obj):
amount = obj.get_remaining_amount()
color = "green" if amount <= 0 else "red"
return format_html('<span style="color: {};">{:,.0f} ریال</span>', color, amount)
remaining_amount_display.short_description = "مبلغ باقی‌مانده"
@admin.register(Payment)
class PaymentAdmin(SimpleHistoryAdmin):
list_display = ['invoice', 'amount', 'payment_method', 'payment_date', 'created_by']

View file

@ -0,0 +1,29 @@
# Generated by Django 5.2.4 on 2025-10-04 08:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('invoices', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='historicalinvoice',
name='paid_amount',
),
migrations.RemoveField(
model_name='historicalinvoice',
name='remaining_amount',
),
migrations.RemoveField(
model_name='invoice',
name='paid_amount',
),
migrations.RemoveField(
model_name='invoice',
name='remaining_amount',
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-10-09 10:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoices', '0002_remove_historicalinvoice_paid_amount_and_more'),
]
operations = [
migrations.AddField(
model_name='historicalpayment',
name='payment_stage',
field=models.CharField(choices=[('quote', 'پیش\u200cفاکتور'), ('final_settlement', 'تسویه نهایی')], default='quote', max_length=20, verbose_name='مرحله پرداخت'),
),
migrations.AddField(
model_name='payment',
name='payment_stage',
field=models.CharField(choices=[('quote', 'پیش\u200cفاکتور'), ('final_settlement', 'تسویه نهایی')], default='quote', max_length=20, verbose_name='مرحله پرداخت'),
),
]

View file

@ -38,7 +38,7 @@ class Item(NameSlugModel):
ordering = ['name']
def __str__(self):
return f"{self.name} - {self.unit_price} تومان"
return f"{self.name} - {self.unit_price} ریال"
class Quote(NameSlugModel):
"""مدل پیش‌فاکتور"""
@ -106,7 +106,6 @@ class Quote(NameSlugModel):
def calculate_totals(self):
"""محاسبه مبالغ کل"""
total = sum(item.total_price for item in self.items.filter(is_deleted=False).all())
total = sum(item.total_price for item in self.items.filter(is_deleted=False).all())
self.total_amount = total
# محاسبه تخفیف
@ -115,7 +114,14 @@ class Quote(NameSlugModel):
else:
self.discount_amount = 0
self.final_amount = self.total_amount - self.discount_amount
# محاسبه مبلغ نهایی با احتساب مالیات
base_amount = self.total_amount - self.discount_amount
try:
vat_rate = Decimal(str(getattr(settings, 'VAT_RATE', 0)))
except Exception:
vat_rate = Decimal('0')
vat_amount = base_amount * vat_rate
self.final_amount = base_amount + vat_amount
self.save()
def get_status_display_with_color(self):
@ -131,11 +137,11 @@ class Quote(NameSlugModel):
return '<span class="badge bg-{}">{}</span>'.format(color, self.get_status_display())
def get_paid_amount(self):
"""مبلغ پرداخت شده برای این پیش‌فاکتور بر اساس پرداخت‌های فاکتور مرتبط"""
"""خالص پرداختی (دریافتی از مشتری منهای پرداختی به مشتری) برای این پیش‌فاکتور بر اساس پرداخت‌های فاکتور مرتبط"""
invoice = Invoice.objects.filter(quote=self).first()
if not invoice:
return Decimal('0')
return sum(p.amount for p in invoice.payments.filter(is_deleted=False).all())
return sum((p.amount if p.direction == 'in' else -p.amount) for p in invoice.payments.filter(is_deleted=False).all())
def get_remaining_amount(self):
"""مبلغ باقی‌مانده بر اساس پرداخت‌ها"""
@ -145,6 +151,15 @@ class Quote(NameSlugModel):
remaining = Decimal('0')
return remaining
def get_vat_amount(self) -> Decimal:
"""محاسبه مبلغ مالیات به صورت جداگانه بر اساس VAT_RATE."""
base_amount = (self.total_amount or Decimal('0')) - (self.discount_amount or Decimal('0'))
try:
vat_rate = Decimal(str(getattr(settings, 'VAT_RATE', 0)))
except Exception:
vat_rate = Decimal('0')
return base_amount * vat_rate
class QuoteItem(BaseModel):
"""مدل آیتم‌های پیش‌فاکتور"""
quote = models.ForeignKey(Quote, on_delete=models.CASCADE, related_name='items', verbose_name="پیش‌فاکتور")
@ -222,18 +237,6 @@ class Invoice(NameSlugModel):
default=0,
verbose_name="مبلغ نهایی"
)
paid_amount = models.DecimalField(
max_digits=15,
decimal_places=2,
default=0,
verbose_name="مبلغ پرداخت شده"
)
remaining_amount = models.DecimalField(
max_digits=15,
decimal_places=2,
default=0,
verbose_name="مبلغ باقی‌مانده"
)
due_date = models.DateField(verbose_name="تاریخ سررسید")
notes = models.TextField(verbose_name="یادداشت‌ها", blank=True)
created_by = models.ForeignKey(
@ -263,23 +266,50 @@ class Invoice(NameSlugModel):
else:
self.discount_amount = 0
self.final_amount = self.total_amount - self.discount_amount
# خالص مانده به نفع شرکت (مثبت) یا به نفع مشتری (منفی)
net_due = self.final_amount - self.paid_amount
self.remaining_amount = net_due
# وضعیت بر اساس مانده خالص
# محاسبه مبلغ نهایی با احتساب مالیات
base_amount = self.total_amount - self.discount_amount
try:
vat_rate = Decimal(str(getattr(settings, 'VAT_RATE', 0)))
except Exception:
vat_rate = Decimal('0')
vat_amount = base_amount * vat_rate
self.final_amount = base_amount + vat_amount
# وضعیت بر اساس مانده خالص (استفاده از تابع‌ها)
paid = self.get_paid_amount()
net_due = self.final_amount - paid
if net_due == 0:
self.status = 'paid'
elif net_due > 0:
# مشتری هنوز باید پرداخت کند
self.status = 'partially_paid' if self.paid_amount > 0 else 'sent'
self.status = 'partially_paid' if paid > 0 else 'sent'
else:
# شرکت باید به مشتری پرداخت کند
self.status = 'partially_paid'
self.save()
def get_paid_amount(self):
"""مبلغ پرداخت شده بر اساس پرداخت‌ها (مثل Quote)"""
return sum((p.amount if p.direction == 'in' else -p.amount) for p in self.payments.filter(is_deleted=False).all())
def get_remaining_amount(self):
"""مبلغ باقی‌مانده بر اساس پرداخت‌ها (مثل Quote)"""
paid = self.get_paid_amount()
remaining = self.final_amount - paid
return remaining
def get_vat_amount(self) -> Decimal:
"""محاسبه مبلغ مالیات به صورت جداگانه بر اساس VAT_RATE."""
base_amount = (self.total_amount or Decimal('0')) - (self.discount_amount or Decimal('0'))
try:
vat_rate = Decimal(str(getattr(settings, 'VAT_RATE', 0)))
except Exception:
vat_rate = Decimal('0')
return base_amount * vat_rate
def get_status_display_with_color(self):
"""نمایش وضعیت با رنگ"""
status_colors = {
@ -320,6 +350,11 @@ class InvoiceItem(BaseModel):
class Payment(BaseModel):
"""مدل پرداخت‌ها"""
PAYMENT_STAGE_CHOICES = [
('quote', 'پیش‌فاکتور'),
('final_settlement', 'تسویه نهایی'),
]
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments', verbose_name="فاکتور")
amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="مبلغ پرداخت")
direction = models.CharField(
@ -340,6 +375,12 @@ class Payment(BaseModel):
default='cash',
verbose_name="روش پرداخت"
)
payment_stage = models.CharField(
max_length=20,
choices=PAYMENT_STAGE_CHOICES,
default='quote',
verbose_name="مرحله پرداخت"
)
reference_number = models.CharField(max_length=100, verbose_name="شماره مرجع", blank=True, unique=True)
payment_date = models.DateField(verbose_name="تاریخ پرداخت")
notes = models.TextField(verbose_name="یادداشت‌ها", blank=True)
@ -353,22 +394,18 @@ class Payment(BaseModel):
ordering = ['-payment_date']
def __str__(self):
return f"پرداخت {self.amount} تومان - {self.invoice.name}"
return f"پرداخت {self.amount} ریال - {self.invoice.name}"
def save(self, *args, **kwargs):
"""بروزرسانی مبالغ فاکتور"""
super().save(*args, **kwargs)
# بروزرسانی مبلغ پرداخت شده فاکتور
total_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in self.invoice.payments.filter(is_deleted=False).all())
self.invoice.paid_amount = total_paid
# فقط مجدداً calculate_totals را صدا کن (مثل Quote)
self.invoice.calculate_totals()
def delete(self, using=None, keep_parents=False):
"""حذف نرم و بروزرسانی مبالغ فاکتور پس از حذف"""
result = super().delete(using=using, keep_parents=keep_parents)
try:
total_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in self.invoice.payments.filter(is_deleted=False).all())
self.invoice.paid_amount = total_paid
self.invoice.calculate_totals()
except Exception:
pass

View file

@ -7,6 +7,7 @@
{% load static %}
{% load humanize %}
{% load common_tags %}
<!-- Fonts (match base) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@ -48,67 +49,38 @@
</head>
<body>
<div class="container-fluid">
<!-- Header -->
<!-- Invoice Header (compact, matches preview) -->
<div class="invoice-header">
<div class="row align-items-center">
<div class="col-6 d-flex align-items-center">
<div class="me-3" style="width:64px;height:64px;display:flex;align-items:center;justify-content:center;background:#eef2ff;border-radius:8px;">
{% if instance.broker.company and instance.broker.company.logo %}
<img src="{{ instance.broker.company.logo.url }}" alt="لوگو" style="max-height:58px;max-width:120px;">
{% else %}
<span class="company-logo">شرکت</span>
{% endif %}
</div>
<div>
{% if instance.broker.company %}
{{ instance.broker.company.name }}
{% endif %}
{% if instance.broker.company %}
<div class="text-muted small">
{% if instance.broker.company.address %}
<div>{{ instance.broker.company.address }}</div>
{% endif %}
{% if instance.broker.affairs.county.city.name %}
<div>{{ instance.broker.affairs.county.city.name }}، ایران</div>
{% endif %}
{% if instance.broker.company.phone %}
<div>تلفن: {{ instance.broker.company.phone }}</div>
{% endif %}
<div class="row align-items-start justify-content-end">
<h5 class="mb-0 text-center fw-bold">فاکتور</h5>
<div class="col-3 text-start">
<div class="mt-2">
<div>شماره : {{ instance.code }}</div>
<div class="small">تاریخ صدور: {{ invoice.jcreated_date }}</div>
</div>
{% endif %}
</div>
</div>
<div class="col-6 text-end">
<div class="mt-2">
<div><strong>#فاکتور نهایی {{ instance.code }}</strong></div>
<div class="text-muted small">تاریخ صدور: {{ invoice.jcreated_date }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Customer & Well Info -->
<div class="row mb-3">
<div class="col-6">
<h6 class="fw-bold mb-2">اطلاعات مشترک</h6>
<div class="small mb-1"><span class="text-muted">نام:</span> {{ invoice.customer.get_full_name|default:instance.representative.get_full_name }}</div>
<h6 class="fw-bold mb-2">اطلاعات مشترک {% if instance.representative.profile and instance.representative.profile.user_type == 'legal' %}(حقوقی){% else %}(حقیقی){% endif %}</h6>
<div class="col-4 small mb-1"><span class="text-muted">شماره اشتراک آب:</span> {{ instance.well.water_subscription_number }}</div>
{% if instance.representative.profile and instance.representative.profile.user_type == 'legal' %}
<div class="col-4 small mb-1"><span class="text-muted">نام شرکت:</span> {{ instance.representative.profile.company_name|default:"-" }}</div>
<div class="col-4 small mb-1"><span class="text-muted">شناسه ملی:</span> {{ instance.representative.profile.company_national_id|default:"-" }}</div>
{% endif %}
<div class="col-4 small mb-1"><span class="text-muted">نام و نام خانوادگی:</span> {{ invoice.customer.get_full_name|default:instance.representative.get_full_name }}</div>
{% if instance.representative.profile and instance.representative.profile.national_code %}
<div class="small mb-1"><span class="text-muted">کد ملی:</span> {{ instance.representative.profile.national_code }}</div>
<div class="col-4 small mb-1"><span class="text-muted">کد ملی:</span> {{ instance.representative.profile.national_code }}</div>
{% endif %}
{% if instance.representative.profile and instance.representative.profile.phone_number_1 %}
<div class="small mb-1"><span class="text-muted">تلفن:</span> {{ instance.representative.profile.phone_number_1 }}</div>
<div class="col-4 small mb-1"><span class="text-muted">تلفن:</span> {{ instance.representative.profile.phone_number_1 }}</div>
{% endif %}
{% if instance.representative.profile and instance.representative.profile.address %}
<div class="small"><span class="text-muted">آدرس:</span> {{ instance.representative.profile.address }}</div>
<div class="col-12 small"><span class="text-muted">آدرس:</span> {{ instance.representative.profile.address }}</div>
{% endif %}
</div>
<div class="col-6">
<h6 class="fw-bold mb-2">اطلاعات چاه</h6>
<div class="small mb-1"><span class="text-muted">شماره اشتراک آب:</span> {{ instance.well.water_subscription_number }}</div>
<div class="small mb-1"><span class="text-muted">شماره اشتراک برق:</span> {{ instance.well.electricity_subscription_number|default:"-" }}</div>
<div class="small mb-1"><span class="text-muted">سریال کنتور:</span> {{ instance.well.water_meter_serial_number|default:"-" }}</div>
<div class="small"><span class="text-muted">قدرت چاه:</span> {{ instance.well.well_power|default:"-" }}</div>
</div>
</div>
<!-- Items Table -->
@ -120,8 +92,8 @@
<th style="width: 30%">شرح کالا/خدمات</th>
<th style="width: 30%">توضیحات</th>
<th style="width: 10%">تعداد</th>
<th style="width: 12.5%">قیمت واحد(تومان)</th>
<th style="width: 12.5%">قیمت کل(تومان)</th>
<th style="width: 12.5%">قیمت واحد(ریال)</th>
<th style="width: 12.5%">قیمت کل(ریال)</th>
</tr>
</thead>
<tbody>
@ -140,43 +112,43 @@
</tbody>
<tfoot>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>جمع کل(تومان):</strong></td>
<td><strong>{{ invoice.total_amount|floatformat:0|intcomma:False }}</strong></td>
<td colspan="3" class="text-start"><strong>جمع کل(ریال):</strong></td>
<td colspan="6" class="text-end"><strong>{{ invoice.total_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
{% if invoice.discount_amount > 0 %}
<tr class="total-section">
<td colspan="5" class="text-end"><strong>تخفیف(تومان):</strong></td>
<td><strong>{{ invoice.discount_amount|floatformat:0|intcomma:False }}</strong></td>
<td colspan="3" class="text-start"><strong>تخفیف(ریال):</strong></td>
<td colspan="6" class="text-end"><strong>{{ invoice.discount_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
{% endif %}
<tr class="total-section">
<td colspan="3" class="text-start"><strong>مالیات بر ارزش افزوده(ریال):</strong></td>
<td colspan="6" class="text-end"><strong>{{ invoice.get_vat_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی(تومان):</strong></td>
<td><strong>{{ invoice.final_amount|floatformat:0|intcomma:False }}</strong></td>
<td colspan="3" class="text-start"><strong>مبلغ نهایی (شامل مالیات)(ریال):</strong></td>
<td colspan="6" class="text-end"><strong>{{ invoice.final_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>پرداختی‌ها(تومان):</strong></td>
<td><strong">{{ invoice.paid_amount|floatformat:0|intcomma:False }}</strong></td>
<td colspan="3" class="text-start"><strong>پرداختی‌ها(ریال):</strong></td>
<td colspan="6" class="text-end"><strong">{{ invoice.get_paid_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>مانده(تومان):</strong></td>
<td><strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }}</strong></td>
<td colspan="3" class="text-start"><strong>مانده(ریال):</strong></td>
<td colspan="6" class="text-end"><strong>{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section small border-top border-2">
<td colspan="2" class="text-start"><strong>مبلغ نهایی به حروف:</strong></td>
<td colspan="6" class="text-end"><strong>{{ invoice.final_amount|amount_to_words }}</strong></td>
</tr>
</tfoot>
</table>
</div>
<!-- Conditions & Payment -->
<div class="row">
<div class="col-8">
<h6 class="fw-bold">مهر و امضا:</h6>
<ul class="small mb-0">
{% if instance.broker.company and instance.broker.company.signature %}
<li class="mt-3" style="list-style:none;"><img src="{{ instance.broker.company.signature.url }}" alt="امضا" style="height: 200px;"></li>
{% endif %}
</ul>
</div>
{% if instance.broker.company %}
<div class="col-4">
<div class="col-8">
<h6 class="fw-bold mb-2">اطلاعات پرداخت</h6>
{% if instance.broker.company.card_number %}
<div class="small mb-1"><span class="text-muted">شماره کارت:</span> {{ instance.broker.company.card_number }}</div>
@ -192,6 +164,20 @@
{% endif %}
</div>
{% endif %}
<div class="col-4">
{% if instance.broker.company and instance.broker.company.signature %}
<div class="row d-flex justify-content-center">
<h6 class="mb-3 text-center">مهر و امضا
{% if instance.broker.company.signature %}
<img class="img-fluid" src="{{ instance.broker.company.signature.url }}" alt="امضای شرکت" style="">
{% endif %}
</h6>
</div>
{% endif %}
</div>
</div>
</div>

View file

@ -67,24 +67,24 @@
<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 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 class="h5 mt-1 text-success">{{ invoice.get_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 class="h5 mt-1 {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} ریال</div>
</div>
</div>
<div class="col-6 col-md-3 d-flex align-items-center">
{% if invoice.remaining_amount <= 0 %}
{% if invoice.get_remaining_amount <= 0 %}
<span class="badge bg-success">تسویه کامل</span>
{% else %}
<span class="badge bg-warning text-dark">باقی‌مانده دارد</span>
@ -100,13 +100,13 @@
<th class="text-center">افزوده</th>
<th class="text-center">حذف</th>
<th class="text-center">تعداد نهایی</th>
<th class="text-end">قیمت واحد (تومان)</th>
<th class="text-end">قیمت کل (تومان)</th>
<th class="text-end">قیمت واحد (ریال)</th>
<th class="text-end">قیمت کل (ریال)</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<tr class="{% if r.is_removed %}table-light text-muted{% endif %}">
<td>
<div class="d-flex flex-column">
<span class="fw-semibold">{{ r.item.name }}</span>
@ -118,7 +118,13 @@
<td class="text-center text-danger">{{ r.removed_qty }}</td>
<td class="text-center">{{ r.quantity }}</td>
<td class="text-end">{{ r.unit_price|floatformat:0|intcomma:False }}</td>
<td class="text-end">{{ r.total_price|floatformat:0|intcomma:False }}</td>
<td class="text-end">
{% if r.is_removed %}
<span class="text-muted">-</span>
{% else %}
{{ r.total_price|floatformat:0|intcomma:False }}
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="7" class="text-center text-muted">آیتمی یافت نشد</td></tr>
@ -147,23 +153,27 @@
<tfoot>
<tr>
<th colspan="6" class="text-end">مبلغ کل</th>
<th class="text-end">{{ invoice.total_amount|floatformat:0|intcomma:False }} تومان</th>
<th class="text-end">{{ invoice.total_amount|floatformat:0|intcomma:False }} ریال</th>
</tr>
<tr>
<th colspan="6" class="text-end">تخفیف</th>
<th class="text-end">{{ invoice.discount_amount|floatformat:0|intcomma:False }} تومان</th>
<th class="text-end">{{ invoice.discount_amount|floatformat:0|intcomma:False }} ریال</th>
</tr>
<tr>
<th colspan="6" class="text-end">مبلغ نهایی</th>
<th class="text-end">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</th>
<th colspan="6" class="text-end">مالیات بر ارزش افزوده</th>
<th class="text-end">{{ invoice.get_vat_amount|floatformat:0|intcomma:False }} ریال</th>
</tr>
<tr>
<th colspan="6" class="text-end">مبلغ نهایی (با مالیات)</th>
<th class="text-end">{{ invoice.final_amount|floatformat:0|intcomma:False }} ریال</th>
</tr>
<tr>
<th colspan="6" class="text-end">پرداختی‌ها</th>
<th class="text-end">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</th>
<th class="text-end">{{ invoice.get_paid_amount|floatformat:0|intcomma:False }} ریال</th>
</tr>
<tr>
<th colspan="6" class="text-end">مانده</th>
<th class="text-end {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</th>
<th class="text-end {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} ریال</th>
</tr>
</tfoot>
</table>
@ -217,8 +227,8 @@
</select>
</div>
<div class="mb-3">
<label class="form-label">مبلغ (تومان)</label>
<input type="number" class="form-control" name="amount" id="id_charge_amount" min="1" required>
<label class="form-label">مبلغ (ریال)</label>
<input type="text" inputmode="numeric" pattern="\d*" class="form-control" name="amount" id="id_charge_amount" dir="ltr" autocomplete="off" required>
</div>
</form>
</div>
@ -240,8 +250,17 @@
else { el.classList.add('show'); el.style.display = 'block'; }
}
function submitSpecialCharge(){
const fd = new FormData(document.getElementById('specialChargeForm'));
const form = document.getElementById('specialChargeForm');
const fd = new FormData(form);
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
// Ensure raw numeric amount is sent
(function ensureRawAmount(){
const amountInput = document.getElementById('id_charge_amount');
if (amountInput){
const raw = (amountInput.getAttribute('data-raw-value') || amountInput.value.replace(/\D/g, ''));
if (raw) fd.set('amount', raw);
}
})();
fetch('{% url "invoices:add_special_charge" instance.id step.id %}', { method: 'POST', body: fd })
.then(r=>r.json()).then(resp=>{
if (resp.success){
@ -279,6 +298,8 @@
}
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
});
// Number formatting is handled by number-formatter.js
</script>
{% endblock %}

View file

@ -42,6 +42,11 @@
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer me-2"></i> پرینت
</a>
{% if request.user|is_manager and step_instance.status != 'approved' and step_instance.status != 'completed' and invoice.get_remaining_amount != 0 %}
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#forceApproveModal">
<i class="bx bx-bolt-circle me-1"></i> تایید اضطراری
</button>
{% endif %}
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
@ -55,7 +60,7 @@
<div class="bs-stepper-content">
<div class="row g-3">
{% if is_broker %}
{% if is_broker and needs_approval %}
<div class="col-12 col-lg-5">
<div class="card border h-100">
<div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div>
@ -70,8 +75,8 @@
</select>
</div>
<div class="mb-3">
<label class="form-label">مبلغ (تومان)</label>
<input type="number" min="1" class="form-control" name="amount" id="id_amount" required>
<label class="form-label">مبلغ (ریال)</label>
<input type="text" inputmode="numeric" pattern="\d*" class="form-control" name="amount" id="id_amount" dir="ltr" autocomplete="off" required>
</div>
<div class="mb-3">
<label class="form-label">تاریخ</label>
@ -106,30 +111,34 @@
<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 d-flex justify-content-between">
<h5 class="mb-0">وضعیت فاکتور</h5>
<h5 class="mb-0">وضعیت فاکتور
{% if step_instance.status == 'approved' %}
<span class="badge bg-warning">تایید اضطراری</span>
{% endif %}
</h5>
</div>
<div class="card-body">
<div class="row g-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 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-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 class="h5 mt-1 text-success">{{ invoice.get_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 class="h5 mt-1 {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} ریال</div>
</div>
</div>
<div class="col-6 d-flex align-items-center">
{% if invoice.remaining_amount <= 0 %}
{% if invoice.get_remaining_amount <= 0 %}
<span class="badge bg-success">تسویه کامل</span>
{% else %}
<span class="badge bg-warning text-dark">باقی‌مانده دارد</span>
@ -157,7 +166,7 @@
{% 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.amount|floatformat:0|intcomma:False }} ریال</td>
<td>{{ p.jpayment_date }}</td>
<td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td>
@ -184,15 +193,22 @@
</div>
</div>
</div>
{% if approver_statuses %}
{% if approver_statuses and needs_approval and step_instance.status != 'completed' %}
<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">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
</div>
{% if current_user_has_decided %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" disabled>تایید</button>
<button type="button" class="btn btn-danger btn-sm" disabled>رد</button>
</div>
{% else %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveFinalSettleModal">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
</div>
{% endif %}
{% endif %}
</div>
<div class="card-body py-3">
@ -243,6 +259,32 @@
</div>
</div>
</div>
<!-- Force Approve Modal -->
<div class="modal fade" id="forceApproveModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="force_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 class="alert alert-warning" role="alert">
با تایید اضطراری ممکن است هنوز پرداخت کامل نشده باشد و این مرحله به صورت استثنا تایید می‌شود.
</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-warning">تایید اضطراری</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal (final settlement payments) -->
@ -276,9 +318,13 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if invoice.remaining_amount != 0 %}
{% if not needs_approval %}
<div class="alert alert-info" role="alert">
فاکتور کاملاً تسویه شده است و نیازی به تایید ندارد.
</div>
{% elif invoice.get_remaining_amount != 0 %}
<div class="alert alert-warning" role="alert">
مانده فاکتور: <strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br>
مانده فاکتور: <strong>{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} ریال</strong><br>
امکان تایید تا تسویه کامل فاکتور وجود ندارد.
</div>
{% else %}
@ -287,7 +333,7 @@
</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>
<button type="submit" class="btn btn-success" {% if invoice.get_remaining_amount != 0 %}disabled{% endif %}>تایید</button>
</div>
</form>
</div>
@ -363,6 +409,14 @@
function buildForm(){
const fd = new FormData(document.getElementById('formFinalPayment'));
// Ensure raw numeric amount is sent
(function ensureRawAmount(){
const amountInput = document.getElementById('id_amount');
if (amountInput){
const raw = (amountInput.getAttribute('data-raw-value') || amountInput.value.replace(/\D/g, ''));
if (raw) fd.set('amount', raw);
}
})();
// تبدیل تاریخ شمسی به میلادی برای ارسال
const persianDateValue = $('#id_payment_date').val();
@ -423,6 +477,24 @@
}
// Legacy approve button removed; using modal forms below
// Handle AJAX form submission with number formatting
$(document).ready(function() {
// Override buildForm function for AJAX submission
const originalBuildForm = window.buildForm;
window.buildForm = function() {
// Set raw values before creating FormData
if (window.setRawValuesForSubmission) {
window.setRawValuesForSubmission();
}
const result = originalBuildForm ? originalBuildForm() : new FormData(document.querySelector('form'));
// Restore formatted values for display
if (window.restoreFormattedValues) {
window.restoreFormattedValues();
}
return result;
};
});
</script>
{% endblock %}

View file

@ -71,11 +71,11 @@
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">مبلغ (تومان)</label>
<input type="number" min="1" class="form-control" name="amount" id="id_amount" required>
<label class="form-label">مبلغ (ریال)</label>
<input type="text" inputmode="numeric" pattern="\d*" class="form-control" name="amount" id="id_amount" dir="ltr" autocomplete="off" required>
</div>
<div class="mb-3">
<label class="form-label">تاریخ پرداخت</label>
<label class="form-label">تاریخ پرداخت/سررسید چک</label>
<input type="text" class="form-control" id="id_payment_date" name="payment_date" placeholder="انتخاب تاریخ" readonly required>
</div>
<div class="mb-3">
@ -89,7 +89,7 @@
</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">
@ -116,20 +116,20 @@
<div class="row g-3">
<div class="col-6">
<div class="border rounded p-3">
<div class="small text-muted">مبلغ نهایی پیش‌فاکتور</div>
<div class="h5 mt-1">{{ totals.final_amount|floatformat:0|intcomma:False }} تومان</div>
<div class="small text-muted">مبلغ نهایی پیش‌فاکتور (با مالیات)</div>
<div class="h5 mt-1">{{ totals.final_amount|floatformat:0|intcomma:False }} ریال</div>
</div>
</div>
<div class="col-6">
<div class="border rounded p-3">
<div class="small text-muted">مبلغ پرداخت‌شده</div>
<div class="h5 mt-1 text-success">{{ totals.paid_amount|floatformat:0|intcomma:False }} تومان</div>
<div class="h5 mt-1 text-success">{{ totals.paid_amount|floatformat:0|intcomma:False }} ریال</div>
</div>
</div>
<div class="col-6">
<div class="border rounded p-3">
<div class="small text-muted">مانده</div>
<div class="h5 mt-1 {% if totals.is_fully_paid %}text-success{% else %}text-danger{% endif %}">{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان</div>
<div class="h5 mt-1 {% if totals.is_fully_paid %}text-success{% else %}text-danger{% endif %}">{{ totals.remaining_amount|floatformat:0|intcomma:False }} ریال</div>
</div>
</div>
<div class="col-6 d-flex align-items-center">
@ -153,17 +153,19 @@
<table class="table table-striped mb-0">
<thead>
<tr>
<th>نوع</th>
<th>مبلغ</th>
<th>تاریخ</th>
<th>تاریخ پرداخت/سررسید چک</th>
<th>روش</th>
<th>شماره مرجع/چک</th>
<th>شماره پیگیری/شماره صیادی چک</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
{% for p in payments %}
<tr>
<td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
<td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td>
<td class="{% if p.direction == 'in' %}text-success{% else %}text-danger{% endif %}">{{ p.amount|floatformat:0|intcomma:False }} ریال</td>
<td>{{ p.jpayment_date }}</td>
<td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td>
@ -175,9 +177,7 @@
</a>
{% endif %}
{% 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>
<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>
@ -197,10 +197,17 @@
<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">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
</div>
{% if current_user_has_decided %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" disabled>تایید</button>
<button type="button" class="btn btn-danger btn-sm" disabled>رد</button>
</div>
{% else %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
</div>
{% endif %}
{% endif %}
</div>
<div class="card-body py-3">
@ -294,7 +301,7 @@
{% 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 class="mt-1">مانده: <strong>{{ totals.remaining_amount|floatformat:0|intcomma:False }} ریال</strong></div>
</div>
آیا مطمئن هستید که می‌خواهید مرحله را تایید کنید؟
{% else %}
@ -359,6 +366,12 @@
}
const form = document.getElementById('formAddPayment');
const fd = buildFormData(form);
// Ensure raw numeric amount is sent
(function ensureRawAmount(){
const amountInput = document.getElementById('id_amount');
const raw = (amountInput.getAttribute('data-raw-value') || amountInput.value.replace(/\D/g, ''));
if (raw) fd.set('amount', raw);
})();
// تبدیل تاریخ شمسی به میلادی برای ارسال
const persianDateValue = $('#id_payment_date').val();
@ -376,7 +389,7 @@
setTimeout(() => { window.location.href = resp.redirect; }, 700);
}
} else {
showToast(resp.message + ':' + resp.error || 'خطا در ثبت فیش', 'danger');
showToast((resp.message || resp.error || 'خطا در ثبت فیش'), 'danger');
}
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
});
@ -453,6 +466,7 @@
} catch (e) { console.error('Error initializing Persian Date Picker:', e); }
}
})();
</script>
{% endblock %}

View file

@ -2,6 +2,7 @@
{% load static %}
{% load processes_tags %}
{% load humanize %}
{% load common_tags %}
{% block sidebar %}
{% include 'sidebars/admin.html' %}
@ -56,8 +57,9 @@
<!-- Invoice Preview Card -->
<div class="card invoice-preview-card mt-4 border">
<div class="card-body">
<div class="d-flex justify-content-between flex-xl-row flex-md-column flex-sm-row flex-column p-sm-3 p-0 align-items-center">
<div class="mb-xl-0 mb-4">
<h5 class="mb-0 text-center fw-bold">پیش‌فاکتور</h5>
<div class="d-flex justify-content-end flex-xl-row flex-md-column flex-sm-row flex-column p-0 align-items-center">
<div class="mb-xl-0 mb-4 d-none">
<!-- Company Logo & Info -->
<div class="d-flex align-items-center">
<div class="avatar avatar-lg me-3">
@ -94,13 +96,13 @@
</div>
</div>
<!-- Invoice Details -->
<div class="text-center">
<div class="mb-3">
<h5 class="text-body">#{{ quote.name }}</h5>
<div class="text-start">
<div class="">
<h6 class="text-body">شماره : {{ quote.name }}</h6>
</div>
<div class="invoice-details">
<div class="d-flex justify-content-end align-items-center mb-2">
<span class="text-muted me-2">تاریخ صدور:</span>
<span class="me-2">تاریخ صدور:</span>
<span class="fw-medium text-body">{{ quote.jcreated_date }}</span>
</div>
</div>
@ -110,39 +112,59 @@
<hr class="my-0">
<div class="card-body py-1">
<div class="row">
<div class="col-xl-6 col-md-12 col-sm-6 col-12 mb-3">
<div class="col-xl-12 col-md-12 col-sm-12 col-12 mb-3">
<div class="">
<div class="card-body p-3">
<h6 class="card-title text-primary mb-2">
<i class="bx bx-user me-1"></i>اطلاعات مشترک
<i class="bx bx-user me-1"></i>
{% if instance.representative.profile.user_type == 'legal' %}
اطلاعات مشترک (حقوقی)
{% else %}
اطلاعات مشترک (حقیقی)
{% endif %}
</h6>
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">نام:</span>
<span class="fw-medium small">{{ quote.customer.get_full_name }}</span>
<div class="row">
<div class="col-md-3 d-flex gap-2 mb-1">
<span class="text-muted small">شماره اشتراک آب:</span>
<span class="fw-medium small">{{ instance.well.water_subscription_number }}</span>
</div>
{% if instance.representative.profile.user_type == 'legal' %}
<div class="col-md-3 d-flex gap-2 mb-1">
<span class="text-muted small">نام شرکت:</span>
<span class="fw-medium small">{{ instance.representative.profile.company_name|default:"-" }}</span>
</div>
<div class="col-md-3 d-flex gap-2 mb-1">
<span class="text-muted small">شناسه ملی:</span>
<span class="fw-medium small">{{ instance.representative.profile.company_national_id|default:"-" }}</span>
</div>
{% endif %}
<div class="col-md-3 d-flex gap-2 mb-1">
<span class="text-muted small">نام:</span>
<span class="fw-medium small">{{ quote.customer.get_full_name }}</span>
</div>
{% if instance.representative.profile.national_code %}
<div class="col-md-3 d-flex gap-2 mb-1">
<span class="text-muted small">کد ملی:</span>
<span class="fw-medium small">{{ instance.representative.profile.national_code }}</span>
</div>
{% endif %}
{% if instance.representative.profile.phone_number_1 %}
<div class="col-md-3 d-flex gap-2 mb-1">
<span class="text-muted small">تلفن:</span>
<span class="fw-medium small">{{ instance.representative.profile.phone_number_1 }}</span>
</div>
{% endif %}
{% if instance.representative.profile.address %}
<div class="col-md-12 d-flex gap-2">
<span class="text-muted small">آدرس:</span>
<span class="fw-medium small">{{ instance.representative.profile.address }}</span>
</div>
{% endif %}
</div>
{% if instance.representative.profile.national_code %}
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">کد ملی:</span>
<span class="fw-medium small">{{ instance.representative.profile.national_code }}</span>
</div>
{% endif %}
{% if instance.representative.profile.phone_number_1 %}
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">تلفن:</span>
<span class="fw-medium small">{{ instance.representative.profile.phone_number_1 }}</span>
</div>
{% endif %}
{% if instance.representative.profile.address %}
<div class="d-flex gap-2">
<span class="text-muted small">آدرس:</span>
<span class="fw-medium small">{{ instance.representative.profile.address }}</span>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-xl-6 col-md-12 col-sm-6 col-12 mb-3">
<div class="col-xl-6 col-md-12 col-sm-6 col-12 mb-3 d-none">
<div class="border-0 bg-light">
<div class="card-body p-3">
<h6 class="card-title text-primary mb-2">
@ -185,9 +207,9 @@
<tr>
<td class="text-nowrap">{{ quote_item.item.name }}</td>
<td class="text-nowrap">{{ quote_item.item.description|default:"-" }}</td>
<td>{{ quote_item.unit_price|floatformat:0|intcomma:False }} تومان</td>
<td>{{ quote_item.unit_price|floatformat:0|intcomma:False }} ریال</td>
<td>{{ quote_item.quantity }}</td>
<td>{{ quote_item.total_price|floatformat:0|intcomma:False }} تومان</td>
<td>{{ quote_item.total_price|floatformat:0|intcomma:False }} ریال</td>
</tr>
{% endfor %}
<tr>
@ -198,14 +220,18 @@
{% if quote.discount_amount > 0 %}
<p class="mb-2">تخفیف:</p>
{% endif %}
<p class="mb-0 fw-bold">مبلغ نهایی:</p>
<p class="mb-2">مالیات بر ارزش افزوده:</p>
<p class="mb-2 fw-bold">مبلغ نهایی (شامل مالیات):</p>
<p class="mb-0 small text-muted">مبلغ نهایی به حروف:</p>
</td>
<td class="px-4 py-5">
<p class="fw-medium mb-2">{{ quote.total_amount|floatformat:0|intcomma:False }} تومان</p>
<p class="fw-medium mb-2">{{ quote.total_amount|floatformat:0|intcomma:False }} ریال</p>
{% if quote.discount_amount > 0 %}
<p class="fw-medium mb-2">{{ quote.discount_amount|floatformat:0|intcomma:False }} تومان</p>
<p class="fw-medium mb-2">{{ quote.discount_amount|floatformat:0|intcomma:False }} ریال</p>
{% endif %}
<p class="fw-bold mb-0">{{ quote.final_amount|floatformat:0|intcomma:False }} تومان</p>
<p class="fw-medium mb-2">{{ quote.get_vat_amount|floatformat:0|intcomma:False }} ریال</p>
<p class="fw-bold mb-2">{{ quote.final_amount|floatformat:0|intcomma:False }} ریال</p>
<p class="mb-0 small text-muted">{{ quote.final_amount|amount_to_words }}</p>
</td>
</tr>
</tbody>
@ -228,50 +254,53 @@
<i class="bx bx-info-circle text-muted me-2"></i>
این برگه صرفاً جهت اعلام قیمت بوده و ارزش قانونی دیگری ندارد
</li>
{% if instance.broker.company.signature %}
<li class="mb-0 text-start mt-4 ms-5">
<img src="{{ instance.broker.company.signature.url }}" alt="امضای شرکت" style="height: 200px;">
</li>
{% endif %}
</ul>
{% if instance.broker.company %}
<div class="col-md-4 mt-4">
<h6 class="mb-1">اطلاعات پرداخت:</h6>
<div class="d-flex flex-column gap-1">
{% if instance.broker.company.card_number %}
<div>
<small class="text-muted">شماره کارت:</small>
<div class="fw-medium">{{ instance.broker.company.card_number }}</div>
</div>
{% endif %}
{% if instance.broker.company.account_number %}
<div>
<small class="text-muted">شماره حساب:</small>
<div class="fw-medium">{{ instance.broker.company.account_number }}</div>
</div>
{% endif %}
{% if instance.broker.company.sheba_number %}
<div>
<small class="text-muted">شماره شبا:</small>
<div class="fw-medium">{{ instance.broker.company.sheba_number }}</div>
</div>
{% endif %}
{% if instance.broker.company.bank_name %}
<div>
<small class="text-muted">بانک:</small>
<div class="fw-medium">{{ instance.broker.company.get_bank_name_display }}</div>
</div>
{% endif %}
{% if instance.broker.company.branch_name %}
<div>
<small class="text-muted">شعبه:</small>
<div class="fw-medium">{{ instance.broker.company.branch_name }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% if instance.broker.company %}
<div class="col-md-4">
<h6 class="mb-3">اطلاعات پرداخت:</h6>
<div class="d-flex flex-column gap-2">
{% if instance.broker.company.card_number %}
<div>
<small class="text-muted">شماره کارت:</small>
<div class="fw-medium">{{ instance.broker.company.card_number }}</div>
</div>
<div class="col-md-4 mt-5">
<div class="row d-flex justify-content-center">
<h6 class="mb-3 text-center">مهر و امضا</h6>
{% if instance.broker.company.signature %}
<img class="img-fluid" src="{{ instance.broker.company.signature.url }}" alt="امضای شرکت" style="">
{% endif %}
{% if instance.broker.company.account_number %}
<div>
<small class="text-muted">شماره حساب:</small>
<div class="fw-medium">{{ instance.broker.company.account_number }}</div>
</div>
{% endif %}
{% if instance.broker.company.sheba_number %}
<div>
<small class="text-muted">شماره شبا:</small>
<div class="fw-medium">{{ instance.broker.company.sheba_number }}</div>
</div>
{% endif %}
{% if instance.broker.company.bank_name %}
<div>
<small class="text-muted">بانک:</small>
<div class="fw-medium">{{ instance.broker.company.get_bank_name_display }}</div>
</div>
{% endif %}
{% if instance.broker.company.branch_name %}
<div>
<small class="text-muted">شعبه:</small>
<div class="fw-medium">{{ instance.broker.company.branch_name }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>

View file

@ -7,6 +7,7 @@
{% load static %}
{% load humanize %}
{% load common_tags %}
<!-- Fonts (match base) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@ -75,6 +76,7 @@
.items-table td {
border-bottom: 1px solid #dee2e6;
text-align: center;
font-size: 8px;
}
.total-section {
@ -105,38 +107,12 @@
<!-- Invoice Header (compact, matches preview) -->
<div class="invoice-header">
<div class="row align-items-center">
<div class="col-6 d-flex align-items-center">
<div class="me-3" style="width:64px;height:64px;display:flex;align-items:center;justify-content:center;background:#eef2ff;border-radius:8px;">
{% if instance.broker.company and instance.broker.company.logo %}
<img src="{{ instance.broker.company.logo.url }}" alt="لوگو" style="max-height:58px;max-width:120px;">
{% else %}
<span class="company-logo">شرکت</span>
{% endif %}
</div>
<div>
{% if instance.broker.company %}
{{ instance.broker.company.name }}
{% endif %}
{% if instance.broker.company %}
<div class="text-muted small">
{% if instance.broker.company.address %}
<div>{{ instance.broker.company.address }}</div>
{% endif %}
{% if instance.broker.affairs.county.city.name %}
<div>{{ instance.broker.affairs.county.city.name }}، ایران</div>
{% endif %}
{% if instance.broker.company.phone %}
<div>تلفن: {{ instance.broker.company.phone }}</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div class="col-6 text-end">
<div class="row align-items-start justify-content-end">
<h5 class="mb-3 text-center fw-bold">پیش‌فاکتور</h5>
<div class="col-3 text-start">
<div class="mt-2">
<div><strong>#{{ quote.name }}</strong></div>
<div class="text-muted small">تاریخ صدور: {{ quote.jcreated_date }}</div>
<div>شماره : {{ quote.name }}</div>
<div class="small">تاریخ صدور: {{ quote.jcreated_date }}</div>
</div>
</div>
</div>
@ -144,27 +120,29 @@
<!-- Customer & Well Info (compact to match preview) -->
<div class="row mb-3">
<div class="col-6">
<h6 class="fw-bold mb-2">اطلاعات مشترک</h6>
<div class="small mb-1"><span class="text-muted">نام:</span> {{ quote.customer.get_full_name }}</div>
{% if instance.representative.profile and instance.representative.profile.national_code %}
<div class="small mb-1"><span class="text-muted">کد ملی:</span> {{ instance.representative.profile.national_code }}</div>
{% endif %}
{% if instance.representative.profile and instance.representative.profile.phone_number_1 %}
<div class="small mb-1"><span class="text-muted">تلفن:</span> {{ instance.representative.profile.phone_number_1 }}</div>
{% endif %}
{% if instance.representative.profile and instance.representative.profile.address %}
<div class="small"><span class="text-muted">آدرس:</span> {{ instance.representative.profile.address }}</div>
<h6 class="fw-bold mb-2">
{% if instance.representative.profile.user_type == 'legal' %}
اطلاعات مشترک (حقوقی)
{% else %}
اطلاعات مشترک (حقیقی)
{% endif %}
</h6>
<div class="col-4 small mb-1"><span class="text-muted">شماره اشتراک آب:</span> {{ instance.well.water_subscription_number }}</div>
{% if instance.representative.profile.user_type == 'legal' %}
<div class="col-4 small mb-1"><span class="text-muted">نام شرکت:</span> {{ instance.representative.profile.company_name|default:"-" }}</div>
<div class="col-4 small mb-1"><span class="text-muted">شناسه ملی:</span> {{ instance.representative.profile.company_national_id|default:"-" }}</div>
{% endif %}
<div class="col-4 small mb-1"><span class="text-muted">نام و نام خانوادگی:</span> {{ quote.customer.get_full_name }}</div>
{% if instance.representative.profile and instance.representative.profile.national_code %}
<div class="col-4 small mb-1"><span class="text-muted">کد ملی:</span> {{ instance.representative.profile.national_code }}</div>
{% endif %}
{% if instance.representative.profile and instance.representative.profile.phone_number_1 %}
<div class="col-4 small mb-1"><span class="text-muted">تلفن:</span> {{ instance.representative.profile.phone_number_1 }}</div>
{% endif %}
{% if instance.representative.profile and instance.representative.profile.address %}
<div class="col-12 small"><span class="text-muted">آدرس:</span> {{ instance.representative.profile.address }}</div>
{% endif %}
</div>
<div class="col-6">
<h6 class="fw-bold mb-2">اطلاعات چاه</h6>
<div class="small mb-1"><span class="text-muted">شماره اشتراک آب:</span> {{ instance.well.water_subscription_number }}</div>
<div class="small mb-1"><span class="text-muted">شماره اشتراک برق:</span> {{ instance.well.electricity_subscription_number|default:"-" }}</div>
<div class="small mb-1"><span class="text-muted">سریال کنتور:</span> {{ instance.well.water_meter_serial_number|default:"-" }}</div>
<div class="small"><span class="text-muted">قدرت چاه:</span> {{ instance.well.well_power|default:"-" }}</div>
</div>
</div>
<!-- Items Table -->
<div class="mb-4">
@ -175,8 +153,8 @@
<th style="width: 30%">شرح کالا/خدمات</th>
<th style="width: 30%">توضیحات</th>
<th style="width: 10%">تعداد</th>
<th style="width: 12.5%">قیمت واحد(تومان)</th>
<th style="width: 12.5%">قیمت کل(تومان)</th>
<th style="width: 12.5%">قیمت واحد(ریال)</th>
<th style="width: 12.5%">قیمت کل(ریال)</th>
</tr>
</thead>
<tbody>
@ -193,18 +171,26 @@
</tbody>
<tfoot>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>جمع کل(تومان):</strong></td>
<td><strong>{{ quote.total_amount|floatformat:0|intcomma:False }}</strong></td>
<td colspan="3" class="text-start"><strong>جمع کل(ریال):</strong></td>
<td colspan="5" class="text-end"><strong>{{ quote.total_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
{% if quote.discount_amount > 0 %}
<tr class="total-section">
<td colspan="5" class="text-end"><strong>تخفیف(تومان):</strong></td>
<td><strong>{{ quote.discount_amount|floatformat:0|intcomma:False }}</strong></td>
<td colspan="3" class="text-start"><strong>تخفیف(ریال):</strong></td>
<td colspan="5" class="text-end"><strong>{{ quote.discount_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
{% endif %}
<tr class="total-section">
<td colspan="3" class="text-start"><strong>مالیات بر ارزش افزوده(ریال):</strong></td>
<td colspan="5" class="text-end"><strong>{{ quote.get_vat_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی(تومان):</strong></td>
<td><strong>{{ quote.final_amount|floatformat:0|intcomma:False }}</strong></td>
<td colspan="3" class="text-start"><strong>مبلغ نهایی (با مالیات)(ریال):</strong></td>
<td colspan="5" class="text-end"><strong>{{ quote.final_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section small border-top border-2">
<td colspan="2" class="text-start"><strong>مبلغ نهایی به حروف:</strong></td>
<td colspan="6" class="text-end"><strong>{{ quote.final_amount|amount_to_words }}</strong></td>
</tr>
</tfoot>
</table>
@ -218,14 +204,11 @@
<li class="mb-1">اعتبار پیش‌فاکتور صادر شده ۴۸ ساعت پس از تاریخ صدور می‌باشد</li>
<li class="mb-1">مبلغ فوق به صورت علی‌الحساب دریافت می‌گردد</li>
<li class="mb-1">این برگه صرفاً جهت اعلام قیمت بوده و ارزش قانونی دیگری ندارد</li>
{% if instance.broker.company and instance.broker.company.signature %}
<li class="mt-3" style="list-style:none;"><img src="{{ instance.broker.company.signature.url }}" alt="امضا" style="height: 200px;"></li>
{% endif %}
</ul>
</div>
{% if instance.broker.company %}
<div class="col-4">
<h6 class="fw-bold mb-2">اطلاعات پرداخت</h6>
{% if instance.broker.company %}
<h6 class="fw-bold mt-3">اطلاعات پرداخت</h6>
{% if instance.broker.company.card_number %}
<div class="small mb-1"><span class="text-muted">شماره کارت:</span> {{ instance.broker.company.card_number }}</div>
{% endif %}
@ -238,8 +221,22 @@
{% if instance.broker.company.bank_name %}
<div class="small"><span class="text-muted">بانک:</span> {{ instance.broker.company.get_bank_name_display }}</div>
{% endif %}
{% endif %}
</div>
<div class="col-4">
{% if instance.broker.company and instance.broker.company.signature %}
<div class="row d-flex justify-content-center">
<h6 class="mb-3 text-center">مهر و امضا
{% if instance.broker.company.signature %}
<img class="img-fluid" src="{{ instance.broker.company.signature.url }}" alt="امضای شرکت" style="">
{% endif %}
</h6>
</div>
{% endif %}
</div>
{% endif %}
</div>
<!-- Signature Section (optional, compact) -->

View file

@ -57,7 +57,7 @@
<div class="alert alert-info">
<h6>پیش‌فاکتور موجود</h6>
<span class="mb-1">{{ existing_quote.name }} | </span>
<span class="mb-1">مبلغ کل: {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان | </span>
<span class="mb-1">مبلغ کل (با احتساب مالیات): {{ existing_quote.final_amount|floatformat:0|intcomma:False }} ریال | </span>
<span class="mb-0">وضعیت: {{ existing_quote.get_status_display_with_color|safe }}</span>
</div>
</div>
@ -97,7 +97,7 @@
{% if item.description %}<small class="text-muted">{{ item.description }}</small>{% endif %}
</div>
</td>
<td>{{ item.unit_price|floatformat:0|intcomma:False }} تومان</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 }}"

View file

@ -5,6 +5,7 @@ from django.contrib import messages
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.utils import timezone
from django.conf import settings
from django.urls import reverse
from decimal import Decimal, InvalidOperation
import json
@ -114,7 +115,7 @@ def create_quote(request, instance_id, step_id):
quote, created_q = Quote.objects.get_or_create(
process_instance=instance,
defaults={
'name': f"پیش‌فاکتور {instance.code}",
'name': f"{instance.code}",
'customer': instance.representative or request.user,
'valid_until': timezone.now().date(),
'created_by': request.user,
@ -356,16 +357,30 @@ def quote_payment_step(request, instance_id, step_id):
reqs = list(step.approver_requirements.select_related('role').all())
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
approvals_list = list(step_instance.approvals.select_related('role').all())
approvals_list = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False))
rejections_list = list(step_instance.rejections.select_related('role', 'rejected_by').filter(is_deleted=False))
approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [
{
rejections_by_role = {r.role_id: r for r in rejections_list}
approver_statuses = []
for r in reqs:
appr = approvals_by_role.get(r.role_id)
rejection = rejections_by_role.get(r.role_id)
if appr:
status = 'approved'
reason = appr.reason
elif rejection:
status = 'rejected'
reason = rejection.reason
else:
status = None
reason = ''
approver_statuses.append({
'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
]
'status': status,
'reason': reason,
})
# dynamic permission: who can approve/reject this step (based on requirements)
try:
@ -374,6 +389,15 @@ def quote_payment_step(request, instance_id, step_id):
can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0
except Exception:
can_approve_reject = False
# Compute whether current user has already decided (approved/rejected)
current_user_has_decided = False
try:
user_has_approval = step_instance.approvals.filter(approved_by=request.user, is_deleted=False).exists()
user_has_rejection = step_instance.rejections.filter(rejected_by=request.user, is_deleted=False).exists()
current_user_has_decided = bool(user_has_approval or user_has_rejection)
except Exception:
current_user_has_decided = False
# Accountant/Admin approval and rejection via POST (multi-role)
@ -388,22 +412,39 @@ def quote_payment_step(request, instance_id, step_id):
action = request.POST.get('action')
if action == 'approve':
StepApproval.objects.update_or_create(
StepApproval.objects.create(
step_instance=step_instance,
role=matching_role,
defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''}
approved_by=request.user,
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'
# Auto-complete next step if it exists
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)
next_step_instance, _ = StepInstance.objects.get_or_create(
process_instance=instance,
step=next_step,
defaults={'status': 'in_progress'}
)
next_step_instance.status = 'completed'
next_step_instance.completed_at = timezone.now()
next_step_instance.save()
# Move to the step after next
step_after_next = instance.process.steps.filter(order__gt=next_step.order).first()
if step_after_next:
instance.current_step = step_after_next
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=step_after_next.id)
else:
# No more steps, go to request list
return redirect('processes:request_list')
return redirect('processes:request_list')
messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقش‌ها.')
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
@ -412,12 +453,12 @@ def quote_payment_step(request, instance_id, step_id):
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(
StepRejection.objects.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)
rejected_by=request.user,
reason=reason
)
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
@ -452,6 +493,7 @@ def quote_payment_step(request, instance_id, step_id):
'is_broker': is_broker,
'is_accountant': is_accountant,
'can_approve_reject': can_approve_reject,
'current_user_has_decided': current_user_has_decided,
})
@ -522,6 +564,7 @@ def add_quote_payment(request, instance_id, step_id):
amount=amount_dec,
payment_date=payment_date,
payment_method=payment_method,
payment_stage='quote',
reference_number=reference_number,
receipt_image=receipt_image,
notes=notes,
@ -537,7 +580,17 @@ def add_quote_payment(request, instance_id, step_id):
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
except Exception:
pass
try:
for appr in list(si.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
@ -554,7 +607,8 @@ def add_quote_payment(request, instance_id, step_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
@ -596,7 +650,7 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
try:
# soft delete using project's BaseModel delete override
payment.delete()
payment.hard_delete()
except Exception:
return JsonResponse({'success': False, 'message': 'خطا در حذف فیش'})
# On delete, return to awaiting approval
@ -605,7 +659,10 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
for appr in list(si.approvals.all()):
appr.delete()
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
@ -622,7 +679,8 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
@ -707,16 +765,15 @@ def final_invoice_step(request, instance_id, step_id):
if ch.unit_price:
row['base_price'] = _to_decimal(ch.unit_price)
# Compute final invoice lines
# Compute final invoice lines (include fully removed items for display)
rows = []
total_amount = Decimal('0')
for _, r in item_id_to_row.items():
final_qty = max(0, (r['base_qty'] + r['added_qty'] - r['removed_qty']))
if final_qty == 0:
continue
unit_price_dec = _to_decimal(r['base_price'])
line_total = Decimal(final_qty) * unit_price_dec
total_amount += line_total
line_total = Decimal(final_qty) * unit_price_dec if final_qty > 0 else Decimal('0')
if final_qty > 0:
total_amount += line_total
rows.append({
'item': r['item'],
'quantity': final_qty,
@ -725,6 +782,7 @@ def final_invoice_step(request, instance_id, step_id):
'base_qty': r['base_qty'],
'added_qty': r['added_qty'],
'removed_qty': r['removed_qty'],
'is_removed': True if final_qty == 0 else False,
})
# Create or reuse final invoice
@ -745,6 +803,8 @@ def final_invoice_step(request, instance_id, step_id):
except Exception:
qs.delete()
for r in rows:
if r['quantity'] <= 0:
continue
from .models import InvoiceItem
InvoiceItem.objects.create(
invoice=invoice,
@ -826,6 +886,8 @@ def add_special_charge(request, instance_id, step_id):
"""افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی به‌صورت آیتم جداگانه"""
instance = get_scoped_instance_or_404(request, instance_id)
invoice = get_object_or_404(Invoice, process_instance=instance)
step = get_object_or_404(instance.process.steps, id=step_id)
# only MANAGER can add special charges
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
@ -855,6 +917,50 @@ def add_special_charge(request, instance_id, step_id):
unit_price=amount_dec,
)
invoice.calculate_totals()
# After modifying payments, 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
# Reset ALL subsequent completed steps to in_progress
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance:
# Bypass validation by using update() instead of save()
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
# Clear prior approvals/rejections as the underlying totals changed
try:
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(subsequent_step_instance.rejections.all()):
rej.delete()
except Exception:
pass
except Exception:
pass
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
@ -863,6 +969,8 @@ def add_special_charge(request, instance_id, step_id):
def delete_special_charge(request, instance_id, step_id, item_id):
instance = get_scoped_instance_or_404(request, instance_id)
invoice = get_object_or_404(Invoice, process_instance=instance)
step = get_object_or_404(instance.process.steps, id=step_id)
# only MANAGER can delete special charges
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
@ -878,6 +986,51 @@ def delete_special_charge(request, instance_id, step_id, item_id):
return JsonResponse({'success': False, 'message': 'امکان حذف این مورد وجود ندارد'})
inv_item.hard_delete()
invoice.calculate_totals()
# After modifying payments, 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
# Reset ALL subsequent completed steps to in_progress
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance:
# Bypass validation by using update() instead of save()
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
# Clear prior approvals/rejections as the underlying totals changed
try:
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(subsequent_step_instance.rejections.all()):
rej.delete()
except Exception:
pass
except Exception:
pass
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
@ -896,19 +1049,96 @@ def final_settlement_step(request, instance_id, step_id):
# Ensure step instance exists
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
# Check if there are changes that require approval
# (used for both auto-complete and UI display)
has_special_charges = False
has_installation_changes = False
has_final_settlement_payments = False
try:
has_special_charges = invoice.items.filter(item__is_special=True, is_deleted=False).exists()
except Exception:
pass
try:
from installations.models import InstallationAssignment
assignment = InstallationAssignment.objects.filter(process_instance=instance).first()
if assignment:
reports = assignment.reports.all()
for report in reports:
if report.item_changes.filter(is_deleted=False).exists():
has_installation_changes = True
break
except Exception:
pass
# Check if there are payments added during final settlement step
# using the payment_stage field
try:
final_settlement_payments = invoice.payments.filter(
is_deleted=False,
payment_stage='final_settlement'
)
if final_settlement_payments.exists():
has_final_settlement_payments = True
except Exception:
pass
# Auto-complete step when invoice is fully settled (no approvals needed)
# BUT only if no special charges were added in final_invoice step
# AND no installation item changes were made
# AND no payments were added in this final settlement step
# (meaning the remaining amount is from the original quote_payment step)
try:
invoice.calculate_totals()
remaining = invoice.get_remaining_amount()
# Only auto-complete if:
# 1. Remaining amount is zero
# 2. No special charges were added (meaning this is settling the original quote)
# 3. No installation item changes (meaning no items added/removed in installation step)
# 4. No payments added in final settlement step (meaning no new receipts need approval)
if remaining == 0 and not has_special_charges and not has_installation_changes and not has_final_settlement_payments:
if step_instance.status != 'completed':
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
if next_step:
instance.current_step = next_step
instance.save(update_fields=['current_step'])
# return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
# return redirect('processes:request_list')
except Exception:
# If totals calculation fails, continue with normal flow
pass
# Build approver statuses for template (include reason to display in UI)
reqs = list(step.approver_requirements.select_related('role').all())
approvals = list(step_instance.approvals.select_related('role').all())
approvals = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False))
rejections = list(step_instance.rejections.select_related('role', 'rejected_by').filter(is_deleted=False))
approvals_by_role = {a.role_id: a for a in approvals}
approver_statuses = [
{
rejections_by_role = {r.role_id: r for r in rejections}
approver_statuses = []
for r in reqs:
appr = approvals_by_role.get(r.role_id)
rejection = rejections_by_role.get(r.role_id)
if appr:
status = 'approved'
reason = appr.reason
elif rejection:
status = 'rejected'
reason = rejection.reason
else:
status = None
reason = ''
approver_statuses.append({
'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
]
'status': status,
'reason': reason,
})
# dynamic permission to control approve/reject UI
try:
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none())
@ -918,12 +1148,21 @@ def final_settlement_step(request, instance_id, step_id):
except Exception:
can_approve_reject = False
# Compute whether current user has already decided (approved/rejected)
current_user_has_decided = False
try:
user_has_approval = step_instance.approvals.filter(approved_by=request.user, is_deleted=False).exists()
user_has_rejection = step_instance.rejections.filter(rejected_by=request.user, is_deleted=False).exists()
current_user_has_decided = bool(user_has_approval or user_has_rejection)
except Exception:
current_user_has_decided = False
# Accountant/Admin approval and rejection (multi-role)
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject', 'force_approve']:
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:
if matching_role is None and request.POST.get('action') != 'force_approve':
messages.error(request, 'شما دسترسی لازم برای تایید/رد این مرحله را ندارید.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
@ -931,13 +1170,14 @@ def final_settlement_step(request, instance_id, step_id):
if action == 'approve':
# enforce zero remaining
invoice.calculate_totals()
if invoice.remaining_amount != 0:
messages.error(request, f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})")
if invoice.get_remaining_amount() != 0:
messages.error(request, f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.get_remaining_amount()})")
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
StepApproval.objects.update_or_create(
StepApproval.objects.create(
step_instance=step_instance,
role=matching_role,
defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''}
approved_by=request.user,
reason=''
)
if step_instance.is_fully_approved():
step_instance.status = 'completed'
@ -956,12 +1196,12 @@ def final_settlement_step(request, instance_id, step_id):
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(
StepRejection.objects.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)
rejected_by=request.user,
reason=reason
)
# If current step is ahead of this step, reset it back to this step (align behavior with other steps)
try:
if instance.current_step and instance.current_step.order > step.order:
@ -972,6 +1212,32 @@ def final_settlement_step(request, instance_id, step_id):
messages.success(request, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
if action == 'force_approve':
# Only MANAGER can force approve
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
messages.error(request, 'فقط مدیر مجاز به تایید اضطراری است.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
except Exception:
messages.error(request, 'فقط مدیر مجاز به تایید اضطراری است.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
# Allow emergency approval only when invoice has a remaining (non-zero)
try:
invoice.calculate_totals()
if invoice.get_remaining_amount() == 0:
messages.error(request, 'فاکتور تسویه شده است؛ تایید اضطراری لازم نیست.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
except Exception:
pass
# Mark step completed regardless of remaining amount/approvals
step_instance.status = 'approved'
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')
# broker flag for payment management permission
profile = getattr(request.user, 'profile', None)
is_broker = False
@ -980,6 +1246,21 @@ def final_settlement_step(request, instance_id, step_id):
except Exception:
is_broker = False
# Determine if approval is needed
# Approval is needed if:
# 1. Remaining amount is not zero, OR
# 2. Special charges were added (meaning new balance was created in final_invoice step), OR
# 3. Installation item changes were made (meaning items were added/removed in installation step), OR
# 4. Payments were added in final settlement step (new receipts need approval)
needs_approval = True
try:
remaining = invoice.get_remaining_amount()
# No approval needed only if: remaining is zero AND no special charges AND no installation changes AND no final settlement payments
if remaining == 0 and not has_special_charges and not has_installation_changes and not has_final_settlement_payments:
needs_approval = False
except Exception:
needs_approval = True
return render(request, 'invoices/final_settlement_step.html', {
'instance': instance,
'step': step,
@ -991,6 +1272,9 @@ def final_settlement_step(request, instance_id, step_id):
'approver_statuses': approver_statuses,
'can_approve_reject': can_approve_reject,
'is_broker': is_broker,
'current_user_has_decided': current_user_has_decided,
'needs_approval': needs_approval,
'is_manager': bool(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).filter(slug=UserRoles.MANAGER.value).exists()) if getattr(request.user, 'profile', None) else False,
})
@ -1007,6 +1291,14 @@ def add_final_payment(request, instance_id, step_id):
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403)
# Prevent adding payments if invoice already settled
try:
invoice.calculate_totals()
if invoice.get_remaining_amount() == 0:
return JsonResponse({'success': False, 'message': 'فاکتور تسویه شده است؛ افزودن تراکنش مجاز نیست'})
except Exception:
pass
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()
@ -1054,6 +1346,7 @@ def add_final_payment(request, instance_id, step_id):
amount=amount_dec,
payment_date=payment_date,
payment_method=payment_method,
payment_stage='final_settlement',
reference_number=reference_number,
direction='in' if direction != 'out' else 'out',
receipt_image=receipt_image,
@ -1065,10 +1358,20 @@ def add_final_payment(request, instance_id, step_id):
# On delete, return to awaiting approval
try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress'
if si.status != 'approved':
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
try:
for appr in list(si.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
except Exception:
pass
@ -1085,7 +1388,8 @@ def add_final_payment(request, instance_id, step_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
@ -1105,8 +1409,8 @@ def add_final_payment(request, instance_id, step_id):
'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]),
'totals': {
'final_amount': str(invoice.final_amount),
'paid_amount': str(invoice.paid_amount),
'remaining_amount': str(invoice.remaining_amount),
'paid_amount': str(invoice.get_paid_amount()),
'remaining_amount': str(invoice.get_remaining_amount()),
}
})
@ -1118,14 +1422,17 @@ def delete_final_payment(request, instance_id, step_id, payment_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()
# Delete payment and recalculate invoice totals
payment.hard_delete()
invoice.calculate_totals() # This is what was missing!
# On delete, return to awaiting approval
try:
@ -1133,7 +1440,11 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
# Clear approvals and rejections (like in quote_payment)
for appr in list(si.approvals.all()):
appr.delete()
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
@ -1150,7 +1461,8 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
@ -1166,7 +1478,7 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'totals': {
'final_amount': str(invoice.final_amount),
'paid_amount': str(invoice.paid_amount),
'remaining_amount': str(invoice.remaining_amount),
'paid_amount': str(invoice.get_paid_amount()),
'remaining_amount': str(invoice.get_remaining_amount()),
}})

View file

@ -0,0 +1,19 @@
# Generated by Django 5.2.4 on 2025-09-21 07:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('locations', '0003_remove_broker_company'),
]
operations = [
migrations.AlterField(
model_name='county',
name='city',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='locations.city', verbose_name='استان'),
),
]

View file

@ -162,9 +162,9 @@ class StepInstanceAdmin(SimpleHistoryAdmin):
@admin.register(StepRejection)
class StepRejectionAdmin(SimpleHistoryAdmin):
list_display = ['step_instance', 'rejected_by', 'reason_short', 'created_at']
list_filter = ['rejected_by', 'created_at', 'step_instance__step__process']
search_fields = ['step_instance__step__name', 'rejected_by__username', 'reason']
list_display = ['step_instance', 'role', 'rejected_by', 'reason_short', 'created_at', 'is_deleted']
list_filter = ['role', 'rejected_by', 'created_at', 'step_instance__step__process']
search_fields = ['step_instance__step__name', 'rejected_by__username', 'reason', 'role__name']
readonly_fields = ['created_at']
ordering = ['-created_at']
@ -182,6 +182,6 @@ class StepApproverRequirementAdmin(admin.ModelAdmin):
@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")
list_display = ("step_instance", "role", "approved_by", "created_at", "is_deleted")
list_filter = ("role", "step_instance__step__process")
search_fields = ("step_instance__process_instance__code", "role__name", "approved_by__username")

View file

@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2025-09-27 06:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('processes', '0003_historicalstepinstance_edit_count_and_more'),
]
operations = [
migrations.AddField(
model_name='historicalsteprejection',
name='is_deleted',
field=models.BooleanField(default=False, verbose_name='حذف شده'),
),
migrations.AddField(
model_name='stepapproval',
name='is_deleted',
field=models.BooleanField(default=False, verbose_name='حذف شده'),
),
migrations.AddField(
model_name='steprejection',
name='is_deleted',
field=models.BooleanField(default=False, verbose_name='حذف شده'),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-09-27 15:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('processes', '0004_historicalsteprejection_is_deleted_and_more'),
]
operations = [
migrations.AlterField(
model_name='historicalstepinstance',
name='status',
field=models.CharField(choices=[('pending', 'در انتظار'), ('in_progress', 'در حال انجام'), ('completed', 'تکمیل شده'), ('skipped', 'رد شده'), ('blocked', 'مسدود شده'), ('rejected', 'رد شده و نیاز به اصلاح'), ('approved', 'تایید اضطراری')], default='pending', max_length=20, verbose_name='وضعیت'),
),
migrations.AlterField(
model_name='stepinstance',
name='status',
field=models.CharField(choices=[('pending', 'در انتظار'), ('in_progress', 'در حال انجام'), ('completed', 'تکمیل شده'), ('skipped', 'رد شده'), ('blocked', 'مسدود شده'), ('rejected', 'رد شده و نیاز به اصلاح'), ('approved', 'تایید اضطراری')], default='pending', max_length=20, verbose_name='وضعیت'),
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 5.2.4 on 2025-10-02 09:32
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_alter_historicalprofile_phone_number_1_and_more'),
('processes', '0005_alter_historicalstepinstance_status_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='stepapproval',
unique_together=set(),
),
migrations.AddField(
model_name='historicalsteprejection',
name='role',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='accounts.role', verbose_name='نقش'),
),
migrations.AddField(
model_name='steprejection',
name='role',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.role', verbose_name='نقش'),
),
migrations.AlterField(
model_name='stepapproval',
name='role',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.role', verbose_name='نقش'),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 5.2.4 on 2025-10-02 09:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('processes', '0006_alter_stepapproval_unique_together_and_more'),
]
operations = [
migrations.RemoveField(
model_name='stepapproval',
name='decision',
),
migrations.AlterField(
model_name='stepapproval',
name='reason',
field=models.TextField(blank=True, verbose_name='توضیحات'),
),
]

View file

@ -290,6 +290,10 @@ class ProcessInstance(SluggedModel):
dependencies = step.get_dependencies()
for dependency_id in dependencies:
step_instance = self.step_instances.filter(step_id=dependency_id).first()
if step_instance and step_instance.status == 'in_progress' and step_instance.step.order == 3 and step.order == 4:
return True
if step_instance and step_instance.status == 'approved' and step_instance.step.order == 8 and step.order == 9:
return True
if not step_instance or step_instance.status != 'completed':
return False
return True
@ -320,6 +324,7 @@ class StepInstance(models.Model):
('skipped', 'رد شده'),
('blocked', 'مسدود شده'),
('rejected', 'رد شده و نیاز به اصلاح'),
('approved', 'تایید اضطراری'),
],
default='pending',
verbose_name="وضعیت"
@ -373,7 +378,7 @@ class StepInstance(models.Model):
def get_latest_rejection(self):
"""دریافت آخرین رد شدن"""
return self.rejections.order_by('-created_at').first()
return self.rejections.filter(is_deleted=False).order_by('-created_at').first()
# -------- Multi-role approval helpers --------
def required_roles(self):
@ -381,8 +386,8 @@ class StepInstance(models.Model):
def approvals_by_role(self):
decisions = {}
for a in self.approvals.select_related('role').order_by('created_at'):
decisions[a.role_id] = a.decision
for a in self.approvals.filter(is_deleted=False).select_related('role').order_by('created_at'):
decisions[a.role_id] = 'approved'
return decisions
def is_fully_approved(self) -> bool:
@ -404,6 +409,7 @@ class StepRejection(models.Model):
related_name='rejections',
verbose_name="نمونه مرحله"
)
role = models.ForeignKey(Role, on_delete=models.SET_NULL, blank=True, null=True, verbose_name="نقش")
rejected_by = models.ForeignKey(
User,
on_delete=models.CASCADE,
@ -417,6 +423,7 @@ class StepRejection(models.Model):
blank=True
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ رد شدن")
is_deleted = models.BooleanField(default=False, verbose_name='حذف شده')
history = HistoricalRecords()
class Meta:
@ -425,14 +432,22 @@ class StepRejection(models.Model):
ordering = ['-created_at']
def __str__(self):
return f"رد شدن {self.step_instance} توسط {self.rejected_by.get_full_name()}"
return f"رد شدن {self.step_instance} توسط {self.rejected_by.get_full_name()} ({self.role.name})"
def save(self, *args, **kwargs):
"""ذخیره با تغییر وضعیت مرحله"""
self.step_instance.status = 'rejected'
self.step_instance.save()
if self.is_deleted == False:
self.step_instance.status = 'rejected'
self.step_instance.save()
super().save(*args, **kwargs)
def hard_delete(self):
super().delete()
def delete(self, *args, **kwargs):
self.is_deleted = True
self.save()
class StepApproverRequirement(models.Model):
"""Required approver roles for a step."""
@ -452,16 +467,23 @@ class StepApproverRequirement(models.Model):
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="نقش")
role = models.ForeignKey(Role, on_delete=models.SET_NULL, blank=True, null=True, 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='علت (برای رد)')
reason = models.TextField(blank=True, verbose_name='توضیحات')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ')
is_deleted = models.BooleanField(default=False, verbose_name='حذف شده')
class Meta:
unique_together = ('step_instance', 'role')
verbose_name = 'تایید مرحله'
verbose_name_plural = 'تاییدهای مرحله'
def delete(self, *args, **kwargs):
self.is_deleted = True
self.save()
def hard_delete(self):
super().delete()
def __str__(self):
return f"{self.step_instance} - {self.role} - {self.decision}"
return f"{self.step_instance} - {self.role} - تایید شده"

View file

@ -3,6 +3,7 @@
{% load humanize %}
{% load common_tags %}
{% load processes_tags %}
{% load accounts_tags %}
{% block sidebar %}
{% include 'sidebars/admin.html' %}
@ -37,9 +38,11 @@
<i class="bx bx-printer me-2"></i> پرینت فاکتور
</a>
{% endif %}
<a href="{% url 'certificates:certificate_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
{% if request.user|is_broker or request.user|is_manager %}
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#printHologramModal">
<i class="bx bx-printer me-2"></i> پرینت گواهی
</a>
</button>
{% endif %}
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
@ -56,9 +59,9 @@
<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 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.get_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.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} ریال</div></div></div>
</div>
<div class="table-responsive">
<table class="table table-striped mb-0">
@ -95,32 +98,113 @@
<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 class="d-flex align-items-center gap-3">
{% if installation_delay_days > 0 %}
<span class="badge bg-warning text-dark">
<i class="bx bx-time-five bx-xs"></i> {{ installation_delay_days }} روز تاخیر
</span>
{% elif installation_assignment and latest_report %}
<span class="badge bg-success">
<i class="bx bx-check bx-xs"></i> به موقع
</span>
{% endif %}
{% 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>
<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>
<!-- اطلاعات گزارش نصب -->
<div class="row g-3 mb-3">
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-calendar bx-sm me-2"></i>تاریخ مراجعه: {{ latest_report.visited_date|to_jalali|default:'-' }}</p>
</div>
{% if installation_assignment.scheduled_date %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-calendar-star bx-sm me-2"></i>تاریخ برنامه‌ریزی: {{ installation_assignment.scheduled_date|to_jalali }}</p>
</div>
{% endif %}
<div class="col-12 col-md-4">
<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>
</div>
<div class="col-12 col-md-4">
<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">
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-help-circle bx-sm me-2"></i>کنتور مشکوک: {{ latest_report.is_meter_suspicious|yesno:'بله,خیر' }}</p>
</div>
{% if latest_report.sim_number %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-mobile bx-sm me-2"></i>شماره سیمکارت: {{ latest_report.sim_number }}</p>
</div>
{% endif %}
{% if latest_report.meter_type %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-category bx-sm me-2"></i>نوع کنتور: {{ latest_report.get_meter_type_display }}</p>
</div>
{% endif %}
{% if latest_report.meter_size %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-ruler bx-sm me-2"></i>سایز کنتور: {{ latest_report.meter_size }}</p>
</div>
{% endif %}
{% if latest_report.water_meter_manufacturer %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-buildings bx-sm me-2"></i>سازنده: {{ latest_report.water_meter_manufacturer.name }}</p>
</div>
{% endif %}
{% if latest_report.discharge_pipe_diameter %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-shape-circle bx-sm me-2"></i>قطر لوله آبده: {{ latest_report.discharge_pipe_diameter }} اینچ</p>
</div>
{% endif %}
{% if latest_report.usage_type %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-droplet bx-sm me-2"></i>نوع مصرف: {{ latest_report.get_usage_type_display }}</p>
</div>
{% endif %}
{% if latest_report.driving_force %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-car bx-sm me-2"></i>نیرو محرکه: {{ latest_report.driving_force }}</p>
</div>
{% endif %}
{% if latest_report.motor_power %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-tag bx-sm me-2"></i>قدرت موتور: {{ latest_report.motor_power }} کیلووات ساعت</p>
</div>
{% endif %}
{% if latest_report.exploitation_license_number %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-id-card bx-sm me-2"></i>شماره پروانه: {{ latest_report.exploitation_license_number }}</p>
</div>
{% endif %}
{% if latest_report.pre_calibration_flow_rate %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-water bx-sm me-2"></i>دبی قبل از کالیبراسیون: {{ latest_report.pre_calibration_flow_rate }} لیتر/ثانیه</p>
</div>
{% endif %}
{% if latest_report.post_calibration_flow_rate %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-tachometer bx-sm me-2"></i>دبی بعد از کالیبراسیون: {{ latest_report.post_calibration_flow_rate }} لیتر/ثانیه</p>
</div>
{% endif %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-map bx-sm me-2"></i>UTM X: {{ latest_report.utm_x|default:'-' }}</p>
</div>
<div class="col-12 col-md-4">
<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="mb-3">
<h6 class="text-primary mb-2"><i class="bx bx-text me-1"></i>توضیحات</h6>
<div class="text-muted">{{ latest_report.description }}</div>
</div>
{% endif %}
<hr>
<h6>عکس‌ها</h6>
<h6 class="text-primary mb-2"><i class="bx bx-image me-1"></i>عکس‌ها</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>
@ -156,7 +240,7 @@
{% 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.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>
@ -175,6 +259,30 @@
</div>
</div>
</div>
<!-- Print Hologram Modal -->
<div class="modal fade" id="printHologramModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'certificates:certificate_print' instance.id %}" target="_blank">
{% csrf_token %}
<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">
<label class="form-label">کد هولوگرام</label>
<input type="text" class="form-control" name="hologram_code" value="{{ certificate.hologram_code|default:'' }}" placeholder="مثال: 123456" required>
<div class="form-text">این کد باید با کد هولوگرام روی گواهی یکسان باشد.</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-primary">ثبت و پرینت</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,6 +1,7 @@
{% extends '_base.html' %}
{% load static %}
{% load accounts_tags %}
{% load common_tags %}
{% block sidebar %}
{% include 'sidebars/admin.html' %}
@ -36,14 +37,14 @@
<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>
{% if not request.user|is_installer %}
<button class="btn btn-label-success me-2" type="button" onclick="exportToExcel()">
<span class="d-flex align-items-center gap-2">
<i class="bx bx-export me-sm-1"></i>
<span class="d-none d-sm-inline-block">خروجی اکسل</span>
</span>
</button>
{% endif %}
{% if request.user|is_broker %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#requestModal">
<i class="bx bx-plus me-1"></i>
@ -212,6 +213,7 @@
<th>امور</th>
<th>پیشرفت</th>
<th>وضعیت</th>
<th>تاریخ نصب/تاخیر</th>
<th>تاریخ ایجاد</th>
<th>عملیات</th>
</tr>
@ -221,11 +223,24 @@
<tr>
<td>{{ item.instance.code }}</td>
<td>{{ item.instance.process.name }}</td>
<td class="text-primary">
<td>
{% 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>
{% if item.current_step_approval_status %}
<br>
<small class="{% if item.current_step_approval_status.status == 'rejected' %}text-danger{% elif item.current_step_approval_status.status == 'approved' %}text-success{% else %}text-warning{% endif %}">
{% if item.current_step_approval_status.status == 'rejected' %}
<i class="bx bx-x-circle"></i>
{% elif item.current_step_approval_status.status == 'approved' %}
<i class="bx bx-check-circle"></i>
{% else %}
<i class="bx bx-time"></i>
{% endif %}
{{ item.current_step_approval_status.display }}
</small>
{% endif %}
{% else %}
--
{% endif %}
@ -243,7 +258,26 @@
<small class="text-muted">{{ item.progress_percentage }}%</small>
</div>
</td>
<td>{{ item.instance.get_status_display_with_color|safe }}</td>
<td>
{{ item.instance.get_status_display_with_color|safe }}
{% if item.emergency_approved %}
<span class="badge bg-warning text-dark ms-1" title="تایید اضطراری">تایید اضطراری</span>
{% endif %}
</td>
<td>
{% if item.installation_scheduled_date %}
<div>
<span title="{{ item.installation_scheduled_date|date:'Y-m-d' }}">{{ item.installation_scheduled_date | to_jalali }}</span>
</div>
{% if item.installation_overdue_days and item.installation_overdue_days > 0 %}
<small class="text-danger d-block">{{ item.installation_overdue_days }} روز تاخیر</small>
{% else %}
<small class="text-muted d-block">بدون تاخیر</small>
{% endif %}
{% else %}
<span class="text-muted">تاریخ نصب تعیین نشده</span>
{% endif %}
</td>
<td>{{ item.instance.jcreated_date }}</td>
<td>
<div class="d-inline-block">
@ -287,6 +321,7 @@
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
{% endfor %}
</tbody>
@ -419,6 +454,10 @@
<div id="repNewFields" class="col-sm-12" style="display:none;">
<div class="row g-3">
<div class="col-sm-12">
<label class="form-label" for="id_user_type">{{ customer_form.user_type.label }}</label>
{{ customer_form.user_type }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_first_name">{{ customer_form.first_name.label }}</label>
{{ customer_form.first_name }}
@ -439,6 +478,15 @@
<label class="form-label" for="id_national_code">{{ customer_form.national_code.label }}</label>
{{ customer_form.national_code }}
</div>
<!-- Company fields for legal entities -->
<div class="col-sm-6 company-fields" style="display:none;">
<label class="form-label" for="id_company_name">{{ customer_form.company_name.label }}</label>
{{ customer_form.company_name }}
</div>
<div class="col-sm-6 company-fields" style="display:none;">
<label class="form-label" for="id_company_national_id">{{ customer_form.company_national_id.label }}</label>
{{ customer_form.company_national_id }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_card_number">{{ customer_form.card_number.label }}</label>
{{ customer_form.card_number }}
@ -717,6 +765,21 @@
.fail(function(){ setStatus('#wellStatus', 'خطا در بررسی چاه', 'danger'); });
});
function toggleRepCompanyFields() {
const userType = $('#user-type-select').val();
if (userType === 'legal') {
$('#repNewFields .company-fields').show();
$('input[name="company_name"]').attr('required', true);
$('input[name="company_national_id"]').attr('required', true);
} else {
$('#repNewFields .company-fields').hide();
$('input[name="company_name"]').removeAttr('required');
$('input[name="company_national_id"]').removeAttr('required');
}
}
$('#user-type-select').on('change', toggleRepCompanyFields);
$('#btnLookupRep').on('click', function() {
const nc = $('#rep_national_code').val().trim();
if (!nc) { setStatus('#repStatus', 'لطفا کد ملی نماینده را وارد کنید', 'danger'); return; }
@ -732,36 +795,47 @@
$('#id_first_name').val(resp.user.first_name || '');
$('#id_last_name').val(resp.user.last_name || '');
if (resp.user.profile) {
$('#user-type-select').val(resp.user.profile.user_type || 'individual');
$('#id_national_code').val(resp.user.profile.national_code || nc);
$('#id_phone_number_1').val(resp.user.profile.phone_number_1 || '');
$('#id_phone_number_2').val(resp.user.profile.phone_number_2 || '');
$('#id_company_name').val(resp.user.profile.company_name || '');
$('#id_company_national_id').val(resp.user.profile.company_national_id || '');
$('#id_card_number').val(resp.user.profile.card_number || '');
$('#id_account_number').val(resp.user.profile.account_number || '');
$('#id_bank_name').val(resp.user.profile.bank_name || '');
$('#id_address').val(resp.user.profile.address || '');
} else {
$('#user-type-select').val('individual');
$('#id_national_code').val(nc);
$('#id_phone_number_1').val('');
$('#id_phone_number_2').val('');
$('#id_company_name').val('');
$('#id_company_national_id').val('');
$('#id_card_number').val('');
$('#id_account_number').val('');
$('#id_bank_name').val('');
$('#id_address').val('');
}
toggleRepCompanyFields();
setStatus('#repStatus', 'نماینده یافت شد.', 'success');
} else {
currentRepId = null;
$('#repNewFields').show();
// Clear form and prefill national code
$('#user-type-select').val('individual');
$('#id_first_name').val('');
$('#id_last_name').val('');
$('#id_national_code').val(nc);
$('#id_phone_number_1').val('');
$('#id_phone_number_2').val('');
$('#id_company_name').val('');
$('#id_company_national_id').val('');
$('#id_card_number').val('');
$('#id_account_number').val('');
$('#id_bank_name').val('');
$('#id_address').val('');
toggleRepCompanyFields();
setStatus('#repStatus', 'نماینده یافت نشد. لطفا اطلاعات را تکمیل کنید.', 'danger');
}
})
@ -954,6 +1028,45 @@
});
};
};
// Export to Excel function
window.exportToExcel = function() {
// Get current filter parameters from the form
const form = document.querySelector('form[method="get"]');
const formData = new FormData(form);
// Build query string
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value.trim()) {
params.append(key, value);
}
}
// Create export URL with current filters
const exportUrl = '{% url "processes:export_requests_excel" %}' + '?' + params.toString();
// Show loading state
const btn = document.querySelector('button[onclick="exportToExcel()"]');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="bx bx-loader-circle bx-spin me-1"></i>در حال تولید...';
btn.disabled = true;
// Create invisible link and trigger download
const link = document.createElement('a');
link.href = exportUrl;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Reset button after a short delay
setTimeout(() => {
btn.innerHTML = originalText;
btn.disabled = false;
showToast('فایل اکسل آماده دانلود است', 'success');
}, 1000);
};
});
</script>
{% endblock %}

View file

@ -28,9 +28,11 @@ def stepper_header(instance, current_step=None):
status = step_id_to_status.get(step.id, 'pending')
# بررسی دسترسی به مرحله (UI navigation constraints):
# can_access = instance.can_access_step(step)
can_access = instance.can_access_step(step)
# فقط مراحل تکمیل‌شده یا مرحله جاری قابل کلیک هستند
can_access = (step_id_to_status.get(step.id) == 'completed') or (instance.current_step and step.id == instance.current_step.id)
# 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

@ -6,6 +6,7 @@ app_name = 'processes'
urlpatterns = [
# Requests UI
path('requests/', views.request_list, name='request_list'),
path('requests/export/excel/', views.export_requests_excel, name='export_requests_excel'),
path('requests/create/', views.create_request_with_entities, name='create_request_with_entities'),
path('requests/lookup/well/', views.lookup_well_by_subscription, name='lookup_well_by_subscription'),
path('requests/lookup/representative/', views.lookup_representative_by_national_code, name='lookup_representative_by_national_code'),

View file

@ -20,7 +20,7 @@ def scope_instances_queryset(user, queryset=None):
return qs.filter(id__in=assign_ids)
if profile.has_role(UserRoles.BROKER):
return qs.filter(broker=profile.broker)
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER):
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER) or profile.has_role(UserRoles.WATER_RESOURCE_MANAGER):
return qs.filter(broker__affairs__county=profile.county)
if profile.has_role(UserRoles.ADMIN):
return qs
@ -69,7 +69,7 @@ def scope_wells_queryset(user, queryset=None):
return qs
if profile.has_role(UserRoles.BROKER):
return qs.filter(broker=profile.broker)
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER):
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER) or profile.has_role(UserRoles.WATER_RESOURCE_MANAGER):
return qs.filter(broker__affairs__county=profile.county)
if profile.has_role(UserRoles.INSTALLER):
# Wells that have instances assigned to this installer
@ -102,7 +102,7 @@ def scope_customers_queryset(user, queryset=None):
return qs
if profile.has_role(UserRoles.BROKER):
return qs.filter(broker=profile.broker)
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER):
if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER) or profile.has_role(UserRoles.WATER_RESOURCE_MANAGER):
return qs.filter(county=profile.county)
if profile.has_role(UserRoles.INSTALLER):
# Customers that are representatives of instances assigned to this installer

View file

@ -3,13 +3,19 @@ from django.urls import reverse
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.http import JsonResponse
from django.http import JsonResponse, HttpResponse
from django.views.decorators.http import require_POST, require_GET
from django.utils import timezone
from django.db import transaction
from django.contrib.auth import get_user_model
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill
from openpyxl.utils import get_column_letter
from datetime import datetime
from _helpers.utils import persian_converter3
from .models import Process, ProcessInstance, StepInstance, ProcessStep
from .utils import scope_instances_queryset, get_scoped_instance_or_404
from installations.models import InstallationAssignment
from installations.models import InstallationAssignment, InstallationReport
from wells.models import Well
from accounts.models import Profile, Broker
from locations.models import Affairs
@ -65,18 +71,143 @@ def request_list(request):
steps_list = ProcessStep.objects.select_related('process').all().order_by('process__name', 'order')
manufacturers = WaterMeterManufacturer.objects.all().order_by('name')
# Calculate progress for each instance
# Prepare installation assignments map (scheduled date by instance)
try:
instance_ids = list(instances.values_list('id', flat=True))
except Exception:
instance_ids = []
assignments_map = {}
reports_map = {}
if instance_ids:
try:
ass_qs = InstallationAssignment.objects.filter(process_instance_id__in=instance_ids).values('process_instance_id', 'scheduled_date')
for row in ass_qs:
assignments_map[row['process_instance_id']] = row['scheduled_date']
except Exception:
assignments_map = {}
# latest report per instance (visited_date)
try:
rep_qs = InstallationReport.objects.filter(assignment__process_instance_id__in=instance_ids).order_by('-created').values('assignment__process_instance_id', 'visited_date')
for row in rep_qs:
pid = row['assignment__process_instance_id']
if pid not in reports_map:
reports_map[pid] = row['visited_date']
except Exception:
reports_map = {}
# Build a map to check if installation reports exist (for approval status logic)
has_installation_report_map = {}
if instance_ids:
try:
report_exists_qs = InstallationReport.objects.filter(
assignment__process_instance_id__in=instance_ids
).values_list('assignment__process_instance_id', flat=True).distinct()
has_installation_report_map = {pid: True for pid in report_exists_qs}
except Exception:
has_installation_report_map = {}
# Calculate progress for each instance and attach install schedule info
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
sched_date = assignments_map.get(instance.id)
overdue_days = 0
reference_date = None
if sched_date:
# Reference date: until installer submits a report, use today; otherwise use visited_date
try:
visited_date = reports_map.get(instance.id)
if visited_date:
reference_date = visited_date
else:
try:
reference_date = timezone.localdate()
except Exception:
from datetime import date as _date
reference_date = _date.today()
if reference_date > sched_date:
overdue_days = (reference_date - sched_date).days
except Exception:
overdue_days = 0
reference_date = None
installation_scheduled_date = reference_date if reference_date and reference_date > sched_date else sched_date
# Emergency approved flag (final settlement step forced approval)
try:
final_settlement_step = instance.process.steps.filter(order=8).first()
emergency_approved = False
if final_settlement_step:
si = instance.step_instances.filter(step=final_settlement_step).first()
emergency_approved = bool(si and si.status == 'approved')
except Exception:
emergency_approved = False
# Get current step approval status
current_step_approval_status = None
if instance.current_step:
try:
current_step_instance = instance.step_instances.filter(step=instance.current_step).first()
if current_step_instance:
# Special check: For installation report step (order=6), only show approval status if report exists
should_show_approval_status = True
if instance.current_step.order == 6:
# Check if installation report exists
if not has_installation_report_map.get(instance.id, False):
should_show_approval_status = False
if should_show_approval_status:
# Check if this step requires approvals
required_roles = current_step_instance.required_roles()
if required_roles:
# Get approvals by role
approvals_by_role = current_step_instance.approvals_by_role()
# Check for rejections
latest_rejection = current_step_instance.get_latest_rejection()
if latest_rejection and current_step_instance.status == 'rejected':
role_name = latest_rejection.role.name if latest_rejection.role else 'نامشخص'
current_step_approval_status = {
'status': 'rejected',
'role': role_name,
'display': f'رد شده توسط {role_name}'
}
else:
# Check approval status
pending_roles = []
approved_roles = []
for role in required_roles:
if approvals_by_role.get(role.id) == 'approved':
approved_roles.append(role.name)
else:
pending_roles.append(role.name)
if pending_roles:
current_step_approval_status = {
'status': 'pending',
'roles': pending_roles,
'display': f'در انتظار تایید {" و ".join(pending_roles)}'
}
elif approved_roles and not pending_roles:
current_step_approval_status = {
'status': 'approved',
'roles': approved_roles,
'display': f'تایید شده توسط {" و ".join(approved_roles)}'
}
except Exception:
current_step_approval_status = None
instances_with_progress.append({
'instance': instance,
'progress_percentage': round(progress_percentage),
'completed_steps': completed_steps,
'total_steps': total_steps,
'installation_scheduled_date': installation_scheduled_date,
'installation_overdue_days': overdue_days,
'emergency_approved': emergency_approved,
'current_step_approval_status': current_step_approval_status,
})
# Summary stats for header cards
@ -160,7 +291,10 @@ def lookup_representative_by_national_code(request):
'last_name': user.last_name,
'full_name': user.get_full_name(),
'profile': {
'user_type': profile.user_type,
'national_code': profile.national_code,
'company_name': profile.company_name,
'company_national_id': profile.company_national_id,
'phone_number_1': profile.phone_number_1,
'phone_number_2': profile.phone_number_2,
'card_number': profile.card_number,
@ -240,6 +374,7 @@ def create_request_with_entities(request):
well = existing
well_data = request.POST.copy()
print(well_data)
# Ensure representative set from created/selected user if not provided
if representative_user and not well_data.get('representative'):
well_data['representative'] = str(representative_user.id)
@ -366,12 +501,12 @@ def step_detail(request, instance_id, step_id):
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
# 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):
@ -450,13 +585,22 @@ def instance_summary(request, instance_id):
# Collect final invoice, payments, and certificate if any
from invoices.models import Invoice
from installations.models import InstallationReport
from installations.models import InstallationReport, InstallationAssignment
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()
# Calculate installation delay
installation_assignment = InstallationAssignment.objects.filter(process_instance=instance).first()
installation_delay_days = 0
if installation_assignment and latest_report:
scheduled_date = installation_assignment.scheduled_date
visited_date = latest_report.visited_date
if scheduled_date and visited_date and visited_date > scheduled_date:
installation_delay_days = (visited_date - scheduled_date).days
# Build rows like final invoice step
rows = []
if invoice:
@ -470,5 +614,374 @@ def instance_summary(request, instance_id):
'rows': rows,
'latest_report': latest_report,
'certificate': certificate,
'installation_assignment': installation_assignment,
'installation_delay_days': installation_delay_days,
})
def format_date_jalali(date_obj):
"""Convert date to Jalali format without time"""
if not date_obj:
return ""
try:
# If it's a datetime, get just the date part
if hasattr(date_obj, 'date'):
date_obj = date_obj.date()
return persian_converter3(date_obj)
except Exception:
return ""
def format_datetime_jalali(datetime_obj):
"""Convert datetime to Jalali format without time"""
if not datetime_obj:
return ""
try:
# Get just the date part
date_part = datetime_obj.date() if hasattr(datetime_obj, 'date') else datetime_obj
return persian_converter3(date_part)
except Exception:
return ""
@login_required
def export_requests_excel(request):
"""Export filtered requests to Excel"""
# Get the same queryset as request_list view (with filters)
instances = ProcessInstance.objects.select_related(
'process', 'current_step', 'representative', 'well', 'well__county', 'well__affairs'
).prefetch_related('step_instances')
# Apply scoping
instances = scope_instances_queryset(request.user, instances)
# Apply filters (same logic as request_list view)
filter_status = request.GET.get('status', '').strip()
if filter_status:
instances = instances.filter(status=filter_status)
filter_affairs = request.GET.get('affairs', '').strip()
if filter_affairs and filter_affairs.isdigit():
instances = instances.filter(well__affairs_id=filter_affairs)
filter_broker = request.GET.get('broker', '').strip()
if filter_broker and filter_broker.isdigit():
instances = instances.filter(well__broker_id=filter_broker)
filter_step = request.GET.get('step', '').strip()
if filter_step and filter_step.isdigit():
instances = instances.filter(current_step_id=filter_step)
# Get installation data
assignment_ids = list(instances.values_list('id', flat=True))
assignments_map = {}
reports_map = {}
installers_map = {}
if assignment_ids:
assignments = InstallationAssignment.objects.filter(
process_instance_id__in=assignment_ids
).select_related('process_instance', 'installer')
assignments_map = {a.process_instance_id: a.scheduled_date for a in assignments}
installers_map = {a.process_instance_id: a.installer for a in assignments}
reports = InstallationReport.objects.filter(
assignment__process_instance_id__in=assignment_ids
).select_related('assignment')
reports_map = {r.assignment.process_instance_id: r for r in reports}
# Get quotes and payments data
from invoices.models import Quote, Payment, Invoice
quotes_map = {}
payments_map = {}
settlement_dates_map = {}
approval_dates_map = {}
approval_users_map = {}
if assignment_ids:
# Get quotes
quotes = Quote.objects.filter(
process_instance_id__in=assignment_ids
).select_related('process_instance')
quotes_map = {q.process_instance_id: q for q in quotes}
# Get payments with reference numbers
payments = Payment.objects.filter(
invoice__process_instance_id__in=assignment_ids,
is_deleted=False
).select_related('invoice__process_instance').order_by('created')
for payment in payments:
if payment.invoice.process_instance_id not in payments_map:
payments_map[payment.invoice.process_instance_id] = []
payments_map[payment.invoice.process_instance_id].append(payment)
# Get final invoices to check settlement dates
invoices = Invoice.objects.filter(
process_instance_id__in=assignment_ids
).select_related('process_instance')
for invoice in invoices:
if invoice.get_remaining_amount() == 0: # Fully settled
# Find the last payment date for this invoice
last_payment = Payment.objects.filter(
invoice__process_instance=invoice.process_instance,
is_deleted=False
).order_by('-created').first()
if last_payment:
settlement_dates_map[invoice.process_instance_id] = last_payment.created
# Get installation approval data by Water Resource Manager role
from processes.models import StepInstance, StepApproval
from accounts.models import Role
from common.consts import UserRoles
# Get the Water Resource Manager role
water_manager_role = Role.objects.filter(slug=UserRoles.WATER_RESOURCE_MANAGER.value).first()
installation_steps = StepInstance.objects.filter(
process_instance_id__in=assignment_ids,
step__order=6, # Installation report step is order 6
status='completed'
).select_related('process_instance')
for step_instance in installation_steps:
# Get the approval by Water Resource Manager role that completed this step
approval = StepApproval.objects.filter(
step_instance=step_instance,
role=water_manager_role,
is_deleted=False
).select_related('approved_by').order_by('-created_at').first()
if approval:
approval_dates_map[step_instance.process_instance_id] = approval.created_at
approval_users_map[step_instance.process_instance_id] = approval.approved_by
# Calculate progress and installation data
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
sched_date = assignments_map.get(instance.id)
overdue_days = 0
reference_date = None
if sched_date:
try:
report = reports_map.get(instance.id)
if report and report.visited_date:
reference_date = report.visited_date
else:
try:
reference_date = timezone.localdate()
except Exception:
from datetime import date as _date
reference_date = _date.today()
if reference_date > sched_date:
overdue_days = (reference_date - sched_date).days
except Exception:
overdue_days = 0
installation_scheduled_date = reference_date if reference_date and reference_date > sched_date else sched_date
instances_with_progress.append({
'instance': instance,
'progress_percentage': round(progress_percentage),
'completed_steps': completed_steps,
'total_steps': total_steps,
'installation_scheduled_date': installation_scheduled_date,
'installation_overdue_days': overdue_days,
})
# Create Excel workbook
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "لیست درخواست‌ها"
# Set RTL (Right-to-Left) direction
ws.sheet_view.rightToLeft = True
# Define column headers
headers = [
'شناسه',
'تاریخ ایجاد درخواست',
'نام نماینده',
'نام خانوادگی نماینده',
'کد ملی نماینده',
'نام شرکت',
'شناسه شرکت',
'سریال کنتور',
'سریال کنتور جدید',
'شماره اشتراک آب',
'شماره اشتراک برق',
'قدرت چاه',
'شماره تماس ۱',
'شماره تماس ۲',
'آدرس',
'مبلغ پیش‌فاکتور',
'تاریخ واریزی‌ها و کدهای رهگیری',
'تاریخ مراجعه نصاب',
'تاخیر نصاب',
'نام نصاب',
'تاریخ تایید نصب توسط مدیر',
'نام تایید کننده نصب',
'تاریخ تسویه'
]
# Write headers
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal='center')
cell.fill = PatternFill(start_color="CCCCCC", end_color="CCCCCC", fill_type="solid")
# Write data rows
for row_num, item in enumerate(instances_with_progress, 2):
instance = item['instance']
# Get representative info
rep_first_name = ""
rep_last_name = ""
rep_national_code = ""
rep_phone_1 = ""
rep_phone_2 = ""
rep_address = ""
company_name = ""
company_national_id = ""
if instance.representative:
rep_first_name = instance.representative.first_name or ""
rep_last_name = instance.representative.last_name or ""
if hasattr(instance.representative, 'profile') and instance.representative.profile:
profile = instance.representative.profile
rep_national_code = profile.national_code or ""
rep_phone_1 = profile.phone_number_1 or ""
rep_phone_2 = profile.phone_number_2 or ""
rep_address = profile.address or ""
if profile.user_type == 'legal':
company_name = profile.company_name or ""
company_national_id = profile.company_national_id or ""
# Get well info
water_subscription = ""
electricity_subscription = ""
well_power = ""
old_meter_serial = ""
if instance.well:
water_subscription = instance.well.water_subscription_number or ""
electricity_subscription = instance.well.electricity_subscription_number or ""
well_power = str(instance.well.well_power) if instance.well.well_power else ""
old_meter_serial = instance.well.water_meter_serial_number or ""
# Get new meter serial from installation report
new_meter_serial = ""
installer_visit_date = ""
report = reports_map.get(instance.id)
if report:
new_meter_serial = report.new_water_meter_serial or ""
installer_visit_date = format_date_jalali(report.visited_date)
# Get quote amount
quote_amount = ""
quote = quotes_map.get(instance.id)
if quote:
quote_amount = str(quote.final_amount) if quote.final_amount else ""
# Get payments info
payments_info = ""
payments = payments_map.get(instance.id, [])
if payments:
payment_strings = []
for payment in payments:
date_str = format_datetime_jalali(payment.created)
reference_number = payment.reference_number or "بدون کد"
payment_strings.append(f"{date_str} - {reference_number}")
payments_info = " | ".join(payment_strings)
# Get installer name
installer_name = ""
installer = installers_map.get(instance.id)
if installer:
installer_name = installer.get_full_name() or str(installer)
# Get overdue days
overdue_days = ""
if item['installation_overdue_days'] and item['installation_overdue_days'] > 0:
overdue_days = str(item['installation_overdue_days'])
# Get approval info
approval_date = ""
approval_user = ""
approval_date_obj = approval_dates_map.get(instance.id)
approval_user_obj = approval_users_map.get(instance.id)
if approval_date_obj:
approval_date = format_datetime_jalali(approval_date_obj)
if approval_user_obj:
approval_user = approval_user_obj.get_full_name() or str(approval_user_obj)
# Get settlement date
settlement_date = ""
settlement_date_obj = settlement_dates_map.get(instance.id)
if settlement_date_obj:
settlement_date = format_datetime_jalali(settlement_date_obj)
row_data = [
instance.code, # شناسه
format_datetime_jalali(instance.created), # تاریخ ایجاد درخواست
rep_first_name, # نام نماینده
rep_last_name, # نام خانوادگی نماینده
rep_national_code, # کد ملی نماینده
company_name, # نام شرکت
company_national_id, # شناسه شرکت
old_meter_serial, # سریال کنتور
new_meter_serial, # سریال کنتور جدید
water_subscription, # شماره اشتراک آب
electricity_subscription, # شماره اشتراک برق
well_power, # قدرت چاه
rep_phone_1, # شماره تماس ۱
rep_phone_2, # شماره تماس ۲
rep_address, # آدرس
quote_amount, # مبلغ پیش‌فاکتور
payments_info, # تاریخ واریزی‌ها و کدهای رهگیری
installer_visit_date, # تاریخ مراجعه نصاب
overdue_days, # تاخیر نصاب
installer_name, # نام نصاب
approval_date, # تاریخ تایید نصب توسط مدیر
approval_user, # نام تایید کننده نصب
settlement_date # تاریخ تسویه
]
for col, value in enumerate(row_data, 1):
cell = ws.cell(row=row_num, column=col, value=value)
# Set right alignment for Persian text
cell.alignment = Alignment(horizontal='right')
# Auto-adjust column widths
for col in range(1, len(headers) + 1):
column_letter = get_column_letter(col)
max_length = 0
for row in ws[column_letter]:
try:
if len(str(row.value)) > max_length:
max_length = len(str(row.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# Prepare response
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
# Generate filename with current date
current_date = datetime.now().strftime('%Y%m%d_%H%M')
filename = f'requests_export_{current_date}.xlsx'
response['Content-Disposition'] = f'attachment; filename="{filename}"'
# Save workbook to response
wb.save(response)
return response

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -0,0 +1,13 @@
<svg width="623" height="389" viewBox="0 0 623 389" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M622.408 2L461.408 163V245L505.285 220.983C507.605 219.713 510.334 219.414 512.875 220.152L524.408 223.5L445.908 301L448.908 387.5L563.772 272.143C564.849 271.062 565.663 269.749 566.154 268.305L574.9 242.566C579.944 227.724 577.735 211.376 568.933 198.404L544.908 163L619.591 85.9085C621.398 84.0431 622.408 81.5478 622.408 78.9505V2Z" fill="#8889FF" stroke="#8889FF"/>
<path d="M286.408 253.895C286.408 247.944 289.059 242.301 293.64 238.502L350.908 191V321.588C350.908 327.26 348.499 332.666 344.281 336.459L286.408 388.5V253.895Z" fill="#8889FF"/>
<path d="M363.908 253.895C363.908 247.944 366.559 242.301 371.14 238.502L428.408 191V321.588C428.408 327.26 425.999 332.666 421.781 336.459L363.908 388.5V253.895Z" fill="#8889FF"/>
<path d="M368.65 179.938C368.65 173.677 371.582 167.777 376.572 163.996L424.083 128L421.982 180.206C421.754 185.873 419.13 191.176 414.765 194.796L368.65 233.043L368.65 179.938Z" fill="#6A6CFF"/>
<path d="M291.558 177.938C291.558 171.677 294.49 165.777 299.48 161.996L346.991 126L344.89 178.206C344.662 183.873 342.039 189.176 337.674 192.796L291.558 231.043L291.558 177.938Z" fill="#6A6CFF"/>
<path d="M291.558 108.938C291.558 102.677 294.49 96.7772 299.48 92.9963L346.991 56.9999L344.89 109.206C344.662 114.873 342.039 120.176 337.674 123.796L291.558 162.043L291.558 108.938Z" fill="#6A6CFF"/>
<path d="M170.908 314.5L101.408 387.5H158.427C163.216 387.5 167.807 385.592 171.185 382.198L270.408 282.5V212.227C270.408 209.128 269.608 206.082 268.086 203.383L265.374 198.575C261.769 192.184 254.644 188.621 247.368 189.57L244.75 189.912C239.249 190.629 233.957 192.483 229.211 195.356L217.408 202.5L199.908 217L184.908 232.5L174.417 246.738C172.138 249.831 170.908 253.573 170.908 257.415V314.5Z" fill="#8889FF"/>
<path d="M152.408 243L7.9082 387H62.2682C67.487 387 72.4991 384.96 76.2347 381.316L146.375 312.886C150.233 309.122 152.408 303.961 152.408 298.571V243Z" fill="#8889FF"/>
<path d="M63.5462 74C63.5462 74 2 161.461 2 195.454C2 229.445 29.5553 257 63.5462 257C97.5371 257 125.092 229.445 125.092 195.454C125.092 161.461 63.5462 74 63.5462 74ZM63.5462 229.92C46.3212 229.92 32.3595 215.956 32.3595 198.733C32.3595 181.508 63.5462 137.189 63.5462 137.189C63.5462 137.189 94.7329 181.508 94.7329 198.733C94.7329 215.956 80.7692 229.92 63.5462 229.92Z" fill="#60B8DF"/>
<path d="M52.9863 153.224C58.7827 143.957 63.5422 137.189 63.5422 137.189L43.5169 104.344C40.2713 109.509 36.8438 115.103 33.4043 120.912C39.8795 130.712 52.5509 152.498 52.9863 153.224Z" fill="#5696AC"/>
<path d="M42.0612 185.986H57.2421V170.301C57.2421 168.922 57.9482 168.233 59.3604 168.233L67.5812 168.334C67.6485 168.334 67.7157 168.334 67.783 168.334C69.027 168.334 69.6491 168.99 69.6491 170.301V185.986H84.9812C86.2589 185.986 86.9146 186.675 86.9482 188.054V196.426C86.9482 197.704 86.2926 198.359 84.9812 198.393H69.8004V214.179C69.8004 215.322 69.0943 215.978 67.6821 216.146H59.3604C58.0827 216.146 57.3766 215.49 57.2421 214.179V198.393H42.0612C40.6827 198.393 39.9934 197.737 39.9934 196.426V188.054C39.9934 186.675 40.6827 185.986 42.0612 185.986Z" fill="#56AE27"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1,144 @@
/**
* Number Formatter Utility
* Formats numbers with comma separators for better readability
*/
// Format number with comma separators (e.g., 1234567 -> 1,234,567)
function formatNumber(num) {
if (!num) return '';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
// Remove comma separators from formatted number
function unformatNumber(str) {
if (!str) return '';
return str.replace(/,/g, '');
}
// Extract only digits from any string
function extractDigits(str) {
if (!str) return '';
return str.replace(/\D/g, '');
}
// Initialize number formatting for specified input selectors
function initNumberFormatting(selectors) {
if (typeof $ === 'undefined') {
console.warn('jQuery not found. Number formatting requires jQuery.');
return;
}
$(document).ready(function() {
selectors.forEach(function(selector) {
// Store cursor position to maintain it after formatting
function setCursorPosition(input, pos) {
if (input.setSelectionRange) {
input.setSelectionRange(pos, pos);
}
}
$(selector).on('input', function(e) {
let input = $(this);
let inputElement = this;
let value = input.val();
let cursorPos = inputElement.selectionStart;
// Extract only digits
let digitsOnly = extractDigits(value);
// Store raw value
input.attr('data-raw-value', digitsOnly);
// Format and set the value
let formattedValue = formatNumber(digitsOnly);
input.val(formattedValue);
// Adjust cursor position
let oldLength = value.length;
let newLength = formattedValue.length;
let newCursorPos = cursorPos + (newLength - oldLength);
// Make sure cursor position is valid
if (newCursorPos < 0) newCursorPos = 0;
if (newCursorPos > newLength) newCursorPos = newLength;
// Set cursor position after a short delay
setTimeout(function() {
setCursorPosition(inputElement, newCursorPos);
}, 1);
});
// Handle paste events
$(selector).on('paste', function(e) {
let input = $(this);
setTimeout(function() {
let value = input.val();
let digitsOnly = extractDigits(value);
input.attr('data-raw-value', digitsOnly);
input.val(formatNumber(digitsOnly));
}, 1);
});
});
// Before form submission, replace formatted values with raw values
$('form').on('submit', function() {
selectors.forEach(function(selector) {
let input = $(selector);
let rawValue = input.attr('data-raw-value');
if (rawValue) {
input.val(rawValue);
}
});
});
});
}
// Helper function to get raw value from formatted input
function getRawValue(input) {
return $(input).attr('data-raw-value') || unformatNumber($(input).val());
}
// Helper function to set raw value before AJAX submission
function setRawValuesForSubmission(selectors) {
selectors.forEach(function(selector) {
let input = $(selector);
let rawValue = input.attr('data-raw-value');
if (rawValue) {
input.val(rawValue);
}
});
}
// Helper function to restore formatted values after AJAX submission
function restoreFormattedValues(selectors) {
selectors.forEach(function(selector) {
let input = $(selector);
let rawValue = input.attr('data-raw-value');
if (rawValue) {
input.val(formatNumber(rawValue));
}
});
}
// Auto-initialize for common amount input selectors
$(document).ready(function() {
const commonSelectors = [
'#id_amount',
'#id_charge_amount',
'input[name="amount"]',
'input[name="unit_price"]',
'input[name="price"]'
];
initNumberFormatting(commonSelectors);
// Make helper functions globally available for AJAX forms
window.formatNumber = formatNumber;
window.unformatNumber = unformatNumber;
window.getRawValue = getRawValue;
// Avoid name collision causing recursion by aliasing helpers
const __nf_setRawValuesForSubmission = setRawValuesForSubmission;
const __nf_restoreFormattedValues = restoreFormattedValues;
window.setRawValuesForSubmission = function() { __nf_setRawValuesForSubmission(commonSelectors); };
window.restoreFormattedValues = function() { __nf_restoreFormattedValues(commonSelectors); };
});

View file

@ -24340,8 +24340,8 @@ html:not(.layout-footer-fixed) .content-wrapper {
}
.menu-vertical .app-brand {
padding-right: 2rem;
padding-left: 2rem
padding-right: 1.5rem;
padding-left: 1.5rem
}
.menu-horizontal .app-brand, .menu-horizontal .app-brand + .menu-divider {
@ -24379,7 +24379,7 @@ html:not(.layout-footer-fixed) .content-wrapper {
@media (min-width: 1200px) {
.layout-menu-collapsed:not(.layout-menu-hover):not(.layout-menu-offcanvas):not(.layout-menu-fixed-offcanvas) .layout-menu .app-brand {
width: 5.25rem
width: 6.75rem
}
.layout-menu-collapsed:not(.layout-menu-hover):not(.layout-menu-offcanvas):not(.layout-menu-fixed-offcanvas) .layout-menu .app-brand-logo, .layout-menu-collapsed:not(.layout-menu-hover):not(.layout-menu-offcanvas):not(.layout-menu-fixed-offcanvas) .layout-menu .app-brand-link, .layout-menu-collapsed:not(.layout-menu-hover):not(.layout-menu-offcanvas):not(.layout-menu-fixed-offcanvas) .layout-menu .app-brand-text {

View file

@ -24375,8 +24375,8 @@ html:not(.layout-footer-fixed) .content-wrapper {
}
.menu-vertical .app-brand {
padding-right: 2rem;
padding-left: 2rem
padding-right: 1.5rem;
padding-left: 1.5rem
}
.menu-horizontal .app-brand, .menu-horizontal .app-brand + .menu-divider {
@ -24414,7 +24414,7 @@ html:not(.layout-footer-fixed) .content-wrapper {
@media (min-width: 1200px) {
.layout-menu-collapsed:not(.layout-menu-hover):not(.layout-menu-offcanvas):not(.layout-menu-fixed-offcanvas) .layout-menu .app-brand {
width: 5.25rem
width: 6.75rem
}
.layout-menu-collapsed:not(.layout-menu-hover):not(.layout-menu-offcanvas):not(.layout-menu-fixed-offcanvas) .layout-menu .app-brand-logo, .layout-menu-collapsed:not(.layout-menu-hover):not(.layout-menu-offcanvas):not(.layout-menu-fixed-offcanvas) .layout-menu .app-brand-link, .layout-menu-collapsed:not(.layout-menu-hover):not(.layout-menu-offcanvas):not(.layout-menu-fixed-offcanvas) .layout-menu .app-brand-text {

View file

@ -17,14 +17,10 @@ layout-navbar-fixed layout-menu-fixed layout-compact
</title>
<meta name="description" content="Most Powerful &amp; Comprehensive Bootstrap 5 HTML Admin Dashboard Template built for developers!"/>
<meta name="keywords" content="dashboard, bootstrap 5 dashboard, bootstrap 5 design, bootstrap 5">
<!-- Canonical SEO -->
<link rel="canonical" href="https://themeselection.com/item/sneat-bootstrap-html-admin-template/">
<meta name="description" content="Meter Plus"/>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="{% static 'assets/img/favicon/favicon.ico' %}"/>
<link rel="icon" type="image/x-icon" href="{% static 'assets/img/logo/fav.png' %}"/ height="50">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@ -99,7 +95,7 @@ layout-navbar-fixed layout-menu-fixed layout-compact
<div class="container-xxl d-flex flex-wrap justify-content-between py-3 flex-md-row flex-column">
<div class="mb-2 mb-md-0">
<div class="d-flex flex-column">
<span class="fw-medium">© {{ current_year|default:"2024" }} تمامی حقوق متعلق به شرکت زیست آب است.</span>
<span class="fw-medium">© {{ current_year|default:"2024" }} تمامی حقوق متعلق به شرکت زیست‌آب پرآب است.</span>
<small class="text-muted mt-1">طراحی و توسعه با ❤️ در ایران</small>
</div>
</div>
@ -112,17 +108,17 @@ layout-navbar-fixed layout-menu-fixed layout-compact
<span class="text-muted">|</span>
<span class="text-muted">
<i class="bx bx-envelope me-1"></i>
پشتیبانی: info@zistab.com
پشتیبانی: info@poraab.com
</span>
<span class="text-muted">|</span>
<span class="text-muted">
<i class="bx bx-phone me-1"></i>
تلفن: 021-12345678
تلفن: 02188728477
</span>
<span class="text-muted">|</span>
<span class="text-muted">
<i class="bx bx-map me-1"></i>
تهران، خیابان ولیعصر
تهران، خیابان شهید بهشتی، پلاک ۴۳۶
</span>
</div>
</div>
@ -169,6 +165,8 @@ layout-navbar-fixed layout-menu-fixed layout-compact
<!-- Main JS -->
<script src="{% static 'assets/js/main.js' %}"></script>
<!-- Number Formatter JS -->
<script src="{% static 'assets/js/number-formatter.js' %}"></script>
<!-- Page JS -->
<script src="{% static 'assets/js/dashboards-analytics.js' %}"></script>

View file

@ -35,6 +35,7 @@ id="layout-navbar">
<!-- /Language -->
<!-- Quick links -->
<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">
@ -144,6 +145,11 @@ id="layout-navbar">
</li>
<!-- / Style Switcher-->
<li class="nav-item align-items-center">
{% if request.user.profile %}
<p class="text-muted badge bg-label-primary m-0">{{ request.user.profile.roles_str }}</p>
{% endif %}
</li>
<!-- Notification -->
<li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-1 d-none">

View file

@ -5,55 +5,10 @@
<aside id="layout-menu" class="layout-menu menu-vertical menu bg-menu-theme">
<div class="app-brand demo ">
<a href="index.html" class="app-brand-link">
<span class="app-brand-logo demo">
<svg width="25" viewBox="0 0 25 42" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<path
d="M13.7918663,0.358365126 L3.39788168,7.44174259 C0.566865006,9.69408886 -0.379795268,12.4788597 0.557900856,15.7960551 C0.68998853,16.2305145 1.09562888,17.7872135 3.12357076,19.2293357 C3.8146334,19.7207684 5.32369333,20.3834223 7.65075054,21.2172976 L7.59773219,21.2525164 L2.63468769,24.5493413 C0.445452254,26.3002124 0.0884951797,28.5083815 1.56381646,31.1738486 C2.83770406,32.8170431 5.20850219,33.2640127 7.09180128,32.5391577 C8.347334,32.0559211 11.4559176,30.0011079 16.4175519,26.3747182 C18.0338572,24.4997857 18.6973423,22.4544883 18.4080071,20.2388261 C17.963753,17.5346866 16.1776345,15.5799961 13.0496516,14.3747546 L10.9194936,13.4715819 L18.6192054,7.984237 L13.7918663,0.358365126 Z"
id="path-1"></path>
<path
d="M5.47320593,6.00457225 C4.05321814,8.216144 4.36334763,10.0722806 6.40359441,11.5729822 C8.61520715,12.571656 10.0999176,13.2171421 10.8577257,13.5094407 L15.5088241,14.433041 L18.6192054,7.984237 C15.5364148,3.11535317 13.9273018,0.573395879 13.7918663,0.358365126 C13.5790555,0.511491653 10.8061687,2.3935607 5.47320593,6.00457225 Z"
id="path-3"></path>
<path
d="M7.50063644,21.2294429 L12.3234468,23.3159332 C14.1688022,24.7579751 14.397098,26.4880487 13.008334,28.506154 C11.6195701,30.5242593 10.3099883,31.790241 9.07958868,32.3040991 C5.78142938,33.4346997 4.13234973,34 4.13234973,34 C4.13234973,34 2.75489982,33.0538207 2.37032616e-14,31.1614621 C-0.55822714,27.8186216 -0.55822714,26.0572515 -4.05231404e-15,25.8773518 C0.83734071,25.6075023 2.77988457,22.8248993 3.3049379,22.52991 C3.65497346,22.3332504 5.05353963,21.8997614 7.50063644,21.2294429 Z"
id="path-4"></path>
<path
d="M20.6,7.13333333 L25.6,13.8 C26.2627417,14.6836556 26.0836556,15.9372583 25.2,16.6 C24.8538077,16.8596443 24.4327404,17 24,17 L14,17 C12.8954305,17 12,16.1045695 12,15 C12,14.5672596 12.1403557,14.1461923 12.4,13.8 L17.4,7.13333333 C18.0627417,6.24967773 19.3163444,6.07059163 20.2,6.73333333 C20.3516113,6.84704183 20.4862915,6.981722 20.6,7.13333333 Z"
id="path-5"></path>
</defs>
<g id="g-app-brand" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Brand-Logo" transform="translate(-27.000000, -15.000000)">
<g id="Icon" transform="translate(27.000000, 15.000000)">
<g id="Mask" transform="translate(0.000000, 8.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use fill="#696cff" xlink:href="#path-1"></use>
<g id="Path-3" mask="url(#mask-2)">
<use fill="#696cff" xlink:href="#path-3"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-3"></use>
</g>
<g id="Path-4" mask="url(#mask-2)">
<use fill="#696cff" xlink:href="#path-4"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-4"></use>
</g>
</g>
<g id="Triangle" transform="translate(19.000000, 11.000000) rotate(-300.000000) translate(-19.000000, -11.000000) ">
<use fill="#696cff" xlink:href="#path-5"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-5"></use>
</g>
</g>
</g>
</g>
</svg>
</span>
<span class="app-brand-text demo menu-text fw-bold ms-2 fs-4">سامانه شفافیت</span>
<div class="app-brand demo justify-content-center">
<a href="./" class="app-brand-link">
<img src="{% static 'assets/img/logo/logo.png' %}" alt="logo" class="img-fluid" width="100">
</a>
<a href="#" class="layout-menu-toggle menu-link text-large ms-auto">
<i class="bx bx-chevron-left bx-sm align-middle"></i>
</a>
@ -114,7 +69,7 @@
</a>
</li>
{% if request.user|is_admin or request.user|is_broker or request.user|is_manager or request.user|is_accountant %}
{% if request.user|is_admin or request.user|is_broker or request.user|is_manager or request.user|is_accountant or request.user|is_water_resource_manager %}
<!-- Customers -->
<li class="menu-header small text-uppercase">
<span class="menu-header-text">مشترک‌ها</span>

View file

@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from .models import Well, WaterMeterManufacturer
from locations.models import Affairs, County, Broker
from common.consts import UserRoles
class WaterMeterManufacturerForm(forms.ModelForm):
@ -94,16 +95,17 @@ class WellForm(forms.ModelForm):
'max': '60'
}),
'utm_hemisphere': forms.Select(attrs={
'class': 'form-select'
'class': 'form-select',
}),
'well_power': forms.NumberInput(attrs={
'class': 'form-control',
'placeholder': 'قدرت چاه',
'min': '0'
'min': '0',
'required': True
}),
'reference_letter_number': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'شماره معرفی نامه'
'placeholder': 'شماره معرفی نامه',
}),
'reference_letter_date': forms.DateInput(attrs={
'class': 'form-control',
@ -118,8 +120,10 @@ class WellForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# تنظیم querysetها
self.fields['representative'].queryset = get_user_model().objects.all()
# تنظیم querysetها - فقط کاربرانی که نقش مشترک دارند
self.fields['representative'].queryset = get_user_model().objects.filter(
profile__roles__slug=UserRoles.CUSTOMER.value
)
self.fields['water_meter_manufacturer'].queryset = WaterMeterManufacturer.objects.all()
# اضافه کردن گزینه خالی

View file

@ -0,0 +1,33 @@
# Generated by Django 5.2.4 on 2025-09-21 07:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wells', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='historicalwell',
name='utm_x',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='X UTM'),
),
migrations.AlterField(
model_name='historicalwell',
name='utm_y',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='Y UTM'),
),
migrations.AlterField(
model_name='well',
name='utm_x',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='X UTM'),
),
migrations.AlterField(
model_name='well',
name='utm_y',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='Y UTM'),
),
]

View file

@ -0,0 +1,113 @@
# Generated by Django 5.2.4 on 2025-09-24 11:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wells', '0002_alter_historicalwell_utm_x_and_more'),
]
operations = [
migrations.AddField(
model_name='historicalwell',
name='discharge_pipe_diameter',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='قطر لوله آبده (اینچ)'),
),
migrations.AddField(
model_name='historicalwell',
name='driving_force',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='نیرو محرکه چاه'),
),
migrations.AddField(
model_name='historicalwell',
name='exploitation_license_number',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='شماره پروانه بهره\u200cبرداری چاه'),
),
migrations.AddField(
model_name='historicalwell',
name='meter_size',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='سایز کنتور'),
),
migrations.AddField(
model_name='historicalwell',
name='meter_type',
field=models.CharField(blank=True, choices=[('smart', 'هوشمند (آبی/برق)'), ('volumetric', 'حجمی')], max_length=20, null=True, verbose_name='نوع کنتور'),
),
migrations.AddField(
model_name='historicalwell',
name='motor_power',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='(کیلووات ساعت)قدرت موتور'),
),
migrations.AddField(
model_name='historicalwell',
name='post_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی بعد از کالیبراسیون'),
),
migrations.AddField(
model_name='historicalwell',
name='pre_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
),
migrations.AddField(
model_name='historicalwell',
name='sim_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='شماره سیمکارت'),
),
migrations.AddField(
model_name='historicalwell',
name='usage_type',
field=models.CharField(blank=True, choices=[('domestic', 'شرب و خدمات'), ('agriculture', 'کشاورزی'), ('industrial', 'صنعتی')], max_length=20, null=True, verbose_name='نوع مصرف'),
),
migrations.AddField(
model_name='well',
name='discharge_pipe_diameter',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='قطر لوله آبده (اینچ)'),
),
migrations.AddField(
model_name='well',
name='driving_force',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='نیرو محرکه چاه'),
),
migrations.AddField(
model_name='well',
name='exploitation_license_number',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='شماره پروانه بهره\u200cبرداری چاه'),
),
migrations.AddField(
model_name='well',
name='meter_size',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='سایز کنتور'),
),
migrations.AddField(
model_name='well',
name='meter_type',
field=models.CharField(blank=True, choices=[('smart', 'هوشمند (آبی/برق)'), ('volumetric', 'حجمی')], max_length=20, null=True, verbose_name='نوع کنتور'),
),
migrations.AddField(
model_name='well',
name='motor_power',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='(کیلووات ساعت)قدرت موتور'),
),
migrations.AddField(
model_name='well',
name='post_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی بعد از کالیبراسیون'),
),
migrations.AddField(
model_name='well',
name='pre_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
),
migrations.AddField(
model_name='well',
name='sim_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='شماره سیمکارت'),
),
migrations.AddField(
model_name='well',
name='usage_type',
field=models.CharField(blank=True, choices=[('domestic', 'شرب و خدمات'), ('agriculture', 'کشاورزی'), ('industrial', 'صنعتی')], max_length=20, null=True, verbose_name='نوع مصرف'),
),
]

View file

@ -0,0 +1,93 @@
# Generated by Django 5.2.4 on 2025-09-24 11:15
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('wells', '0003_historicalwell_discharge_pipe_diameter_and_more'),
]
operations = [
migrations.RemoveField(
model_name='historicalwell',
name='discharge_pipe_diameter',
),
migrations.RemoveField(
model_name='historicalwell',
name='driving_force',
),
migrations.RemoveField(
model_name='historicalwell',
name='exploitation_license_number',
),
migrations.RemoveField(
model_name='historicalwell',
name='meter_size',
),
migrations.RemoveField(
model_name='historicalwell',
name='meter_type',
),
migrations.RemoveField(
model_name='historicalwell',
name='motor_power',
),
migrations.RemoveField(
model_name='historicalwell',
name='post_calibration_flow_rate',
),
migrations.RemoveField(
model_name='historicalwell',
name='pre_calibration_flow_rate',
),
migrations.RemoveField(
model_name='historicalwell',
name='sim_number',
),
migrations.RemoveField(
model_name='historicalwell',
name='usage_type',
),
migrations.RemoveField(
model_name='well',
name='discharge_pipe_diameter',
),
migrations.RemoveField(
model_name='well',
name='driving_force',
),
migrations.RemoveField(
model_name='well',
name='exploitation_license_number',
),
migrations.RemoveField(
model_name='well',
name='meter_size',
),
migrations.RemoveField(
model_name='well',
name='meter_type',
),
migrations.RemoveField(
model_name='well',
name='motor_power',
),
migrations.RemoveField(
model_name='well',
name='post_calibration_flow_rate',
),
migrations.RemoveField(
model_name='well',
name='pre_calibration_flow_rate',
),
migrations.RemoveField(
model_name='well',
name='sim_number',
),
migrations.RemoveField(
model_name='well',
name='usage_type',
),
]

View file

@ -0,0 +1,35 @@
# Generated by Django 5.2.4 on 2025-10-02 09:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wells', '0004_remove_historicalwell_discharge_pipe_diameter_and_more'),
]
operations = [
migrations.AlterField(
model_name='historicalwell',
name='utm_x',
field=models.DecimalField(decimal_places=0, default=11, max_digits=10, verbose_name='X UTM'),
preserve_default=False,
),
migrations.AlterField(
model_name='historicalwell',
name='utm_y',
field=models.DecimalField(decimal_places=0, default=2, max_digits=10, verbose_name='Y UTM'),
preserve_default=False,
),
migrations.AlterField(
model_name='well',
name='utm_x',
field=models.DecimalField(decimal_places=0, max_digits=10, verbose_name='X UTM'),
),
migrations.AlterField(
model_name='well',
name='utm_y',
field=models.DecimalField(decimal_places=0, max_digits=10, verbose_name='Y UTM'),
),
]

View file

@ -0,0 +1,43 @@
# Generated by Django 5.2.4 on 2025-10-04 10:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wells', '0005_alter_historicalwell_utm_x_and_more'),
]
operations = [
migrations.AlterField(
model_name='historicalwell',
name='utm_x',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='X UTM'),
),
migrations.AlterField(
model_name='historicalwell',
name='utm_y',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='Y UTM'),
),
migrations.AlterField(
model_name='historicalwell',
name='well_power',
field=models.PositiveIntegerField(verbose_name='قدرت چاه'),
),
migrations.AlterField(
model_name='well',
name='utm_x',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='X UTM'),
),
migrations.AlterField(
model_name='well',
name='utm_y',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='Y UTM'),
),
migrations.AlterField(
model_name='well',
name='well_power',
field=models.PositiveIntegerField(verbose_name='قدرت چاه'),
),
]

View file

@ -107,8 +107,6 @@ class Well(SluggedModel):
well_power = models.PositiveIntegerField(
verbose_name="قدرت چاه",
null=True,
blank=True
)
reference_letter_number = models.CharField(

View file

@ -338,6 +338,134 @@
</div>
</div>
<!-- Well Details Modal -->
<div class="modal fade" id="wellDetailsModal" tabindex="-1" aria-labelledby="wellDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="wellDetailsModalLabel">جزئیات چاه</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="well-details-loading" class="text-center py-4" style="display:none;">
<div class="spinner-border" role="status"></div>
<div class="mt-2">در حال بارگذاری...</div>
</div>
<div id="well-details-content" style="display:none;">
<div class="card mb-4">
<div class="card-body">
<h6 class="fw-bold mb-3 text-primary">مشخصات چاه</h6>
<div class="row">
<div class="col-md-6">
<table class="table table-borderless table-sm mb-0">
<tbody>
<tr>
<td class="text-muted" style="width: 40%;"><i class="bx bx-droplet me-1"></i>شماره اشتراک آب</td>
<td><strong id="wd-water-sub">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class='bx bx-bolt-circle me-1'></i>شماره اشتراک برق</td>
<td><strong id="wd-elec-sub">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-user me-1"></i>نماینده</td>
<td><strong id="wd-rep">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-briefcase me-1"></i>کارگزار</td>
<td><strong id="wd-broker">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-buildings me-1"></i>امور</td>
<td><strong id="wd-affairs">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-map me-1"></i>شهرستان</td>
<td><strong id="wd-county">-</strong></td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-6">
<table class="table table-borderless table-sm mb-0">
<tbody>
<tr>
<td class="text-muted" style="width: 40%;"><i class="bx bx-barcode me-1"></i>سریال کنتور</td>
<td><strong id="wd-meter-serial">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-barcode me-1"></i>سریال قدیمی</td>
<td><strong id="wd-meter-serial-old">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-factory me-1"></i>شرکت سازنده</td>
<td><strong id="wd-meter-maker">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-tachometer me-1"></i>قدرت چاه</td>
<td><strong id="wd-power">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-current-location me-1"></i>مختصات</td>
<td>
<div><small class="text-muted">X:</small> <span id="wd-utm-x">-</span></div>
<div><small class="text-muted">Y:</small> <span id="wd-utm-y">-</span></div>
<div><small class="text-muted">زون:</small> <span id="wd-utm-zone">-</span> <span id="wd-utm-hem">-</span></div>
<div id="wd-latlon-row" style="display:none;"><small class="text-muted">Lat/Lon:</small> <span id="wd-latlon">-</span></div>
</td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-file me-1"></i>نامه نمایندگی</td>
<td>
<a id="wd-letter-link" href="#" target="_blank" class="btn btn-sm btn-outline-primary" style="display:none;"><i class="bx bx-file me-1"></i>مشاهده</a>
<span id="wd-letter-missing" class="text-muted">-</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-bold">سوابق درخواست‌ها
<span class="badge bg-label-primary" id="wd-req-count">0</span>
</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>کد</th>
<th>فرآیند</th>
<th>مرحله فعلی</th>
<th>وضعیت</th>
<th>نماینده</th>
<th>تاریخ ایجاد</th>
<th></th>
</tr>
</thead>
<tbody id="wd-requests-body">
<tr><td class="text-center py-3" colspan="7"><span class="text-muted">رکوردی یافت نشد</span></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">بستن</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-labelledby="deleteConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog">
@ -556,8 +684,80 @@
// Well functions
function viewWell(id) {
// Implement view functionality
showToast('قابلیت مشاهده جزئیات به زودی اضافه خواهد شد', 'info');
currentWellId = id;
const modalEl = document.getElementById('wellDetailsModal');
const modal = new bootstrap.Modal(modalEl);
// reset content
$('#well-details-content').hide();
$('#well-details-loading').show();
$('#wd-requests-body').html('<tr><td class="text-center py-3" colspan="7"><span class="text-muted">در حال بارگذاری...</span></td></tr>');
$('#wd-req-count').text('0');
$('#wd-letter-link').hide();
$('#wd-letter-missing').show();
modal.show();
// Fetch well details
$.get('{% url "wells:get_well_details" 0 %}'.replace('0', id))
.done(function(resp){
if (!resp.success) { showToast('خطا در دریافت جزئیات چاه', 'danger'); return; }
const w = resp.well;
$('#wellDetailsModalLabel').text('جزئیات چاه ' + (w.water_subscription_number || ''));
$('#wd-water-sub').text(w.water_subscription_number || '-');
$('#wd-elec-sub').text(w.electricity_subscription_number || '-');
$('#wd-rep').text((w.representative && (w.representative.full_name || w.representative.username)) || '-');
$('#wd-broker').text(w.broker || '-');
$('#wd-affairs').text(w.affairs || '-');
$('#wd-county').text(w.county || '-');
$('#wd-meter-serial').text(w.water_meter_serial_number || '-');
$('#wd-meter-serial-old').text(w.water_meter_old_serial_number || '-');
$('#wd-meter-maker').text(w.water_meter_manufacturer || '-');
$('#wd-power').text(w.well_power || '-');
$('#wd-utm-x').text((w.utm && w.utm.x) || '-');
$('#wd-utm-y').text((w.utm && w.utm.y) || '-');
$('#wd-utm-zone').text((w.utm && w.utm.zone) || '-');
$('#wd-utm-hem').text((w.utm && w.utm.hemisphere) || '-');
if (w.lat_long && w.lat_long.lat !== undefined) {
$('#wd-latlon').text(w.lat_long.lat + ', ' + w.lat_long.lon);
$('#wd-latlon-row').show();
} else {
$('#wd-latlon-row').hide();
}
if (w.representative_letter_file_url) {
$('#wd-letter-link').attr('href', w.representative_letter_file_url).show();
$('#wd-letter-missing').hide();
}
$('#wd-req-count').text(resp.total_requests || '0');
$('#well-details-loading').hide();
$('#well-details-content').show();
})
.fail(function(){ showToast('خطا در ارتباط با سرور', 'danger'); $('#well-details-loading').hide(); });
// Fetch requests
$.get('{% url "wells:get_well_requests" 0 %}'.replace('0', id))
.done(function(resp){
if (!resp.success) { $('#wd-requests-body').html('<tr><td class="text-center py-3" colspan="7"><span class="text-danger">خطا در بارگذاری سوابق</span></td></tr>'); return; }
const rows = (resp.requests || []).map(function(r){
const status = r.status_display || r.status;
const step = r.current_step || '-';
const href = r.url || '#';
const rep = r.representative || '-';
return '<tr>'+
'<td>'+ (r.code || '-') +'</td>'+
'<td>'+ (r.process || '-') +'</td>'+
'<td>'+ step +'</td>'+
'<td>'+ status +'</td>'+
'<td>'+ rep +'</td>'+
'<td>'+ (r.created || '-') +'</td>'+
'<td><a class="btn btn-sm btn-outline-primary" href="'+ href +'" target="_blank">جزئیات</a></td>'+
'</tr>';
});
if (!rows.length) {
$('#wd-requests-body').html('<tr><td class="text-center py-3" colspan="7"><span class="text-muted">رکوردی یافت نشد</span></td></tr>');
} else {
$('#wd-requests-body').html(rows.join(''));
}
})
.fail(function(){ $('#wd-requests-body').html('<tr><td class="text-center py-3" colspan="7"><span class="text-danger">خطا در بارگذاری سوابق</span></td></tr>'); });
}
// حذف فایل موجود

View file

@ -10,6 +10,8 @@ urlpatterns = [
path('<int:well_id>/edit/', views.edit_well_ajax, name='edit_well_ajax'),
path('<int:well_id>/delete/', views.delete_well, name='delete_well'),
path('<int:well_id>/data/', views.get_well_data, name='get_well_data'),
path('<int:well_id>/details/', views.get_well_details, name='get_well_details'),
path('<int:well_id>/requests/', views.get_well_requests, name='get_well_requests'),
# شرکت‌های سازنده کنتور آب
path('manufacturer/create/', views.create_water_meter_manufacturer, name='create_water_meter_manufacturer'),

View file

@ -1,5 +1,6 @@
from django.shortcuts import render, get_object_or_404
from django.http import JsonResponse
from django.urls import reverse
from django.views.decorators.http import require_http_methods, require_GET, require_POST
from django.core.paginator import Paginator
from django.db.models import Q
@ -11,9 +12,10 @@ from django.contrib.auth.decorators import login_required
from common.decorators import allowed_roles
from common.consts import UserRoles
from processes.utils import scope_wells_queryset
from processes.models import ProcessInstance
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def well_list(request):
"""نمایش لیست چاه‌ها"""
base = Well.objects.select_related(
@ -38,7 +40,7 @@ def well_list(request):
@require_POST
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def add_well_ajax(request):
"""AJAX endpoint for adding wells"""
try:
@ -96,7 +98,7 @@ def add_well_ajax(request):
@require_POST
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def edit_well_ajax(request, well_id):
"""AJAX endpoint for editing wells"""
well = get_object_or_404(Well, id=well_id)
@ -152,7 +154,7 @@ def edit_well_ajax(request, well_id):
@require_POST
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT])
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def delete_well(request, well_id):
"""حذف چاه"""
well = get_object_or_404(Well, id=well_id)
@ -195,6 +197,101 @@ def get_well_data(request, well_id):
})
@require_GET
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def get_well_details(request, well_id):
"""جزئیات کامل چاه برای نمایش در مدال"""
well = get_object_or_404(
Well.objects.select_related(
'representative', 'water_meter_manufacturer', 'affairs', 'county', 'broker'
),
id=well_id
)
lat_long = None
try:
lat_long_val = well.lat_long()
if lat_long_val:
# utm.to_latlon returns (lat, lon)
lat_long = {
'lat': round(float(lat_long_val[0]), 6),
'lon': round(float(lat_long_val[1]), 6),
}
except Exception:
lat_long = None
data = {
'id': well.id,
'water_subscription_number': well.water_subscription_number,
'electricity_subscription_number': well.electricity_subscription_number or '',
'representative': {
'id': well.representative.id if well.representative else None,
'full_name': well.representative.get_full_name() if well.representative else '',
'username': well.representative.username if well.representative else '',
},
'water_meter_serial_number': well.water_meter_serial_number or '',
'water_meter_old_serial_number': well.water_meter_old_serial_number or '',
'water_meter_manufacturer': str(well.water_meter_manufacturer) if well.water_meter_manufacturer else '',
'utm': {
'x': str(well.utm_x) if well.utm_x is not None else '',
'y': str(well.utm_y) if well.utm_y is not None else '',
'zone': well.utm_zone or '',
'hemisphere': well.utm_hemisphere or '',
},
'lat_long': lat_long,
'well_power': well.well_power or '',
'reference_letter_number': well.reference_letter_number or '',
'reference_letter_date': well.reference_letter_date.strftime('%Y-%m-%d') if well.reference_letter_date else '',
'representative_letter_file_url': well.representative_letter_file.url if well.representative_letter_file else '',
'affairs': str(well.affairs) if well.affairs else '',
'county': str(well.county) if well.county else '',
'broker': str(well.broker) if well.broker else '',
}
# تعداد درخواست‌ها برای نمایش سریع
try:
total_requests = ProcessInstance.objects.filter(well_id=well.id, is_deleted=False).count()
except Exception:
total_requests = 0
return JsonResponse({'success': True, 'well': data, 'total_requests': total_requests})
@require_GET
@login_required
@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER])
def get_well_requests(request, well_id):
"""سوابق درخواست‌های مرتبط با یک چاه"""
# Scoped access: reuse base scoping by filtering on ProcessInstance via broker/affairs of current user if needed
qs = ProcessInstance.objects.select_related(
'process', 'current_step', 'requester', 'representative'
).filter(well_id=well_id, is_deleted=False).order_by('-created')
items = []
for inst in qs[:100]: # محدودسازی برای عملکرد
try:
url = reverse('processes:instance_summary', args=[inst.id]) if inst.status == 'completed' else reverse('processes:instance_steps', args=[inst.id])
except Exception:
url = ''
items.append({
'id': inst.id,
'code': inst.code,
'process': inst.process.name if inst.process else '',
'status': inst.status,
'status_display': inst.get_status_display(),
'priority': inst.priority,
'priority_display': inst.get_priority_display(),
'current_step': inst.current_step.name if inst.current_step else '',
'requester': inst.requester.get_full_name() if inst.requester else '',
'representative': inst.representative.get_full_name() if inst.representative else '',
'created': inst.jcreated_date() if hasattr(inst, 'created') and inst.created else '',
'url': url,
})
return JsonResponse({'success': True, 'requests': items})
@require_POST
@login_required
def create_water_meter_manufacturer(request):