From f2fc2362a79b32147727ab8c4a41e748a0d43f93 Mon Sep 17 00:00:00 2001 From: aminhashemi92 Date: Wed, 27 Aug 2025 07:11:26 +0330 Subject: [PATCH] complete first version of main proccess --- _base/settings.py | 3 + _base/urls.py | 3 + accounts/admin.py | 11 +- accounts/forms.py | 7 +- accounts/migrations/0002_company.py | 34 ++ ...icalprofile_bank_name_profile_bank_name.py | 23 + accounts/models.py | 28 +- .../templates/accounts/customer_list.html | 23 + accounts/views.py | 4 +- certificates/__init__.py | 0 certificates/admin.py | 20 + certificates/apps.py | 10 + certificates/migrations/0001_initial.py | 58 +++ ...rtificatetemplate_company_logo_and_more.py | 32 ++ certificates/migrations/__init__.py | 0 certificates/models.py | 38 ++ .../templates/certificates/print.html | 28 ++ certificates/templates/certificates/step.html | 53 +++ certificates/urls.py | 11 + certificates/views.py | 114 +++++ common/consts.py | 18 + common/templatetags/__init__.py | 3 + common/templatetags/common_tags.py | 7 + contracts/__init__.py | 0 contracts/admin.py | 15 + contracts/apps.py | 8 + contracts/migrations/0001_initial.py | 118 +++++ ...lcontracttemplate_history_user_and_more.py | 38 ++ contracts/migrations/__init__.py | 0 contracts/models.py | 36 ++ .../templates/contracts/contract_missing.html | 29 ++ .../templates/contracts/contract_print.html | 52 ++ .../templates/contracts/contract_step.html | 92 ++++ contracts/urls.py | 11 + contracts/views.py | 89 ++++ installations/__init__.py | 0 installations/admin.py | 39 ++ installations/apps.py | 8 + installations/migrations/0001_initial.py | 106 +++++ ...02_installationreport_approved_and_more.py | 23 + installations/migrations/__init__.py | 0 installations/models.py | 111 +++++ .../installation_assign_step.html | 159 +++++++ .../installation_report_step.html | 448 ++++++++++++++++++ installations/urls.py | 11 + installations/views.py | 255 ++++++++++ ...oricalpayment_reference_number_and_more.py | 23 + ...icalpayment_direction_payment_direction.py | 23 + ...0005_historicalitem_is_special_and_more.py | 33 ++ ...ve_historicalitem_special_kind_and_more.py | 21 + invoices/models.py | 43 +- .../invoices/final_invoice_print.html | 55 +++ .../invoices/final_invoice_step.html | 260 ++++++++++ .../invoices/final_settlement_step.html | 248 ++++++++++ .../invoices/quote_payment_step.html | 24 +- .../invoices/quote_preview_step.html | 2 +- invoices/urls.py | 13 + invoices/views.py | 356 ++++++++++++++ .../templates/processes/request_list.html | 12 +- processes/templatetags/processes_tags.py | 2 + processes/views.py | 17 + 61 files changed, 3280 insertions(+), 28 deletions(-) create mode 100644 accounts/migrations/0002_company.py create mode 100644 accounts/migrations/0003_historicalprofile_bank_name_profile_bank_name.py create mode 100644 certificates/__init__.py create mode 100644 certificates/admin.py create mode 100644 certificates/apps.py create mode 100644 certificates/migrations/0001_initial.py create mode 100644 certificates/migrations/0002_remove_certificatetemplate_company_logo_and_more.py create mode 100644 certificates/migrations/__init__.py create mode 100644 certificates/models.py create mode 100644 certificates/templates/certificates/print.html create mode 100644 certificates/templates/certificates/step.html create mode 100644 certificates/urls.py create mode 100644 certificates/views.py create mode 100644 common/templatetags/__init__.py create mode 100644 common/templatetags/common_tags.py create mode 100644 contracts/__init__.py create mode 100644 contracts/admin.py create mode 100644 contracts/apps.py create mode 100644 contracts/migrations/0001_initial.py create mode 100644 contracts/migrations/0002_remove_historicalcontracttemplate_history_user_and_more.py create mode 100644 contracts/migrations/__init__.py create mode 100644 contracts/models.py create mode 100644 contracts/templates/contracts/contract_missing.html create mode 100644 contracts/templates/contracts/contract_print.html create mode 100644 contracts/templates/contracts/contract_step.html create mode 100644 contracts/urls.py create mode 100644 contracts/views.py create mode 100644 installations/__init__.py create mode 100644 installations/admin.py create mode 100644 installations/apps.py create mode 100644 installations/migrations/0001_initial.py create mode 100644 installations/migrations/0002_installationreport_approved_and_more.py create mode 100644 installations/migrations/__init__.py create mode 100644 installations/models.py create mode 100644 installations/templates/installations/installation_assign_step.html create mode 100644 installations/templates/installations/installation_report_step.html create mode 100644 installations/urls.py create mode 100644 installations/views.py create mode 100644 invoices/migrations/0003_alter_historicalpayment_reference_number_and_more.py create mode 100644 invoices/migrations/0004_historicalpayment_direction_payment_direction.py create mode 100644 invoices/migrations/0005_historicalitem_is_special_and_more.py create mode 100644 invoices/migrations/0006_remove_historicalitem_special_kind_and_more.py create mode 100644 invoices/templates/invoices/final_invoice_print.html create mode 100644 invoices/templates/invoices/final_invoice_step.html create mode 100644 invoices/templates/invoices/final_settlement_step.html 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 %} + 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 %} + seal + {% 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 %} + 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 %} + seal + {% 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 %} +
+
+
+
+
+
+ {% csrf_token %} + {% if previous_step %} + قبلی + {% else %} + + {% endif %} + {% if next_step %} + + {% else %} + + {% 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 %} + +
+ +
+ {% csrf_token %} +
+
+ + +
+
+ + + +
+
+
+ {% if previous_step %} + قبلی + {% else %} + + {% endif %} + +
+
+
+
+
+
+
+{% 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 %} +
photo
+ {% empty %} +
بدون عکس
+ {% endfor %} +
+
+
+
+
اقلام
+
+ + + + + + + + + + + + {% for ch in report.item_changes.all %} + + + + + + + + {% empty %} + + {% endfor %} + +
نوعآیتمتعدادقیمت واحدقیمت کل
{% 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 %} +
تغییری ثبت نشده است
+
+
+
+
+
+ +
+ {% if previous_step %} + قبلی + {% else %} + + {% endif %} + {% if next_step %} + بعدی + {% endif %} +
+ {% else %} +
+ {% csrf_token %} +
+
+
+
+ + + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+ {% if report %} +
+ {% for p in report.photos.all %} +
+
+ photo + + +
+
+ {% empty %} + {% endfor %} +
+ {% endif %} +
+
+
+
+
+ +
+
+
اقلام
+
+
+
+
+
اقلام انتخاب‌شده قبلی (برای حذف در نصب تیک بزنید)
+
+ + + + + + + + + + + {% for qi in quote_items %} + + + + + + + {% empty %} + + {% endfor %} + +
حذفآیتمقیمت واحدتعداد
+ + + +
+ {{ qi.item.name }} + {% if qi.item.description %}{{ qi.item.description }}{% endif %} +
+
{{ qi.unit_price|floatformat:0|intcomma:False }} تومان + {% if removed_qty|get_item:qi.item.id %}{{ removed_qty|get_item:qi.item.id }}{% else %}{{ qi.quantity }}{% endif %} +
اقلامی ثبت نشده است
+
+
+
+
+
افزودن اقلام جدید
+
+ + + + + + + + + + + {% for it in all_items %} + + + + + + + {% endfor %} + +
آیتمقیمت واحدتعداد
+ {% with add_entry=added_map|get_item:it.id %} + + + {% endwith %} + +
+ {{ it.name }} + {% if it.description %}{{ it.description }}{% endif %} +
+
{{ it.unit_price|floatformat:0|intcomma:False }} تومان + {% with add_entry=added_map|get_item:it.id %} + + {% endwith %} +
+
+
+
+
+ +
+
+ +
+ {% 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 %} + + + + + + + {% empty %} + + {% endfor %} + + + + + + + + +
آیتمتعدادقیمت واحدقیمت کل
{{ it.item.name }}{{ it.quantity }}{{ it.unit_price|floatformat:0|intcomma:False }}{{ it.total_price|floatformat:0|intcomma:False }}
آیتمی ندارد
مبلغ کل{{ 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 %} + + + + + + + + + + {% empty %} + + {% endfor %} + {% for si in invoice_specials %} + + + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + + + + + + + + + +
آیتمتعداد پایهافزودهحذفتعداد نهاییقیمت واحد (تومان)قیمت کل (تومان)
+
+ {{ 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 }}
آیتمی یافت نشد
+
+ {{ si.item.name }}ویژه +
+
---{{ si.quantity }}{{ si.unit_price|floatformat:0|intcomma:False }} + {{ si.total_price|floatformat:0|intcomma:False }} + +
مبلغ کل{{ 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 %} +
+ +
+
+
+
ثبت تراکنش تسویه
+
+
+ {% csrf_token %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+
وضعیت فاکتور
+
+
+
+
+
مبلغ نهایی
+
{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان
+
+
+
+
+
مانده
+
{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان
+
+
+
+
+
+ +
+
تراکنش‌ها
+
+ + + + + + + + + + + + + {% for p in payments %} + + + + + + + + + {% empty %} + + {% endfor %} + +
نوعمبلغتاریخروششماره مرجععملیات
{% 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 %} + +
+
تراکنشی ندارد
+
+ +
+
+
+
+
+
+{% 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()