diff --git a/_base/settings.py b/_base/settings.py
index 45716ae..e53a6f1 100644
--- a/_base/settings.py
+++ b/_base/settings.py
@@ -54,6 +54,9 @@ INSTALLED_APPS = [
'common.apps.CommonConfig',
'processes.apps.ProcessesConfig',
'invoices.apps.InvoicesConfig',
+ 'contracts.apps.ContractsConfig',
+ 'certificates.apps.CertificatesConfig',
+ 'installations.apps.InstallationsConfig',
# ----------------------- #
]
diff --git a/_base/urls.py b/_base/urls.py
index e4a1acb..417f6ca 100644
--- a/_base/urls.py
+++ b/_base/urls.py
@@ -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:
diff --git a/accounts/admin.py b/accounts/admin.py
index 0f04ac8..1d58e16 100644
--- a/accounts/admin.py
+++ b/accounts/admin.py
@@ -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']
\ No newline at end of file
diff --git a/accounts/forms.py b/accounts/forms.py
index c5b408e..7654a13 100644
--- a/accounts/forms.py
+++ b/accounts/forms.py
@@ -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):
diff --git a/accounts/migrations/0002_company.py b/accounts/migrations/0002_company.py
new file mode 100644
index 0000000..c944cdf
--- /dev/null
+++ b/accounts/migrations/0002_company.py
@@ -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ها',
+ },
+ ),
+ ]
diff --git a/accounts/migrations/0003_historicalprofile_bank_name_profile_bank_name.py b/accounts/migrations/0003_historicalprofile_bank_name_profile_bank_name.py
new file mode 100644
index 0000000..6becfec
--- /dev/null
+++ b/accounts/migrations/0003_historicalprofile_bank_name_profile_bank_name.py
@@ -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='نام بانک'),
+ ),
+ ]
diff --git a/accounts/models.py b/accounts/models.py
index 1e7c73c..a94311a 100644
--- a/accounts/models.py
+++ b/accounts/models.py
@@ -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"
")
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
\ No newline at end of file
diff --git a/accounts/templates/accounts/customer_list.html b/accounts/templates/accounts/customer_list.html
index 3393f6a..2d00356 100644
--- a/accounts/templates/accounts/customer_list.html
+++ b/accounts/templates/accounts/customer_list.html
@@ -64,6 +64,7 @@
کد ملی |
تلفن |
آدرس |
+ بانک |
وضعیت |
عملیات |
@@ -122,6 +123,17 @@
آدرس ثبت نشده
{% endif %}
+
+
+ {% if customer.bank_name %}
+ {{ customer.get_bank_name_display }}
+ {{ customer.card_number }}
+ {{ customer.account_number }}
+ {% else %}
+ بانک ثبت نشده
+ {% endif %}
+
+ |
{% if customer.is_completed %}
تکمیل شده
@@ -241,6 +253,17 @@
{{ form.national_code.errors.0 }}
{% endif %}
+
+
+
+
+
+ {{ form.bank_name }}
+
+ {% if form.bank_name.errors %}
+ {{ form.bank_name.errors.0 }}
+ {% endif %}
+
diff --git a/accounts/views.py b/accounts/views.py
index 089c426..b238b5e 100644
--- a/accounts/views.py
+++ b/accounts/views.py
@@ -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
})
diff --git a/certificates/__init__.py b/certificates/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/certificates/admin.py b/certificates/admin.py
new file mode 100644
index 0000000..de9ba72
--- /dev/null
+++ b/certificates/admin.py
@@ -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')
+
+
diff --git a/certificates/apps.py b/certificates/apps.py
new file mode 100644
index 0000000..292b8a3
--- /dev/null
+++ b/certificates/apps.py
@@ -0,0 +1,10 @@
+from django.apps import AppConfig
+
+
+class CertificatesConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'certificates'
+ verbose_name = 'گواهیها'
+
+
+
diff --git a/certificates/migrations/0001_initial.py b/certificates/migrations/0001_initial.py
new file mode 100644
index 0000000..83533bd
--- /dev/null
+++ b/certificates/migrations/0001_initial.py
@@ -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ها',
+ },
+ ),
+ ]
diff --git a/certificates/migrations/0002_remove_certificatetemplate_company_logo_and_more.py b/certificates/migrations/0002_remove_certificatetemplate_company_logo_and_more.py
new file mode 100644
index 0000000..e929c5a
--- /dev/null
+++ b/certificates/migrations/0002_remove_certificatetemplate_company_logo_and_more.py
@@ -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='شرکت صادر کننده'),
+ ),
+ ]
diff --git a/certificates/migrations/__init__.py b/certificates/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/certificates/models.py b/certificates/models.py
new file mode 100644
index 0000000..64d53f9
--- /dev/null
+++ b/certificates/models.py
@@ -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}"
+
+
diff --git a/certificates/templates/certificates/print.html b/certificates/templates/certificates/print.html
new file mode 100644
index 0000000..5bd26c2
--- /dev/null
+++ b/certificates/templates/certificates/print.html
@@ -0,0 +1,28 @@
+{% extends '_base.html' %}
+
+{% block content %}
+
+
+ {% if template.company and template.company.logo %}
+ 
+ {% endif %}
+ {{ cert.rendered_title }}
+ {% if template.company %} {{ template.company.name }} {% endif %}
+
+
+ {{ cert.rendered_body|safe }}
+
+
+ تاریخ: {{ cert.issued_at }}
+
+ {% if template.company and template.company.signature %}
+ 
+ {% endif %}
+ مهر و امضای شرکت
+
+
+
+
+{% endblock %}
+
+
diff --git a/certificates/templates/certificates/step.html b/certificates/templates/certificates/step.html
new file mode 100644
index 0000000..584f4ba
--- /dev/null
+++ b/certificates/templates/certificates/step.html
@@ -0,0 +1,53 @@
+{% extends '_base.html' %}
+{% load static %}
+
+{% block content %}
+
+
+
+ گواهی نهایی
+ کد درخواست: {{ instance.code }}
+
+
+
+
+
+
+
+ {% if template.company and template.company.logo %}
+ 
+ {% endif %}
+ {{ cert.rendered_title }}
+ {% if template.company %} {{ template.company.name }} {% endif %}
+
+
+ {{ cert.rendered_body|safe }}
+
+
+
+ تاریخ صدور: {{ cert.issued_at }}
+
+
+ {% if template.company and template.company.signature %}
+ 
+ {% endif %}
+ مهر و امضای شرکت
+
+
+
+
+
+
+{% endblock %}
+
+
diff --git a/certificates/urls.py b/certificates/urls.py
new file mode 100644
index 0000000..3b0d102
--- /dev/null
+++ b/certificates/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+from . import views
+
+app_name = 'certificates'
+
+urlpatterns = [
+ path('instance/ /step//', views.certificate_step, name='certificate_step'),
+ path('instance//print/', views.certificate_print, name='certificate_print'),
+]
+
+
diff --git a/certificates/views.py b/certificates/views.py
new file mode 100644
index 0000000..de9c9b6
--- /dev/null
+++ b/certificates/views.py
@@ -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,
+ })
+
+
diff --git a/common/consts.py b/common/consts.py
index 36bbcfe..04099f8 100644
--- a/common/consts.py
+++ b/common/consts.py
@@ -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', 'سایر'),
+]
\ No newline at end of file
diff --git a/common/templatetags/__init__.py b/common/templatetags/__init__.py
new file mode 100644
index 0000000..448ac32
--- /dev/null
+++ b/common/templatetags/__init__.py
@@ -0,0 +1,3 @@
+# Intentionally empty to mark templatetags package
+
+
diff --git a/common/templatetags/common_tags.py b/common/templatetags/common_tags.py
new file mode 100644
index 0000000..ef17d42
--- /dev/null
+++ b/common/templatetags/common_tags.py
@@ -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)
\ No newline at end of file
diff --git a/contracts/__init__.py b/contracts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/contracts/admin.py b/contracts/admin.py
new file mode 100644
index 0000000..d823dbe
--- /dev/null
+++ b/contracts/admin.py
@@ -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']
\ No newline at end of file
diff --git a/contracts/apps.py b/contracts/apps.py
new file mode 100644
index 0000000..41f96d0
--- /dev/null
+++ b/contracts/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class ContractsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'contracts'
+ verbose_name = 'قراردادها'
+
diff --git a/contracts/migrations/0001_initial.py b/contracts/migrations/0001_initial.py
new file mode 100644
index 0000000..8f9f4b7
--- /dev/null
+++ b/contracts/migrations/0001_initial.py
@@ -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),
+ ),
+ ]
diff --git a/contracts/migrations/0002_remove_historicalcontracttemplate_history_user_and_more.py b/contracts/migrations/0002_remove_historicalcontracttemplate_history_user_and_more.py
new file mode 100644
index 0000000..60d434d
--- /dev/null
+++ b/contracts/migrations/0002_remove_historicalcontracttemplate_history_user_and_more.py
@@ -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',
+ ),
+ ]
diff --git a/contracts/migrations/__init__.py b/contracts/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/contracts/models.py b/contracts/models.py
new file mode 100644
index 0000000..24d8c82
--- /dev/null
+++ b/contracts/models.py
@@ -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}"
+
diff --git a/contracts/templates/contracts/contract_missing.html b/contracts/templates/contracts/contract_missing.html
new file mode 100644
index 0000000..432af77
--- /dev/null
+++ b/contracts/templates/contracts/contract_missing.html
@@ -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 %}
+
+
+ قالب قرارداد تعریف نشده است
+ برای نمایش این مرحله، ابتدا یک «قالب قرارداد» ایجاد کنید.
+
+ - از منوی ادمین یک قالب با متن قرارداد ایجاد کنید.
+ - در متن میتوانید از جاینگهدارهایی مثل {{customer_full_name}} ، {{national_code}} ، {{water_subscription_number}} استفاده کنید.
+
+ ایجاد قالب قرارداد
+ بازگشت
+
+
+{% endblock %}
+
+
diff --git a/contracts/templates/contracts/contract_print.html b/contracts/templates/contracts/contract_print.html
new file mode 100644
index 0000000..bc47f05
--- /dev/null
+++ b/contracts/templates/contracts/contract_print.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+ چاپ قرارداد {{ instance.code }}
+
+
+
+
+
+
+
+
+ {{ contract.template.company.name }}
+ {{ contract.template.name }}
+ کد درخواست: {{ instance.code }} | تاریخ: {{ contract.jcreated }}
+
+ {% if contract.template.company.logo %}
+ 
+ {% endif %}
+
+
+
+ {{ contract.rendered_body|safe }}
+
+
+
+
+ امضای شرکت
+
+ {% if contract.template.company.signature %}
+ 
+ {% endif %}
+
+
+
+
+
+
+
+
diff --git a/contracts/templates/contracts/contract_step.html b/contracts/templates/contracts/contract_step.html
new file mode 100644
index 0000000..33ff326
--- /dev/null
+++ b/contracts/templates/contracts/contract_step.html
@@ -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 %}
+
+{% endblock %}
+
+{% block content %}
+{% include '_toasts.html' %}
+
+
+
+
+
+ {{ step.name }}: {{ instance.process.name }}
+
+ اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
+ | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
+
+
+
+
+
+
+ {% stepper_header instance step %}
+
+
+
+ {% if template.company.logo %}
+
+ 
+ {{ contract.template.company.name }}
+ {{ contract.template.name }}
+
+ {% endif %}
+
+ تاریخ: {{ contract.jcreated }}
+
+ {{ contract.rendered_body|safe }}
+
+
+
+
+ امضای شرکت
+
+ {% if template.company.signature %}
+ 
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+
diff --git a/contracts/urls.py b/contracts/urls.py
new file mode 100644
index 0000000..e4257e1
--- /dev/null
+++ b/contracts/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+from . import views
+
+app_name = 'contracts'
+
+urlpatterns = [
+ path('instance//step//', views.contract_step, name='contract_step'),
+ path('instance//print/', views.contract_print, name='contract_print'),
+]
+
+
diff --git a/contracts/views.py b/contracts/views.py
new file mode 100644
index 0000000..f2d0deb
--- /dev/null
+++ b/contracts/views.py
@@ -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,
+ })
+
+
diff --git a/installations/__init__.py b/installations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/installations/admin.py b/installations/admin.py
new file mode 100644
index 0000000..656b0b5
--- /dev/null
+++ b/installations/admin.py
@@ -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',)
+
diff --git a/installations/apps.py b/installations/apps.py
new file mode 100644
index 0000000..cb52194
--- /dev/null
+++ b/installations/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class InstallationsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'installations'
+ verbose_name = 'نصب'
+
diff --git a/installations/migrations/0001_initial.py b/installations/migrations/0001_initial.py
new file mode 100644
index 0000000..05cedcd
--- /dev/null
+++ b/installations/migrations/0001_initial.py
@@ -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'],
+ },
+ ),
+ ]
diff --git a/installations/migrations/0002_installationreport_approved_and_more.py b/installations/migrations/0002_installationreport_approved_and_more.py
new file mode 100644
index 0000000..d5df3c8
--- /dev/null
+++ b/installations/migrations/0002_installationreport_approved_and_more.py
@@ -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='تاریخ تایید'),
+ ),
+ ]
diff --git a/installations/migrations/__init__.py b/installations/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/installations/models.py b/installations/models.py
new file mode 100644
index 0000000..62f7ecc
--- /dev/null
+++ b/installations/models.py
@@ -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']
+
+
diff --git a/installations/templates/installations/installation_assign_step.html b/installations/templates/installations/installation_assign_step.html
new file mode 100644
index 0000000..c1d6c79
--- /dev/null
+++ b/installations/templates/installations/installation_assign_step.html
@@ -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 %}
+
+
+
+
+{% endblock %}
+
+{% block content %}
+{% include '_toasts.html' %}
+
+
+
+
+
+ {{ step.name }}: {{ instance.process.name }}
+
+ اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
+ | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
+
+
+ بازگشت
+
+
+
+ {% stepper_header instance step %}
+
+
+
+
+
+
+{% endblock %}
+
+{% block script %}
+
+
+
+
+
+{% endblock %}
+
+
diff --git a/installations/templates/installations/installation_report_step.html b/installations/templates/installations/installation_report_step.html
new file mode 100644
index 0000000..b6a6d37
--- /dev/null
+++ b/installations/templates/installations/installation_report_step.html
@@ -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 %}
+
+
+
+
+{% endblock %}
+
+{% block content %}
+{% include '_toasts.html' %}
+
+
+
+
+
+ {{ step.name }}: {{ instance.process.name }}
+
+ اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
+ | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
+
+
+ بازگشت
+
+
+
+ {% stepper_header instance step %}
+
+
+
+ {% if report and not edit_mode %}
+
+
+
+
+
+ تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}
+ سریال جدید: {{ report.new_water_meter_serial|default:'-' }}
+ شماره پلمپ: {{ report.seal_number|default:'-' }}
+
+
+ کنتور مشکوک: {{ report.is_meter_suspicious|yesno:'بله,خیر' }}
+ UTM X: {{ report.utm_x|default:'-' }}
+ UTM Y: {{ report.utm_y|default:'-' }}
+
+
+ {% if report.description %}
+
+ توضیحات:
+ {{ report.description|default:'-' }}
+
+ {% endif %}
+
+ عکسها
+
+ {% for p in report.photos.all %}
+
+ {% empty %}
+ بدون عکس
+ {% endfor %}
+
+
+
+
+ اقلام
+
+
+
+
+ نوع |
+ آیتم |
+ تعداد |
+ قیمت واحد |
+ قیمت کل |
+
+
+
+ {% for ch in report.item_changes.all %}
+
+ {% if ch.change_type == 'add' %}{% else %}{% endif %} |
+ {{ ch.item.name }} |
+ {{ ch.quantity }} |
+ {% if ch.unit_price %}{{ ch.unit_price|floatformat:0|intcomma:False }}{% else %}-{% endif %} |
+
+ {% if ch.total_price %}
+ {{ ch.total_price|floatformat:0|intcomma:False }}
+ {% elif ch.unit_price %}
+ {{ ch.unit_price|floatformat:0|intcomma:False }}
+ {% else %}-{% endif %}
+ |
+
+ {% empty %}
+ تغییری ثبت نشده است |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+ {% if previous_step %}
+ قبلی
+ {% else %}
+
+ {% endif %}
+ {% if next_step %}
+ بعدی
+ {% endif %}
+
+ {% else %}
+
+
+
+ {% if previous_step %}
+ قبلی
+ {% else %}
+
+ {% endif %}
+
+
+ {% if next_step %}
+ بعدی
+ {% endif %}
+
+
+ {% endif %}
+
+
+
+
+
+
+{% endblock %}
+
+{% block script %}
+
+
+
+
+{% endblock %}
+
+
diff --git a/installations/urls.py b/installations/urls.py
new file mode 100644
index 0000000..fbd5979
--- /dev/null
+++ b/installations/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+from . import views
+
+app_name = 'installations'
+
+urlpatterns = [
+ path('instance//step//assign/', views.installation_assign_step, name='installation_assign_step'),
+ path('instance//step//report/', views.installation_report_step, name='installation_report_step'),
+]
+
+
diff --git a/installations/views.py b/installations/views.py
new file mode 100644
index 0000000..9271e8d
--- /dev/null
+++ b/installations/views.py
@@ -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,
+ })
+
+
diff --git a/invoices/migrations/0003_alter_historicalpayment_reference_number_and_more.py b/invoices/migrations/0003_alter_historicalpayment_reference_number_and_more.py
new file mode 100644
index 0000000..d84cc14
--- /dev/null
+++ b/invoices/migrations/0003_alter_historicalpayment_reference_number_and_more.py
@@ -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='شماره مرجع'),
+ ),
+ ]
diff --git a/invoices/migrations/0004_historicalpayment_direction_payment_direction.py b/invoices/migrations/0004_historicalpayment_direction_payment_direction.py
new file mode 100644
index 0000000..e3416cc
--- /dev/null
+++ b/invoices/migrations/0004_historicalpayment_direction_payment_direction.py
@@ -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='نوع تراکنش'),
+ ),
+ ]
diff --git a/invoices/migrations/0005_historicalitem_is_special_and_more.py b/invoices/migrations/0005_historicalitem_is_special_and_more.py
new file mode 100644
index 0000000..7524867
--- /dev/null
+++ b/invoices/migrations/0005_historicalitem_is_special_and_more.py
@@ -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='نوع ویژه'),
+ ),
+ ]
diff --git a/invoices/migrations/0006_remove_historicalitem_special_kind_and_more.py b/invoices/migrations/0006_remove_historicalitem_special_kind_and_more.py
new file mode 100644
index 0000000..5abdd01
--- /dev/null
+++ b/invoices/migrations/0006_remove_historicalitem_special_kind_and_more.py
@@ -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',
+ ),
+ ]
diff --git a/invoices/models.py b/invoices/models.py
index 015c675..10d8f83 100644
--- a/invoices/models.py
+++ b/invoices/models.py
@@ -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
-
- # بروزرسانی وضعیت
- if self.remaining_amount <= 0:
+ # خالص مانده به نفع شرکت (مثبت) یا به نفع مشتری (منفی)
+ net_due = self.final_amount - self.paid_amount
+ self.remaining_amount = net_due
+
+ # وضعیت بر اساس مانده خالص
+ 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
diff --git a/invoices/templates/invoices/final_invoice_print.html b/invoices/templates/invoices/final_invoice_print.html
new file mode 100644
index 0000000..cfa58eb
--- /dev/null
+++ b/invoices/templates/invoices/final_invoice_print.html
@@ -0,0 +1,55 @@
+{% extends '_base.html' %}
+{% load humanize %}
+
+{% block content %}
+
+
+
+ فاکتور نهایی
+ کد درخواست: {{ instance.code }}
+
+
+
+
+
+
+
+ آیتم |
+ تعداد |
+ قیمت واحد |
+ قیمت کل |
+
+
+
+ {% for it in items %}
+
+ {{ it.item.name }} |
+ {{ it.quantity }} |
+ {{ it.unit_price|floatformat:0|intcomma:False }} |
+ {{ it.total_price|floatformat:0|intcomma:False }} |
+
+ {% empty %}
+ آیتمی ندارد |
+ {% endfor %}
+
+
+ مبلغ کل | {{ invoice.total_amount|floatformat:0|intcomma:False }} |
+ تخفیف | {{ invoice.discount_amount|floatformat:0|intcomma:False }} |
+ مبلغ نهایی | {{ invoice.final_amount|floatformat:0|intcomma:False }} |
+ پرداختیها | {{ invoice.paid_amount|floatformat:0|intcomma:False }} |
+ مانده | {{ invoice.remaining_amount|floatformat:0|intcomma:False }} |
+
+
+
+
+ امضا مشتری
+ امضا شرکت
+
+
+
+{% endblock %}
+
+
diff --git a/invoices/templates/invoices/final_invoice_step.html b/invoices/templates/invoices/final_invoice_step.html
new file mode 100644
index 0000000..9376705
--- /dev/null
+++ b/invoices/templates/invoices/final_invoice_step.html
@@ -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 %}
+
+
+{% endblock %}
+
+{% block content %}
+{% include '_toasts.html' %}
+{% csrf_token %}
+
+
+
+
+
+ {{ step.name }}: {{ instance.process.name }}
+
+ اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
+ | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
+
+
+
+
+
+
+ {% stepper_header instance step %}
+
+
+
+
+
+
+
+
+
+ مبلغ نهایی
+ {{ invoice.final_amount|floatformat:0|intcomma:False }} تومان
+
+
+
+
+ پرداختیها
+ {{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان
+
+
+
+
+ مانده
+ {{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان
+
+
+
+ {% if invoice.remaining_amount <= 0 %}
+ تسویه کامل
+ {% else %}
+ باقیمانده دارد
+ {% endif %}
+
+
+
+
+
+
+ آیتم |
+ تعداد پایه |
+ افزوده |
+ حذف |
+ تعداد نهایی |
+ قیمت واحد (تومان) |
+ قیمت کل (تومان) |
+
+
+
+ {% for r in rows %}
+
+
+
+ {{ r.item.name }}
+ {% if r.item.description %}{{ r.item.description }}{% endif %}
+
+ |
+ {{ r.base_qty }} |
+ {{ r.added_qty }} |
+ {{ r.removed_qty }} |
+ {{ r.quantity }} |
+ {{ r.unit_price|floatformat:0|intcomma:False }} |
+ {{ r.total_price|floatformat:0|intcomma:False }} |
+
+ {% empty %}
+ آیتمی یافت نشد |
+ {% endfor %}
+ {% for si in invoice_specials %}
+
+
+
+ {{ si.item.name }}ویژه
+
+ |
+ - |
+ - |
+ - |
+ {{ si.quantity }} |
+ {{ si.unit_price|floatformat:0|intcomma:False }} |
+
+ {{ si.total_price|floatformat:0|intcomma:False }}
+
+ |
+
+ {% endfor %}
+
+
+
+ مبلغ کل |
+ {{ invoice.total_amount|floatformat:0|intcomma:False }} تومان |
+
+
+ تخفیف |
+ {{ invoice.discount_amount|floatformat:0|intcomma:False }} تومان |
+
+
+ مبلغ نهایی |
+ {{ invoice.final_amount|floatformat:0|intcomma:False }} تومان |
+
+
+ پرداختیها |
+ {{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان |
+
+
+ مانده |
+ {{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block script %}
+
+{% endblock %}
+
+
diff --git a/invoices/templates/invoices/final_settlement_step.html b/invoices/templates/invoices/final_settlement_step.html
new file mode 100644
index 0000000..5058a09
--- /dev/null
+++ b/invoices/templates/invoices/final_settlement_step.html
@@ -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 %}
+
+
+
+{% endblock %}
+
+{% block content %}
+{% include '_toasts.html' %}
+{% csrf_token %}
+
+
+
+
+
+ {{ step.name }}: {{ instance.process.name }}
+
+ اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
+ | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
+
+
+
+
+
+
+ {% stepper_header instance step %}
+
+
+
+
+
+
+
+
+
+
+
+ مبلغ نهایی
+ {{ invoice.final_amount|floatformat:0|intcomma:False }} تومان
+
+
+
+
+ مانده
+ {{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان
+
+
+
+
+
+
+
+
+
+
+
+
+ نوع |
+ مبلغ |
+ تاریخ |
+ روش |
+ شماره مرجع |
+ عملیات |
+
+
+
+ {% for p in payments %}
+
+ {% if p.direction == 'in' %}دریافتی{% else %}پرداختی{% endif %} |
+ {{ p.amount|floatformat:0|intcomma:False }} تومان |
+ {{ p.payment_date|to_jalali }} |
+ {{ p.get_payment_method_display }} |
+ {{ p.reference_number|default:'-' }} |
+
+
+ {% if p.receipt_image %}
+
+
+
+ {% endif %}
+
+
+ |
+
+ {% empty %}
+ تراکنشی ندارد |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block script %}
+
+
+
+{% endblock %}
+
+
diff --git a/invoices/templates/invoices/quote_payment_step.html b/invoices/templates/invoices/quote_payment_step.html
index 268aa72..ab9f518 100644
--- a/invoices/templates/invoices/quote_payment_step.html
+++ b/invoices/templates/invoices/quote_payment_step.html
@@ -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');
- setTimeout(() => { window.location.href = resp.redirect; }, 700);
+ 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');
- setTimeout(() => { window.location.href = resp.redirect; }, 600);
+ 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'));
}
diff --git a/invoices/templates/invoices/quote_preview_step.html b/invoices/templates/invoices/quote_preview_step.html
index f098634..4ba69f1 100644
--- a/invoices/templates/invoices/quote_preview_step.html
+++ b/invoices/templates/invoices/quote_preview_step.html
@@ -48,7 +48,7 @@
{% stepper_header instance step %}
-
+
diff --git a/invoices/urls.py b/invoices/urls.py
index 2acbd82..c338c4c 100644
--- a/invoices/urls.py
+++ b/invoices/urls.py
@@ -21,4 +21,17 @@ urlpatterns = [
# Quote print
path('instance/ /quote/print/', views.quote_print, name='quote_print'),
+
+ # Final invoice (step 7?) and print
+ path('instance//step//final-invoice/', views.final_invoice_step, name='final_invoice_step'),
+ path('instance//final-invoice/print/', views.final_invoice_print, name='final_invoice_print'),
+ path('instance//step//final-invoice/special/add/', views.add_special_charge, name='add_special_charge'),
+ path('instance//step//final-invoice/special//delete/', views.delete_special_charge, name='delete_special_charge'),
+ path('instance//step//final-invoice/approve/', views.approve_final_invoice, name='approve_final_invoice'),
+
+ # Final settlement payments (step 8?)
+ path('instance//step//final-settlement/', views.final_settlement_step, name='final_settlement_step'),
+ path('instance//step//final-settlement/add/', views.add_final_payment, name='add_final_payment'),
+ path('instance//step//final-settlement//delete/', views.delete_final_payment, name='delete_final_payment'),
+ path('instance//step//final-settlement/approve/', views.approve_final_settlement, name='approve_final_settlement'),
]
diff --git a/invoices/views.py b/invoices/views.py
index 096808f..d766496 100644
--- a/invoices/views.py
+++ b/invoices/views.py
@@ -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})
diff --git a/processes/templates/processes/request_list.html b/processes/templates/processes/request_list.html
index 8b175d8..acf274c 100644
--- a/processes/templates/processes/request_list.html
+++ b/processes/templates/processes/request_list.html
@@ -43,6 +43,7 @@
شناسه |
فرآیند |
+ مرحله فعلی |
شماره اشتراک آب |
نماینده |
درخواستکننده |
@@ -57,6 +58,7 @@
{{ inst.code }} |
{{ inst.process.name }} |
+ {{ inst.current_step.name|default:"--" }} |
{{ inst.well.water_subscription_number }} |
{% if inst.representative %}{{ inst.representative.get_full_name }}{% else %}-{% endif %} |
{% if inst.requester %}{{ inst.requester.get_full_name }}{% else %}-{% endif %} |
@@ -247,6 +249,10 @@
{{ customer_form.account_number }}
+
+
+ {{ customer_form.bank_name }}
+
{{ 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() || '');
diff --git a/processes/templatetags/processes_tags.py b/processes/templatetags/processes_tags.py
index e587f75..0dc1918 100644
--- a/processes/templatetags/processes_tags.py
+++ b/processes/templatetags/processes_tags.py
@@ -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
diff --git a/processes/views.py b/processes/views.py
index ddf1b41..1a0cac6 100644
--- a/processes/views.py
+++ b/processes/views.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()
|