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',
 | 
			
		||||
    'processes.apps.ProcessesConfig',
 | 
			
		||||
    'invoices.apps.InvoicesConfig',
 | 
			
		||||
    'contracts.apps.ContractsConfig',
 | 
			
		||||
    'certificates.apps.CertificatesConfig',
 | 
			
		||||
    'installations.apps.InstallationsConfig',
 | 
			
		||||
    # ----------------------- #
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,6 +25,9 @@ urlpatterns = [
 | 
			
		|||
    path('wells/', include('wells.urls')),
 | 
			
		||||
    path('processes/', include('processes.urls')),
 | 
			
		||||
    path('invoices/', include('invoices.urls')),
 | 
			
		||||
    path('contracts/', include('contracts.urls')),
 | 
			
		||||
    path('certificates/', include('certificates.urls')),
 | 
			
		||||
    path('installations/', include('installations.urls')),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
if settings.DEBUG:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from accounts.models import Role, Profile
 | 
			
		||||
from accounts.models import Role, Profile, Company
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Register your models here.
 | 
			
		||||
| 
						 | 
				
			
			@ -30,3 +30,12 @@ class ProfileAdmin(admin.ModelAdmin):
 | 
			
		|||
    date_hierarchy = 'created'
 | 
			
		||||
    ordering = ['-created']
 | 
			
		||||
    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
 | 
			
		||||
        fields = [
 | 
			
		||||
            'phone_number_1', 'phone_number_2', 'national_code', 
 | 
			
		||||
            'address', 'card_number', 'account_number'
 | 
			
		||||
            'address', 'card_number', 'account_number', 'bank_name'
 | 
			
		||||
        ]
 | 
			
		||||
        widgets = {
 | 
			
		||||
            'phone_number_1': forms.TextInput(attrs={
 | 
			
		||||
| 
						 | 
				
			
			@ -61,6 +61,10 @@ class CustomerForm(forms.ModelForm):
 | 
			
		|||
                'placeholder': 'شماره حساب بانکی',
 | 
			
		||||
                'maxlength': '20'
 | 
			
		||||
            }),
 | 
			
		||||
            'bank_name': forms.Select(attrs={
 | 
			
		||||
                'class': 'form-control',
 | 
			
		||||
                'placeholder': 'نام بانک',
 | 
			
		||||
            }),
 | 
			
		||||
        }
 | 
			
		||||
        labels = {
 | 
			
		||||
            'phone_number_1': 'تلفن ۱',
 | 
			
		||||
| 
						 | 
				
			
			@ -69,6 +73,7 @@ class CustomerForm(forms.ModelForm):
 | 
			
		|||
            'address': 'آدرس',
 | 
			
		||||
            'card_number': 'شماره کارت',
 | 
			
		||||
            'account_number': 'شماره حساب',
 | 
			
		||||
            'bank_name': 'نام بانک',
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    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.core.validators import RegexValidator
 | 
			
		||||
from simple_history.models import HistoricalRecords
 | 
			
		||||
from common.models import TagModel, BaseModel
 | 
			
		||||
from common.consts import UserRoles
 | 
			
		||||
from common.models import TagModel, BaseModel, NameSlugModel
 | 
			
		||||
from common.consts import UserRoles, BANK_CHOICES
 | 
			
		||||
from locations.models import Affairs, Broker, County
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Create your models here.
 | 
			
		||||
 | 
			
		||||
class Role(TagModel):
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = "نقش"
 | 
			
		||||
        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(
 | 
			
		||||
        max_length=11,
 | 
			
		||||
        null=True,
 | 
			
		||||
| 
						 | 
				
			
			@ -170,3 +178,17 @@ class Profile(BaseModel):
 | 
			
		|||
        return format_html(f"<img style='width:30px;' src='{self.pic.url}'>")
 | 
			
		||||
 | 
			
		||||
    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>
 | 
			
		||||
          </tr>
 | 
			
		||||
| 
						 | 
				
			
			@ -122,6 +123,17 @@
 | 
			
		|||
                <span class="text-muted">آدرس ثبت نشده</span>
 | 
			
		||||
              {% endif %}
 | 
			
		||||
            </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>
 | 
			
		||||
              {% if customer.is_completed %}
 | 
			
		||||
                <span class="badge bg-label-success">تکمیل شده</span>
 | 
			
		||||
| 
						 | 
				
			
			@ -242,6 +254,17 @@
 | 
			
		|||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="col-sm-12">
 | 
			
		||||
        <label class="form-label fw-bold" for="{{ form.bank_name.id_for_label }}">{{ form.bank_name.label }}</label>
 | 
			
		||||
        <div class="input-group input-group-merge">
 | 
			
		||||
          <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">
 | 
			
		||||
        <label class="form-label fw-bold" for="{{ form.card_number.id_for_label }}">{{ form.card_number.label }}</label>
 | 
			
		||||
        <div class="input-group input-group-merge">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -144,6 +144,7 @@ def get_customer_data(request, customer_id):
 | 
			
		|||
        'card_number': str(form['card_number']),
 | 
			
		||||
        'account_number': str(form['account_number']),
 | 
			
		||||
        'address': str(form['address']),
 | 
			
		||||
        'bank_name': str(form['bank_name']),
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return JsonResponse({
 | 
			
		||||
| 
						 | 
				
			
			@ -157,7 +158,8 @@ def get_customer_data(request, customer_id):
 | 
			
		|||
            'national_code': customer.national_code or '',
 | 
			
		||||
            'card_number': customer.card_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
 | 
			
		||||
    })
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										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" # کارشناس امور
 | 
			
		||||
    WATER_RESOURCE_MANAGER = "wrm" # مدیر منابع آب
 | 
			
		||||
    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, 
 | 
			
		||||
        verbose_name="قیمت واحد"
 | 
			
		||||
    )
 | 
			
		||||
    is_special = models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی')
 | 
			
		||||
    default_quantity = models.PositiveIntegerField(
 | 
			
		||||
        default=1, 
 | 
			
		||||
        verbose_name="تعداد پیشفرض"
 | 
			
		||||
| 
						 | 
				
			
			@ -102,7 +103,8 @@ class Quote(NameSlugModel):
 | 
			
		|||
 | 
			
		||||
    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
 | 
			
		||||
        
 | 
			
		||||
        # محاسبه تخفیف
 | 
			
		||||
| 
						 | 
				
			
			@ -260,15 +262,19 @@ class Invoice(NameSlugModel):
 | 
			
		|||
            self.discount_amount = 0
 | 
			
		||||
        
 | 
			
		||||
        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'
 | 
			
		||||
        elif self.paid_amount > 0:
 | 
			
		||||
            self.status = 'partially_paid'
 | 
			
		||||
        elif net_due > 0:
 | 
			
		||||
            # مشتری هنوز باید پرداخت کند
 | 
			
		||||
            self.status = 'partially_paid' if self.paid_amount > 0 else 'sent'
 | 
			
		||||
        else:
 | 
			
		||||
            self.status = 'sent'
 | 
			
		||||
            # شرکت باید به مشتری پرداخت کند
 | 
			
		||||
            self.status = 'partially_paid'
 | 
			
		||||
        
 | 
			
		||||
        self.save()
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -314,6 +320,12 @@ class Payment(BaseModel):
 | 
			
		|||
    """مدل پرداختها"""
 | 
			
		||||
    invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments', verbose_name="فاکتور")
 | 
			
		||||
    amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="مبلغ پرداخت")
 | 
			
		||||
    direction = models.CharField(
 | 
			
		||||
        max_length=3,
 | 
			
		||||
        choices=[('in', 'دریافتی'), ('out', 'پرداختی')],
 | 
			
		||||
        default='in',
 | 
			
		||||
        verbose_name='نوع تراکنش'
 | 
			
		||||
    )
 | 
			
		||||
    payment_method = models.CharField(
 | 
			
		||||
        max_length=20,
 | 
			
		||||
        choices=[
 | 
			
		||||
| 
						 | 
				
			
			@ -326,7 +338,7 @@ class Payment(BaseModel):
 | 
			
		|||
        default='cash',
 | 
			
		||||
        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="تاریخ پرداخت")
 | 
			
		||||
    notes = models.TextField(verbose_name="یادداشتها", blank=True)
 | 
			
		||||
    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)
 | 
			
		||||
        # بروزرسانی مبلغ پرداخت شده فاکتور
 | 
			
		||||
        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.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
 | 
			
		||||
    }).then(r => r.json()).then(resp => {
 | 
			
		||||
      if (resp.success) {
 | 
			
		||||
        showToast('فیش با موفقیت ثبت شد', 'success');
 | 
			
		||||
        showToast(resp.message || 'فیش با موفقیت ثبت شد', 'success');
 | 
			
		||||
        if (resp.redirect) {
 | 
			
		||||
          setTimeout(() => { window.location.href = resp.redirect; }, 700);
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        showToast(resp.message || 'خطا در ثبت فیش', 'danger');
 | 
			
		||||
        showToast(resp.message + ':' + resp.error || 'خطا در ثبت فیش', 'danger');
 | 
			
		||||
      }
 | 
			
		||||
    }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
 | 
			
		||||
  });
 | 
			
		||||
| 
						 | 
				
			
			@ -267,11 +267,13 @@
 | 
			
		|||
      method: 'POST',
 | 
			
		||||
      body: fd
 | 
			
		||||
    }).then(r => r.json()).then(resp => {
 | 
			
		||||
      if (resp.success && resp.redirect) {
 | 
			
		||||
        showToast('فیش با موفقیت حذف شد', 'success');
 | 
			
		||||
      if (resp.success) {
 | 
			
		||||
        showToast(resp.message || 'فیش با موفقیت حذف شد', 'success');
 | 
			
		||||
        if (resp.redirect) {
 | 
			
		||||
          setTimeout(() => { window.location.href = resp.redirect; }, 700);
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        showToast(resp.message || 'خطا در حذف فیش', 'danger');
 | 
			
		||||
        showToast(resp.message || resp.error || 'خطا در حذف فیش', 'danger');
 | 
			
		||||
      }
 | 
			
		||||
    }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -288,11 +290,13 @@
 | 
			
		|||
      method: 'POST',
 | 
			
		||||
      body: fd
 | 
			
		||||
    }).then(r => r.json()).then(resp => {
 | 
			
		||||
      if (resp.success && resp.redirect) {
 | 
			
		||||
        showToast(resp.message, 'success');
 | 
			
		||||
      if (resp.success) {
 | 
			
		||||
        showToast(resp.message || 'پرداختها تایید شد', 'success');
 | 
			
		||||
        if (resp.redirect) {
 | 
			
		||||
          setTimeout(() => { window.location.href = resp.redirect; }, 600);
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        showToast(resp.message || 'خطا در تایید پرداختها', 'danger');
 | 
			
		||||
        showToast(resp.message || resp.error || 'خطا در تایید پرداختها', 'danger');
 | 
			
		||||
      }
 | 
			
		||||
    }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -48,7 +48,7 @@
 | 
			
		|||
        {% stepper_header instance step %}
 | 
			
		||||
        <div class="bs-stepper-content">
 | 
			
		||||
            <!-- 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="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">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,4 +21,17 @@ urlpatterns = [
 | 
			
		|||
 | 
			
		||||
    # 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 .models import Item, Quote, QuoteItem, Payment, Invoice
 | 
			
		||||
from installations.models import InstallationReport, InstallationItemChange
 | 
			
		||||
 | 
			
		||||
@login_required
 | 
			
		||||
def quote_step(request, instance_id, step_id):
 | 
			
		||||
| 
						 | 
				
			
			@ -413,3 +414,358 @@ def approve_payments(request, instance_id, step_id):
 | 
			
		|||
        msg += ' - توجه: مبلغ پیشفاکتور به طور کامل پرداخت نشده است.'
 | 
			
		||||
 | 
			
		||||
    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>
 | 
			
		||||
            <th>شناسه</th>
 | 
			
		||||
            <th>فرآیند</th>
 | 
			
		||||
            <th>مرحله فعلی</th>
 | 
			
		||||
            <th>شماره اشتراک آب</th>
 | 
			
		||||
            <th>نماینده</th>
 | 
			
		||||
            <th>درخواستکننده</th>
 | 
			
		||||
| 
						 | 
				
			
			@ -57,6 +58,7 @@
 | 
			
		|||
          <tr>
 | 
			
		||||
            <td>{{ inst.code }}</td>
 | 
			
		||||
            <td>{{ inst.process.name }}</td>
 | 
			
		||||
            <td class="text-primary">{{ inst.current_step.name|default:"--" }}</td>
 | 
			
		||||
            <td>{{ inst.well.water_subscription_number }}</td>
 | 
			
		||||
            <td>{% if inst.representative %}{{ inst.representative.get_full_name }}{% else %}-{% endif %}</td>
 | 
			
		||||
            <td>{% if inst.requester %}{{ inst.requester.get_full_name }}{% else %}-{% endif %}</td>
 | 
			
		||||
| 
						 | 
				
			
			@ -247,6 +249,10 @@
 | 
			
		|||
                    <label class="form-label" for="id_account_number">{{ customer_form.account_number.label }}</label>
 | 
			
		||||
                    {{ customer_form.account_number }}
 | 
			
		||||
                  </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">
 | 
			
		||||
                    <label class="form-label" for="id_address">{{ customer_form.address.label }}</label>
 | 
			
		||||
                    {{ customer_form.address }}
 | 
			
		||||
| 
						 | 
				
			
			@ -426,6 +432,7 @@
 | 
			
		|||
        case 'phone_number_2': return '#id_phone_number_2';
 | 
			
		||||
        case 'card_number': return '#id_card_number';
 | 
			
		||||
        case 'account_number': return '#id_account_number';
 | 
			
		||||
        case 'bank_name': return '#id_bank_name';
 | 
			
		||||
        case 'address': return '#id_address';
 | 
			
		||||
        default: return '#id_' + field;
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -549,6 +556,7 @@
 | 
			
		|||
              $('#id_phone_number_2').val(resp.user.profile.phone_number_2 || '');
 | 
			
		||||
              $('#id_card_number').val(resp.user.profile.card_number || '');
 | 
			
		||||
              $('#id_account_number').val(resp.user.profile.account_number || '');
 | 
			
		||||
              $('#id_bank_name').val(resp.user.profile.bank_name || '');
 | 
			
		||||
              $('#id_address').val(resp.user.profile.address || '');
 | 
			
		||||
            } else {
 | 
			
		||||
              $('#id_national_code').val(nc);
 | 
			
		||||
| 
						 | 
				
			
			@ -556,6 +564,7 @@
 | 
			
		|||
              $('#id_phone_number_2').val('');
 | 
			
		||||
              $('#id_card_number').val('');
 | 
			
		||||
              $('#id_account_number').val('');
 | 
			
		||||
              $('#id_bank_name').val('');
 | 
			
		||||
              $('#id_address').val('');
 | 
			
		||||
            }
 | 
			
		||||
            setStatus('#repStatus', 'نماینده یافت شد.', 'success');
 | 
			
		||||
| 
						 | 
				
			
			@ -570,6 +579,7 @@
 | 
			
		|||
            $('#id_phone_number_2').val('');
 | 
			
		||||
            $('#id_card_number').val('');
 | 
			
		||||
            $('#id_account_number').val('');
 | 
			
		||||
            $('#id_bank_name').val('');
 | 
			
		||||
            $('#id_address').val('');
 | 
			
		||||
            setStatus('#repStatus', 'نماینده یافت نشد. لطفا اطلاعات را تکمیل کنید.', 'danger');
 | 
			
		||||
          }
 | 
			
		||||
| 
						 | 
				
			
			@ -595,7 +605,7 @@
 | 
			
		|||
      formData.append('card_number', $('#id_card_number').val() || '');
 | 
			
		||||
      formData.append('account_number', $('#id_account_number').val() || '');
 | 
			
		||||
      formData.append('address', $('#id_address').val() || '');
 | 
			
		||||
 | 
			
		||||
      formData.append('bank_name', $('#id_bank_name').val() || '');
 | 
			
		||||
      // Include WellForm fields so edits are saved
 | 
			
		||||
      if ($('#wellFormBlock').is(':visible')) {
 | 
			
		||||
        formData.append('electricity_subscription_number', $('#id_electricity_subscription_number').val() || '');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -46,3 +46,5 @@ def stepper_header(instance, current_step=None):
 | 
			
		|||
        'instance': instance,
 | 
			
		||||
        '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,
 | 
			
		||||
                'card_number': profile.card_number,
 | 
			
		||||
                'account_number': profile.account_number,
 | 
			
		||||
                'bank_name': profile.bank_name,
 | 
			
		||||
                '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_card_number = request.POST.get('card_number') or request.POST.get('representative_card_number')
 | 
			
		||||
    representative_account_number = request.POST.get('account_number') or request.POST.get('representative_account_number')
 | 
			
		||||
    representative_bank_name = request.POST.get('bank_name') or request.POST.get('representative_bank_name')
 | 
			
		||||
    representative_address = request.POST.get('address') or request.POST.get('representative_address')
 | 
			
		||||
 | 
			
		||||
    if not process_id:
 | 
			
		||||
| 
						 | 
				
			
			@ -174,6 +176,8 @@ def create_request_with_entities(request):
 | 
			
		|||
            representative_profile.card_number = representative_card_number
 | 
			
		||||
        if representative_account_number is not None:
 | 
			
		||||
            representative_profile.account_number = representative_account_number
 | 
			
		||||
        if representative_bank_name is not None:
 | 
			
		||||
            representative_profile.bank_name = representative_bank_name
 | 
			
		||||
        if representative_address is not None:
 | 
			
		||||
            representative_profile.address = representative_address
 | 
			
		||||
        representative_profile.save()
 | 
			
		||||
| 
						 | 
				
			
			@ -191,6 +195,7 @@ def create_request_with_entities(request):
 | 
			
		|||
            'address': representative_address or '',
 | 
			
		||||
            'card_number': representative_card_number or '',
 | 
			
		||||
            'account_number': representative_account_number or '',
 | 
			
		||||
            'bank_name': representative_bank_name or '',
 | 
			
		||||
        }
 | 
			
		||||
        customer_form = CustomerForm(customer_data, instance=profile_instance)
 | 
			
		||||
        customer_form.request = request
 | 
			
		||||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
    elif step.order == 3:  # مرحله سوم - ثبت فیشهای واریزی
 | 
			
		||||
        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 عمومی نمایش داده میشود
 | 
			
		||||
    step_instance = instance.step_instances.filter(step=step).first()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue