complete first version of main proccess
This commit is contained in:
		
							parent
							
								
									6ff4740d04
								
							
						
					
					
						commit
						f2fc2362a7
					
				
					 61 changed files with 3280 additions and 28 deletions
				
			
		| 
						 | 
					@ -54,6 +54,9 @@ INSTALLED_APPS = [
 | 
				
			||||||
    'common.apps.CommonConfig',
 | 
					    'common.apps.CommonConfig',
 | 
				
			||||||
    'processes.apps.ProcessesConfig',
 | 
					    'processes.apps.ProcessesConfig',
 | 
				
			||||||
    'invoices.apps.InvoicesConfig',
 | 
					    'invoices.apps.InvoicesConfig',
 | 
				
			||||||
 | 
					    'contracts.apps.ContractsConfig',
 | 
				
			||||||
 | 
					    'certificates.apps.CertificatesConfig',
 | 
				
			||||||
 | 
					    'installations.apps.InstallationsConfig',
 | 
				
			||||||
    # ----------------------- #
 | 
					    # ----------------------- #
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -25,6 +25,9 @@ urlpatterns = [
 | 
				
			||||||
    path('wells/', include('wells.urls')),
 | 
					    path('wells/', include('wells.urls')),
 | 
				
			||||||
    path('processes/', include('processes.urls')),
 | 
					    path('processes/', include('processes.urls')),
 | 
				
			||||||
    path('invoices/', include('invoices.urls')),
 | 
					    path('invoices/', include('invoices.urls')),
 | 
				
			||||||
 | 
					    path('contracts/', include('contracts.urls')),
 | 
				
			||||||
 | 
					    path('certificates/', include('certificates.urls')),
 | 
				
			||||||
 | 
					    path('installations/', include('installations.urls')),
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
if settings.DEBUG:
 | 
					if settings.DEBUG:
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,6 @@
 | 
				
			||||||
from django.contrib import admin
 | 
					from django.contrib import admin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from accounts.models import Role, Profile
 | 
					from accounts.models import Role, Profile, Company
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Register your models here.
 | 
					# Register your models here.
 | 
				
			||||||
| 
						 | 
					@ -30,3 +30,12 @@ class ProfileAdmin(admin.ModelAdmin):
 | 
				
			||||||
    date_hierarchy = 'created'
 | 
					    date_hierarchy = 'created'
 | 
				
			||||||
    ordering = ['-created']
 | 
					    ordering = ['-created']
 | 
				
			||||||
    readonly_fields = ['created', 'updated']
 | 
					    readonly_fields = ['created', 'updated']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@admin.register(Company)
 | 
				
			||||||
 | 
					class CompanyAdmin(admin.ModelAdmin):
 | 
				
			||||||
 | 
					    list_display = ['name', 'logo', 'signature', 'address', 'phone']
 | 
				
			||||||
 | 
					    prepopulated_fields = {'slug': ('name',)}
 | 
				
			||||||
 | 
					    search_fields = ['name', 'address', 'phone']
 | 
				
			||||||
 | 
					    list_filter = ['is_active']
 | 
				
			||||||
 | 
					    date_hierarchy = 'created'
 | 
				
			||||||
 | 
					    ordering = ['-created']
 | 
				
			||||||
| 
						 | 
					@ -29,7 +29,7 @@ class CustomerForm(forms.ModelForm):
 | 
				
			||||||
        model = Profile
 | 
					        model = Profile
 | 
				
			||||||
        fields = [
 | 
					        fields = [
 | 
				
			||||||
            'phone_number_1', 'phone_number_2', 'national_code', 
 | 
					            'phone_number_1', 'phone_number_2', 'national_code', 
 | 
				
			||||||
            'address', 'card_number', 'account_number'
 | 
					            'address', 'card_number', 'account_number', 'bank_name'
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
        widgets = {
 | 
					        widgets = {
 | 
				
			||||||
            'phone_number_1': forms.TextInput(attrs={
 | 
					            'phone_number_1': forms.TextInput(attrs={
 | 
				
			||||||
| 
						 | 
					@ -61,6 +61,10 @@ class CustomerForm(forms.ModelForm):
 | 
				
			||||||
                'placeholder': 'شماره حساب بانکی',
 | 
					                'placeholder': 'شماره حساب بانکی',
 | 
				
			||||||
                'maxlength': '20'
 | 
					                'maxlength': '20'
 | 
				
			||||||
            }),
 | 
					            }),
 | 
				
			||||||
 | 
					            'bank_name': forms.Select(attrs={
 | 
				
			||||||
 | 
					                'class': 'form-control',
 | 
				
			||||||
 | 
					                'placeholder': 'نام بانک',
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        labels = {
 | 
					        labels = {
 | 
				
			||||||
            'phone_number_1': 'تلفن ۱',
 | 
					            'phone_number_1': 'تلفن ۱',
 | 
				
			||||||
| 
						 | 
					@ -69,6 +73,7 @@ class CustomerForm(forms.ModelForm):
 | 
				
			||||||
            'address': 'آدرس',
 | 
					            'address': 'آدرس',
 | 
				
			||||||
            'card_number': 'شماره کارت',
 | 
					            'card_number': 'شماره کارت',
 | 
				
			||||||
            'account_number': 'شماره حساب',
 | 
					            'account_number': 'شماره حساب',
 | 
				
			||||||
 | 
					            'bank_name': 'نام بانک',
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def clean_national_code(self):
 | 
					    def clean_national_code(self):
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										34
									
								
								accounts/migrations/0002_company.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								accounts/migrations/0002_company.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,34 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-21 06:33
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('accounts', '0001_initial'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='Company',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
 | 
				
			||||||
 | 
					                ('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
 | 
				
			||||||
 | 
					                ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
 | 
				
			||||||
 | 
					                ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
 | 
				
			||||||
 | 
					                ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
 | 
				
			||||||
 | 
					                ('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')),
 | 
				
			||||||
 | 
					                ('name', models.CharField(max_length=100, verbose_name='نام')),
 | 
				
			||||||
 | 
					                ('logo', models.ImageField(blank=True, null=True, upload_to='companies/logos', verbose_name='لوگوی شرکت')),
 | 
				
			||||||
 | 
					                ('signature', models.ImageField(blank=True, null=True, upload_to='companies/signatures', verbose_name='امضای شرکت')),
 | 
				
			||||||
 | 
					                ('address', models.TextField(blank=True, null=True, verbose_name='آدرس')),
 | 
				
			||||||
 | 
					                ('phone', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس')),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                'verbose_name': 'شرکت',
 | 
				
			||||||
 | 
					                'verbose_name_plural': 'شرکت\u200cها',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,23 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-21 07:06
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('accounts', '0002_company'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='historicalprofile',
 | 
				
			||||||
 | 
					            name='bank_name',
 | 
				
			||||||
 | 
					            field=models.CharField(blank=True, choices=[('mellat', 'بانک ملت'), ('saman', 'بانک سامان'), ('parsian', 'بانک پارسیان'), ('sina', 'بانک سینا'), ('tejarat', 'بانک تجارت'), ('tosee', 'بانک توسعه'), ('iran_zamin', 'بانک ایران زمین'), ('meli', 'بانک ملی'), ('saderat', 'بانک توسعه صادرات'), ('iran_zamin', 'بانک ایران زمین'), ('refah', 'بانک رفاه'), ('eghtesad_novin', 'بانک اقتصاد نوین'), ('pasargad', 'بانک پاسارگاد'), ('other', 'سایر')], max_length=255, null=True, verbose_name='نام بانک'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='profile',
 | 
				
			||||||
 | 
					            name='bank_name',
 | 
				
			||||||
 | 
					            field=models.CharField(blank=True, choices=[('mellat', 'بانک ملت'), ('saman', 'بانک سامان'), ('parsian', 'بانک پارسیان'), ('sina', 'بانک سینا'), ('tejarat', 'بانک تجارت'), ('tosee', 'بانک توسعه'), ('iran_zamin', 'بانک ایران زمین'), ('meli', 'بانک ملی'), ('saderat', 'بانک توسعه صادرات'), ('iran_zamin', 'بانک ایران زمین'), ('refah', 'بانک رفاه'), ('eghtesad_novin', 'بانک اقتصاد نوین'), ('pasargad', 'بانک پاسارگاد'), ('other', 'سایر')], max_length=255, null=True, verbose_name='نام بانک'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -3,14 +3,15 @@ from django.db import models
 | 
				
			||||||
from django.utils.html import format_html
 | 
					from django.utils.html import format_html
 | 
				
			||||||
from django.core.validators import RegexValidator
 | 
					from django.core.validators import RegexValidator
 | 
				
			||||||
from simple_history.models import HistoricalRecords
 | 
					from simple_history.models import HistoricalRecords
 | 
				
			||||||
from common.models import TagModel, BaseModel
 | 
					from common.models import TagModel, BaseModel, NameSlugModel
 | 
				
			||||||
from common.consts import UserRoles
 | 
					from common.consts import UserRoles, BANK_CHOICES
 | 
				
			||||||
from locations.models import Affairs, Broker, County
 | 
					from locations.models import Affairs, Broker, County
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Create your models here.
 | 
					# Create your models here.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Role(TagModel):
 | 
					class Role(TagModel):
 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        verbose_name = "نقش"
 | 
					        verbose_name = "نقش"
 | 
				
			||||||
        verbose_name_plural = "نقشها"
 | 
					        verbose_name_plural = "نقشها"
 | 
				
			||||||
| 
						 | 
					@ -68,6 +69,13 @@ class Profile(BaseModel):
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					    bank_name = models.CharField(
 | 
				
			||||||
 | 
					        max_length=255,
 | 
				
			||||||
 | 
					        choices=BANK_CHOICES,
 | 
				
			||||||
 | 
					        null=True,
 | 
				
			||||||
 | 
					        verbose_name="نام بانک",
 | 
				
			||||||
 | 
					        blank=True
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    phone_number_1 = models.CharField(
 | 
					    phone_number_1 = models.CharField(
 | 
				
			||||||
        max_length=11,
 | 
					        max_length=11,
 | 
				
			||||||
        null=True,
 | 
					        null=True,
 | 
				
			||||||
| 
						 | 
					@ -170,3 +178,17 @@ class Profile(BaseModel):
 | 
				
			||||||
        return format_html(f"<img style='width:30px;' src='{self.pic.url}'>")
 | 
					        return format_html(f"<img style='width:30px;' src='{self.pic.url}'>")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    pic_tag.short_description = "تصویر"
 | 
					    pic_tag.short_description = "تصویر"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Company(NameSlugModel):
 | 
				
			||||||
 | 
					    logo = models.ImageField(upload_to='companies/logos', null=True, blank=True, verbose_name='لوگوی شرکت')
 | 
				
			||||||
 | 
					    signature = models.ImageField(upload_to='companies/signatures', null=True, blank=True, verbose_name='امضای شرکت')
 | 
				
			||||||
 | 
					    address = models.TextField(null=True, blank=True, verbose_name='آدرس')
 | 
				
			||||||
 | 
					    phone = models.CharField(max_length=11, null=True, blank=True, verbose_name='شماره تماس')
 | 
				
			||||||
 | 
					   
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = 'شرکت'
 | 
				
			||||||
 | 
					        verbose_name_plural = 'شرکتها'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self):
 | 
				
			||||||
 | 
					        return self.name
 | 
				
			||||||
| 
						 | 
					@ -64,6 +64,7 @@
 | 
				
			||||||
            <th>کد ملی</th>
 | 
					            <th>کد ملی</th>
 | 
				
			||||||
            <th>تلفن</th>
 | 
					            <th>تلفن</th>
 | 
				
			||||||
            <th>آدرس</th>
 | 
					            <th>آدرس</th>
 | 
				
			||||||
 | 
					            <th>بانک</th>
 | 
				
			||||||
            <th>وضعیت</th>
 | 
					            <th>وضعیت</th>
 | 
				
			||||||
            <th>عملیات</th>
 | 
					            <th>عملیات</th>
 | 
				
			||||||
          </tr>
 | 
					          </tr>
 | 
				
			||||||
| 
						 | 
					@ -122,6 +123,17 @@
 | 
				
			||||||
                <span class="text-muted">آدرس ثبت نشده</span>
 | 
					                <span class="text-muted">آدرس ثبت نشده</span>
 | 
				
			||||||
              {% endif %}
 | 
					              {% endif %}
 | 
				
			||||||
            </td>
 | 
					            </td>
 | 
				
			||||||
 | 
					            <td>
 | 
				
			||||||
 | 
					              <div class="d-flex flex-column">
 | 
				
			||||||
 | 
					                {% if customer.bank_name %}
 | 
				
			||||||
 | 
					                  <span class="fw-medium">{{ customer.get_bank_name_display }}</span>
 | 
				
			||||||
 | 
					                  <span class="text-muted">{{ customer.card_number }}</span>
 | 
				
			||||||
 | 
					                  <span class="text-muted">{{ customer.account_number }}</span>
 | 
				
			||||||
 | 
					                {% else %}
 | 
				
			||||||
 | 
					                  <span class="text-muted">بانک ثبت نشده</span>
 | 
				
			||||||
 | 
					                {% endif %}
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </td>
 | 
				
			||||||
            <td>
 | 
					            <td>
 | 
				
			||||||
              {% if customer.is_completed %}
 | 
					              {% if customer.is_completed %}
 | 
				
			||||||
                <span class="badge bg-label-success">تکمیل شده</span>
 | 
					                <span class="badge bg-label-success">تکمیل شده</span>
 | 
				
			||||||
| 
						 | 
					@ -241,6 +253,17 @@
 | 
				
			||||||
          <div class="invalid-feedback d-block">{{ form.national_code.errors.0 }}</div>
 | 
					          <div class="invalid-feedback d-block">{{ form.national_code.errors.0 }}</div>
 | 
				
			||||||
        {% endif %}
 | 
					        {% endif %}
 | 
				
			||||||
      </div>
 | 
					      </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">
 | 
				
			||||||
 | 
					          <span class="input-group-text"><i class="bx bx-credit-card"></i></span>
 | 
				
			||||||
 | 
					          {{ form.bank_name }}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        {% if form.bank_name.errors %}
 | 
				
			||||||
 | 
					          <div class="invalid-feedback d-block">{{ form.bank_name.errors.0 }}</div>
 | 
				
			||||||
 | 
					        {% endif %}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
      
 | 
					      
 | 
				
			||||||
      <div class="col-sm-12">
 | 
					      <div class="col-sm-12">
 | 
				
			||||||
        <label class="form-label fw-bold" for="{{ form.card_number.id_for_label }}">{{ form.card_number.label }}</label>
 | 
					        <label class="form-label fw-bold" for="{{ form.card_number.id_for_label }}">{{ form.card_number.label }}</label>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -144,6 +144,7 @@ def get_customer_data(request, customer_id):
 | 
				
			||||||
        'card_number': str(form['card_number']),
 | 
					        'card_number': str(form['card_number']),
 | 
				
			||||||
        'account_number': str(form['account_number']),
 | 
					        'account_number': str(form['account_number']),
 | 
				
			||||||
        'address': str(form['address']),
 | 
					        'address': str(form['address']),
 | 
				
			||||||
 | 
					        'bank_name': str(form['bank_name']),
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    return JsonResponse({
 | 
					    return JsonResponse({
 | 
				
			||||||
| 
						 | 
					@ -157,7 +158,8 @@ def get_customer_data(request, customer_id):
 | 
				
			||||||
            'national_code': customer.national_code or '',
 | 
					            'national_code': customer.national_code or '',
 | 
				
			||||||
            'card_number': customer.card_number or '',
 | 
					            'card_number': customer.card_number or '',
 | 
				
			||||||
            'account_number': customer.account_number or '',
 | 
					            'account_number': customer.account_number or '',
 | 
				
			||||||
            'address': customer.address or ''
 | 
					            'address': customer.address or '',
 | 
				
			||||||
 | 
					            'bank_name': customer.bank_name or '',
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        'form_html': form_html
 | 
					        'form_html': form_html
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										0
									
								
								certificates/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								certificates/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										20
									
								
								certificates/admin.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								certificates/admin.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,20 @@
 | 
				
			||||||
 | 
					from django.contrib import admin
 | 
				
			||||||
 | 
					from .models import CertificateTemplate, CertificateInstance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@admin.register(CertificateTemplate)
 | 
				
			||||||
 | 
					class CertificateTemplateAdmin(admin.ModelAdmin):
 | 
				
			||||||
 | 
					    list_display = ('title', 'company', 'is_active', 'created')
 | 
				
			||||||
 | 
					    list_filter = ('is_active', 'company')
 | 
				
			||||||
 | 
					    search_fields = ('title', 'company__name')
 | 
				
			||||||
 | 
					    autocomplete_fields = ('company',)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@admin.register(CertificateInstance)
 | 
				
			||||||
 | 
					class CertificateInstanceAdmin(admin.ModelAdmin):
 | 
				
			||||||
 | 
					    list_display = ('process_instance', 'rendered_title', 'issued_at', 'approved')
 | 
				
			||||||
 | 
					    list_filter = ('approved', 'issued_at')
 | 
				
			||||||
 | 
					    search_fields = ('process_instance__code', 'rendered_title')
 | 
				
			||||||
 | 
					    autocomplete_fields = ('process_instance', 'template')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										10
									
								
								certificates/apps.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								certificates/apps.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,10 @@
 | 
				
			||||||
 | 
					from django.apps import AppConfig
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CertificatesConfig(AppConfig):
 | 
				
			||||||
 | 
					    default_auto_field = 'django.db.models.BigAutoField'
 | 
				
			||||||
 | 
					    name = 'certificates'
 | 
				
			||||||
 | 
					    verbose_name = 'گواهیها'
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										58
									
								
								certificates/migrations/0001_initial.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								certificates/migrations/0001_initial.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,58 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-22 09:58
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    initial = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('processes', '0001_initial'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='CertificateTemplate',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
 | 
				
			||||||
 | 
					                ('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
 | 
				
			||||||
 | 
					                ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
 | 
				
			||||||
 | 
					                ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
 | 
				
			||||||
 | 
					                ('title', models.CharField(max_length=200, verbose_name='عنوان')),
 | 
				
			||||||
 | 
					                ('body', models.TextField(verbose_name='متن قالب (با جایگزین\u200cها)')),
 | 
				
			||||||
 | 
					                ('company_logo', models.ImageField(blank=True, null=True, upload_to='certificates/logos/%Y/%m/%d/', verbose_name='لوگو')),
 | 
				
			||||||
 | 
					                ('company_name', models.CharField(blank=True, max_length=200, verbose_name='نام شرکت')),
 | 
				
			||||||
 | 
					                ('company_seal_signature', models.ImageField(blank=True, null=True, upload_to='certificates/seals/%Y/%m/%d/', verbose_name='مهر و امضا')),
 | 
				
			||||||
 | 
					                ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                'verbose_name': 'قالب گواهی',
 | 
				
			||||||
 | 
					                'verbose_name_plural': 'قالب\u200cهای گواهی',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='CertificateInstance',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
 | 
				
			||||||
 | 
					                ('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
 | 
				
			||||||
 | 
					                ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
 | 
				
			||||||
 | 
					                ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
 | 
				
			||||||
 | 
					                ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
 | 
				
			||||||
 | 
					                ('rendered_title', models.CharField(max_length=250, verbose_name='عنوان رندر شده')),
 | 
				
			||||||
 | 
					                ('rendered_body', models.TextField(verbose_name='متن رندر شده')),
 | 
				
			||||||
 | 
					                ('issued_at', models.DateField(auto_now_add=True, verbose_name='تاریخ صدور')),
 | 
				
			||||||
 | 
					                ('approved', models.BooleanField(default=False, verbose_name='تایید شده')),
 | 
				
			||||||
 | 
					                ('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید')),
 | 
				
			||||||
 | 
					                ('process_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to='processes.processinstance', verbose_name='نمونه فرآیند')),
 | 
				
			||||||
 | 
					                ('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='certificates.certificatetemplate', verbose_name='قالب')),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                'verbose_name': 'گواهی',
 | 
				
			||||||
 | 
					                'verbose_name_plural': 'گواهی\u200cها',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,32 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-22 10:05
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('accounts', '0003_historicalprofile_bank_name_profile_bank_name'),
 | 
				
			||||||
 | 
					        ('certificates', '0001_initial'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.RemoveField(
 | 
				
			||||||
 | 
					            model_name='certificatetemplate',
 | 
				
			||||||
 | 
					            name='company_logo',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.RemoveField(
 | 
				
			||||||
 | 
					            model_name='certificatetemplate',
 | 
				
			||||||
 | 
					            name='company_name',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.RemoveField(
 | 
				
			||||||
 | 
					            model_name='certificatetemplate',
 | 
				
			||||||
 | 
					            name='company_seal_signature',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='certificatetemplate',
 | 
				
			||||||
 | 
					            name='company',
 | 
				
			||||||
 | 
					            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.company', verbose_name='شرکت صادر کننده'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										0
									
								
								certificates/migrations/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								certificates/migrations/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										38
									
								
								certificates/models.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								certificates/models.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,38 @@
 | 
				
			||||||
 | 
					from django.db import models
 | 
				
			||||||
 | 
					from django.contrib.auth import get_user_model
 | 
				
			||||||
 | 
					from common.models import BaseModel
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					User = get_user_model()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CertificateTemplate(BaseModel):
 | 
				
			||||||
 | 
					    title = models.CharField(max_length=200, verbose_name='عنوان')
 | 
				
			||||||
 | 
					    body = models.TextField(verbose_name='متن قالب (با جایگزینها)')
 | 
				
			||||||
 | 
					    company = models.ForeignKey('accounts.Company', on_delete=models.SET_NULL, null=True, blank=True, verbose_name='شرکت صادر کننده')
 | 
				
			||||||
 | 
					    is_active = models.BooleanField(default=True, verbose_name='فعال')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = 'قالب گواهی'
 | 
				
			||||||
 | 
					        verbose_name_plural = 'قالبهای گواهی'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self):
 | 
				
			||||||
 | 
					        return self.title
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CertificateInstance(BaseModel):
 | 
				
			||||||
 | 
					    process_instance = models.ForeignKey('processes.ProcessInstance', on_delete=models.CASCADE, related_name='certificates', verbose_name='نمونه فرآیند')
 | 
				
			||||||
 | 
					    template = models.ForeignKey(CertificateTemplate, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='قالب')
 | 
				
			||||||
 | 
					    rendered_title = models.CharField(max_length=250, verbose_name='عنوان رندر شده')
 | 
				
			||||||
 | 
					    rendered_body = models.TextField(verbose_name='متن رندر شده')
 | 
				
			||||||
 | 
					    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='تاریخ تایید')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = 'گواهی'
 | 
				
			||||||
 | 
					        verbose_name_plural = 'گواهیها'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self):
 | 
				
			||||||
 | 
					        return f"گواهی {self.process_instance.code}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										28
									
								
								certificates/templates/certificates/print.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								certificates/templates/certificates/print.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,28 @@
 | 
				
			||||||
 | 
					{% extends '_base.html' %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					<div class="container py-4">
 | 
				
			||||||
 | 
					  <div class="text-center mb-4">
 | 
				
			||||||
 | 
					    {% 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>
 | 
				
			||||||
 | 
					    {% if template.company %}<div class="text-muted">{{ template.company.name }}</div>{% endif %}
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					  <div style="white-space:pre-line; line-height:1.9;">
 | 
				
			||||||
 | 
					    {{ cert.rendered_body|safe }}
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					  <div class="mt-5 d-flex justify-content-between">
 | 
				
			||||||
 | 
					    <div>تاریخ: {{ cert.issued_at }}</div>
 | 
				
			||||||
 | 
					    <div class="text-center">
 | 
				
			||||||
 | 
					      {% if template.company and template.company.signature %}
 | 
				
			||||||
 | 
					        <img src="{{ template.company.signature.url }}" alt="seal" style="max-height:120px">
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
 | 
					      <div>مهر و امضای شرکت</div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					<script>window.print()</script>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										53
									
								
								certificates/templates/certificates/step.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								certificates/templates/certificates/step.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,53 @@
 | 
				
			||||||
 | 
					{% extends '_base.html' %}
 | 
				
			||||||
 | 
					{% load static %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					<div class="container-xxl flex-grow-1 container-p-y">
 | 
				
			||||||
 | 
					  <div class="d-flex align-items-center justify-content-between mb-3">
 | 
				
			||||||
 | 
					    <div>
 | 
				
			||||||
 | 
					      <h4 class="mb-1">گواهی نهایی</h4>
 | 
				
			||||||
 | 
					      <small class="text-muted d-block">کد درخواست: {{ instance.code }}</small>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="d-flex gap-2">
 | 
				
			||||||
 | 
					      <a class="btn btn-outline-secondary" target="_blank" href="{% url 'certificates:certificate_print' instance.id %}"><i class="bx bx-printer"></i> پرینت</a>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <div class="card">
 | 
				
			||||||
 | 
					    <div class="card-body">
 | 
				
			||||||
 | 
					      <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 %}
 | 
				
			||||||
 | 
					        <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;">
 | 
				
			||||||
 | 
					        {{ cert.rendered_body|safe }}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="mt-4 d-flex justify-content-between align-items-end">
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <div>تاریخ صدور: {{ cert.issued_at }}</div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="text-center">
 | 
				
			||||||
 | 
					          {% if template.company and template.company.signature %}
 | 
				
			||||||
 | 
					            <img src="{{ template.company.signature.url }}" alt="seal" style="max-height:100px">
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					          <div>مهر و امضای شرکت</div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div class="card-footer d-flex justify-content-between">
 | 
				
			||||||
 | 
					      {% if previous_step %}
 | 
				
			||||||
 | 
					        <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
 | 
				
			||||||
 | 
					      {% else %}<span></span>{% endif %}
 | 
				
			||||||
 | 
					      <form method="post">
 | 
				
			||||||
 | 
					        {% csrf_token %}
 | 
				
			||||||
 | 
					        <button class="btn btn-primary" type="submit">تایید و پایان</button>
 | 
				
			||||||
 | 
					      </form>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										11
									
								
								certificates/urls.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								certificates/urls.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,11 @@
 | 
				
			||||||
 | 
					from django.urls import path
 | 
				
			||||||
 | 
					from . import views
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app_name = 'certificates'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					urlpatterns = [
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/', views.certificate_step, name='certificate_step'),
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/print/', views.certificate_print, name='certificate_print'),
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										114
									
								
								certificates/views.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								certificates/views.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,114 @@
 | 
				
			||||||
 | 
					from django.shortcuts import render, get_object_or_404, redirect
 | 
				
			||||||
 | 
					from django.contrib.auth.decorators import login_required
 | 
				
			||||||
 | 
					from django.contrib import messages
 | 
				
			||||||
 | 
					from django.http import JsonResponse
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from django.utils import timezone
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from processes.models import ProcessInstance, StepInstance
 | 
				
			||||||
 | 
					from invoices.models import Invoice
 | 
				
			||||||
 | 
					from installations.models import InstallationReport
 | 
				
			||||||
 | 
					from .models import CertificateTemplate, CertificateInstance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from _helpers.jalali import Gregorian
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _to_jalali(date_obj):
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        g = Gregorian(date_obj)
 | 
				
			||||||
 | 
					        y, m, d = g.persian_tuple()
 | 
				
			||||||
 | 
					        return f"{y}/{m:02d}/{d:02d}"
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        return ''
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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()
 | 
				
			||||||
 | 
					    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, 'address', '') or '',
 | 
				
			||||||
 | 
					        'visit_date_jalali': _to_jalali(getattr(latest_report, 'visited_date', None)) if latest_report else '',
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    title = (template.title or '').format(**ctx)
 | 
				
			||||||
 | 
					    body = (template.body or '')
 | 
				
			||||||
 | 
					    for k, v in ctx.items():
 | 
				
			||||||
 | 
					        body = body.replace(f"{{{{ {k} }}}}", str(v))
 | 
				
			||||||
 | 
					    return title, body
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def certificate_step(request, instance_id, step_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_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()
 | 
				
			||||||
 | 
					    if incomplete:
 | 
				
			||||||
 | 
					        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')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    template = CertificateTemplate.objects.filter(is_active=True).order_by('-created').first()
 | 
				
			||||||
 | 
					    if not template:
 | 
				
			||||||
 | 
					        return render(request, 'certificates/missing.html', {})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    title, body = _render_template(template, instance)
 | 
				
			||||||
 | 
					    cert, _ = CertificateInstance.objects.get_or_create(
 | 
				
			||||||
 | 
					        process_instance=instance,
 | 
				
			||||||
 | 
					        defaults={'template': template, 'rendered_title': title, 'rendered_body': body}
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    # keep rendered up-to-date
 | 
				
			||||||
 | 
					    cert.template = template
 | 
				
			||||||
 | 
					    cert.rendered_title = title
 | 
				
			||||||
 | 
					    cert.rendered_body = body
 | 
				
			||||||
 | 
					    cert.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    previous_step = instance.process.steps.filter(order__lt=instance.current_step.order).last() if instance.current_step else None
 | 
				
			||||||
 | 
					    next_step = instance.process.steps.filter(order__gt=instance.current_step.order).first() if instance.current_step else None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if request.method == 'POST':
 | 
				
			||||||
 | 
					        cert.approved = True
 | 
				
			||||||
 | 
					        cert.approved_at = timezone.now()
 | 
				
			||||||
 | 
					        cert.save()
 | 
				
			||||||
 | 
					        step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step_id=step_id)
 | 
				
			||||||
 | 
					        step_instance.status = 'completed'
 | 
				
			||||||
 | 
					        step_instance.completed_at = timezone.now()
 | 
				
			||||||
 | 
					        step_instance.save()
 | 
				
			||||||
 | 
					        if next_step:
 | 
				
			||||||
 | 
					            instance.current_step = next_step
 | 
				
			||||||
 | 
					            instance.save()
 | 
				
			||||||
 | 
					            return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
 | 
				
			||||||
 | 
					        return redirect('processes:request_list')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return render(request, 'certificates/step.html', {
 | 
				
			||||||
 | 
					        'instance': instance,
 | 
				
			||||||
 | 
					        'template': template,
 | 
				
			||||||
 | 
					        'cert': cert,
 | 
				
			||||||
 | 
					        'previous_step': previous_step,
 | 
				
			||||||
 | 
					        'next_step': next_step,
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def certificate_print(request, instance_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    cert = CertificateInstance.objects.filter(process_instance=instance).order_by('-created').first()
 | 
				
			||||||
 | 
					    template = cert.template if cert else None
 | 
				
			||||||
 | 
					    return render(request, 'certificates/print.html', {
 | 
				
			||||||
 | 
					        'instance': instance,
 | 
				
			||||||
 | 
					        'cert': cert,
 | 
				
			||||||
 | 
					        'template': template,
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,3 +11,21 @@ class UserRoles(Enum):
 | 
				
			||||||
    REGIONAL_WATER_AUTHORITY = "rwa" # کارشناس امور
 | 
					    REGIONAL_WATER_AUTHORITY = "rwa" # کارشناس امور
 | 
				
			||||||
    WATER_RESOURCE_MANAGER = "wrm" # مدیر منابع آب
 | 
					    WATER_RESOURCE_MANAGER = "wrm" # مدیر منابع آب
 | 
				
			||||||
    HEADQUARTER = "hdq" # ستاد آب منطقهای
 | 
					    HEADQUARTER = "hdq" # ستاد آب منطقهای
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					BANK_CHOICES = [
 | 
				
			||||||
 | 
					    ('mellat', 'بانک ملت'),
 | 
				
			||||||
 | 
					    ('saman', 'بانک سامان'),
 | 
				
			||||||
 | 
					    ('parsian', 'بانک پارسیان'),
 | 
				
			||||||
 | 
					    ('sina', 'بانک سینا'),    
 | 
				
			||||||
 | 
					    ('tejarat', 'بانک تجارت'),    
 | 
				
			||||||
 | 
					    ('tosee', 'بانک توسعه'),    
 | 
				
			||||||
 | 
					    ('iran_zamin', 'بانک ایران زمین'),    
 | 
				
			||||||
 | 
					    ('meli', 'بانک ملی'),  
 | 
				
			||||||
 | 
					    ('saderat', 'بانک توسعه صادرات'),    
 | 
				
			||||||
 | 
					    ('iran_zamin', 'بانک ایران زمین'),    
 | 
				
			||||||
 | 
					    ('refah', 'بانک رفاه'),
 | 
				
			||||||
 | 
					    ('eghtesad_novin', 'بانک اقتصاد نوین'),
 | 
				
			||||||
 | 
					    ('pasargad', 'بانک پاسارگاد'),
 | 
				
			||||||
 | 
					    ('other', 'سایر'),
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
							
								
								
									
										3
									
								
								common/templatetags/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								common/templatetags/__init__.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,3 @@
 | 
				
			||||||
 | 
					# Intentionally empty to mark templatetags package
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										7
									
								
								common/templatetags/common_tags.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								common/templatetags/common_tags.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,7 @@
 | 
				
			||||||
 | 
					from django import template
 | 
				
			||||||
 | 
					from _helpers.utils import jalali_converter2
 | 
				
			||||||
 | 
					register = template.Library()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@register.filter(name='to_jalali')
 | 
				
			||||||
 | 
					def to_jalali(value):
 | 
				
			||||||
 | 
					    return jalali_converter2(value)
 | 
				
			||||||
							
								
								
									
										0
									
								
								contracts/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								contracts/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										15
									
								
								contracts/admin.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								contracts/admin.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,15 @@
 | 
				
			||||||
 | 
					from django.contrib import admin
 | 
				
			||||||
 | 
					from .models import ContractTemplate, ContractInstance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@admin.register(ContractTemplate)
 | 
				
			||||||
 | 
					class ContractTemplateAdmin(admin.ModelAdmin):
 | 
				
			||||||
 | 
					    list_display = ['name',]
 | 
				
			||||||
 | 
					    search_fields = ['name']
 | 
				
			||||||
 | 
					    prepopulated_fields = {'slug': ('name',)}
 | 
				
			||||||
 | 
					    readonly_fields = ['created', 'updated']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@admin.register(ContractInstance)
 | 
				
			||||||
 | 
					class ContractInstanceAdmin(admin.ModelAdmin):
 | 
				
			||||||
 | 
					    list_display = ['process_instance', 'template']
 | 
				
			||||||
 | 
					    search_fields = ['process_instance__code', 'template__name']
 | 
				
			||||||
 | 
					    readonly_fields = ['created', 'updated']
 | 
				
			||||||
							
								
								
									
										8
									
								
								contracts/apps.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								contracts/apps.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,8 @@
 | 
				
			||||||
 | 
					from django.apps import AppConfig
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ContractsConfig(AppConfig):
 | 
				
			||||||
 | 
					    default_auto_field = 'django.db.models.BigAutoField'
 | 
				
			||||||
 | 
					    name = 'contracts'
 | 
				
			||||||
 | 
					    verbose_name = 'قراردادها'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										118
									
								
								contracts/migrations/0001_initial.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								contracts/migrations/0001_initial.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,118 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-21 06:00
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					import simple_history.models
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    initial = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('processes', '0001_initial'),
 | 
				
			||||||
 | 
					        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='ContractTemplate',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
 | 
				
			||||||
 | 
					                ('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
 | 
				
			||||||
 | 
					                ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
 | 
				
			||||||
 | 
					                ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
 | 
				
			||||||
 | 
					                ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
 | 
				
			||||||
 | 
					                ('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')),
 | 
				
			||||||
 | 
					                ('name', models.CharField(max_length=100, verbose_name='نام')),
 | 
				
			||||||
 | 
					                ('body', models.TextField(verbose_name='متن قرارداد')),
 | 
				
			||||||
 | 
					                ('company_logo', models.ImageField(blank=True, null=True, upload_to='contracts/logos/%Y/%m/%d/', verbose_name='لوگوی شرکت')),
 | 
				
			||||||
 | 
					                ('company_signature', models.ImageField(blank=True, null=True, upload_to='contracts/signatures/%Y/%m/%d/', verbose_name='امضای شرکت')),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                'verbose_name': 'قالب قرارداد',
 | 
				
			||||||
 | 
					                'verbose_name_plural': 'قالب\u200cهای قرارداد',
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='ContractInstance',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
 | 
				
			||||||
 | 
					                ('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
 | 
				
			||||||
 | 
					                ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
 | 
				
			||||||
 | 
					                ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
 | 
				
			||||||
 | 
					                ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
 | 
				
			||||||
 | 
					                ('rendered_body', models.TextField(verbose_name='متن نهایی قرارداد')),
 | 
				
			||||||
 | 
					                ('approved', models.BooleanField(default=False, verbose_name='تایید شده')),
 | 
				
			||||||
 | 
					                ('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید')),
 | 
				
			||||||
 | 
					                ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='ایجاد کننده')),
 | 
				
			||||||
 | 
					                ('process_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contracts', to='processes.processinstance', verbose_name='نمونه فرآیند')),
 | 
				
			||||||
 | 
					                ('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contracts.contracttemplate', verbose_name='قالب مورد استفاده')),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                'verbose_name': 'قرارداد',
 | 
				
			||||||
 | 
					                'verbose_name_plural': 'قراردادها',
 | 
				
			||||||
 | 
					                'ordering': ['-created'],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='HistoricalContractInstance',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('created', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ ایجاد')),
 | 
				
			||||||
 | 
					                ('updated', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ بروزرسانی')),
 | 
				
			||||||
 | 
					                ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
 | 
				
			||||||
 | 
					                ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
 | 
				
			||||||
 | 
					                ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
 | 
				
			||||||
 | 
					                ('rendered_body', models.TextField(verbose_name='متن نهایی قرارداد')),
 | 
				
			||||||
 | 
					                ('approved', models.BooleanField(default=False, verbose_name='تایید شده')),
 | 
				
			||||||
 | 
					                ('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید')),
 | 
				
			||||||
 | 
					                ('history_id', models.AutoField(primary_key=True, serialize=False)),
 | 
				
			||||||
 | 
					                ('history_date', models.DateTimeField(db_index=True)),
 | 
				
			||||||
 | 
					                ('history_change_reason', models.CharField(max_length=100, null=True)),
 | 
				
			||||||
 | 
					                ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
 | 
				
			||||||
 | 
					                ('created_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='ایجاد کننده')),
 | 
				
			||||||
 | 
					                ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
 | 
				
			||||||
 | 
					                ('process_instance', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='processes.processinstance', verbose_name='نمونه فرآیند')),
 | 
				
			||||||
 | 
					                ('template', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='contracts.contracttemplate', verbose_name='قالب مورد استفاده')),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                'verbose_name': 'historical قرارداد',
 | 
				
			||||||
 | 
					                'verbose_name_plural': 'historical قراردادها',
 | 
				
			||||||
 | 
					                'ordering': ('-history_date', '-history_id'),
 | 
				
			||||||
 | 
					                'get_latest_by': ('history_date', 'history_id'),
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            bases=(simple_history.models.HistoricalChanges, models.Model),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='HistoricalContractTemplate',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('created', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ ایجاد')),
 | 
				
			||||||
 | 
					                ('updated', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ بروزرسانی')),
 | 
				
			||||||
 | 
					                ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
 | 
				
			||||||
 | 
					                ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
 | 
				
			||||||
 | 
					                ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
 | 
				
			||||||
 | 
					                ('slug', models.SlugField(max_length=100, verbose_name='اسلاگ')),
 | 
				
			||||||
 | 
					                ('name', models.CharField(max_length=100, verbose_name='نام')),
 | 
				
			||||||
 | 
					                ('body', models.TextField(verbose_name='متن قرارداد')),
 | 
				
			||||||
 | 
					                ('company_logo', models.TextField(blank=True, max_length=100, null=True, verbose_name='لوگوی شرکت')),
 | 
				
			||||||
 | 
					                ('company_signature', models.TextField(blank=True, max_length=100, null=True, verbose_name='امضای شرکت')),
 | 
				
			||||||
 | 
					                ('history_id', models.AutoField(primary_key=True, serialize=False)),
 | 
				
			||||||
 | 
					                ('history_date', models.DateTimeField(db_index=True)),
 | 
				
			||||||
 | 
					                ('history_change_reason', models.CharField(max_length=100, null=True)),
 | 
				
			||||||
 | 
					                ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
 | 
				
			||||||
 | 
					                ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                'verbose_name': 'historical قالب قرارداد',
 | 
				
			||||||
 | 
					                'verbose_name_plural': 'historical قالب\u200cهای قرارداد',
 | 
				
			||||||
 | 
					                'ordering': ('-history_date', '-history_id'),
 | 
				
			||||||
 | 
					                'get_latest_by': ('history_date', 'history_id'),
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            bases=(simple_history.models.HistoricalChanges, models.Model),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,38 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-21 06:33
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('accounts', '0002_company'),
 | 
				
			||||||
 | 
					        ('contracts', '0001_initial'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.RemoveField(
 | 
				
			||||||
 | 
					            model_name='historicalcontracttemplate',
 | 
				
			||||||
 | 
					            name='history_user',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.RemoveField(
 | 
				
			||||||
 | 
					            model_name='contracttemplate',
 | 
				
			||||||
 | 
					            name='company_logo',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.RemoveField(
 | 
				
			||||||
 | 
					            model_name='contracttemplate',
 | 
				
			||||||
 | 
					            name='company_signature',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='contracttemplate',
 | 
				
			||||||
 | 
					            name='company',
 | 
				
			||||||
 | 
					            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.company', verbose_name='شرکت'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.DeleteModel(
 | 
				
			||||||
 | 
					            name='HistoricalContractInstance',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.DeleteModel(
 | 
				
			||||||
 | 
					            name='HistoricalContractTemplate',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										0
									
								
								contracts/migrations/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								contracts/migrations/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										36
									
								
								contracts/models.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								contracts/models.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,36 @@
 | 
				
			||||||
 | 
					from django.db import models
 | 
				
			||||||
 | 
					from django.contrib.auth import get_user_model
 | 
				
			||||||
 | 
					from common.models import NameSlugModel, BaseModel
 | 
				
			||||||
 | 
					from accounts.models import Company
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					User = get_user_model()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ContractTemplate(NameSlugModel):
 | 
				
			||||||
 | 
					    body = models.TextField(verbose_name='متن قرارداد')
 | 
				
			||||||
 | 
					    company = models.ForeignKey(Company, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='شرکت')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = 'قالب قرارداد'
 | 
				
			||||||
 | 
					        verbose_name_plural = 'قالبهای قرارداد'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self):
 | 
				
			||||||
 | 
					        return self.name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ContractInstance(BaseModel):
 | 
				
			||||||
 | 
					    process_instance = models.ForeignKey('processes.ProcessInstance', on_delete=models.CASCADE, related_name='contracts', verbose_name='نمونه فرآیند')
 | 
				
			||||||
 | 
					    template = models.ForeignKey(ContractTemplate, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='قالب مورد استفاده')
 | 
				
			||||||
 | 
					    rendered_body = models.TextField(verbose_name='متن نهایی قرارداد')
 | 
				
			||||||
 | 
					    approved = models.BooleanField(default=False, verbose_name='تایید شده')
 | 
				
			||||||
 | 
					    approved_at = models.DateTimeField(null=True, blank=True, verbose_name='تاریخ تایید')
 | 
				
			||||||
 | 
					    created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='ایجاد کننده')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = 'قرارداد'
 | 
				
			||||||
 | 
					        verbose_name_plural = 'قراردادها'
 | 
				
			||||||
 | 
					        ordering = ['-created']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self):
 | 
				
			||||||
 | 
					        return f"Contract for {self.process_instance}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										29
									
								
								contracts/templates/contracts/contract_missing.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								contracts/templates/contracts/contract_missing.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,29 @@
 | 
				
			||||||
 | 
					{% extends '_base.html' %}
 | 
				
			||||||
 | 
					{% load static %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block sidebar %}
 | 
				
			||||||
 | 
					  {% include 'sidebars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block navbar %}
 | 
				
			||||||
 | 
					  {% include 'navbars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block title %}قالب قرارداد یافت نشد{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					<div class="container-xxl flex-grow-1 container-p-y">
 | 
				
			||||||
 | 
					  <div class="alert alert-warning">
 | 
				
			||||||
 | 
					    <h5 class="alert-heading mb-2">قالب قرارداد تعریف نشده است</h5>
 | 
				
			||||||
 | 
					    <p class="mb-2">برای نمایش این مرحله، ابتدا یک «قالب قرارداد» ایجاد کنید.</p>
 | 
				
			||||||
 | 
					    <ul class="mb-2">
 | 
				
			||||||
 | 
					      <li>از منوی ادمین یک قالب با متن قرارداد ایجاد کنید.</li>
 | 
				
			||||||
 | 
					      <li>در متن میتوانید از جاینگهدارهایی مثل {{customer_full_name}} ، {{national_code}} ، {{water_subscription_number}} استفاده کنید.</li>
 | 
				
			||||||
 | 
					    </ul>
 | 
				
			||||||
 | 
					    <a class="btn btn-primary" href="/admin/contracts/contracttemplate/add/">ایجاد قالب قرارداد</a>
 | 
				
			||||||
 | 
					    <a class="btn btn-outline-secondary" href="{% url 'processes:request_list' %}">بازگشت</a>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										52
									
								
								contracts/templates/contracts/contract_print.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								contracts/templates/contracts/contract_print.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,52 @@
 | 
				
			||||||
 | 
					<!DOCTYPE html>
 | 
				
			||||||
 | 
					<html lang="fa" dir="rtl">
 | 
				
			||||||
 | 
					<head>
 | 
				
			||||||
 | 
					  <meta charset="utf-8">
 | 
				
			||||||
 | 
					  <meta name="viewport" content="width=device-width, initial-scale=1">
 | 
				
			||||||
 | 
					  <title>چاپ قرارداد {{ instance.code }}</title>
 | 
				
			||||||
 | 
					  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
 | 
				
			||||||
 | 
					  <style>
 | 
				
			||||||
 | 
					    @page { size: A4; margin: 1.2cm; }
 | 
				
			||||||
 | 
					    body { font-family: 'Vazirmatn', sans-serif; }
 | 
				
			||||||
 | 
					    .logo { max-height: 80px; }
 | 
				
			||||||
 | 
					    .signature { height: 90px; border: 1px dashed #ccc; }
 | 
				
			||||||
 | 
					  </style>
 | 
				
			||||||
 | 
					  <script>
 | 
				
			||||||
 | 
					    window.addEventListener('load', function(){ setTimeout(function(){ window.print(); }, 300); });
 | 
				
			||||||
 | 
					  </script>
 | 
				
			||||||
 | 
					</head>
 | 
				
			||||||
 | 
					<body>
 | 
				
			||||||
 | 
					  <div class="container-fluid">
 | 
				
			||||||
 | 
					    <div class="d-flex justify-content-between align-items-center mb-3">
 | 
				
			||||||
 | 
					      <div>
 | 
				
			||||||
 | 
					        <h5>{{ contract.template.company.name }}</h5>
 | 
				
			||||||
 | 
					        <h5 class="mb-1">{{ contract.template.name }}</h5>
 | 
				
			||||||
 | 
					        <div class="text-muted small">کد درخواست: {{ instance.code }} | تاریخ: {{ contract.jcreated }}</div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      {% if contract.template.company.logo %}
 | 
				
			||||||
 | 
					        <img class="logo" src="{{ contract.template.company.logo.url }}" alt="لوگو" />
 | 
				
			||||||
 | 
					      {% endif %}
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <hr>
 | 
				
			||||||
 | 
					    <div style="white-space: pre-line; line-height: 1.9;">{{ contract.rendered_body|safe }}</div>
 | 
				
			||||||
 | 
					    <hr>
 | 
				
			||||||
 | 
					    <div class="row mt-4">
 | 
				
			||||||
 | 
					      <div class="col-6 text-center">
 | 
				
			||||||
 | 
					        <div>امضای مشترک</div>
 | 
				
			||||||
 | 
					        <div class="signature mt-2"></div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="col-6 text-center">
 | 
				
			||||||
 | 
					        <div>امضای شرکت</div>
 | 
				
			||||||
 | 
					        <div class="signature mt-2">
 | 
				
			||||||
 | 
					          {% if contract.template.company.signature %}
 | 
				
			||||||
 | 
					            <img src="{{ contract.template.company.signature.url }}" alt="امضای شرکت" style="max-height: 80px;" />
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										92
									
								
								contracts/templates/contracts/contract_step.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								contracts/templates/contracts/contract_step.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,92 @@
 | 
				
			||||||
 | 
					{% extends '_base.html' %}
 | 
				
			||||||
 | 
					{% load static %}
 | 
				
			||||||
 | 
					{% load processes_tags %}
 | 
				
			||||||
 | 
					{% load humanize %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block sidebar %}
 | 
				
			||||||
 | 
					    {% include 'sidebars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock sidebar %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block navbar %}
 | 
				
			||||||
 | 
					    {% include 'navbars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock navbar %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block style %}
 | 
				
			||||||
 | 
					<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					{% include '_toasts.html' %}
 | 
				
			||||||
 | 
					<div class="container-xxl flex-grow-1 container-p-y">
 | 
				
			||||||
 | 
					  <div class="row">
 | 
				
			||||||
 | 
					    <div class="col-12 mb-4">
 | 
				
			||||||
 | 
					      <div class="d-flex align-items-center justify-content-between mb-3">
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
 | 
				
			||||||
 | 
					          <small class="text-muted d-block">
 | 
				
			||||||
 | 
					            اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
 | 
				
			||||||
 | 
					            | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
 | 
				
			||||||
 | 
					          </small>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="d-flex gap-2">
 | 
				
			||||||
 | 
					          <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
 | 
				
			||||||
 | 
					          <a href="{% url 'contracts:contract_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">پرینت</a>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div class="bs-stepper wizard-vertical vertical mt-2">
 | 
				
			||||||
 | 
					        {% stepper_header instance step %}
 | 
				
			||||||
 | 
					        <div class="bs-stepper-content">
 | 
				
			||||||
 | 
					          <div class="card border">
 | 
				
			||||||
 | 
					            <div class="card-body">
 | 
				
			||||||
 | 
					              {% if template.company.logo %}
 | 
				
			||||||
 | 
					                <div class="text-center mb-3">
 | 
				
			||||||
 | 
					                  <img src="{{ template.company.logo.url }}" alt="لوگوی شرکت" style="max-height:80px;">
 | 
				
			||||||
 | 
					                  <h4 class="text-muted">{{ contract.template.company.name }}</h4>
 | 
				
			||||||
 | 
					                  <h5 class="text-muted">{{ contract.template.name }}</h5>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <div class="small text-muted mb-2">تاریخ: {{ contract.jcreated }}</div>
 | 
				
			||||||
 | 
					              <hr>
 | 
				
			||||||
 | 
					              <div class="contract-body" style="white-space: pre-line; line-height:1.9;">{{ contract.rendered_body|safe }}</div>
 | 
				
			||||||
 | 
					              <hr>
 | 
				
			||||||
 | 
					              <div class="row mt-4">
 | 
				
			||||||
 | 
					                <div class="col-6 text-center">
 | 
				
			||||||
 | 
					                  <div>امضای مشترک</div>
 | 
				
			||||||
 | 
					                  <div style="height:90px;border:1px dashed #ccc; margin-top:10px;"></div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div class="col-6 text-center">
 | 
				
			||||||
 | 
					                  <div>امضای شرکت</div>
 | 
				
			||||||
 | 
					                  <div style="height:90px;border:1px dashed #ccc; margin-top:10px;">
 | 
				
			||||||
 | 
					                    {% if template.company.signature %}
 | 
				
			||||||
 | 
					                      <img src="{{ template.company.signature.url }}" alt="امضای شرکت" style="max-height:80px;">
 | 
				
			||||||
 | 
					                    {% endif %}
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <form method="post" class="d-flex justify-content-between mt-3">
 | 
				
			||||||
 | 
					            {% csrf_token %}
 | 
				
			||||||
 | 
					            {% if previous_step %}
 | 
				
			||||||
 | 
					              <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
 | 
				
			||||||
 | 
					            {% else %}
 | 
				
			||||||
 | 
					              <span></span>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					            {% if next_step %}
 | 
				
			||||||
 | 
					              <button type="submit" class="btn btn-primary">بعدی</button>
 | 
				
			||||||
 | 
					            {% else %}
 | 
				
			||||||
 | 
					              <button class="btn btn-success" type="button">اتمام</button>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					          </form>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										11
									
								
								contracts/urls.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								contracts/urls.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,11 @@
 | 
				
			||||||
 | 
					from django.urls import path
 | 
				
			||||||
 | 
					from . import views
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app_name = 'contracts'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					urlpatterns = [
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/', views.contract_step, name='contract_step'),
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/print/', views.contract_print, name='contract_print'),
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										89
									
								
								contracts/views.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								contracts/views.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,89 @@
 | 
				
			||||||
 | 
					from django.shortcuts import render, get_object_or_404, redirect
 | 
				
			||||||
 | 
					from django.contrib.auth.decorators import login_required
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from django.utils import timezone
 | 
				
			||||||
 | 
					from django.template import Template, Context
 | 
				
			||||||
 | 
					from processes.models import ProcessInstance, StepInstance
 | 
				
			||||||
 | 
					from .models import ContractTemplate, ContractInstance
 | 
				
			||||||
 | 
					from _helpers.utils import jalali_converter2
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def build_contract_context(instance: ProcessInstance) -> dict:
 | 
				
			||||||
 | 
					    representative = instance.representative
 | 
				
			||||||
 | 
					    profile = getattr(representative, 'profile', None)
 | 
				
			||||||
 | 
					    well = instance.well
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					        'customer_full_name': representative.get_full_name() if representative else '',
 | 
				
			||||||
 | 
					        'national_code': profile.national_code if profile else '',
 | 
				
			||||||
 | 
					        'address': profile.address if profile else '',
 | 
				
			||||||
 | 
					        'phone': profile.phone_number_1 if profile else '',
 | 
				
			||||||
 | 
					        'phone2': profile.phone_number_2 if profile else '',
 | 
				
			||||||
 | 
					        'water_subscription_number': well.water_subscription_number if well else '',
 | 
				
			||||||
 | 
					        'electricity_subscription_number': well.electricity_subscription_number if well else '',
 | 
				
			||||||
 | 
					        'water_meter_serial_number': well.water_meter_serial_number if well else '',
 | 
				
			||||||
 | 
					        'well_power': well.well_power if well else '',
 | 
				
			||||||
 | 
					        'request_code': instance.code,
 | 
				
			||||||
 | 
					        'today': jalali_converter2(timezone.now()),
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def contract_step(request, instance_id, step_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    # Resolve step navigation
 | 
				
			||||||
 | 
					    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
				
			||||||
 | 
					    previous_step = instance.process.steps.filter(order__lt=step.order).last()
 | 
				
			||||||
 | 
					    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
				
			||||||
 | 
					    template_obj = ContractTemplate.objects.first()
 | 
				
			||||||
 | 
					    if not template_obj:
 | 
				
			||||||
 | 
					        return render(request, 'contracts/contract_missing.html', {'instance': instance})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ctx = build_contract_context(instance)
 | 
				
			||||||
 | 
					    rendered = Template(template_obj.body).render(Context(ctx))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    contract, _ = ContractInstance.objects.get_or_create(
 | 
				
			||||||
 | 
					        process_instance=instance,
 | 
				
			||||||
 | 
					        defaults={
 | 
				
			||||||
 | 
					            'template': template_obj,
 | 
				
			||||||
 | 
					            'rendered_body': rendered,
 | 
				
			||||||
 | 
					            'created_by': request.user,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    # keep latest rendering if template changed (optional)
 | 
				
			||||||
 | 
					    contract.template = template_obj
 | 
				
			||||||
 | 
					    contract.rendered_body = rendered
 | 
				
			||||||
 | 
					    contract.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # If user submits to go next, mark this step completed and go to next
 | 
				
			||||||
 | 
					    if request.method == 'POST':
 | 
				
			||||||
 | 
					        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.save()
 | 
				
			||||||
 | 
					            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', {
 | 
				
			||||||
 | 
					        'instance': instance,
 | 
				
			||||||
 | 
					        'step': step,
 | 
				
			||||||
 | 
					        'contract': contract,
 | 
				
			||||||
 | 
					        'template': template_obj,
 | 
				
			||||||
 | 
					        'previous_step': previous_step,
 | 
				
			||||||
 | 
					        'next_step': next_step,
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def contract_print(request, instance_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    contract = get_object_or_404(ContractInstance, process_instance=instance)
 | 
				
			||||||
 | 
					    return render(request, 'contracts/contract_print.html', {
 | 
				
			||||||
 | 
					        'instance': instance,
 | 
				
			||||||
 | 
					        'contract': contract,
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										0
									
								
								installations/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								installations/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										39
									
								
								installations/admin.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								installations/admin.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,39 @@
 | 
				
			||||||
 | 
					from django.contrib import admin
 | 
				
			||||||
 | 
					from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@admin.register(InstallationAssignment)
 | 
				
			||||||
 | 
					class InstallationAssignmentAdmin(admin.ModelAdmin):
 | 
				
			||||||
 | 
					    list_display = ('process_instance', 'installer', 'scheduled_date', 'created')
 | 
				
			||||||
 | 
					    search_fields = ('process_instance__code', 'installer__username', 'installer__first_name', 'installer__last_name')
 | 
				
			||||||
 | 
					    list_filter = ('scheduled_date',)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InstallationPhotoInline(admin.TabularInline):
 | 
				
			||||||
 | 
					    model = InstallationPhoto
 | 
				
			||||||
 | 
					    extra = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InstallationItemChangeInline(admin.TabularInline):
 | 
				
			||||||
 | 
					    model = InstallationItemChange
 | 
				
			||||||
 | 
					    extra = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@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')
 | 
				
			||||||
 | 
					    inlines = [InstallationPhotoInline, InstallationItemChangeInline]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@admin.register(InstallationPhoto)
 | 
				
			||||||
 | 
					class InstallationPhotoAdmin(admin.ModelAdmin):
 | 
				
			||||||
 | 
					    list_display = ('report', 'created')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@admin.register(InstallationItemChange)
 | 
				
			||||||
 | 
					class InstallationItemChangeAdmin(admin.ModelAdmin):
 | 
				
			||||||
 | 
					    list_display = ('report', 'item', 'change_type', 'quantity', 'unit_price', 'total_price', 'created')
 | 
				
			||||||
 | 
					    list_filter = ('change_type',)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										8
									
								
								installations/apps.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								installations/apps.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,8 @@
 | 
				
			||||||
 | 
					from django.apps import AppConfig
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InstallationsConfig(AppConfig):
 | 
				
			||||||
 | 
					    default_auto_field = 'django.db.models.BigAutoField'
 | 
				
			||||||
 | 
					    name = 'installations'
 | 
				
			||||||
 | 
					    verbose_name = 'نصب'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										106
									
								
								installations/migrations/0001_initial.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								installations/migrations/0001_initial.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,106 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-21 08:25
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    initial = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('invoices', '0002_historicalpayment_receipt_image_and_more'),
 | 
				
			||||||
 | 
					        ('processes', '0001_initial'),
 | 
				
			||||||
 | 
					        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='InstallationAssignment',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
 | 
				
			||||||
 | 
					                ('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
 | 
				
			||||||
 | 
					                ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
 | 
				
			||||||
 | 
					                ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
 | 
				
			||||||
 | 
					                ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
 | 
				
			||||||
 | 
					                ('scheduled_date', models.DateField(blank=True, null=True, verbose_name='تاریخ مراجعه')),
 | 
				
			||||||
 | 
					                ('notes', models.TextField(blank=True, verbose_name='یادداشت')),
 | 
				
			||||||
 | 
					                ('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigner_installations', to=settings.AUTH_USER_MODEL, verbose_name='اختصاص\u200cدهنده')),
 | 
				
			||||||
 | 
					                ('installer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_installations', to=settings.AUTH_USER_MODEL, verbose_name='نصاب')),
 | 
				
			||||||
 | 
					                ('process_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='installation_assignments', to='processes.processinstance', verbose_name='نمونه فرآیند')),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                'verbose_name': 'اختصاص نصاب',
 | 
				
			||||||
 | 
					                'verbose_name_plural': 'اختصاص\u200cهای نصاب',
 | 
				
			||||||
 | 
					                'ordering': ['-created'],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='InstallationReport',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
 | 
				
			||||||
 | 
					                ('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
 | 
				
			||||||
 | 
					                ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
 | 
				
			||||||
 | 
					                ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
 | 
				
			||||||
 | 
					                ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
 | 
				
			||||||
 | 
					                ('visited_date', models.DateField(blank=True, null=True, verbose_name='تاریخ مراجعه')),
 | 
				
			||||||
 | 
					                ('new_water_meter_serial', models.CharField(blank=True, max_length=50, null=True, verbose_name='سریال کنتور جدید')),
 | 
				
			||||||
 | 
					                ('seal_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='شماره پلمپ')),
 | 
				
			||||||
 | 
					                ('is_meter_suspicious', models.BooleanField(default=False, verbose_name='کنتور مشکوک است؟')),
 | 
				
			||||||
 | 
					                ('utm_x', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='UTM X')),
 | 
				
			||||||
 | 
					                ('utm_y', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='UTM Y')),
 | 
				
			||||||
 | 
					                ('description', models.TextField(blank=True, verbose_name='توضیحات')),
 | 
				
			||||||
 | 
					                ('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='installations.installationassignment', verbose_name='اختصاص')),
 | 
				
			||||||
 | 
					                ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='ایجادکننده')),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                'verbose_name': 'گزارش نصب',
 | 
				
			||||||
 | 
					                'verbose_name_plural': 'گزارش\u200cهای نصب',
 | 
				
			||||||
 | 
					                'ordering': ['-created'],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='InstallationPhoto',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
 | 
				
			||||||
 | 
					                ('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
 | 
				
			||||||
 | 
					                ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
 | 
				
			||||||
 | 
					                ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
 | 
				
			||||||
 | 
					                ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
 | 
				
			||||||
 | 
					                ('image', models.ImageField(upload_to='installations/photos/%Y/%m/%d/', verbose_name='عکس')),
 | 
				
			||||||
 | 
					                ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='installations.installationreport', verbose_name='گزارش')),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                'verbose_name': 'عکس نصب',
 | 
				
			||||||
 | 
					                'verbose_name_plural': 'عکس\u200cهای نصب',
 | 
				
			||||||
 | 
					                'ordering': ['created'],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name='InstallationItemChange',
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
				
			||||||
 | 
					                ('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
 | 
				
			||||||
 | 
					                ('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
 | 
				
			||||||
 | 
					                ('is_active', models.BooleanField(default=True, verbose_name='فعال')),
 | 
				
			||||||
 | 
					                ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
 | 
				
			||||||
 | 
					                ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
 | 
				
			||||||
 | 
					                ('change_type', models.CharField(choices=[('add', 'افزودن'), ('remove', 'حذف')], max_length=6, verbose_name='نوع تغییر')),
 | 
				
			||||||
 | 
					                ('quantity', models.PositiveIntegerField(verbose_name='تعداد')),
 | 
				
			||||||
 | 
					                ('unit_price', models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True, verbose_name='قیمت واحد')),
 | 
				
			||||||
 | 
					                ('total_price', models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True, verbose_name='قیمت کل')),
 | 
				
			||||||
 | 
					                ('notes', models.TextField(blank=True, verbose_name='یادداشت')),
 | 
				
			||||||
 | 
					                ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='invoices.item', verbose_name='آیتم')),
 | 
				
			||||||
 | 
					                ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_changes', to='installations.installationreport', verbose_name='گزارش')),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                'verbose_name': 'تغییر آیتم نصب',
 | 
				
			||||||
 | 
					                'verbose_name_plural': 'تغییرات آیتم\u200cهای نصب',
 | 
				
			||||||
 | 
					                'ordering': ['created'],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,23 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-21 09:04
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('installations', '0001_initial'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='installationreport',
 | 
				
			||||||
 | 
					            name='approved',
 | 
				
			||||||
 | 
					            field=models.BooleanField(default=False, verbose_name='تایید شده'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='installationreport',
 | 
				
			||||||
 | 
					            name='approved_at',
 | 
				
			||||||
 | 
					            field=models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										0
									
								
								installations/migrations/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								installations/migrations/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										111
									
								
								installations/models.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								installations/models.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,111 @@
 | 
				
			||||||
 | 
					from django.db import models
 | 
				
			||||||
 | 
					from django.contrib.auth import get_user_model
 | 
				
			||||||
 | 
					from django.utils import timezone
 | 
				
			||||||
 | 
					from common.models import BaseModel
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					User = get_user_model()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InstallationAssignment(BaseModel):
 | 
				
			||||||
 | 
					    """انتخاب نصاب و زمان مراجعه برای یک درخواست"""
 | 
				
			||||||
 | 
					    process_instance = models.ForeignKey(
 | 
				
			||||||
 | 
					        'processes.ProcessInstance', on_delete=models.CASCADE,
 | 
				
			||||||
 | 
					        related_name='installation_assignments', verbose_name='نمونه فرآیند'
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    installer = models.ForeignKey(
 | 
				
			||||||
 | 
					        User, on_delete=models.SET_NULL, null=True, blank=True,
 | 
				
			||||||
 | 
					        related_name='assigned_installations', verbose_name='نصاب'
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    scheduled_date = models.DateField(null=True, blank=True, verbose_name='تاریخ مراجعه')
 | 
				
			||||||
 | 
					    notes = models.TextField(blank=True, verbose_name='یادداشت')
 | 
				
			||||||
 | 
					    assigned_by = models.ForeignKey(
 | 
				
			||||||
 | 
					        User, on_delete=models.SET_NULL, null=True, blank=True,
 | 
				
			||||||
 | 
					        related_name='assigner_installations', verbose_name='اختصاصدهنده'
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = 'اختصاص نصاب'
 | 
				
			||||||
 | 
					        verbose_name_plural = 'اختصاصهای نصاب'
 | 
				
			||||||
 | 
					        ordering = ['-created']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self):
 | 
				
			||||||
 | 
					        return f"Assignment for {self.process_instance.code} to {getattr(self.installer, 'username', '-') }"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InstallationReport(BaseModel):
 | 
				
			||||||
 | 
					    """گزارش نصب توسط نصاب"""
 | 
				
			||||||
 | 
					    assignment = models.ForeignKey(
 | 
				
			||||||
 | 
					        InstallationAssignment, on_delete=models.CASCADE,
 | 
				
			||||||
 | 
					        related_name='reports', verbose_name='اختصاص'
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    visited_date = models.DateField(null=True, blank=True, verbose_name='تاریخ مراجعه')
 | 
				
			||||||
 | 
					    new_water_meter_serial = models.CharField(max_length=50, null=True, blank=True, verbose_name='سریال کنتور جدید')
 | 
				
			||||||
 | 
					    seal_number = models.CharField(max_length=50, null=True, blank=True, verbose_name='شماره پلمپ')
 | 
				
			||||||
 | 
					    is_meter_suspicious = models.BooleanField(default=False, verbose_name='کنتور مشکوک است؟')
 | 
				
			||||||
 | 
					    utm_x = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True, verbose_name='UTM X')
 | 
				
			||||||
 | 
					    utm_y = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True, verbose_name='UTM Y')
 | 
				
			||||||
 | 
					    description = models.TextField(blank=True, verbose_name='توضیحات')
 | 
				
			||||||
 | 
					    created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='ایجادکننده')
 | 
				
			||||||
 | 
					    approved = models.BooleanField(default=False, verbose_name='تایید شده')
 | 
				
			||||||
 | 
					    approved_at = models.DateTimeField(null=True, blank=True, verbose_name='تاریخ تایید')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = 'گزارش نصب'
 | 
				
			||||||
 | 
					        verbose_name_plural = 'گزارشهای نصب'
 | 
				
			||||||
 | 
					        ordering = ['-created']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self):
 | 
				
			||||||
 | 
					        return f"Report for {self.assignment.process_instance.code}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
 | 
					        # set approved time
 | 
				
			||||||
 | 
					        if self.approved and self.approved_at is None:
 | 
				
			||||||
 | 
					            self.approved_at = timezone.now()
 | 
				
			||||||
 | 
					        super().save(*args, **kwargs)
 | 
				
			||||||
 | 
					        # if approved, propagate UTM to well
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            if self.approved and self.assignment and self.assignment.process_instance and self.assignment.process_instance.well:
 | 
				
			||||||
 | 
					                well = self.assignment.process_instance.well
 | 
				
			||||||
 | 
					                changed = False
 | 
				
			||||||
 | 
					                if self.utm_x is not None:
 | 
				
			||||||
 | 
					                    well.utm_x = self.utm_x
 | 
				
			||||||
 | 
					                    changed = True
 | 
				
			||||||
 | 
					                if self.utm_y is not None:
 | 
				
			||||||
 | 
					                    well.utm_y = self.utm_y
 | 
				
			||||||
 | 
					                    changed = True
 | 
				
			||||||
 | 
					                if changed:
 | 
				
			||||||
 | 
					                    well.save()
 | 
				
			||||||
 | 
					        except Exception:
 | 
				
			||||||
 | 
					            pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InstallationPhoto(BaseModel):
 | 
				
			||||||
 | 
					    report = models.ForeignKey(InstallationReport, on_delete=models.CASCADE, related_name='photos', verbose_name='گزارش')
 | 
				
			||||||
 | 
					    image = models.ImageField(upload_to='installations/photos/%Y/%m/%d/', verbose_name='عکس')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = 'عکس نصب'
 | 
				
			||||||
 | 
					        verbose_name_plural = 'عکسهای نصب'
 | 
				
			||||||
 | 
					        ordering = ['created']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InstallationItemChange(BaseModel):
 | 
				
			||||||
 | 
					    """تغییرات اقلام در مرحله نصب (افزودن/حذف نسبت به اقلام مرحله ۱)"""
 | 
				
			||||||
 | 
					    CHANGE_CHOICES = [
 | 
				
			||||||
 | 
					        ('add', 'افزودن'),
 | 
				
			||||||
 | 
					        ('remove', 'حذف'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    report = models.ForeignKey(InstallationReport, on_delete=models.CASCADE, related_name='item_changes', verbose_name='گزارش')
 | 
				
			||||||
 | 
					    item = models.ForeignKey('invoices.Item', on_delete=models.CASCADE, verbose_name='آیتم')
 | 
				
			||||||
 | 
					    change_type = models.CharField(max_length=6, choices=CHANGE_CHOICES, verbose_name='نوع تغییر')
 | 
				
			||||||
 | 
					    quantity = models.PositiveIntegerField(verbose_name='تعداد')
 | 
				
			||||||
 | 
					    unit_price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='قیمت واحد', null=True, blank=True)
 | 
				
			||||||
 | 
					    total_price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='قیمت کل', null=True, blank=True)
 | 
				
			||||||
 | 
					    notes = models.TextField(blank=True, verbose_name='یادداشت')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        verbose_name = 'تغییر آیتم نصب'
 | 
				
			||||||
 | 
					        verbose_name_plural = 'تغییرات آیتمهای نصب'
 | 
				
			||||||
 | 
					        ordering = ['created']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,159 @@
 | 
				
			||||||
 | 
					{% extends '_base.html' %}
 | 
				
			||||||
 | 
					{% load static %}
 | 
				
			||||||
 | 
					{% load processes_tags %}
 | 
				
			||||||
 | 
					{% load humanize %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block sidebar %}
 | 
				
			||||||
 | 
					    {% include 'sidebars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock sidebar %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block navbar %}
 | 
				
			||||||
 | 
					    {% include 'navbars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock navbar %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block style %}
 | 
				
			||||||
 | 
					<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<!-- Persian Date Picker CSS -->
 | 
				
			||||||
 | 
					<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					{% include '_toasts.html' %}
 | 
				
			||||||
 | 
					<div class="container-xxl flex-grow-1 container-p-y">
 | 
				
			||||||
 | 
					  <div class="row">
 | 
				
			||||||
 | 
					    <div class="col-12 mb-4">
 | 
				
			||||||
 | 
					      <div class="d-flex align-items-center justify-content-between mb-3">
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
 | 
				
			||||||
 | 
					          <small class="text-muted d-block">
 | 
				
			||||||
 | 
					            اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
 | 
				
			||||||
 | 
					            | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
 | 
				
			||||||
 | 
					          </small>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div class="bs-stepper wizard-vertical vertical mt-2">
 | 
				
			||||||
 | 
					        {% stepper_header instance step %}
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="bs-stepper-content">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <form method="post">
 | 
				
			||||||
 | 
					            {% csrf_token %}
 | 
				
			||||||
 | 
					            <div class="row g-3">
 | 
				
			||||||
 | 
					              <div class="col-md-6">
 | 
				
			||||||
 | 
					                <label class="form-label">نصاب</label>
 | 
				
			||||||
 | 
					                <select name="installer_id" class="form-select" required>
 | 
				
			||||||
 | 
					                  <option value="">انتخاب کنید...</option>
 | 
				
			||||||
 | 
					                  {% for p in installers %}
 | 
				
			||||||
 | 
					                    <option value="{{ p.user.id }}" {% if assignment.installer and p.user.id == assignment.installer.id %}selected{% endif %}>{{ p.user.get_full_name }} ({{ p.user.username }})</option>
 | 
				
			||||||
 | 
					                  {% endfor %}
 | 
				
			||||||
 | 
					                </select>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <div class="col-md-6">
 | 
				
			||||||
 | 
					                <label class="form-label">تاریخ مراجعه نصاب</label>
 | 
				
			||||||
 | 
					                <input type="text" id="id_scheduled_date_display" class="form-control" placeholder="انتخاب تاریخ" readonly required value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y/m/d' }}{% endif %}">
 | 
				
			||||||
 | 
					                <input type="hidden" id="id_scheduled_date" name="scheduled_date" value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y-m-d' }}{% endif %}">
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="d-flex justify-content-between mt-4">
 | 
				
			||||||
 | 
					              {% if previous_step %}
 | 
				
			||||||
 | 
					                <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
 | 
				
			||||||
 | 
					              {% else %}
 | 
				
			||||||
 | 
					                <span></span>
 | 
				
			||||||
 | 
					              {% endif %}
 | 
				
			||||||
 | 
					              <button class="btn btn-primary" type="submit">ثبت و ادامه</button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </form>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block script %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<!-- Persian Date Picker JS -->
 | 
				
			||||||
 | 
					<script src="https://unpkg.com/persian-date@latest/dist/persian-date.min.js"></script>
 | 
				
			||||||
 | 
					<script src="https://unpkg.com/persian-datepicker@latest/dist/js/persian-datepicker.min.js"></script>
 | 
				
			||||||
 | 
					<script>
 | 
				
			||||||
 | 
					  (function(){
 | 
				
			||||||
 | 
					    function convertPersianToEnglishNumbers(str) {
 | 
				
			||||||
 | 
					      const persianNumbers = '۰۱۲۳۴۵۶۷۸۹';
 | 
				
			||||||
 | 
					      const englishNumbers = '0123456789';
 | 
				
			||||||
 | 
					      return String(str || '').split('').map(function(char){
 | 
				
			||||||
 | 
					        const index = persianNumbers.indexOf(char);
 | 
				
			||||||
 | 
					        return index !== -1 ? englishNumbers[index] : char;
 | 
				
			||||||
 | 
					      }).join('');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function initPersianDatePicker() {
 | 
				
			||||||
 | 
					      if ($.fn.persianDatepicker && $('#id_scheduled_date_display').length) {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					          var $display = $('#id_scheduled_date_display');
 | 
				
			||||||
 | 
					          var $hidden = $('#id_scheduled_date');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          // Prefill from hidden Gregorian to visible Jalali
 | 
				
			||||||
 | 
					          var initialGregorian = $hidden.val();
 | 
				
			||||||
 | 
					          if (initialGregorian) {
 | 
				
			||||||
 | 
					            try {
 | 
				
			||||||
 | 
					              var initialJalali = new window.persianDate(new Date(initialGregorian)).format('YYYY/MM/DD');
 | 
				
			||||||
 | 
					              $display.val(initialJalali);
 | 
				
			||||||
 | 
					            } catch (e) {}
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          $display.persianDatepicker({
 | 
				
			||||||
 | 
					            calendarType: 'persian',
 | 
				
			||||||
 | 
					            altField: '#id_scheduled_date',
 | 
				
			||||||
 | 
					            format: 'YYYY/MM/DD',
 | 
				
			||||||
 | 
					            altFormat: 'YYYY-MM-DD',
 | 
				
			||||||
 | 
					            observer: true,
 | 
				
			||||||
 | 
					            autoClose: true,
 | 
				
			||||||
 | 
					            initialValue: false,
 | 
				
			||||||
 | 
					            calendar:{ persian: { leapYearMode: 'astronomical' } },
 | 
				
			||||||
 | 
					            onSelect: function (unixDate) {
 | 
				
			||||||
 | 
					              var g = new window.persianDate(unixDate).toCalendar('gregorian').format('YYYY-MM-DD');
 | 
				
			||||||
 | 
					              g = convertPersianToEnglishNumbers(g);
 | 
				
			||||||
 | 
					              $hidden.val(g);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					        } catch (e) {
 | 
				
			||||||
 | 
					          console.error('Error initializing Persian Date Picker:', e);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    document.addEventListener('DOMContentLoaded', initPersianDatePicker);
 | 
				
			||||||
 | 
					  })();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Require date and show success toast on submit
 | 
				
			||||||
 | 
					  (function(){
 | 
				
			||||||
 | 
					    const form = document.querySelector('form');
 | 
				
			||||||
 | 
					    if (!form) return;
 | 
				
			||||||
 | 
					    form.addEventListener('submit', function(ev){
 | 
				
			||||||
 | 
					      const display = document.getElementById('id_scheduled_date_display');
 | 
				
			||||||
 | 
					      const hidden = document.getElementById('id_scheduled_date');
 | 
				
			||||||
 | 
					      if (!display.value || !hidden.value) {
 | 
				
			||||||
 | 
					        ev.preventDefault(); ev.stopPropagation();
 | 
				
			||||||
 | 
					        if (typeof showToast === 'function') showToast('تاریخ مراجعه نصاب را انتخاب کنید', 'danger');
 | 
				
			||||||
 | 
					        display.scrollIntoView({behavior:'smooth', block:'center'});
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      try { sessionStorage.setItem('assign_saved', '1'); } catch(_) {}
 | 
				
			||||||
 | 
					    }, false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      if (sessionStorage.getItem('assign_saved') === '1') {
 | 
				
			||||||
 | 
					        sessionStorage.removeItem('assign_saved');
 | 
				
			||||||
 | 
					        if (typeof showToast === 'function') showToast('با موفقیت ثبت شد', 'success');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch(_) {}
 | 
				
			||||||
 | 
					  })();
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,448 @@
 | 
				
			||||||
 | 
					{% extends '_base.html' %}
 | 
				
			||||||
 | 
					{% load static %}
 | 
				
			||||||
 | 
					{% load processes_tags %}
 | 
				
			||||||
 | 
					{% load common_tags %}
 | 
				
			||||||
 | 
					{% load humanize %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block sidebar %}
 | 
				
			||||||
 | 
					    {% include 'sidebars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock sidebar %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block navbar %}
 | 
				
			||||||
 | 
					    {% include 'navbars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock navbar %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block style %}
 | 
				
			||||||
 | 
					<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<!-- Persian Date Picker CSS -->
 | 
				
			||||||
 | 
					<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					{% include '_toasts.html' %}
 | 
				
			||||||
 | 
					<div class="container-xxl flex-grow-1 container-p-y">
 | 
				
			||||||
 | 
					  <div class="row">
 | 
				
			||||||
 | 
					    <div class="col-12 mb-4">
 | 
				
			||||||
 | 
					      <div class="d-flex align-items-center justify-content-between mb-3">
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
 | 
				
			||||||
 | 
					          <small class="text-muted d-block">
 | 
				
			||||||
 | 
					            اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
 | 
				
			||||||
 | 
					            | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
 | 
				
			||||||
 | 
					          </small>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div class="bs-stepper wizard-vertical vertical mt-2">
 | 
				
			||||||
 | 
					        {% stepper_header instance step %}
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        <div class="bs-stepper-content">
 | 
				
			||||||
 | 
					                  
 | 
				
			||||||
 | 
					          {% if report and not edit_mode %}
 | 
				
			||||||
 | 
					          <div class="card mb-3 border">
 | 
				
			||||||
 | 
					            <div class="card-header d-flex justify-content-end">
 | 
				
			||||||
 | 
					              <a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="card-body">
 | 
				
			||||||
 | 
					              <div class="row">
 | 
				
			||||||
 | 
					                <div class="col-md-6">
 | 
				
			||||||
 | 
					                  <p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}</p>
 | 
				
			||||||
 | 
					                  <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>
 | 
				
			||||||
 | 
					                </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>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              {% if report.description %}
 | 
				
			||||||
 | 
					              <div class="mt-2">
 | 
				
			||||||
 | 
					                <p class="mb-0"><i class="bx bx-text bx-sm me-2"></i><strong>توضیحات:</strong></p>
 | 
				
			||||||
 | 
					                <div class="text-muted">{{ report.description|default:'-' }}</div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              {% endif %}
 | 
				
			||||||
 | 
					              <hr>
 | 
				
			||||||
 | 
					              <h6>عکسها</h6>
 | 
				
			||||||
 | 
					              <div class="row">
 | 
				
			||||||
 | 
					                {% for p in report.photos.all %}
 | 
				
			||||||
 | 
					                  <div class="col-6 col-md-3 mb-2"><img class="img-fluid rounded border" src="{{ p.image.url }}" alt="photo"></div>
 | 
				
			||||||
 | 
					                {% empty %}
 | 
				
			||||||
 | 
					                  <div class="text-muted">بدون عکس</div>
 | 
				
			||||||
 | 
					                {% endfor %}
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <hr>
 | 
				
			||||||
 | 
					              <div class="row g-3">
 | 
				
			||||||
 | 
					                <div class="col-12">
 | 
				
			||||||
 | 
					                  <h6 class="mb-2">اقلام</h6>
 | 
				
			||||||
 | 
					                  <div class="table-responsive">
 | 
				
			||||||
 | 
					                    <table class="table table-sm align-middle">
 | 
				
			||||||
 | 
					                      <thead>
 | 
				
			||||||
 | 
					                        <tr>
 | 
				
			||||||
 | 
					                          <th style="width:40px">نوع</th>
 | 
				
			||||||
 | 
					                          <th>آیتم</th>
 | 
				
			||||||
 | 
					                          <th>تعداد</th>
 | 
				
			||||||
 | 
					                          <th>قیمت واحد</th>
 | 
				
			||||||
 | 
					                          <th>قیمت کل</th>
 | 
				
			||||||
 | 
					                        </tr>
 | 
				
			||||||
 | 
					                      </thead>
 | 
				
			||||||
 | 
					                      <tbody>
 | 
				
			||||||
 | 
					                        {% for ch in report.item_changes.all %}
 | 
				
			||||||
 | 
					                        <tr>
 | 
				
			||||||
 | 
					                          <td>{% if ch.change_type == 'add' %}<span class="text-success"><i class="bx bx-plus"></i></span>{% else %}<span class="text-danger"><i class="bx bx-minus"></i></span>{% endif %}</td>
 | 
				
			||||||
 | 
					                          <td>{{ ch.item.name }}</td>
 | 
				
			||||||
 | 
					                          <td>{{ ch.quantity }}</td>
 | 
				
			||||||
 | 
					                          <td>{% if ch.unit_price %}{{ ch.unit_price|floatformat:0|intcomma:False }}{% else %}-{% endif %}</td>  
 | 
				
			||||||
 | 
					                          <td>
 | 
				
			||||||
 | 
					                            {% if ch.total_price %}
 | 
				
			||||||
 | 
					                              {{ ch.total_price|floatformat:0|intcomma:False }}
 | 
				
			||||||
 | 
					                            {% elif ch.unit_price %}
 | 
				
			||||||
 | 
					                              {{ ch.unit_price|floatformat:0|intcomma:False }}
 | 
				
			||||||
 | 
					                            {% else %}-{% endif %}
 | 
				
			||||||
 | 
					                          </td>
 | 
				
			||||||
 | 
					                        </tr>
 | 
				
			||||||
 | 
					                        {% empty %}
 | 
				
			||||||
 | 
					                        <tr><td colspan="5" class="text-center text-muted">تغییری ثبت نشده است</td></tr>
 | 
				
			||||||
 | 
					                        {% endfor %}
 | 
				
			||||||
 | 
					                      </tbody>
 | 
				
			||||||
 | 
					                    </table>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <!-- Persistent nav in edit mode (outside cards) -->
 | 
				
			||||||
 | 
					          <div class="d-flex justify-content-between mt-3">
 | 
				
			||||||
 | 
					            {% if previous_step %}
 | 
				
			||||||
 | 
					              <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
 | 
				
			||||||
 | 
					            {% else %}
 | 
				
			||||||
 | 
					              <span></span>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					            {% if next_step %}
 | 
				
			||||||
 | 
					              <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          {% else %}
 | 
				
			||||||
 | 
					          <form method="post" enctype="multipart/form-data">
 | 
				
			||||||
 | 
					            {% 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="انتخاب تاریخ" readonly required value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y/m/d' }}{% endif %}">
 | 
				
			||||||
 | 
					                    <input type="hidden" id="id_visited_date" name="visited_date" value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y-m-d' }}{% endif %}">
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                  <div class="col-md-3">
 | 
				
			||||||
 | 
					                    <label class="form-label">سریال کنتور جدید</label>
 | 
				
			||||||
 | 
					                    <input type="text" class="form-control" name="new_water_meter_serial">
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                  <div class="col-md-3">
 | 
				
			||||||
 | 
					                    <label class="form-label">شماره پلمپ</label>
 | 
				
			||||||
 | 
					                    <input type="text" class="form-control" name="seal_number">
 | 
				
			||||||
 | 
					                  </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">
 | 
				
			||||||
 | 
					                      <label class="form-check-label" for="id_is_meter_suspicious">کنتور مشکوک است</label>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                  <div class="col-md-3">
 | 
				
			||||||
 | 
					                    <label class="form-label">UTM X</label>
 | 
				
			||||||
 | 
					                    <input type="number" step="0.000001" class="form-control" name="utm_x" value="{% if instance.well.utm_x %}{{ instance.well.utm_x }}{% endif %}">
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                  <div class="col-md-3">
 | 
				
			||||||
 | 
					                    <label class="form-label">UTM Y</label>
 | 
				
			||||||
 | 
					                    <input type="number" step="0.000001" class="form-control" name="utm_y" value="{% if instance.well.utm_y %}{{ instance.well.utm_y }}{% endif %}">
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div class="my-3">
 | 
				
			||||||
 | 
					                  <label class="form-label">توضیحات (اختیاری)</label>
 | 
				
			||||||
 | 
					                  <textarea class="form-control" rows="3" name="description"></textarea>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div class="mb-3">
 | 
				
			||||||
 | 
					                  <div class="d-flex justify-content-between align-items-center">
 | 
				
			||||||
 | 
					                    <label class="form-label mb-0">عکسها</label>
 | 
				
			||||||
 | 
					                    <button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                  {% if report %}
 | 
				
			||||||
 | 
					                  <div class="row mt-2">
 | 
				
			||||||
 | 
					                    {% for p in report.photos.all %}
 | 
				
			||||||
 | 
					                      <div class="col-6 col-md-3 mb-2" id="existing-photo-{{ p.id }}">
 | 
				
			||||||
 | 
					                        <div class="position-relative border rounded p-1">
 | 
				
			||||||
 | 
					                          <img class="img-fluid rounded" src="{{ p.image.url }}" alt="photo">
 | 
				
			||||||
 | 
					                          <button type="button" class="btn btn-sm btn-danger position-absolute" style="top:6px; left:6px;" onclick="markDeletePhoto({{ p.id }})" title="حذف/برگردان"><i class='bx bx-trash'></i></button>
 | 
				
			||||||
 | 
					                          <input type="hidden" name="del_photo_{{ p.id }}" id="del-photo-{{ p.id }}" value="0">
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                      </div>
 | 
				
			||||||
 | 
					                    {% empty %}
 | 
				
			||||||
 | 
					                    {% endfor %}
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                  {% endif %}
 | 
				
			||||||
 | 
					                  <div class="row mt-2" id="photosPreview"></div>
 | 
				
			||||||
 | 
					                  <div id="photoInputs" class="d-none"></div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div class="card border">
 | 
				
			||||||
 | 
					              <div class="card-header d-flex justify-content-between align-items-center">
 | 
				
			||||||
 | 
					                <h5 class="mb-0">اقلام</h5>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <div class="card-body">
 | 
				
			||||||
 | 
					                <div class="row g-3">
 | 
				
			||||||
 | 
					                  <div class="col-12 mb-4">
 | 
				
			||||||
 | 
					                    <h6 class="mb-2">اقلام انتخابشده قبلی <small class="text-muted">(برای حذف در نصب تیک بزنید)</small></h6>
 | 
				
			||||||
 | 
					                    <div class="table-responsive">
 | 
				
			||||||
 | 
					                      <table class="table table-sm align-middle">
 | 
				
			||||||
 | 
					                        <thead>
 | 
				
			||||||
 | 
					                          <tr>
 | 
				
			||||||
 | 
					                            <th style="width:40px">حذف</th>
 | 
				
			||||||
 | 
					                            <th>آیتم</th>
 | 
				
			||||||
 | 
					                            <th>قیمت واحد</th>
 | 
				
			||||||
 | 
					                            <th style="width:140px">تعداد</th>
 | 
				
			||||||
 | 
					                          </tr>
 | 
				
			||||||
 | 
					                        </thead>
 | 
				
			||||||
 | 
					                        <tbody>
 | 
				
			||||||
 | 
					                          {% for qi in quote_items %}
 | 
				
			||||||
 | 
					                          <tr>
 | 
				
			||||||
 | 
					                            <td>
 | 
				
			||||||
 | 
					                              <input type="checkbox" class="form-check-input" name="rem_{{ qi.item.id }}_type" value="remove" title="حذف در نصب" {% if removed_qty|get_item:qi.item.id %}checked{% endif %}>
 | 
				
			||||||
 | 
					                              <input type="hidden" name="rem_{{ qi.item.id }}_qty" value="{% if removed_qty|get_item:qi.item.id %}{{ removed_qty|get_item:qi.item.id }}{% else %}{{ qi.quantity }}{% endif %}">
 | 
				
			||||||
 | 
					                            </td>
 | 
				
			||||||
 | 
					                            <td>
 | 
				
			||||||
 | 
					                              <div class="d-flex flex-column">
 | 
				
			||||||
 | 
					                                <span class="fw-semibold">{{ qi.item.name }}</span>
 | 
				
			||||||
 | 
					                                {% 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>
 | 
				
			||||||
 | 
					                              <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>
 | 
				
			||||||
 | 
					                          </tr>
 | 
				
			||||||
 | 
					                          {% empty %}
 | 
				
			||||||
 | 
					                          <tr><td colspan="4" class="text-center text-muted">اقلامی ثبت نشده است</td></tr>
 | 
				
			||||||
 | 
					                          {% endfor %}
 | 
				
			||||||
 | 
					                        </tbody>
 | 
				
			||||||
 | 
					                      </table>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                  <hr>
 | 
				
			||||||
 | 
					                  <div class="col-12">
 | 
				
			||||||
 | 
					                    <h6 class="mb-2">افزودن اقلام جدید</h6>
 | 
				
			||||||
 | 
					                    <div class="table-responsive">
 | 
				
			||||||
 | 
					                      <table class="table table-sm align-middle">
 | 
				
			||||||
 | 
					                        <thead>
 | 
				
			||||||
 | 
					                          <tr>
 | 
				
			||||||
 | 
					                            <th style="width:40px"></th>
 | 
				
			||||||
 | 
					                            <th>آیتم</th>
 | 
				
			||||||
 | 
					                            <th>قیمت واحد</th>
 | 
				
			||||||
 | 
					                            <th style="width:140px">تعداد</th>
 | 
				
			||||||
 | 
					                          </tr>
 | 
				
			||||||
 | 
					                        </thead>
 | 
				
			||||||
 | 
					                        <tbody>
 | 
				
			||||||
 | 
					                          {% for it in all_items %}
 | 
				
			||||||
 | 
					                          <tr>
 | 
				
			||||||
 | 
					                            <td>
 | 
				
			||||||
 | 
					                              {% with add_entry=added_map|get_item:it.id %}
 | 
				
			||||||
 | 
					                              <input type="checkbox" name="add_{{ it.id }}_type" value="add" class="form-check-input" {% if add_entry %}checked{% endif %}>
 | 
				
			||||||
 | 
					                              <input type="hidden" name="add_{{ it.id }}_price" value="{{ it.unit_price }}">
 | 
				
			||||||
 | 
					                              {% endwith %}
 | 
				
			||||||
 | 
					                            </td>
 | 
				
			||||||
 | 
					                            <td>
 | 
				
			||||||
 | 
					                              <div class="d-flex flex-column">
 | 
				
			||||||
 | 
					                                <span class="fw-semibold">{{ it.name }}</span>
 | 
				
			||||||
 | 
					                                {% if it.description %}<small class="text-muted">{{ it.description }}</small>{% endif %}
 | 
				
			||||||
 | 
					                              </div>
 | 
				
			||||||
 | 
					                            </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 %}">
 | 
				
			||||||
 | 
					                              {% endwith %}
 | 
				
			||||||
 | 
					                            </td>
 | 
				
			||||||
 | 
					                          </tr>
 | 
				
			||||||
 | 
					                          {% endfor %}
 | 
				
			||||||
 | 
					                        </tbody>
 | 
				
			||||||
 | 
					                      </table>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <div class="mt-3 d-flex justify-content-between">
 | 
				
			||||||
 | 
					            {% if previous_step %}
 | 
				
			||||||
 | 
					              <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
 | 
				
			||||||
 | 
					            {% else %}
 | 
				
			||||||
 | 
					              <span></span>
 | 
				
			||||||
 | 
					            {% endif %}
 | 
				
			||||||
 | 
					            <div class="d-flex gap-2">
 | 
				
			||||||
 | 
					              <button type="submit" class="btn btn-primary">ثبت گزارش</button>
 | 
				
			||||||
 | 
					              {% if next_step %}
 | 
				
			||||||
 | 
					                <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-success">بعدی</a>
 | 
				
			||||||
 | 
					              {% endif %}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block script %}
 | 
				
			||||||
 | 
					<!-- Persian Date Picker JS -->
 | 
				
			||||||
 | 
					<script src="https://unpkg.com/persian-date@latest/dist/persian-date.min.js"></script>
 | 
				
			||||||
 | 
					<script src="https://unpkg.com/persian-datepicker@latest/dist/js/persian-datepicker.min.js"></script>
 | 
				
			||||||
 | 
					<script>
 | 
				
			||||||
 | 
					  // Persian datepicker for visited_date (exact pattern like sample: display + altField)
 | 
				
			||||||
 | 
					  (function(){
 | 
				
			||||||
 | 
					    function convertPersianToEnglishNumbers(str) {
 | 
				
			||||||
 | 
					      const persianNumbers = '۰۱۲۳۴۵۶۷۸۹';
 | 
				
			||||||
 | 
					      const englishNumbers = '0123456789';
 | 
				
			||||||
 | 
					      return String(str || '').split('').map(function(char){
 | 
				
			||||||
 | 
					        const index = persianNumbers.indexOf(char);
 | 
				
			||||||
 | 
					        return index !== -1 ? englishNumbers[index] : char;
 | 
				
			||||||
 | 
					      }).join('');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (window.$ && $.fn.persianDatepicker && $('#id_visited_date_display').length) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        var $display = $('#id_visited_date_display');
 | 
				
			||||||
 | 
					        var $hidden = $('#id_visited_date');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Prefill from hidden Gregorian to visible Jalali
 | 
				
			||||||
 | 
					        var initialGregorian = $hidden.val();
 | 
				
			||||||
 | 
					        if (initialGregorian) {
 | 
				
			||||||
 | 
					          try {
 | 
				
			||||||
 | 
					            var initialJalali = new window.persianDate(new Date(initialGregorian)).format('YYYY/MM/DD');
 | 
				
			||||||
 | 
					            $display.val(initialJalali);
 | 
				
			||||||
 | 
					          } catch (e) {}
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Initialize datepicker with altField exactly like the sample
 | 
				
			||||||
 | 
					        var picker = $display.persianDatepicker({
 | 
				
			||||||
 | 
					          calendarType: 'persian',
 | 
				
			||||||
 | 
					          altField: '#id_visited_date',
 | 
				
			||||||
 | 
					          format: 'YYYY/MM/DD',
 | 
				
			||||||
 | 
					          altFormat: 'YYYY-MM-DD',
 | 
				
			||||||
 | 
					          observer: true,
 | 
				
			||||||
 | 
					          autoClose: true,
 | 
				
			||||||
 | 
					          initialValue: false,
 | 
				
			||||||
 | 
					          calendar:{ persian: { leapYearMode: 'astronomical' } },
 | 
				
			||||||
 | 
					          onSelect: function (unixDate) {
 | 
				
			||||||
 | 
					            var g = new window.persianDate(unixDate).toCalendar('gregorian').format('YYYY-MM-DD');
 | 
				
			||||||
 | 
					            g = convertPersianToEnglishNumbers(g);
 | 
				
			||||||
 | 
					            $hidden.val(g);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      } catch (e) { console.error('Error initializing Persian Date Picker:', e); }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Require date and show success toast on submit (persist across redirect)
 | 
				
			||||||
 | 
					  (function(){
 | 
				
			||||||
 | 
					    const form = document.querySelector('form[enctype]') || document.querySelector('form');
 | 
				
			||||||
 | 
					    if (!form) return;
 | 
				
			||||||
 | 
					    form.addEventListener('submit', function(ev){
 | 
				
			||||||
 | 
					      const display = document.getElementById('id_visited_date_display');
 | 
				
			||||||
 | 
					      const hidden = document.getElementById('id_visited_date');
 | 
				
			||||||
 | 
					      if (!display || !hidden) return;
 | 
				
			||||||
 | 
					      if (!display.value || !hidden.value) {
 | 
				
			||||||
 | 
					        ev.preventDefault(); ev.stopPropagation();
 | 
				
			||||||
 | 
					        showToast('تاریخ مراجعه را انتخاب کنید', 'danger');
 | 
				
			||||||
 | 
					        display.scrollIntoView({behavior:'smooth', block:'center'});
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      try { sessionStorage.setItem('install_report_saved', '1'); } catch(_) {}
 | 
				
			||||||
 | 
					    }, false);
 | 
				
			||||||
 | 
					    // on load, if saved flag exists, show toast
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      if (sessionStorage.getItem('install_report_saved') === '1') {
 | 
				
			||||||
 | 
					        sessionStorage.removeItem('install_report_saved');
 | 
				
			||||||
 | 
					        showToast('گزارش نصب با موفقیت ثبت شد', 'success');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch(_) {}
 | 
				
			||||||
 | 
					  })();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Dynamic photo add/remove
 | 
				
			||||||
 | 
					  (function(){
 | 
				
			||||||
 | 
					    const photoInputs = document.getElementById('photoInputs');
 | 
				
			||||||
 | 
					    const photosPreview = document.getElementById('photosPreview');
 | 
				
			||||||
 | 
					    const btnAddPhoto = document.getElementById('btnAddPhoto');
 | 
				
			||||||
 | 
					    let photoCounter = 0;
 | 
				
			||||||
 | 
					    function createPhotoInput() {
 | 
				
			||||||
 | 
					      photoCounter += 1;
 | 
				
			||||||
 | 
					      const input = document.createElement('input');
 | 
				
			||||||
 | 
					      input.type = 'file';
 | 
				
			||||||
 | 
					      input.name = 'photos';
 | 
				
			||||||
 | 
					      input.accept = 'image/*';
 | 
				
			||||||
 | 
					      input.className = 'd-none';
 | 
				
			||||||
 | 
					      input.dataset.key = String(photoCounter);
 | 
				
			||||||
 | 
					      input.addEventListener('change', function(){
 | 
				
			||||||
 | 
					        const file = input.files && input.files[0];
 | 
				
			||||||
 | 
					        if (!file) return;
 | 
				
			||||||
 | 
					        const reader = new FileReader();
 | 
				
			||||||
 | 
					        reader.onload = function(){
 | 
				
			||||||
 | 
					          const col = document.createElement('div');
 | 
				
			||||||
 | 
					          col.className = 'col-6 col-md-3 mb-2';
 | 
				
			||||||
 | 
					          col.id = 'photo-preview-' + input.dataset.key;
 | 
				
			||||||
 | 
					          col.innerHTML = `
 | 
				
			||||||
 | 
					            <div class="position-relative border rounded p-1">
 | 
				
			||||||
 | 
					              <img src="${reader.result}" class="img-fluid rounded" alt="photo">
 | 
				
			||||||
 | 
					              <button type="button" class="btn btn-sm btn-danger position-absolute" style="top:6px; left:6px;" data-key="${input.dataset.key}"><i class=\"bx bx-trash\"></i></button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          `;
 | 
				
			||||||
 | 
					          photosPreview.appendChild(col);
 | 
				
			||||||
 | 
					          col.querySelector('button').addEventListener('click', function(ev){
 | 
				
			||||||
 | 
					            const key = ev.currentTarget.getAttribute('data-key');
 | 
				
			||||||
 | 
					            const preview = document.getElementById('photo-preview-' + key);
 | 
				
			||||||
 | 
					            if (preview) preview.remove();
 | 
				
			||||||
 | 
					            const inp = photoInputs.querySelector(`input[data-key="${key}"]`);
 | 
				
			||||||
 | 
					            if (inp) inp.remove();
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        reader.readAsDataURL(file);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      photoInputs.appendChild(input);
 | 
				
			||||||
 | 
					      input.click();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (btnAddPhoto) btnAddPhoto.addEventListener('click', createPhotoInput);
 | 
				
			||||||
 | 
					  })();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Mark delete for existing photos
 | 
				
			||||||
 | 
					  function markDeletePhoto(id){
 | 
				
			||||||
 | 
					    const hidden = document.getElementById('del-photo-' + id);
 | 
				
			||||||
 | 
					    const wrap = document.getElementById('existing-photo-' + id);
 | 
				
			||||||
 | 
					    if (hidden && wrap){
 | 
				
			||||||
 | 
					      // toggle behavior
 | 
				
			||||||
 | 
					      if (hidden.value === '1') {
 | 
				
			||||||
 | 
					        hidden.value = '0';
 | 
				
			||||||
 | 
					        wrap.style.opacity = '1';
 | 
				
			||||||
 | 
					        // update button title back to delete
 | 
				
			||||||
 | 
					        const btn = wrap.querySelector('button');
 | 
				
			||||||
 | 
					        if (btn) btn.title = 'حذف';
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        hidden.value = '1';
 | 
				
			||||||
 | 
					        wrap.style.opacity = '0.5';
 | 
				
			||||||
 | 
					        wrap.style.position = 'relative';
 | 
				
			||||||
 | 
					        // update button title to undo
 | 
				
			||||||
 | 
					        const btn = wrap.querySelector('button');
 | 
				
			||||||
 | 
					        if (btn) btn.title = 'انصراف از حذف';
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										11
									
								
								installations/urls.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								installations/urls.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,11 @@
 | 
				
			||||||
 | 
					from django.urls import path
 | 
				
			||||||
 | 
					from . import views
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app_name = 'installations'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					urlpatterns = [
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/assign/', views.installation_assign_step, name='installation_assign_step'),
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/report/', views.installation_report_step, name='installation_report_step'),
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										255
									
								
								installations/views.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								installations/views.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,255 @@
 | 
				
			||||||
 | 
					from django.shortcuts import render, get_object_or_404, redirect
 | 
				
			||||||
 | 
					from django.contrib.auth.decorators import login_required
 | 
				
			||||||
 | 
					from django.contrib import messages
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from django.utils import timezone
 | 
				
			||||||
 | 
					from accounts.models import Profile
 | 
				
			||||||
 | 
					from common.consts import UserRoles
 | 
				
			||||||
 | 
					from processes.models import ProcessInstance, StepInstance
 | 
				
			||||||
 | 
					from invoices.models import Item, Quote, QuoteItem
 | 
				
			||||||
 | 
					from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange
 | 
				
			||||||
 | 
					from decimal import Decimal, InvalidOperation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def installation_assign_step(request, instance_id, step_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
				
			||||||
 | 
					    previous_step = instance.process.steps.filter(order__lt=step.order).last()
 | 
				
			||||||
 | 
					    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Installers list (profiles that have installer role)
 | 
				
			||||||
 | 
					    installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value).select_related('user').all()
 | 
				
			||||||
 | 
					    assignment, _ = InstallationAssignment.objects.get_or_create(process_instance=instance)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if request.method == 'POST':
 | 
				
			||||||
 | 
					        installer_id = request.POST.get('installer_id')
 | 
				
			||||||
 | 
					        scheduled_date = (request.POST.get('scheduled_date') or '').strip()
 | 
				
			||||||
 | 
					        assignment.installer_id = installer_id or None
 | 
				
			||||||
 | 
					        if scheduled_date:
 | 
				
			||||||
 | 
					            assignment.scheduled_date = scheduled_date.replace('/', '-')
 | 
				
			||||||
 | 
					        assignment.assigned_by = request.user
 | 
				
			||||||
 | 
					        assignment.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # complete step
 | 
				
			||||||
 | 
					        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.save()
 | 
				
			||||||
 | 
					            return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
 | 
				
			||||||
 | 
					        return redirect('processes:request_list')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return render(request, 'installations/installation_assign_step.html', {
 | 
				
			||||||
 | 
					        'instance': instance,
 | 
				
			||||||
 | 
					        'step': step,
 | 
				
			||||||
 | 
					        'assignment': assignment,
 | 
				
			||||||
 | 
					        'installers': installers,
 | 
				
			||||||
 | 
					        'previous_step': previous_step,
 | 
				
			||||||
 | 
					        'next_step': next_step,
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def installation_report_step(request, instance_id, step_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
				
			||||||
 | 
					    previous_step = instance.process.steps.filter(order__lt=step.order).last()
 | 
				
			||||||
 | 
					    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
				
			||||||
 | 
					    assignment = InstallationAssignment.objects.filter(process_instance=instance).first()
 | 
				
			||||||
 | 
					    existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first()
 | 
				
			||||||
 | 
					    edit_mode = True if request.GET.get('edit') == '1' else False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # current quote items baseline
 | 
				
			||||||
 | 
					    quote = Quote.objects.filter(process_instance=instance).first()
 | 
				
			||||||
 | 
					    quote_items = list(quote.items.select_related('item').all()) if quote else []
 | 
				
			||||||
 | 
					    quote_price_map = {qi.item_id: qi.unit_price for qi in quote_items}
 | 
				
			||||||
 | 
					    items = Item.objects.all().order_by('name')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if request.method == 'POST':
 | 
				
			||||||
 | 
					        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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # 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.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()
 | 
				
			||||||
 | 
					            for item_id, qty in remove_map.items():
 | 
				
			||||||
 | 
					                up = quote_price_map.get(item_id)
 | 
				
			||||||
 | 
					                total = (up * qty) if up is not None else None
 | 
				
			||||||
 | 
					                InstallationItemChange.objects.create(
 | 
				
			||||||
 | 
					                    report=report,
 | 
				
			||||||
 | 
					                    item_id=item_id,
 | 
				
			||||||
 | 
					                    change_type='remove',
 | 
				
			||||||
 | 
					                    quantity=qty,
 | 
				
			||||||
 | 
					                    unit_price=up,
 | 
				
			||||||
 | 
					                    total_price=total,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            for item_id, data in add_map.items():
 | 
				
			||||||
 | 
					                unit_price = data.get('price')
 | 
				
			||||||
 | 
					                qty = data.get('qty') or 1
 | 
				
			||||||
 | 
					                total = (unit_price * qty) if (unit_price is not None) else None
 | 
				
			||||||
 | 
					                InstallationItemChange.objects.create(
 | 
				
			||||||
 | 
					                    report=report,
 | 
				
			||||||
 | 
					                    item_id=item_id,
 | 
				
			||||||
 | 
					                    change_type='add',
 | 
				
			||||||
 | 
					                    quantity=qty,
 | 
				
			||||||
 | 
					                    unit_price=unit_price,
 | 
				
			||||||
 | 
					                    total_price=total,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            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
 | 
				
			||||||
 | 
					            for item_id, qty in remove_map.items():
 | 
				
			||||||
 | 
					                up = quote_price_map.get(item_id)
 | 
				
			||||||
 | 
					                total = (up * qty) if up is not None else None
 | 
				
			||||||
 | 
					                InstallationItemChange.objects.create(
 | 
				
			||||||
 | 
					                    report=report,
 | 
				
			||||||
 | 
					                    item_id=item_id,
 | 
				
			||||||
 | 
					                    change_type='remove',
 | 
				
			||||||
 | 
					                    quantity=qty,
 | 
				
			||||||
 | 
					                    unit_price=up,
 | 
				
			||||||
 | 
					                    total_price=total,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            for item_id, data in add_map.items():
 | 
				
			||||||
 | 
					                unit_price = data.get('price')
 | 
				
			||||||
 | 
					                qty = data.get('qty') or 1
 | 
				
			||||||
 | 
					                total = (unit_price * qty) if (unit_price is not None) else None
 | 
				
			||||||
 | 
					                InstallationItemChange.objects.create(
 | 
				
			||||||
 | 
					                    report=report,
 | 
				
			||||||
 | 
					                    item_id=item_id,
 | 
				
			||||||
 | 
					                    change_type='add',
 | 
				
			||||||
 | 
					                    quantity=qty,
 | 
				
			||||||
 | 
					                    unit_price=unit_price,
 | 
				
			||||||
 | 
					                    total_price=total,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # complete step
 | 
				
			||||||
 | 
					        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.save()
 | 
				
			||||||
 | 
					            return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
 | 
				
			||||||
 | 
					        return redirect('processes:request_list')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Build prefill maps from existing report changes
 | 
				
			||||||
 | 
					    removed_ids = set()
 | 
				
			||||||
 | 
					    removed_qty = {}
 | 
				
			||||||
 | 
					    added_map = {}
 | 
				
			||||||
 | 
					    if existing_report:
 | 
				
			||||||
 | 
					        for ch in existing_report.item_changes.all():
 | 
				
			||||||
 | 
					            if ch.change_type == 'remove':
 | 
				
			||||||
 | 
					                removed_ids.add(ch.item_id)
 | 
				
			||||||
 | 
					                removed_qty[ch.item_id] = ch.quantity
 | 
				
			||||||
 | 
					            elif ch.change_type == 'add':
 | 
				
			||||||
 | 
					                added_map[ch.item_id] = {'qty': ch.quantity, 'price': ch.unit_price}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return render(request, 'installations/installation_report_step.html', {
 | 
				
			||||||
 | 
					        'instance': instance,
 | 
				
			||||||
 | 
					        'step': step,
 | 
				
			||||||
 | 
					        'assignment': assignment,
 | 
				
			||||||
 | 
					        'report': existing_report,
 | 
				
			||||||
 | 
					        'edit_mode': edit_mode,
 | 
				
			||||||
 | 
					        'quote': quote,
 | 
				
			||||||
 | 
					        'quote_items': quote_items,
 | 
				
			||||||
 | 
					        'all_items': items,
 | 
				
			||||||
 | 
					        'removed_ids': removed_ids,
 | 
				
			||||||
 | 
					        'removed_qty': removed_qty,
 | 
				
			||||||
 | 
					        'added_map': added_map,
 | 
				
			||||||
 | 
					        'previous_step': previous_step,
 | 
				
			||||||
 | 
					        'next_step': next_step,
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,23 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-21 18:03
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('invoices', '0002_historicalpayment_receipt_image_and_more'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name='historicalpayment',
 | 
				
			||||||
 | 
					            name='reference_number',
 | 
				
			||||||
 | 
					            field=models.CharField(blank=True, db_index=True, max_length=100, verbose_name='شماره مرجع'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name='payment',
 | 
				
			||||||
 | 
					            name='reference_number',
 | 
				
			||||||
 | 
					            field=models.CharField(blank=True, max_length=100, unique=True, verbose_name='شماره مرجع'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,23 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-22 08:18
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('invoices', '0003_alter_historicalpayment_reference_number_and_more'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='historicalpayment',
 | 
				
			||||||
 | 
					            name='direction',
 | 
				
			||||||
 | 
					            field=models.CharField(choices=[('in', 'دریافتی'), ('out', 'پرداختی')], default='in', max_length=3, verbose_name='نوع تراکنش'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='payment',
 | 
				
			||||||
 | 
					            name='direction',
 | 
				
			||||||
 | 
					            field=models.CharField(choices=[('in', 'دریافتی'), ('out', 'پرداختی')], default='in', max_length=3, verbose_name='نوع تراکنش'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,33 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-22 08:54
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('invoices', '0004_historicalpayment_direction_payment_direction'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='historicalitem',
 | 
				
			||||||
 | 
					            name='is_special',
 | 
				
			||||||
 | 
					            field=models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='historicalitem',
 | 
				
			||||||
 | 
					            name='special_kind',
 | 
				
			||||||
 | 
					            field=models.CharField(blank=True, choices=[('repair', 'تعمیر'), ('replace', 'تعویض')], max_length=10, verbose_name='نوع ویژه'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='item',
 | 
				
			||||||
 | 
					            name='is_special',
 | 
				
			||||||
 | 
					            field=models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name='item',
 | 
				
			||||||
 | 
					            name='special_kind',
 | 
				
			||||||
 | 
					            field=models.CharField(blank=True, choices=[('repair', 'تعمیر'), ('replace', 'تعویض')], max_length=10, verbose_name='نوع ویژه'),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,21 @@
 | 
				
			||||||
 | 
					# Generated by Django 5.2.4 on 2025-08-22 08:59
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ('invoices', '0005_historicalitem_is_special_and_more'),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.RemoveField(
 | 
				
			||||||
 | 
					            model_name='historicalitem',
 | 
				
			||||||
 | 
					            name='special_kind',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.RemoveField(
 | 
				
			||||||
 | 
					            model_name='item',
 | 
				
			||||||
 | 
					            name='special_kind',
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
| 
						 | 
					@ -18,6 +18,7 @@ class Item(NameSlugModel):
 | 
				
			||||||
        decimal_places=2, 
 | 
					        decimal_places=2, 
 | 
				
			||||||
        verbose_name="قیمت واحد"
 | 
					        verbose_name="قیمت واحد"
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					    is_special = models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی')
 | 
				
			||||||
    default_quantity = models.PositiveIntegerField(
 | 
					    default_quantity = models.PositiveIntegerField(
 | 
				
			||||||
        default=1, 
 | 
					        default=1, 
 | 
				
			||||||
        verbose_name="تعداد پیشفرض"
 | 
					        verbose_name="تعداد پیشفرض"
 | 
				
			||||||
| 
						 | 
					@ -102,7 +103,8 @@ class Quote(NameSlugModel):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def calculate_totals(self):
 | 
					    def calculate_totals(self):
 | 
				
			||||||
        """محاسبه مبالغ کل"""
 | 
					        """محاسبه مبالغ کل"""
 | 
				
			||||||
        total = sum(item.total_price for item in self.items.all())
 | 
					        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
 | 
					        self.total_amount = total
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        # محاسبه تخفیف
 | 
					        # محاسبه تخفیف
 | 
				
			||||||
| 
						 | 
					@ -260,15 +262,19 @@ class Invoice(NameSlugModel):
 | 
				
			||||||
            self.discount_amount = 0
 | 
					            self.discount_amount = 0
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        self.final_amount = self.total_amount - self.discount_amount
 | 
					        self.final_amount = self.total_amount - self.discount_amount
 | 
				
			||||||
        self.remaining_amount = self.final_amount - self.paid_amount
 | 
					        # خالص مانده به نفع شرکت (مثبت) یا به نفع مشتری (منفی)
 | 
				
			||||||
        
 | 
					        net_due = self.final_amount - self.paid_amount
 | 
				
			||||||
        # بروزرسانی وضعیت
 | 
					        self.remaining_amount = net_due
 | 
				
			||||||
        if self.remaining_amount <= 0:
 | 
					
 | 
				
			||||||
 | 
					        # وضعیت بر اساس مانده خالص
 | 
				
			||||||
 | 
					        if net_due == 0:
 | 
				
			||||||
            self.status = 'paid'
 | 
					            self.status = 'paid'
 | 
				
			||||||
        elif self.paid_amount > 0:
 | 
					        elif net_due > 0:
 | 
				
			||||||
            self.status = 'partially_paid'
 | 
					            # مشتری هنوز باید پرداخت کند
 | 
				
			||||||
 | 
					            self.status = 'partially_paid' if self.paid_amount > 0 else 'sent'
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            self.status = 'sent'
 | 
					            # شرکت باید به مشتری پرداخت کند
 | 
				
			||||||
 | 
					            self.status = 'partially_paid'
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        self.save()
 | 
					        self.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -314,6 +320,12 @@ class Payment(BaseModel):
 | 
				
			||||||
    """مدل پرداختها"""
 | 
					    """مدل پرداختها"""
 | 
				
			||||||
    invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments', verbose_name="فاکتور")
 | 
					    invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments', verbose_name="فاکتور")
 | 
				
			||||||
    amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="مبلغ پرداخت")
 | 
					    amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="مبلغ پرداخت")
 | 
				
			||||||
 | 
					    direction = models.CharField(
 | 
				
			||||||
 | 
					        max_length=3,
 | 
				
			||||||
 | 
					        choices=[('in', 'دریافتی'), ('out', 'پرداختی')],
 | 
				
			||||||
 | 
					        default='in',
 | 
				
			||||||
 | 
					        verbose_name='نوع تراکنش'
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    payment_method = models.CharField(
 | 
					    payment_method = models.CharField(
 | 
				
			||||||
        max_length=20,
 | 
					        max_length=20,
 | 
				
			||||||
        choices=[
 | 
					        choices=[
 | 
				
			||||||
| 
						 | 
					@ -326,7 +338,7 @@ class Payment(BaseModel):
 | 
				
			||||||
        default='cash',
 | 
					        default='cash',
 | 
				
			||||||
        verbose_name="روش پرداخت"
 | 
					        verbose_name="روش پرداخت"
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    reference_number = models.CharField(max_length=100, verbose_name="شماره مرجع", blank=True)
 | 
					    reference_number = models.CharField(max_length=100, verbose_name="شماره مرجع", blank=True, unique=True)
 | 
				
			||||||
    payment_date = models.DateField(verbose_name="تاریخ پرداخت")
 | 
					    payment_date = models.DateField(verbose_name="تاریخ پرداخت")
 | 
				
			||||||
    notes = models.TextField(verbose_name="یادداشتها", blank=True)
 | 
					    notes = models.TextField(verbose_name="یادداشتها", blank=True)
 | 
				
			||||||
    receipt_image = models.ImageField(upload_to='payments/%Y/%m/%d/', null=True, blank=True, verbose_name="تصویر فیش")
 | 
					    receipt_image = models.ImageField(upload_to='payments/%Y/%m/%d/', null=True, blank=True, verbose_name="تصویر فیش")
 | 
				
			||||||
| 
						 | 
					@ -345,6 +357,17 @@ class Payment(BaseModel):
 | 
				
			||||||
        """بروزرسانی مبالغ فاکتور"""
 | 
					        """بروزرسانی مبالغ فاکتور"""
 | 
				
			||||||
        super().save(*args, **kwargs)
 | 
					        super().save(*args, **kwargs)
 | 
				
			||||||
        # بروزرسانی مبلغ پرداخت شده فاکتور
 | 
					        # بروزرسانی مبلغ پرداخت شده فاکتور
 | 
				
			||||||
        total_paid = sum(payment.amount for payment in self.invoice.payments.all())
 | 
					        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.paid_amount = total_paid
 | 
				
			||||||
        self.invoice.calculate_totals()
 | 
					        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
 | 
				
			||||||
 | 
					        return result
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										55
									
								
								invoices/templates/invoices/final_invoice_print.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								invoices/templates/invoices/final_invoice_print.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,55 @@
 | 
				
			||||||
 | 
					{% extends '_base.html' %}
 | 
				
			||||||
 | 
					{% load humanize %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					<div class="container py-4">
 | 
				
			||||||
 | 
					  <div class="mb-4 d-flex justify-content-between align-items-center">
 | 
				
			||||||
 | 
					    <div>
 | 
				
			||||||
 | 
					      <h4 class="mb-1">فاکتور نهایی</h4>
 | 
				
			||||||
 | 
					      <small class="text-muted">کد درخواست: {{ instance.code }}</small>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <div>
 | 
				
			||||||
 | 
					      <!-- Placeholders for logo/signature -->
 | 
				
			||||||
 | 
					      <div class="text-end">لوگو</div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					  <div class="table-responsive">
 | 
				
			||||||
 | 
					    <table class="table table-bordered">
 | 
				
			||||||
 | 
					      <thead>
 | 
				
			||||||
 | 
					        <tr>
 | 
				
			||||||
 | 
					          <th>آیتم</th>
 | 
				
			||||||
 | 
					          <th>تعداد</th>
 | 
				
			||||||
 | 
					          <th>قیمت واحد</th>
 | 
				
			||||||
 | 
					          <th>قیمت کل</th>
 | 
				
			||||||
 | 
					        </tr>
 | 
				
			||||||
 | 
					      </thead>
 | 
				
			||||||
 | 
					      <tbody>
 | 
				
			||||||
 | 
					        {% for it in items %}
 | 
				
			||||||
 | 
					        <tr>
 | 
				
			||||||
 | 
					          <td>{{ it.item.name }}</td>
 | 
				
			||||||
 | 
					          <td>{{ it.quantity }}</td>
 | 
				
			||||||
 | 
					          <td>{{ it.unit_price|floatformat:0|intcomma:False }}</td>
 | 
				
			||||||
 | 
					          <td>{{ it.total_price|floatformat:0|intcomma:False }}</td>
 | 
				
			||||||
 | 
					        </tr>
 | 
				
			||||||
 | 
					        {% empty %}
 | 
				
			||||||
 | 
					        <tr><td colspan="4" class="text-center text-muted">آیتمی ندارد</td></tr>
 | 
				
			||||||
 | 
					        {% endfor %}
 | 
				
			||||||
 | 
					      </tbody>
 | 
				
			||||||
 | 
					      <tfoot>
 | 
				
			||||||
 | 
					        <tr><th colspan="3" class="text-end">مبلغ کل</th><th>{{ invoice.total_amount|floatformat:0|intcomma:False }}</th></tr>
 | 
				
			||||||
 | 
					        <tr><th colspan="3" class="text-end">تخفیف</th><th>{{ invoice.discount_amount|floatformat:0|intcomma:False }}</th></tr>
 | 
				
			||||||
 | 
					        <tr><th colspan="3" class="text-end">مبلغ نهایی</th><th>{{ invoice.final_amount|floatformat:0|intcomma:False }}</th></tr>
 | 
				
			||||||
 | 
					        <tr><th colspan="3" class="text-end">پرداختیها</th><th>{{ invoice.paid_amount|floatformat:0|intcomma:False }}</th></tr>
 | 
				
			||||||
 | 
					        <tr><th colspan="3" class="text-end">مانده</th><th>{{ invoice.remaining_amount|floatformat:0|intcomma:False }}</th></tr>
 | 
				
			||||||
 | 
					      </tfoot>
 | 
				
			||||||
 | 
					    </table>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					  <div class="mt-5 d-flex justify-content-between">
 | 
				
			||||||
 | 
					    <div>امضا مشتری</div>
 | 
				
			||||||
 | 
					    <div>امضا شرکت</div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					<script>window.print()</script>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										260
									
								
								invoices/templates/invoices/final_invoice_step.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								invoices/templates/invoices/final_invoice_step.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,260 @@
 | 
				
			||||||
 | 
					{% extends '_base.html' %}
 | 
				
			||||||
 | 
					{% load static %}
 | 
				
			||||||
 | 
					{% load processes_tags %}
 | 
				
			||||||
 | 
					{% load humanize %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block sidebar %}
 | 
				
			||||||
 | 
					    {% include 'sidebars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock sidebar %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block navbar %}
 | 
				
			||||||
 | 
					    {% include 'navbars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock navbar %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block style %}
 | 
				
			||||||
 | 
					<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
 | 
				
			||||||
 | 
					<style>
 | 
				
			||||||
 | 
					@media print {
 | 
				
			||||||
 | 
					  .no-print { display: none !important; }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					{% include '_toasts.html' %}
 | 
				
			||||||
 | 
					{% csrf_token %}
 | 
				
			||||||
 | 
					<div class="container-xxl flex-grow-1 container-p-y">
 | 
				
			||||||
 | 
					  <div class="row">
 | 
				
			||||||
 | 
					    <div class="col-12 mb-4">
 | 
				
			||||||
 | 
					      <div class="d-flex align-items-center justify-content-between mb-3 no-print">
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
 | 
				
			||||||
 | 
					          <small class="text-muted d-block">
 | 
				
			||||||
 | 
					            اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
 | 
				
			||||||
 | 
					            | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
 | 
				
			||||||
 | 
					          </small>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="d-flex gap-2">
 | 
				
			||||||
 | 
					        <a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت</a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div class="bs-stepper wizard-vertical vertical mt-2 no-print">
 | 
				
			||||||
 | 
					        {% stepper_header instance step %}
 | 
				
			||||||
 | 
					        <div class="bs-stepper-content">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div class="card border">
 | 
				
			||||||
 | 
					        <div class="card-header d-flex justify-content-between align-items-center">
 | 
				
			||||||
 | 
					          <h5 class="mb-0">فاکتور نهایی</h5>
 | 
				
			||||||
 | 
					          <button type="button" class="btn btn-sm btn-outline-primary" onclick="openSpecialChargeModal()"><i class="bx bx-plus"></i> افزودن هزینه تعمیر/تعویض</button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div class="card-body">
 | 
				
			||||||
 | 
					          <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 d-flex align-items-center">
 | 
				
			||||||
 | 
					              {% if invoice.remaining_amount <= 0 %}
 | 
				
			||||||
 | 
					                <span class="badge bg-success">تسویه کامل</span>
 | 
				
			||||||
 | 
					              {% else %}
 | 
				
			||||||
 | 
					                <span class="badge bg-warning text-dark">باقیمانده دارد</span>
 | 
				
			||||||
 | 
					              {% endif %}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div class="table-responsive">
 | 
				
			||||||
 | 
					            <table class="table table-striped">
 | 
				
			||||||
 | 
					              <thead>
 | 
				
			||||||
 | 
					                <tr>
 | 
				
			||||||
 | 
					                  <th>آیتم</th>
 | 
				
			||||||
 | 
					                  <th class="text-center">تعداد پایه</th>
 | 
				
			||||||
 | 
					                  <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>
 | 
				
			||||||
 | 
					                </tr>
 | 
				
			||||||
 | 
					              </thead>
 | 
				
			||||||
 | 
					              <tbody>
 | 
				
			||||||
 | 
					                {% for r in rows %}
 | 
				
			||||||
 | 
					                <tr>
 | 
				
			||||||
 | 
					                  <td>
 | 
				
			||||||
 | 
					                    <div class="d-flex flex-column">
 | 
				
			||||||
 | 
					                      <span class="fw-semibold">{{ r.item.name }}</span>
 | 
				
			||||||
 | 
					                      {% if r.item.description %}<small class="text-muted">{{ r.item.description }}</small>{% endif %}
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                  </td>
 | 
				
			||||||
 | 
					                  <td class="text-center">{{ r.base_qty }}</td>
 | 
				
			||||||
 | 
					                  <td class="text-center text-success">{{ r.added_qty }}</td>
 | 
				
			||||||
 | 
					                  <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>
 | 
				
			||||||
 | 
					                </tr>
 | 
				
			||||||
 | 
					                {% empty %}
 | 
				
			||||||
 | 
					                <tr><td colspan="7" class="text-center text-muted">آیتمی یافت نشد</td></tr>
 | 
				
			||||||
 | 
					                {% endfor %}
 | 
				
			||||||
 | 
					                {% for si in invoice_specials %}
 | 
				
			||||||
 | 
					                <tr class="table-warning">
 | 
				
			||||||
 | 
					                  <td>
 | 
				
			||||||
 | 
					                    <div class="d-flex flex-column">
 | 
				
			||||||
 | 
					                      <span class="fw-semibold">{{ si.item.name }}<span class="badge bg-info mx-2">ویژه</span></span>                      
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                  </td>
 | 
				
			||||||
 | 
					                  <td class="text-center">-</td>
 | 
				
			||||||
 | 
					                  <td class="text-center">-</td>
 | 
				
			||||||
 | 
					                  <td class="text-center">-</td>
 | 
				
			||||||
 | 
					                  <td class="text-center">{{ si.quantity }}</td>
 | 
				
			||||||
 | 
					                  <td class="text-end">{{ si.unit_price|floatformat:0|intcomma:False }}</td>
 | 
				
			||||||
 | 
					                  <td class="text-end">
 | 
				
			||||||
 | 
					                    {{ si.total_price|floatformat:0|intcomma:False }}
 | 
				
			||||||
 | 
					                    <button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="deleteSpecial('{{ si.id }}')" title="حذف"><i class="bx bx-trash"></i></button>
 | 
				
			||||||
 | 
					                  </td>
 | 
				
			||||||
 | 
					                </tr>
 | 
				
			||||||
 | 
					                {% endfor %}
 | 
				
			||||||
 | 
					              </tbody>
 | 
				
			||||||
 | 
					              <tfoot>
 | 
				
			||||||
 | 
					                <tr>
 | 
				
			||||||
 | 
					                  <th colspan="6" class="text-end">مبلغ کل</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>
 | 
				
			||||||
 | 
					                </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>
 | 
				
			||||||
 | 
					                </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>
 | 
				
			||||||
 | 
					                </tr>
 | 
				
			||||||
 | 
					              </tfoot>
 | 
				
			||||||
 | 
					            </table>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="card-footer d-flex justify-content-between">
 | 
				
			||||||
 | 
					          {% if previous_step %}
 | 
				
			||||||
 | 
					            <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
 | 
				
			||||||
 | 
					          {% else %}
 | 
				
			||||||
 | 
					            <span></span>
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					          {% if next_step %}
 | 
				
			||||||
 | 
					            <button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button>
 | 
				
			||||||
 | 
					          {% endif %}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					<!-- Special Charge Modal -->
 | 
				
			||||||
 | 
					<div class="modal fade" id="specialChargeModal" tabindex="-1" aria-labelledby="specialChargeModalLabel" aria-hidden="true">
 | 
				
			||||||
 | 
					  <div class="modal-dialog">
 | 
				
			||||||
 | 
					    <div class="modal-content">
 | 
				
			||||||
 | 
					      <div class="modal-header">
 | 
				
			||||||
 | 
					        <h5 class="modal-title" id="specialChargeModalLabel">افزودن هزینه تعمیر/تعویض</h5>
 | 
				
			||||||
 | 
					        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="modal-body">
 | 
				
			||||||
 | 
					        <form id="specialChargeForm" onsubmit="return false;">
 | 
				
			||||||
 | 
					          {% csrf_token %}
 | 
				
			||||||
 | 
					          <div class="mb-3">
 | 
				
			||||||
 | 
					            <label class="form-label">انتخاب آیتم ویژه</label>
 | 
				
			||||||
 | 
					            <select class="form-select" name="item_id" id="id_special_item" required>
 | 
				
			||||||
 | 
					              <option value="">انتخاب کنید...</option>
 | 
				
			||||||
 | 
					              {% for s in special_choices %}
 | 
				
			||||||
 | 
					                <option value="{{ s.id }}">{{ s.name }}</option>
 | 
				
			||||||
 | 
					              {% endfor %}
 | 
				
			||||||
 | 
					            </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>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </form>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="modal-footer">
 | 
				
			||||||
 | 
					        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
 | 
				
			||||||
 | 
					        <button type="button" class="btn btn-primary" onclick="submitSpecialCharge()">افزودن</button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block script %}
 | 
				
			||||||
 | 
					<script>
 | 
				
			||||||
 | 
					  function openSpecialChargeModal(){
 | 
				
			||||||
 | 
					    const el = document.getElementById('specialChargeModal');
 | 
				
			||||||
 | 
					    if (window.$ && typeof $(el).modal === 'function') { $(el).modal('show'); }
 | 
				
			||||||
 | 
					    else if (window.bootstrap && window.bootstrap.Modal) { new window.bootstrap.Modal(el).show(); }
 | 
				
			||||||
 | 
					    else { el.classList.add('show'); el.style.display = 'block'; }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  function submitSpecialCharge(){
 | 
				
			||||||
 | 
					    const fd = new FormData(document.getElementById('specialChargeForm'));
 | 
				
			||||||
 | 
					    fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
 | 
				
			||||||
 | 
					    fetch('{% url "invoices:add_special_charge" instance.id step.id %}', { method: 'POST', body: fd })
 | 
				
			||||||
 | 
					      .then(r=>r.json()).then(resp=>{
 | 
				
			||||||
 | 
					        if (resp.success){
 | 
				
			||||||
 | 
					          showToast('هزینه ویژه اضافه شد', 'success');
 | 
				
			||||||
 | 
					          if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 600);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          showToast(resp.message || 'خطا در افزودن هزینه', 'danger');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  // No filtering needed; show all special items
 | 
				
			||||||
 | 
					  function deleteSpecial(id){
 | 
				
			||||||
 | 
					    const fd = new FormData();
 | 
				
			||||||
 | 
					    fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
 | 
				
			||||||
 | 
					    fetch(`{% url "invoices:delete_special_charge" instance.id step.id 0 %}`.replace('/0/', `/${id}/`), { method: 'POST', body: fd })
 | 
				
			||||||
 | 
					      .then(r=>r.json()).then(resp=>{
 | 
				
			||||||
 | 
					        if (resp.success){
 | 
				
			||||||
 | 
					          showToast('حذف شد', 'success');
 | 
				
			||||||
 | 
					          if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 500);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          showToast(resp.message || 'خطا در حذف', 'danger');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  document.getElementById('btnApproveFinalInvoice')?.addEventListener('click', function(){
 | 
				
			||||||
 | 
					    const fd = new FormData();
 | 
				
			||||||
 | 
					    fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
 | 
				
			||||||
 | 
					    fetch('{% url "invoices:approve_final_invoice" instance.id step.id %}', { method:'POST', body: fd })
 | 
				
			||||||
 | 
					      .then(r=>r.json()).then(resp=>{
 | 
				
			||||||
 | 
					        if (resp.success){
 | 
				
			||||||
 | 
					          showToast(resp.message || 'تایید شد', 'success');
 | 
				
			||||||
 | 
					          if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 600);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          showToast(resp.message || 'خطا در تایید', 'danger');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										248
									
								
								invoices/templates/invoices/final_settlement_step.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										248
									
								
								invoices/templates/invoices/final_settlement_step.html
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,248 @@
 | 
				
			||||||
 | 
					{% extends '_base.html' %}
 | 
				
			||||||
 | 
					{% load static %}
 | 
				
			||||||
 | 
					{% load processes_tags %}
 | 
				
			||||||
 | 
					{% load common_tags %}
 | 
				
			||||||
 | 
					{% load humanize %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block sidebar %}
 | 
				
			||||||
 | 
					    {% include 'sidebars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock sidebar %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block navbar %}
 | 
				
			||||||
 | 
					    {% include 'navbars/admin.html' %}
 | 
				
			||||||
 | 
					{% endblock navbar %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block style %}
 | 
				
			||||||
 | 
					<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
 | 
				
			||||||
 | 
					<!-- Persian Date Picker CSS -->
 | 
				
			||||||
 | 
					<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					{% include '_toasts.html' %}
 | 
				
			||||||
 | 
					{% csrf_token %}
 | 
				
			||||||
 | 
					<div class="container-xxl flex-grow-1 container-p-y">
 | 
				
			||||||
 | 
					  <div class="row">
 | 
				
			||||||
 | 
					    <div class="col-12 mb-4">
 | 
				
			||||||
 | 
					      <div class="d-flex align-items-center justify-content-between mb-3 no-print">
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
 | 
				
			||||||
 | 
					          <small class="text-muted d-block">
 | 
				
			||||||
 | 
					            اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
 | 
				
			||||||
 | 
					            | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
 | 
				
			||||||
 | 
					          </small>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="d-flex gap-2">
 | 
				
			||||||
 | 
					          <a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت</a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div class="bs-stepper wizard-vertical vertical mt-2 no-print">
 | 
				
			||||||
 | 
					        {% stepper_header instance step %}
 | 
				
			||||||
 | 
					        <div class="bs-stepper-content">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div class="row g-3">
 | 
				
			||||||
 | 
					        <div class="col-12 col-lg-5">
 | 
				
			||||||
 | 
					          <div class="card border h-100">
 | 
				
			||||||
 | 
					            <div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div>
 | 
				
			||||||
 | 
					            <div class="card-body">
 | 
				
			||||||
 | 
					              <form id="formFinalPayment" enctype="multipart/form-data" onsubmit="return false;">
 | 
				
			||||||
 | 
					                {% csrf_token %}
 | 
				
			||||||
 | 
					                <div class="mb-3">
 | 
				
			||||||
 | 
					                  <label class="form-label">نوع تراکنش</label>
 | 
				
			||||||
 | 
					                  <select class="form-select" name="direction" id="id_direction" required>
 | 
				
			||||||
 | 
					                    <option value="in">دریافتی از مشتری</option>
 | 
				
			||||||
 | 
					                    <option value="out">پرداخت به مشتری</option>
 | 
				
			||||||
 | 
					                  </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>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div class="mb-3">
 | 
				
			||||||
 | 
					                  <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">
 | 
				
			||||||
 | 
					                  <label class="form-label">روش پرداخت</label>
 | 
				
			||||||
 | 
					                  <select class="form-select" name="payment_method" id="id_payment_method" required>
 | 
				
			||||||
 | 
					                    <option value="bank_transfer">انتقال بانکی</option>
 | 
				
			||||||
 | 
					                    <option value="card">کارت بانکی</option>
 | 
				
			||||||
 | 
					                    <option value="cash">نقدی</option>
 | 
				
			||||||
 | 
					                    <option value="check">چک</option>
 | 
				
			||||||
 | 
					                    <option value="other">سایر</option>
 | 
				
			||||||
 | 
					                  </select>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div class="mb-3">
 | 
				
			||||||
 | 
					                  <label class="form-label">شماره مرجع</label>
 | 
				
			||||||
 | 
					                  <input type="text" class="form-control" name="reference_number" id="id_reference_number" required>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div class="mb-3">
 | 
				
			||||||
 | 
					                  <label class="form-label">تصویر فیش</label>
 | 
				
			||||||
 | 
					                  <input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div class="d-flex justify-content-end">
 | 
				
			||||||
 | 
					                  <button type="button" id="btnAddFinalPayment" class="btn btn-primary">افزودن</button>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </form>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="col-12 col-lg-7">
 | 
				
			||||||
 | 
					          <div class="card mb-3 border">
 | 
				
			||||||
 | 
					            <div class="card-header"><h5 class="mb-0">وضعیت فاکتور</h5></div>
 | 
				
			||||||
 | 
					            <div class="card-body">
 | 
				
			||||||
 | 
					              <div class="row g-3">
 | 
				
			||||||
 | 
					                <div class="col-6">
 | 
				
			||||||
 | 
					                  <div class="border rounded p-3">
 | 
				
			||||||
 | 
					                    <div class="small text-muted">مبلغ نهایی</div>
 | 
				
			||||||
 | 
					                    <div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div class="col-6">
 | 
				
			||||||
 | 
					                  <div class="border rounded p-3">
 | 
				
			||||||
 | 
					                    <div class="small text-muted">مانده</div>
 | 
				
			||||||
 | 
					                    <div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <div class="card border">
 | 
				
			||||||
 | 
					            <div class="card-header"><h5 class="mb-0">تراکنشها</h5></div>
 | 
				
			||||||
 | 
					            <div class="table-responsive">
 | 
				
			||||||
 | 
					              <table class="table table-striped mb-0">
 | 
				
			||||||
 | 
					                <thead>
 | 
				
			||||||
 | 
					                  <tr>
 | 
				
			||||||
 | 
					                    <th>نوع</th>
 | 
				
			||||||
 | 
					                    <th>مبلغ</th>
 | 
				
			||||||
 | 
					                    <th>تاریخ</th>
 | 
				
			||||||
 | 
					                    <th>روش</th>
 | 
				
			||||||
 | 
					                    <th>شماره مرجع</th>
 | 
				
			||||||
 | 
					                    <th style="width:150px">عملیات</th>
 | 
				
			||||||
 | 
					                  </tr>
 | 
				
			||||||
 | 
					                </thead>
 | 
				
			||||||
 | 
					                <tbody>
 | 
				
			||||||
 | 
					                  {% for p in payments %}
 | 
				
			||||||
 | 
					                  <tr>
 | 
				
			||||||
 | 
					                    <td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td>
 | 
				
			||||||
 | 
					                    <td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
 | 
				
			||||||
 | 
					                    <td>{{ p.payment_date|to_jalali }}</td>
 | 
				
			||||||
 | 
					                    <td>{{ p.get_payment_method_display }}</td>
 | 
				
			||||||
 | 
					                    <td>{{ p.reference_number|default:'-' }}</td>
 | 
				
			||||||
 | 
					                    <td>
 | 
				
			||||||
 | 
					                      <div class="btn-group">
 | 
				
			||||||
 | 
					                        {% if p.receipt_image %}
 | 
				
			||||||
 | 
					                          <a href="{{ p.receipt_image.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="مشاهده" aria-label="مشاهده">
 | 
				
			||||||
 | 
					                            <i class="bx bx-show"></i>
 | 
				
			||||||
 | 
					                          </a>
 | 
				
			||||||
 | 
					                        {% endif %}
 | 
				
			||||||
 | 
					                        <button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteFinalPayment({{ p.id }})" title="حذف" aria-label="حذف"><i class="bx bx-trash"></i></button>
 | 
				
			||||||
 | 
					                      </div>
 | 
				
			||||||
 | 
					                    </td>
 | 
				
			||||||
 | 
					                  </tr>
 | 
				
			||||||
 | 
					                  {% empty %}
 | 
				
			||||||
 | 
					                  <tr><td colspan="6" class="text-center text-muted">تراکنشی ندارد</td></tr>
 | 
				
			||||||
 | 
					                  {% endfor %}
 | 
				
			||||||
 | 
					                </tbody>
 | 
				
			||||||
 | 
					              </table>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					            <div class="card-footer d-flex justify-content-between">
 | 
				
			||||||
 | 
					              {% if previous_step %}
 | 
				
			||||||
 | 
					                <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
 | 
				
			||||||
 | 
					              {% else %}
 | 
				
			||||||
 | 
					                <span></span>
 | 
				
			||||||
 | 
					              {% endif %}
 | 
				
			||||||
 | 
					              <button type="button" id="btnApproveFinalSettlement" class="btn btn-primary">تایید و ادامه</button>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block script %}
 | 
				
			||||||
 | 
					<script src="https://unpkg.com/persian-date@latest/dist/persian-date.min.js"></script>
 | 
				
			||||||
 | 
					<script src="https://unpkg.com/persian-datepicker@latest/dist/js/persian-datepicker.min.js"></script>
 | 
				
			||||||
 | 
					<script>
 | 
				
			||||||
 | 
					  (function initPersianDatePicker(){
 | 
				
			||||||
 | 
					    if (window.$ && $.fn.persianDatepicker && $('#id_payment_date').length) {
 | 
				
			||||||
 | 
					      $('#id_payment_date').persianDatepicker({
 | 
				
			||||||
 | 
					        format: 'YYYY/MM/DD', initialValue: false, autoClose: true, persianDigit: false, observer: true,
 | 
				
			||||||
 | 
					        calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } },
 | 
				
			||||||
 | 
					        onSelect: function(unix){
 | 
				
			||||||
 | 
					          const g = new window.persianDate(unix).toCalendar('gregorian').format('YYYY-MM-DD');
 | 
				
			||||||
 | 
					          $('#id_payment_date').attr('data-gregorian', g);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function buildForm(){
 | 
				
			||||||
 | 
					    const fd = new FormData(document.getElementById('formFinalPayment'));
 | 
				
			||||||
 | 
					    const g = document.getElementById('id_payment_date').getAttribute('data-gregorian');
 | 
				
			||||||
 | 
					    if (g) { fd.set('payment_date', g); }
 | 
				
			||||||
 | 
					    return fd;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  document.getElementById('btnAddFinalPayment').addEventListener('click', function(){
 | 
				
			||||||
 | 
					    const fd = buildForm();
 | 
				
			||||||
 | 
					    // Frontend validation
 | 
				
			||||||
 | 
					    const amount = document.getElementById('id_amount').value.trim();
 | 
				
			||||||
 | 
					    const payDate = document.getElementById('id_payment_date').value.trim();
 | 
				
			||||||
 | 
					    const method = document.getElementById('id_payment_method').value.trim();
 | 
				
			||||||
 | 
					    const ref = document.getElementById('id_reference_number').value.trim();
 | 
				
			||||||
 | 
					    const img = document.getElementById('id_receipt_image').files[0];
 | 
				
			||||||
 | 
					    const dir = document.getElementById('id_direction').value;
 | 
				
			||||||
 | 
					    if (!amount || !payDate || !method || !ref || !img) {
 | 
				
			||||||
 | 
					      showToast('همه فیلدها الزامی است', 'danger');
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    fetch('{% url "invoices:add_final_payment" instance.id step.id %}', { method:'POST', body: fd })
 | 
				
			||||||
 | 
					      .then(r=>r.json()).then(resp=>{
 | 
				
			||||||
 | 
					        if (resp.success) {
 | 
				
			||||||
 | 
					          showToast('تراکنش ثبت شد', 'success');
 | 
				
			||||||
 | 
					          if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 700);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          showToast(resp.message || 'خطا در ثبت تراکنش', 'danger');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function deleteFinalPayment(id){
 | 
				
			||||||
 | 
					    const fd = new FormData();
 | 
				
			||||||
 | 
					    fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
 | 
				
			||||||
 | 
					    fetch(`{% url "invoices:delete_final_payment" instance.id step.id 0 %}`.replace('/0/', `/${id}/`), { method:'POST', body: fd })
 | 
				
			||||||
 | 
					      .then(r=>r.json()).then(resp=>{
 | 
				
			||||||
 | 
					        if (resp.success) {
 | 
				
			||||||
 | 
					          showToast('حذف شد', 'success');
 | 
				
			||||||
 | 
					          if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 500);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          showToast(resp.message || 'خطا در حذف', 'danger');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  document.getElementById('btnApproveFinalSettlement').addEventListener('click', function(){
 | 
				
			||||||
 | 
					    const fd = new FormData();
 | 
				
			||||||
 | 
					    fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
 | 
				
			||||||
 | 
					    fetch('{% url "invoices:approve_final_settlement" instance.id step.id %}', { method:'POST', body: fd })
 | 
				
			||||||
 | 
					      .then(r=>r.json()).then(resp=>{
 | 
				
			||||||
 | 
					        if (resp.success) {
 | 
				
			||||||
 | 
					          showToast(resp.message || 'تایید شد', 'success');
 | 
				
			||||||
 | 
					          if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 600);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          showToast(resp.message || 'خطا در تایید', 'danger');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -243,12 +243,12 @@
 | 
				
			||||||
      body: fd
 | 
					      body: fd
 | 
				
			||||||
    }).then(r => r.json()).then(resp => {
 | 
					    }).then(r => r.json()).then(resp => {
 | 
				
			||||||
      if (resp.success) {
 | 
					      if (resp.success) {
 | 
				
			||||||
        showToast('فیش با موفقیت ثبت شد', 'success');
 | 
					        showToast(resp.message || 'فیش با موفقیت ثبت شد', 'success');
 | 
				
			||||||
        if (resp.redirect) {
 | 
					        if (resp.redirect) {
 | 
				
			||||||
          setTimeout(() => { window.location.href = resp.redirect; }, 700);
 | 
					          setTimeout(() => { window.location.href = resp.redirect; }, 700);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        showToast(resp.message || 'خطا در ثبت فیش', 'danger');
 | 
					        showToast(resp.message + ':' + resp.error || 'خطا در ثبت فیش', 'danger');
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
 | 
					    }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
| 
						 | 
					@ -267,11 +267,13 @@
 | 
				
			||||||
      method: 'POST',
 | 
					      method: 'POST',
 | 
				
			||||||
      body: fd
 | 
					      body: fd
 | 
				
			||||||
    }).then(r => r.json()).then(resp => {
 | 
					    }).then(r => r.json()).then(resp => {
 | 
				
			||||||
      if (resp.success && resp.redirect) {
 | 
					      if (resp.success) {
 | 
				
			||||||
        showToast('فیش با موفقیت حذف شد', 'success');
 | 
					        showToast(resp.message || 'فیش با موفقیت حذف شد', 'success');
 | 
				
			||||||
        setTimeout(() => { window.location.href = resp.redirect; }, 700);
 | 
					        if (resp.redirect) {
 | 
				
			||||||
 | 
					          setTimeout(() => { window.location.href = resp.redirect; }, 700);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        showToast(resp.message || 'خطا در حذف فیش', 'danger');
 | 
					        showToast(resp.message || resp.error || 'خطا در حذف فیش', 'danger');
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
 | 
					    }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -288,11 +290,13 @@
 | 
				
			||||||
      method: 'POST',
 | 
					      method: 'POST',
 | 
				
			||||||
      body: fd
 | 
					      body: fd
 | 
				
			||||||
    }).then(r => r.json()).then(resp => {
 | 
					    }).then(r => r.json()).then(resp => {
 | 
				
			||||||
      if (resp.success && resp.redirect) {
 | 
					      if (resp.success) {
 | 
				
			||||||
        showToast(resp.message, 'success');
 | 
					        showToast(resp.message || 'پرداختها تایید شد', 'success');
 | 
				
			||||||
        setTimeout(() => { window.location.href = resp.redirect; }, 600);
 | 
					        if (resp.redirect) {
 | 
				
			||||||
 | 
					          setTimeout(() => { window.location.href = resp.redirect; }, 600);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        showToast(resp.message || 'خطا در تایید پرداختها', 'danger');
 | 
					        showToast(resp.message || resp.error || 'خطا در تایید پرداختها', 'danger');
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
 | 
					    }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -48,7 +48,7 @@
 | 
				
			||||||
        {% stepper_header instance step %}
 | 
					        {% stepper_header instance step %}
 | 
				
			||||||
        <div class="bs-stepper-content">
 | 
					        <div class="bs-stepper-content">
 | 
				
			||||||
            <!-- Invoice Preview Card -->
 | 
					            <!-- Invoice Preview Card -->
 | 
				
			||||||
      <div class="card invoice-preview-card mt-4">
 | 
					      <div class="card invoice-preview-card mt-4 border">
 | 
				
			||||||
        <div class="card-body">
 | 
					        <div class="card-body">
 | 
				
			||||||
          <div class="d-flex justify-content-between flex-xl-row flex-md-column flex-sm-row flex-column p-sm-3 p-0">
 | 
					          <div class="d-flex justify-content-between flex-xl-row flex-md-column flex-sm-row flex-column p-sm-3 p-0">
 | 
				
			||||||
            <div class="mb-xl-0 mb-4">
 | 
					            <div class="mb-xl-0 mb-4">
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -21,4 +21,17 @@ urlpatterns = [
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Quote print
 | 
					    # Quote print
 | 
				
			||||||
    path('instance/<int:instance_id>/quote/print/', views.quote_print, name='quote_print'),
 | 
					    path('instance/<int:instance_id>/quote/print/', views.quote_print, name='quote_print'),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Final invoice (step 7?) and print
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/', views.final_invoice_step, name='final_invoice_step'),
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/final-invoice/print/', views.final_invoice_print, name='final_invoice_print'),
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/special/add/', views.add_special_charge, name='add_special_charge'),
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/special/<int:item_id>/delete/', views.delete_special_charge, name='delete_special_charge'),
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/approve/', views.approve_final_invoice, name='approve_final_invoice'),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Final settlement payments (step 8?)
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/', views.final_settlement_step, name='final_settlement_step'),
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/add/', views.add_final_payment, name='add_final_payment'),
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/<int:payment_id>/delete/', views.delete_final_payment, name='delete_final_payment'),
 | 
				
			||||||
 | 
					    path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/approve/', views.approve_final_settlement, name='approve_final_settlement'),
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,6 +11,7 @@ import json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from processes.models import ProcessInstance, ProcessStep, StepInstance
 | 
					from processes.models import ProcessInstance, ProcessStep, StepInstance
 | 
				
			||||||
from .models import Item, Quote, QuoteItem, Payment, Invoice
 | 
					from .models import Item, Quote, QuoteItem, Payment, Invoice
 | 
				
			||||||
 | 
					from installations.models import InstallationReport, InstallationItemChange
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@login_required
 | 
					@login_required
 | 
				
			||||||
def quote_step(request, instance_id, step_id):
 | 
					def quote_step(request, instance_id, step_id):
 | 
				
			||||||
| 
						 | 
					@ -413,3 +414,358 @@ def approve_payments(request, instance_id, step_id):
 | 
				
			||||||
        msg += ' - توجه: مبلغ پیشفاکتور به طور کامل پرداخت نشده است.'
 | 
					        msg += ' - توجه: مبلغ پیشفاکتور به طور کامل پرداخت نشده است.'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return JsonResponse({'success': True, 'message': msg, 'redirect': redirect_url, 'is_fully_paid': is_fully_paid})
 | 
					    return JsonResponse({'success': True, 'message': msg, 'redirect': redirect_url, 'is_fully_paid': is_fully_paid})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def final_invoice_step(request, instance_id, step_id):
 | 
				
			||||||
 | 
					    """تجمیع اقلام پیشفاکتور با تغییرات نصب و صدور فاکتور نهایی"""
 | 
				
			||||||
 | 
					    instance = get_object_or_404(
 | 
				
			||||||
 | 
					        ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
 | 
				
			||||||
 | 
					        id=instance_id
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if not instance.can_access_step(step):
 | 
				
			||||||
 | 
					        messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
 | 
				
			||||||
 | 
					        return redirect('processes:request_list')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    quote = get_object_or_404(Quote, process_instance=instance)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Helper to make safe Decimal from various inputs (handles commas/persian digits)
 | 
				
			||||||
 | 
					    def _to_decimal(value):
 | 
				
			||||||
 | 
					        if isinstance(value, Decimal):
 | 
				
			||||||
 | 
					            return value
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            if isinstance(value, (int, float)):
 | 
				
			||||||
 | 
					                return Decimal(str(value))
 | 
				
			||||||
 | 
					            s = str(value or '').strip()
 | 
				
			||||||
 | 
					            if not s:
 | 
				
			||||||
 | 
					                return Decimal('0')
 | 
				
			||||||
 | 
					            # normalize commas and Persian digits
 | 
				
			||||||
 | 
					            persian = '۰۱۲۳۴۵۶۷۸۹'
 | 
				
			||||||
 | 
					            latin = '0123456789'
 | 
				
			||||||
 | 
					            tbl = str.maketrans({persian[i]: latin[i] for i in range(10)})
 | 
				
			||||||
 | 
					            s = s.translate(tbl).replace(',', '')
 | 
				
			||||||
 | 
					            return Decimal(s)
 | 
				
			||||||
 | 
					        except Exception:
 | 
				
			||||||
 | 
					            return Decimal('0')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Build initial map from quote
 | 
				
			||||||
 | 
					    item_id_to_row = {}
 | 
				
			||||||
 | 
					    for qi in quote.items.all():
 | 
				
			||||||
 | 
					        item_id_to_row[qi.item_id] = {
 | 
				
			||||||
 | 
					            'item': qi.item,
 | 
				
			||||||
 | 
					            'base_qty': qi.quantity,
 | 
				
			||||||
 | 
					            'base_price': _to_decimal(qi.unit_price),
 | 
				
			||||||
 | 
					            'added_qty': 0,
 | 
				
			||||||
 | 
					            'removed_qty': 0,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Read installation changes from latest report (if any)
 | 
				
			||||||
 | 
					    latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
 | 
				
			||||||
 | 
					    if latest_report:
 | 
				
			||||||
 | 
					        for ch in latest_report.item_changes.all():
 | 
				
			||||||
 | 
					            row = item_id_to_row.setdefault(ch.item_id, {
 | 
				
			||||||
 | 
					                'item': ch.item,
 | 
				
			||||||
 | 
					                'base_qty': 0,
 | 
				
			||||||
 | 
					                'base_price': _to_decimal(ch.unit_price or ch.item.unit_price),
 | 
				
			||||||
 | 
					                'added_qty': 0,
 | 
				
			||||||
 | 
					                'removed_qty': 0,
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					            if ch.change_type == 'add':
 | 
				
			||||||
 | 
					                row['added_qty'] += ch.quantity
 | 
				
			||||||
 | 
					                if ch.unit_price:
 | 
				
			||||||
 | 
					                    row['base_price'] = _to_decimal(ch.unit_price)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                row['removed_qty'] += ch.quantity
 | 
				
			||||||
 | 
					                if ch.unit_price:
 | 
				
			||||||
 | 
					                    row['base_price'] = _to_decimal(ch.unit_price)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Compute final invoice lines
 | 
				
			||||||
 | 
					    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
 | 
				
			||||||
 | 
					        rows.append({
 | 
				
			||||||
 | 
					            'item': r['item'],
 | 
				
			||||||
 | 
					            'quantity': final_qty,
 | 
				
			||||||
 | 
					            'unit_price': unit_price_dec,
 | 
				
			||||||
 | 
					            'total_price': line_total,
 | 
				
			||||||
 | 
					            'base_qty': r['base_qty'],
 | 
				
			||||||
 | 
					            'added_qty': r['added_qty'],
 | 
				
			||||||
 | 
					            'removed_qty': r['removed_qty'],
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Create or reuse final invoice
 | 
				
			||||||
 | 
					    invoice, _ = Invoice.objects.get_or_create(
 | 
				
			||||||
 | 
					        process_instance=instance,
 | 
				
			||||||
 | 
					        customer=quote.customer,
 | 
				
			||||||
 | 
					        quote=quote,
 | 
				
			||||||
 | 
					        defaults={
 | 
				
			||||||
 | 
					            'name': f"فاکتور نهایی {instance.code}",
 | 
				
			||||||
 | 
					            'due_date': timezone.now().date(),
 | 
				
			||||||
 | 
					            'created_by': request.user,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    # Replace only non-special items (preserve special charges added by user)
 | 
				
			||||||
 | 
					    qs = invoice.items.select_related('item').filter(item__is_special=False)
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        qs._raw_delete(qs.db)
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        qs.delete()
 | 
				
			||||||
 | 
					    for r in rows:
 | 
				
			||||||
 | 
					        from .models import InvoiceItem
 | 
				
			||||||
 | 
					        InvoiceItem.objects.create(
 | 
				
			||||||
 | 
					            invoice=invoice,
 | 
				
			||||||
 | 
					            item=r['item'],
 | 
				
			||||||
 | 
					            quantity=r['quantity'],
 | 
				
			||||||
 | 
					            unit_price=r['unit_price'],
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    invoice.calculate_totals()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    previous_step = instance.process.steps.filter(order__lt=step.order).last()
 | 
				
			||||||
 | 
					    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Choices for special items from DB
 | 
				
			||||||
 | 
					    special_choices = list(Item.objects.filter(is_special=True).values('id', 'name'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return render(request, 'invoices/final_invoice_step.html', {
 | 
				
			||||||
 | 
					        'instance': instance,
 | 
				
			||||||
 | 
					        'step': step,
 | 
				
			||||||
 | 
					        'invoice': invoice,
 | 
				
			||||||
 | 
					        'rows': rows,
 | 
				
			||||||
 | 
					        'special_choices': special_choices,
 | 
				
			||||||
 | 
					        'invoice_specials': invoice.items.select_related('item').filter(item__is_special=True, is_deleted=False).all(),
 | 
				
			||||||
 | 
					        'previous_step': previous_step,
 | 
				
			||||||
 | 
					        'next_step': next_step,
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def final_invoice_print(request, instance_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
				
			||||||
 | 
					    items = invoice.items.select_related('item').filter(is_deleted=False).all()
 | 
				
			||||||
 | 
					    return render(request, 'invoices/final_invoice_print.html', {
 | 
				
			||||||
 | 
					        'instance': instance,
 | 
				
			||||||
 | 
					        'invoice': invoice,
 | 
				
			||||||
 | 
					        'items': items,
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@require_POST
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def approve_final_invoice(request, instance_id, step_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
				
			||||||
 | 
					    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
				
			||||||
 | 
					    # Block approval when there is any remaining (positive or negative)
 | 
				
			||||||
 | 
					    invoice.calculate_totals()
 | 
				
			||||||
 | 
					    if invoice.remaining_amount != 0:
 | 
				
			||||||
 | 
					        return JsonResponse({
 | 
				
			||||||
 | 
					            'success': False,
 | 
				
			||||||
 | 
					            'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})"
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    # mark step completed
 | 
				
			||||||
 | 
					    step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
 | 
				
			||||||
 | 
					    step_instance.status = 'completed'
 | 
				
			||||||
 | 
					    step_instance.completed_at = timezone.now()
 | 
				
			||||||
 | 
					    step_instance.save()
 | 
				
			||||||
 | 
					    # move to next
 | 
				
			||||||
 | 
					    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
				
			||||||
 | 
					    redirect_url = reverse('processes:request_list')
 | 
				
			||||||
 | 
					    if next_step:
 | 
				
			||||||
 | 
					        instance.current_step = next_step
 | 
				
			||||||
 | 
					        instance.save()
 | 
				
			||||||
 | 
					        redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
 | 
				
			||||||
 | 
					    return JsonResponse({'success': True, 'message': 'فاکتور نهایی تایید شد', 'redirect': redirect_url})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@require_POST
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def add_special_charge(request, instance_id, step_id):
 | 
				
			||||||
 | 
					    """افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی بهصورت آیتم جداگانه"""
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
				
			||||||
 | 
					    # charge_type was removed from UI; we no longer require it
 | 
				
			||||||
 | 
					    item_id = request.POST.get('item_id')
 | 
				
			||||||
 | 
					    amount = (request.POST.get('amount') or '').strip()
 | 
				
			||||||
 | 
					    if not item_id:
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'آیتم را انتخاب کنید'})
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        amount_dec = Decimal(amount)
 | 
				
			||||||
 | 
					    except (InvalidOperation, TypeError):
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'مبلغ نامعتبر است'})
 | 
				
			||||||
 | 
					    if amount_dec <= 0:
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'مبلغ باید مثبت باشد'})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Fetch existing special item from DB
 | 
				
			||||||
 | 
					    special_item = get_object_or_404(Item, id=item_id, is_special=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    from .models import InvoiceItem
 | 
				
			||||||
 | 
					    InvoiceItem.objects.create(
 | 
				
			||||||
 | 
					        invoice=invoice,
 | 
				
			||||||
 | 
					        item=special_item,
 | 
				
			||||||
 | 
					        quantity=1,
 | 
				
			||||||
 | 
					        unit_price=amount_dec,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    invoice.calculate_totals()
 | 
				
			||||||
 | 
					    return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@require_POST
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def delete_special_charge(request, instance_id, step_id, item_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
				
			||||||
 | 
					    from .models import InvoiceItem
 | 
				
			||||||
 | 
					    inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice)
 | 
				
			||||||
 | 
					    # allow deletion only for special items
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        if not getattr(inv_item.item, 'is_special', False):
 | 
				
			||||||
 | 
					            return JsonResponse({'success': False, 'message': 'امکان حذف این مورد وجود ندارد'})
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'امکان حذف این مورد وجود ندارد'})
 | 
				
			||||||
 | 
					    inv_item.hard_delete()
 | 
				
			||||||
 | 
					    invoice.calculate_totals()
 | 
				
			||||||
 | 
					    return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def final_settlement_step(request, instance_id, step_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
				
			||||||
 | 
					    if not instance.can_access_step(step):
 | 
				
			||||||
 | 
					        messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
 | 
				
			||||||
 | 
					        return redirect('processes:request_list')
 | 
				
			||||||
 | 
					    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    previous_step = instance.process.steps.filter(order__lt=step.order).last()
 | 
				
			||||||
 | 
					    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return render(request, 'invoices/final_settlement_step.html', {
 | 
				
			||||||
 | 
					        'instance': instance,
 | 
				
			||||||
 | 
					        'step': step,
 | 
				
			||||||
 | 
					        'invoice': invoice,
 | 
				
			||||||
 | 
					        'payments': invoice.payments.filter(is_deleted=False).all(),
 | 
				
			||||||
 | 
					        'previous_step': previous_step,
 | 
				
			||||||
 | 
					        'next_step': next_step,
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@require_POST
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def add_final_payment(request, instance_id, step_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
				
			||||||
 | 
					    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()
 | 
				
			||||||
 | 
					    reference_number = (request.POST.get('reference_number') or '').strip()
 | 
				
			||||||
 | 
					    direction = (request.POST.get('direction') or 'in').strip()
 | 
				
			||||||
 | 
					    receipt_image = request.FILES.get('receipt_image')
 | 
				
			||||||
 | 
					    if not amount:
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'مبلغ را وارد کنید'})
 | 
				
			||||||
 | 
					    if not payment_date:
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'تاریخ پرداخت را وارد کنید'})
 | 
				
			||||||
 | 
					    if not payment_method:
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'روش پرداخت را انتخاب کنید'})
 | 
				
			||||||
 | 
					    if not reference_number:
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'شماره مرجع را وارد کنید'})
 | 
				
			||||||
 | 
					    if not receipt_image:
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'تصویر فیش الزامی است'})
 | 
				
			||||||
 | 
					    if '/' in payment_date:
 | 
				
			||||||
 | 
					        payment_date = payment_date.replace('/', '-')
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        amount_dec = Decimal(amount)
 | 
				
			||||||
 | 
					    except InvalidOperation:
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'مبلغ نامعتبر است'})
 | 
				
			||||||
 | 
					    # Only allow outgoing (پرداخت به مشتری) when current net due is negative
 | 
				
			||||||
 | 
					    # Compute net due explicitly from current items/payments
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        current_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in invoice.payments.filter(is_deleted=False).all())
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        current_paid = Decimal('0')
 | 
				
			||||||
 | 
					    # Ensure invoice totals are up-to-date for final_amount
 | 
				
			||||||
 | 
					    invoice.calculate_totals()
 | 
				
			||||||
 | 
					    net_due = invoice.final_amount - current_paid
 | 
				
			||||||
 | 
					    if direction == 'out' and net_due >= 0:
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'در حال حاضر مانده به نفع مشتری نیست'})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Amount constraints by sign of net due
 | 
				
			||||||
 | 
					    if net_due > 0 and direction == 'in' and amount_dec > net_due:
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'مبلغ فیش بیشتر از مانده فاکتور است'})
 | 
				
			||||||
 | 
					    if net_due < 0 and direction == 'out' and amount_dec > abs(net_due):
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'مبلغ فیش بیشتر از مانده بدهی شرکت به مشتری است'})
 | 
				
			||||||
 | 
					    if net_due < 0 and direction == 'in':
 | 
				
			||||||
 | 
					        return JsonResponse({'success': False, 'message': 'در حال حاضر مانده به نفع مشتری است؛ دریافت از مشتری مجاز نیست'})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Payment.objects.create(
 | 
				
			||||||
 | 
					        invoice=invoice,
 | 
				
			||||||
 | 
					        amount=amount_dec,
 | 
				
			||||||
 | 
					        payment_date=payment_date,
 | 
				
			||||||
 | 
					        payment_method=payment_method,
 | 
				
			||||||
 | 
					        reference_number=reference_number,
 | 
				
			||||||
 | 
					        direction='in' if direction != 'out' else 'out',
 | 
				
			||||||
 | 
					        receipt_image=receipt_image,
 | 
				
			||||||
 | 
					        created_by=request.user,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    # After creation, totals auto-updated by model save. Respond with redirect and new totals for UX.
 | 
				
			||||||
 | 
					    invoice.refresh_from_db()
 | 
				
			||||||
 | 
					    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),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@require_POST
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def delete_final_payment(request, instance_id, step_id, payment_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
				
			||||||
 | 
					    payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
 | 
				
			||||||
 | 
					    payment.delete()
 | 
				
			||||||
 | 
					    invoice.refresh_from_db()
 | 
				
			||||||
 | 
					    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),
 | 
				
			||||||
 | 
					    }})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@require_POST
 | 
				
			||||||
 | 
					@login_required
 | 
				
			||||||
 | 
					def approve_final_settlement(request, instance_id, step_id):
 | 
				
			||||||
 | 
					    instance = get_object_or_404(ProcessInstance, id=instance_id)
 | 
				
			||||||
 | 
					    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
				
			||||||
 | 
					    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
				
			||||||
 | 
					    # Block approval if any remaining exists (positive or negative)
 | 
				
			||||||
 | 
					    invoice.calculate_totals()
 | 
				
			||||||
 | 
					    if invoice.remaining_amount != 0:
 | 
				
			||||||
 | 
					        return JsonResponse({
 | 
				
			||||||
 | 
					            'success': False,
 | 
				
			||||||
 | 
					            'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})"
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    # complete step
 | 
				
			||||||
 | 
					    step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
 | 
				
			||||||
 | 
					    step_instance.status = 'completed'
 | 
				
			||||||
 | 
					    step_instance.completed_at = timezone.now()
 | 
				
			||||||
 | 
					    step_instance.save()
 | 
				
			||||||
 | 
					    # move next
 | 
				
			||||||
 | 
					    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
				
			||||||
 | 
					    redirect_url = reverse('processes:request_list')
 | 
				
			||||||
 | 
					    if next_step:
 | 
				
			||||||
 | 
					        instance.current_step = next_step
 | 
				
			||||||
 | 
					        instance.save()
 | 
				
			||||||
 | 
					        redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
 | 
				
			||||||
 | 
					    return JsonResponse({'success': True, 'message': 'تسویه حساب نهایی ثبت شد', 'redirect': redirect_url})
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -43,6 +43,7 @@
 | 
				
			||||||
          <tr>
 | 
					          <tr>
 | 
				
			||||||
            <th>شناسه</th>
 | 
					            <th>شناسه</th>
 | 
				
			||||||
            <th>فرآیند</th>
 | 
					            <th>فرآیند</th>
 | 
				
			||||||
 | 
					            <th>مرحله فعلی</th>
 | 
				
			||||||
            <th>شماره اشتراک آب</th>
 | 
					            <th>شماره اشتراک آب</th>
 | 
				
			||||||
            <th>نماینده</th>
 | 
					            <th>نماینده</th>
 | 
				
			||||||
            <th>درخواستکننده</th>
 | 
					            <th>درخواستکننده</th>
 | 
				
			||||||
| 
						 | 
					@ -57,6 +58,7 @@
 | 
				
			||||||
          <tr>
 | 
					          <tr>
 | 
				
			||||||
            <td>{{ inst.code }}</td>
 | 
					            <td>{{ inst.code }}</td>
 | 
				
			||||||
            <td>{{ inst.process.name }}</td>
 | 
					            <td>{{ inst.process.name }}</td>
 | 
				
			||||||
 | 
					            <td class="text-primary">{{ inst.current_step.name|default:"--" }}</td>
 | 
				
			||||||
            <td>{{ inst.well.water_subscription_number }}</td>
 | 
					            <td>{{ inst.well.water_subscription_number }}</td>
 | 
				
			||||||
            <td>{% if inst.representative %}{{ inst.representative.get_full_name }}{% else %}-{% endif %}</td>
 | 
					            <td>{% if inst.representative %}{{ inst.representative.get_full_name }}{% else %}-{% endif %}</td>
 | 
				
			||||||
            <td>{% if inst.requester %}{{ inst.requester.get_full_name }}{% else %}-{% endif %}</td>
 | 
					            <td>{% if inst.requester %}{{ inst.requester.get_full_name }}{% else %}-{% endif %}</td>
 | 
				
			||||||
| 
						 | 
					@ -247,6 +249,10 @@
 | 
				
			||||||
                    <label class="form-label" for="id_account_number">{{ customer_form.account_number.label }}</label>
 | 
					                    <label class="form-label" for="id_account_number">{{ customer_form.account_number.label }}</label>
 | 
				
			||||||
                    {{ customer_form.account_number }}
 | 
					                    {{ customer_form.account_number }}
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
 | 
					                  <div class="col-sm-6">
 | 
				
			||||||
 | 
					                    <label class="form-label" for="id_bank_name">{{ customer_form.bank_name.label }}</label>
 | 
				
			||||||
 | 
					                    {{ customer_form.bank_name }}
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
                  <div class="col-sm-12">
 | 
					                  <div class="col-sm-12">
 | 
				
			||||||
                    <label class="form-label" for="id_address">{{ customer_form.address.label }}</label>
 | 
					                    <label class="form-label" for="id_address">{{ customer_form.address.label }}</label>
 | 
				
			||||||
                    {{ customer_form.address }}
 | 
					                    {{ customer_form.address }}
 | 
				
			||||||
| 
						 | 
					@ -426,6 +432,7 @@
 | 
				
			||||||
        case 'phone_number_2': return '#id_phone_number_2';
 | 
					        case 'phone_number_2': return '#id_phone_number_2';
 | 
				
			||||||
        case 'card_number': return '#id_card_number';
 | 
					        case 'card_number': return '#id_card_number';
 | 
				
			||||||
        case 'account_number': return '#id_account_number';
 | 
					        case 'account_number': return '#id_account_number';
 | 
				
			||||||
 | 
					        case 'bank_name': return '#id_bank_name';
 | 
				
			||||||
        case 'address': return '#id_address';
 | 
					        case 'address': return '#id_address';
 | 
				
			||||||
        default: return '#id_' + field;
 | 
					        default: return '#id_' + field;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
| 
						 | 
					@ -549,6 +556,7 @@
 | 
				
			||||||
              $('#id_phone_number_2').val(resp.user.profile.phone_number_2 || '');
 | 
					              $('#id_phone_number_2').val(resp.user.profile.phone_number_2 || '');
 | 
				
			||||||
              $('#id_card_number').val(resp.user.profile.card_number || '');
 | 
					              $('#id_card_number').val(resp.user.profile.card_number || '');
 | 
				
			||||||
              $('#id_account_number').val(resp.user.profile.account_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 || '');
 | 
					              $('#id_address').val(resp.user.profile.address || '');
 | 
				
			||||||
            } else {
 | 
					            } else {
 | 
				
			||||||
              $('#id_national_code').val(nc);
 | 
					              $('#id_national_code').val(nc);
 | 
				
			||||||
| 
						 | 
					@ -556,6 +564,7 @@
 | 
				
			||||||
              $('#id_phone_number_2').val('');
 | 
					              $('#id_phone_number_2').val('');
 | 
				
			||||||
              $('#id_card_number').val('');
 | 
					              $('#id_card_number').val('');
 | 
				
			||||||
              $('#id_account_number').val('');
 | 
					              $('#id_account_number').val('');
 | 
				
			||||||
 | 
					              $('#id_bank_name').val('');
 | 
				
			||||||
              $('#id_address').val('');
 | 
					              $('#id_address').val('');
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            setStatus('#repStatus', 'نماینده یافت شد.', 'success');
 | 
					            setStatus('#repStatus', 'نماینده یافت شد.', 'success');
 | 
				
			||||||
| 
						 | 
					@ -570,6 +579,7 @@
 | 
				
			||||||
            $('#id_phone_number_2').val('');
 | 
					            $('#id_phone_number_2').val('');
 | 
				
			||||||
            $('#id_card_number').val('');
 | 
					            $('#id_card_number').val('');
 | 
				
			||||||
            $('#id_account_number').val('');
 | 
					            $('#id_account_number').val('');
 | 
				
			||||||
 | 
					            $('#id_bank_name').val('');
 | 
				
			||||||
            $('#id_address').val('');
 | 
					            $('#id_address').val('');
 | 
				
			||||||
            setStatus('#repStatus', 'نماینده یافت نشد. لطفا اطلاعات را تکمیل کنید.', 'danger');
 | 
					            setStatus('#repStatus', 'نماینده یافت نشد. لطفا اطلاعات را تکمیل کنید.', 'danger');
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
| 
						 | 
					@ -595,7 +605,7 @@
 | 
				
			||||||
      formData.append('card_number', $('#id_card_number').val() || '');
 | 
					      formData.append('card_number', $('#id_card_number').val() || '');
 | 
				
			||||||
      formData.append('account_number', $('#id_account_number').val() || '');
 | 
					      formData.append('account_number', $('#id_account_number').val() || '');
 | 
				
			||||||
      formData.append('address', $('#id_address').val() || '');
 | 
					      formData.append('address', $('#id_address').val() || '');
 | 
				
			||||||
 | 
					      formData.append('bank_name', $('#id_bank_name').val() || '');
 | 
				
			||||||
      // Include WellForm fields so edits are saved
 | 
					      // Include WellForm fields so edits are saved
 | 
				
			||||||
      if ($('#wellFormBlock').is(':visible')) {
 | 
					      if ($('#wellFormBlock').is(':visible')) {
 | 
				
			||||||
        formData.append('electricity_subscription_number', $('#id_electricity_subscription_number').val() || '');
 | 
					        formData.append('electricity_subscription_number', $('#id_electricity_subscription_number').val() || '');
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -46,3 +46,5 @@ def stepper_header(instance, current_step=None):
 | 
				
			||||||
        'instance': instance,
 | 
					        'instance': instance,
 | 
				
			||||||
        'steps_context': steps_context,
 | 
					        'steps_context': steps_context,
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# moved to _base/common/templatetags/common_tags.py
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -106,6 +106,7 @@ def lookup_representative_by_national_code(request):
 | 
				
			||||||
                'phone_number_2': profile.phone_number_2,
 | 
					                'phone_number_2': profile.phone_number_2,
 | 
				
			||||||
                'card_number': profile.card_number,
 | 
					                'card_number': profile.card_number,
 | 
				
			||||||
                'account_number': profile.account_number,
 | 
					                'account_number': profile.account_number,
 | 
				
			||||||
 | 
					                'bank_name': profile.bank_name,
 | 
				
			||||||
                'address': profile.address,
 | 
					                'address': profile.address,
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
| 
						 | 
					@ -135,6 +136,7 @@ def create_request_with_entities(request):
 | 
				
			||||||
    representative_phone_number_2 = request.POST.get('phone_number_2') or request.POST.get('representative_phone_number_2')
 | 
					    representative_phone_number_2 = request.POST.get('phone_number_2') or request.POST.get('representative_phone_number_2')
 | 
				
			||||||
    representative_card_number = request.POST.get('card_number') or request.POST.get('representative_card_number')
 | 
					    representative_card_number = request.POST.get('card_number') or request.POST.get('representative_card_number')
 | 
				
			||||||
    representative_account_number = request.POST.get('account_number') or request.POST.get('representative_account_number')
 | 
					    representative_account_number = request.POST.get('account_number') or request.POST.get('representative_account_number')
 | 
				
			||||||
 | 
					    representative_bank_name = request.POST.get('bank_name') or request.POST.get('representative_bank_name')
 | 
				
			||||||
    representative_address = request.POST.get('address') or request.POST.get('representative_address')
 | 
					    representative_address = request.POST.get('address') or request.POST.get('representative_address')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if not process_id:
 | 
					    if not process_id:
 | 
				
			||||||
| 
						 | 
					@ -174,6 +176,8 @@ def create_request_with_entities(request):
 | 
				
			||||||
            representative_profile.card_number = representative_card_number
 | 
					            representative_profile.card_number = representative_card_number
 | 
				
			||||||
        if representative_account_number is not None:
 | 
					        if representative_account_number is not None:
 | 
				
			||||||
            representative_profile.account_number = representative_account_number
 | 
					            representative_profile.account_number = representative_account_number
 | 
				
			||||||
 | 
					        if representative_bank_name is not None:
 | 
				
			||||||
 | 
					            representative_profile.bank_name = representative_bank_name
 | 
				
			||||||
        if representative_address is not None:
 | 
					        if representative_address is not None:
 | 
				
			||||||
            representative_profile.address = representative_address
 | 
					            representative_profile.address = representative_address
 | 
				
			||||||
        representative_profile.save()
 | 
					        representative_profile.save()
 | 
				
			||||||
| 
						 | 
					@ -191,6 +195,7 @@ def create_request_with_entities(request):
 | 
				
			||||||
            'address': representative_address or '',
 | 
					            'address': representative_address or '',
 | 
				
			||||||
            'card_number': representative_card_number or '',
 | 
					            'card_number': representative_card_number or '',
 | 
				
			||||||
            'account_number': representative_account_number or '',
 | 
					            'account_number': representative_account_number or '',
 | 
				
			||||||
 | 
					            'bank_name': representative_bank_name or '',
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        customer_form = CustomerForm(customer_data, instance=profile_instance)
 | 
					        customer_form = CustomerForm(customer_data, instance=profile_instance)
 | 
				
			||||||
        customer_form.request = request
 | 
					        customer_form.request = request
 | 
				
			||||||
| 
						 | 
					@ -365,6 +370,18 @@ def step_detail(request, instance_id, step_id):
 | 
				
			||||||
        return redirect('invoices:quote_preview_step', instance_id=instance.id, step_id=step.id)
 | 
					        return redirect('invoices:quote_preview_step', instance_id=instance.id, step_id=step.id)
 | 
				
			||||||
    elif step.order == 3:  # مرحله سوم - ثبت فیشهای واریزی
 | 
					    elif step.order == 3:  # مرحله سوم - ثبت فیشهای واریزی
 | 
				
			||||||
        return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
 | 
					        return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
 | 
				
			||||||
 | 
					    elif step.order == 4:  # مرحله چهارم - قرارداد
 | 
				
			||||||
 | 
					        return redirect('contracts:contract_step', instance_id=instance.id, step_id=step.id)
 | 
				
			||||||
 | 
					    elif step.order == 5:  # مرحله پنجم - انتخاب نصاب
 | 
				
			||||||
 | 
					        return redirect('installations:installation_assign_step', instance_id=instance.id, step_id=step.id)
 | 
				
			||||||
 | 
					    elif step.order == 6:  # مرحله ششم - گزارش نصب
 | 
				
			||||||
 | 
					        return redirect('installations:installation_report_step', instance_id=instance.id, step_id=step.id)
 | 
				
			||||||
 | 
					    elif step.order == 7:  # مرحله هفتم - فاکتور نهایی
 | 
				
			||||||
 | 
					        return redirect('invoices:final_invoice_step', instance_id=instance.id, step_id=step.id)
 | 
				
			||||||
 | 
					    elif step.order == 8:  # مرحله هشتم - تسویه حساب نهایی
 | 
				
			||||||
 | 
					        return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
 | 
				
			||||||
 | 
					    elif step.order == 9:  # مرحله نهم - گواهی نهایی
 | 
				
			||||||
 | 
					        return redirect('certificates:certificate_step', instance_id=instance.id, step_id=step.id)
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    # برای سایر مراحل، template عمومی نمایش داده میشود
 | 
					    # برای سایر مراحل، template عمومی نمایش داده میشود
 | 
				
			||||||
    step_instance = instance.step_instances.filter(step=step).first()
 | 
					    step_instance = instance.step_instances.filter(step=step).first()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue