Merge remote-tracking branch 'origin/main' into shafafiyat/production
This commit is contained in:
		
						commit
						e05fb4c95f
					
				
					 84 changed files with 4988 additions and 803 deletions
				
			
		
							
								
								
									
										4
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -9,8 +9,8 @@
 | 
			
		|||
*.pyc
 | 
			
		||||
__pycache__/
 | 
			
		||||
local_settings.py
 | 
			
		||||
# *.sqlite3
 | 
			
		||||
# db.sqlite3
 | 
			
		||||
#*.sqlite3
 | 
			
		||||
#db.sqlite3
 | 
			
		||||
db.sqlite3-journal
 | 
			
		||||
media
 | 
			
		||||
#static
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			@ -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 ""
 | 
			
		||||
| 
						 | 
				
			
			@ -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']
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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='نوع کاربر'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 %}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'),
 | 
			
		||||
]
 | 
			
		||||
| 
						 | 
				
			
			@ -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."""
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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='کد یکتا هولوگرام'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='کد یکتا هولوگرام'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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 = 'گواهی'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,6 +13,11 @@ class UserRoles(Enum):
 | 
			
		|||
    HEADQUARTER = "hdq" # ستاد آب منطقهای
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
USER_TYPE_CHOICES = [
 | 
			
		||||
    ('individual', 'حقیقی'),
 | 
			
		||||
    ('legal', 'حقوقی'),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
BANK_CHOICES = [
 | 
			
		||||
    ('mellat', 'بانک ملت'),
 | 
			
		||||
    ('saman', 'بانک سامان'),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)}"
 | 
			
		||||
| 
						 | 
				
			
			@ -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">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										
											BIN
										
									
								
								db.sqlite3
									
										
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								db.sqlite3
									
										
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							| 
						 | 
				
			
			@ -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
									
								
							
							
						
						
									
										224
									
								
								installations/forms.py
									
										
									
									
									
										Normal 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
 | 
			
		||||
| 
						 | 
				
			
			@ -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'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='شرکت سازنده کنتور آب'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='(کیلووات ساعت) قدرت موتور'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='نوع مصرف'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='مدل کنتور'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='قطر لوله آبده (اینچ)'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='توضیحات')
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 %}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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']
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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',
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='مرحله پرداخت'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 %}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 %}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 %}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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) -->
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 }}"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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()),
 | 
			
		||||
    }})
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										19
									
								
								locations/migrations/0004_alter_county_city.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								locations/migrations/0004_alter_county_city.py
									
										
									
									
									
										Normal 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='استان'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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='حذف شده'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='وضعیت'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='نقش'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='توضیحات'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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} - تایید شده"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 %}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 %}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
    
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								static/assets/img/logo/fav.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								static/assets/img/logo/fav.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 23 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								static/assets/img/logo/logo.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								static/assets/img/logo/logo.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 58 KiB  | 
							
								
								
									
										13
									
								
								static/assets/img/logo/logo.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								static/assets/img/logo/logo.svg
									
										
									
									
									
										Normal 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  | 
							
								
								
									
										144
									
								
								static/assets/js/number-formatter.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								static/assets/js/number-formatter.js
									
										
									
									
									
										Normal 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); };
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										6
									
								
								static/assets/vendor/css/rtl/core-dark.css
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								static/assets/vendor/css/rtl/core-dark.css
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										6
									
								
								static/assets/vendor/css/rtl/core.css
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								static/assets/vendor/css/rtl/core.css
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,14 +17,10 @@ layout-navbar-fixed layout-menu-fixed layout-compact
 | 
			
		|||
  </title>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  <meta name="description" content="Most Powerful & 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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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()
 | 
			
		||||
        
 | 
			
		||||
        # اضافه کردن گزینه خالی
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										33
									
								
								wells/migrations/0002_alter_historicalwell_utm_x_and_more.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								wells/migrations/0002_alter_historicalwell_utm_x_and_more.py
									
										
									
									
									
										Normal 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'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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='نوع مصرف'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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',
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										35
									
								
								wells/migrations/0005_alter_historicalwell_utm_x_and_more.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								wells/migrations/0005_alter_historicalwell_utm_x_and_more.py
									
										
									
									
									
										Normal 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'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										43
									
								
								wells/migrations/0006_alter_historicalwell_utm_x_and_more.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								wells/migrations/0006_alter_historicalwell_utm_x_and_more.py
									
										
									
									
									
										Normal 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='قدرت چاه'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -107,8 +107,6 @@ class Well(SluggedModel):
 | 
			
		|||
 | 
			
		||||
    well_power = models.PositiveIntegerField(
 | 
			
		||||
        verbose_name="قدرت چاه",
 | 
			
		||||
        null=True,
 | 
			
		||||
        blank=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    reference_letter_number = models.CharField(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>'); });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // حذف فایل موجود
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										105
									
								
								wells/views.py
									
										
									
									
									
								
							
							
						
						
									
										105
									
								
								wells/views.py
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -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):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue