diff --git a/_base/settings.py b/_base/settings.py index 8261409..e9079ca 100644 --- a/_base/settings.py +++ b/_base/settings.py @@ -173,3 +173,6 @@ JAZZMIN_SETTINGS = { "custom_js": None, } + +# VAT / Value Added Tax percent (e.g., 0.09 for 9%) +VAT_RATE = 0.1 \ No newline at end of file diff --git a/_helpers/utils.py b/_helpers/utils.py index e4ee804..a7adccb 100644 --- a/_helpers/utils.py +++ b/_helpers/utils.py @@ -144,7 +144,7 @@ def persian_converter2(time): def persian_converter3(time): - time = time + datetime.timedelta(days=1) + time = time time_to_str = "{},{},{}".format(time.year, time.month, time.day) time_to_tuple = jalali.Gregorian(time_to_str).persian_tuple() time_to_list = list(time_to_tuple) diff --git a/accounts/admin.py b/accounts/admin.py index d741357..7ffe250 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -16,6 +16,8 @@ class ProfileAdmin(admin.ModelAdmin): list_display = [ "user", "fullname", + "user_type_display", + "company_name", "pic_tag", "roles_str", "affairs", @@ -25,8 +27,52 @@ class ProfileAdmin(admin.ModelAdmin): "is_active", "jcreated", ] - search_fields = ['user__username', 'user__first_name', 'user__last_name', 'user__phone_number'] - list_filter = ['user', 'roles', 'affairs', 'county', 'broker'] + search_fields = [ + 'user__username', + 'user__first_name', + 'user__last_name', + 'user__phone_number', + 'company_name', + 'company_national_id', + 'national_code' + ] + list_filter = [ + 'user_type', + 'user', + 'roles', + 'affairs', + 'county', + 'broker', + 'is_completed', + 'is_active' + ] + fieldsets = ( + ('اطلاعات کاربری', { + 'fields': ('user', 'user_type', 'pic', 'roles') + }), + ('اطلاعات شخصی - حقیقی', { + 'fields': ('national_code', 'address', 'phone_number_1', 'phone_number_2'), + 'classes': ('collapse',), + }), + ('اطلاعات شرکت - حقوقی', { + 'fields': ('company_name', 'company_national_id'), + 'classes': ('collapse',), + }), + ('اطلاعات بانکی', { + 'fields': ('card_number', 'account_number', 'bank_name'), + 'classes': ('collapse',), + }), + ('اطلاعات سازمانی', { + 'fields': ('affairs', 'county', 'broker', 'owner'), + }), + ('وضعیت', { + 'fields': ('is_completed', 'is_active'), + }), + ('تاریخ‌ها', { + 'fields': ('created', 'updated'), + 'classes': ('collapse',), + }), + ) date_hierarchy = 'created' ordering = ['-created'] readonly_fields = ['created', 'updated'] diff --git a/accounts/forms.py b/accounts/forms.py index 76beb31..a5d493b 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -2,7 +2,7 @@ from django import forms from django.contrib.auth import get_user_model from django.contrib.auth.forms import UserCreationForm from .models import Profile, Role -from common.consts import UserRoles +from common.consts import UserRoles, USER_TYPE_CHOICES User = get_user_model() @@ -28,10 +28,15 @@ class CustomerForm(forms.ModelForm): class Meta: model = Profile fields = [ - 'phone_number_1', 'phone_number_2', 'national_code', + 'user_type', 'phone_number_1', 'phone_number_2', 'national_code', + 'company_name', 'company_national_id', 'address', 'card_number', 'account_number', 'bank_name' ] widgets = { + 'user_type': forms.Select(attrs={ + 'class': 'form-control', + 'id': 'user-type-select' + }), 'phone_number_1': forms.TextInput(attrs={ 'class': 'form-control', 'placeholder': '09123456789' @@ -46,6 +51,15 @@ class CustomerForm(forms.ModelForm): 'maxlength': '10', 'required': 'required' }), + 'company_name': forms.TextInput(attrs={ + 'class': 'form-control company-field', + 'placeholder': 'نام شرکت' + }), + 'company_national_id': forms.TextInput(attrs={ + 'class': 'form-control company-field', + 'placeholder': 'شناسه ملی شرکت', + 'maxlength': '11' + }), 'address': forms.Textarea(attrs={ 'class': 'form-control', 'placeholder': 'آدرس کامل', @@ -67,9 +81,12 @@ class CustomerForm(forms.ModelForm): }), } labels = { + 'user_type': 'نوع کاربر', 'phone_number_1': 'تلفن ۱', 'phone_number_2': 'تلفن ۲', 'national_code': 'کد ملی', + 'company_name': 'نام شرکت', + 'company_national_id': 'شناسه ملی شرکت', 'address': 'آدرس', 'card_number': 'شماره کارت', 'account_number': 'شماره حساب', @@ -89,6 +106,21 @@ class CustomerForm(forms.ModelForm): raise forms.ValidationError('این کد ملی قبلاً استفاده شده است.') return national_code + def clean(self): + cleaned_data = super().clean() + user_type = cleaned_data.get('user_type') + company_name = cleaned_data.get('company_name') + company_national_id = cleaned_data.get('company_national_id') + + # If user type is legal, company fields are required + if user_type == 'legal': + if not company_name: + self.add_error('company_name', 'برای کاربران حقوقی نام شرکت الزامی است.') + if not company_national_id: + self.add_error('company_national_id', 'برای کاربران حقوقی شناسه ملی شرکت الزامی است.') + + return cleaned_data + def save(self, commit=True): def _compute_completed(cleaned): try: @@ -100,7 +132,15 @@ class CustomerForm(forms.ModelForm): bank_ok = bool(cleaned.get('bank_name')) card_ok = bool((cleaned.get('card_number') or '').strip()) acc_ok = bool((cleaned.get('account_number') or '').strip()) - return all([first_ok, last_ok, nc_ok, phone_ok, addr_ok, bank_ok, card_ok, acc_ok]) + + # Check user type specific requirements + user_type = cleaned.get('user_type', 'individual') + if user_type == 'legal': + company_name_ok = bool((cleaned.get('company_name') or '').strip()) + company_id_ok = bool((cleaned.get('company_national_id') or '').strip()) + return all([first_ok, last_ok, nc_ok, phone_ok, addr_ok, bank_ok, card_ok, acc_ok, company_name_ok, company_id_ok]) + else: + return all([first_ok, last_ok, nc_ok, phone_ok, addr_ok, bank_ok, card_ok, acc_ok]) except Exception: return False # Check if this is an update (instance exists) @@ -125,9 +165,12 @@ class CustomerForm(forms.ModelForm): profile.is_completed = _compute_completed({ 'first_name': user.first_name, 'last_name': user.last_name, + 'user_type': self.cleaned_data.get('user_type'), 'national_code': self.cleaned_data.get('national_code'), 'phone_number_1': self.cleaned_data.get('phone_number_1'), 'phone_number_2': self.cleaned_data.get('phone_number_2'), + 'company_name': self.cleaned_data.get('company_name'), + 'company_national_id': self.cleaned_data.get('company_national_id'), 'address': self.cleaned_data.get('address'), 'bank_name': self.cleaned_data.get('bank_name'), 'card_number': self.cleaned_data.get('card_number'), @@ -171,9 +214,12 @@ class CustomerForm(forms.ModelForm): profile.is_completed = _compute_completed({ 'first_name': user.first_name, 'last_name': user.last_name, + 'user_type': self.cleaned_data.get('user_type'), 'national_code': self.cleaned_data.get('national_code'), 'phone_number_1': self.cleaned_data.get('phone_number_1'), 'phone_number_2': self.cleaned_data.get('phone_number_2'), + 'company_name': self.cleaned_data.get('company_name'), + 'company_national_id': self.cleaned_data.get('company_national_id'), 'address': self.cleaned_data.get('address'), 'bank_name': self.cleaned_data.get('bank_name'), 'card_number': self.cleaned_data.get('card_number'), diff --git a/accounts/migrations/0007_historicalprofile_company_name_and_more.py b/accounts/migrations/0007_historicalprofile_company_name_and_more.py new file mode 100644 index 0000000..0d2edc1 --- /dev/null +++ b/accounts/migrations/0007_historicalprofile_company_name_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.4 on 2025-09-21 07:37 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0006_company_card_holder_name'), + ] + + operations = [ + migrations.AddField( + model_name='historicalprofile', + name='company_name', + field=models.CharField(blank=True, help_text='فقط برای کاربران حقوقی الزامی است', max_length=255, null=True, verbose_name='نام شرکت'), + ), + migrations.AddField( + model_name='historicalprofile', + name='company_national_id', + field=models.CharField(blank=True, help_text='فقط برای کاربران حقوقی الزامی است', max_length=11, null=True, validators=[django.core.validators.RegexValidator(code='invalid_company_national_id', message='شناسه ملی باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شناسه ملی شرکت'), + ), + migrations.AddField( + model_name='historicalprofile', + name='user_type', + field=models.CharField(choices=[('individual', 'حقیقی'), ('legal', 'حقوقی')], default='individual', max_length=20, verbose_name='نوع کاربر'), + ), + migrations.AddField( + model_name='profile', + name='company_name', + field=models.CharField(blank=True, help_text='فقط برای کاربران حقوقی الزامی است', max_length=255, null=True, verbose_name='نام شرکت'), + ), + migrations.AddField( + model_name='profile', + name='company_national_id', + field=models.CharField(blank=True, help_text='فقط برای کاربران حقوقی الزامی است', max_length=11, null=True, validators=[django.core.validators.RegexValidator(code='invalid_company_national_id', message='شناسه ملی باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شناسه ملی شرکت'), + ), + migrations.AddField( + model_name='profile', + name='user_type', + field=models.CharField(choices=[('individual', 'حقیقی'), ('legal', 'حقوقی')], default='individual', max_length=20, verbose_name='نوع کاربر'), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 937c329..348304e 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -4,7 +4,7 @@ from django.utils.html import format_html from django.core.validators import RegexValidator from simple_history.models import HistoricalRecords from common.models import TagModel, BaseModel, NameSlugModel -from common.consts import UserRoles, BANK_CHOICES +from common.consts import UserRoles, BANK_CHOICES, USER_TYPE_CHOICES from locations.models import Affairs, Broker, County @@ -88,6 +88,33 @@ class Profile(BaseModel): verbose_name="شماره تماس ۲", blank=True ) + user_type = models.CharField( + max_length=20, + choices=USER_TYPE_CHOICES, + default='individual', + verbose_name="نوع کاربر" + ) + company_national_id = models.CharField( + max_length=11, + null=True, + verbose_name="شناسه ملی شرکت", + blank=True, + validators=[ + RegexValidator( + regex=r'^\d+$', + message='شناسه ملی باید فقط شامل اعداد باشد.', + code='invalid_company_national_id' + ) + ], + help_text="فقط برای کاربران حقوقی الزامی است" + ) + company_name = models.CharField( + max_length=255, + null=True, + verbose_name="نام شرکت", + blank=True, + help_text="فقط برای کاربران حقوقی الزامی است" + ) pic = models.ImageField( upload_to="profile_images", @@ -179,6 +206,23 @@ class Profile(BaseModel): pic_tag.short_description = "تصویر" + def is_legal_entity(self): + return self.user_type == 'legal' + + def is_individual(self): + return self.user_type == 'individual' + + def get_display_name(self): + """Returns appropriate display name based on user type""" + if self.is_legal_entity() and self.company_name: + return self.company_name + return self.user.get_full_name() or str(self.user) + + def user_type_display(self): + return dict(USER_TYPE_CHOICES).get(self.user_type, self.user_type) + + user_type_display.short_description = "نوع کاربر" + class Company(NameSlugModel): logo = models.ImageField( diff --git a/accounts/templates/accounts/customer_list.html b/accounts/templates/accounts/customer_list.html index 24f5e5a..c1e2c0d 100644 --- a/accounts/templates/accounts/customer_list.html +++ b/accounts/templates/accounts/customer_list.html @@ -61,6 +61,7 @@ ردیف کاربر + نوع کاربر کد ملی تلفن آدرس @@ -100,6 +101,27 @@ + + {% if customer.user_type == 'legal' %} + + حقوقی + +
+ {% if customer.company_name %} + {{ customer.company_name|truncatechars:25 }} + {% endif %} + {% if customer.company_national_id %} + + {{ customer.company_national_id }} + + {% endif %} +
+ {% else %} + + حقیقی + + {% endif %} + {{ customer.national_code|default:"کد ملی ثبت نشده" }}
@@ -205,6 +227,16 @@ +
+ +
+ + {{ form.user_type }} +
+ {% if form.user_type.errors %} +
{{ form.user_type.errors.0 }}
+ {% endif %} +
@@ -261,6 +293,29 @@ {% endif %}
+ + + + +
@@ -347,6 +402,18 @@ کد ملی - + + نوع کاربر + - + + + نام شرکت + - + + + شناسه ملی شرکت + - + شماره تلفن اول - @@ -495,6 +562,9 @@ lengthMenu: [[10, 25, 50, -1], [10, 25, 50, "همه"]], order: [[0, 'asc']], responsive: true, + columnDefs: [ + { targets: [8], orderable: false } // عملیات column غیرقابل مرتب‌سازی + ] }); // Handle form submission @@ -603,6 +673,21 @@ $('#cd-username').text(c.user.username || '-'); $('#cd-fullname').text(c.user.full_name || '-'); $('#cd-national-code').text(c.national_code || '-'); + + // User type and company information + const userTypeDisplay = c.user_type === 'legal' ? 'حقوقی' : 'حقیقی'; + $('#cd-user-type').text(userTypeDisplay); + + if (c.user_type === 'legal') { + $('#cd-company-name').text(c.company_name || '-'); + $('#cd-company-id').text(c.company_national_id || '-'); + $('#cd-company-name-row').show(); + $('#cd-company-id-row').show(); + } else { + $('#cd-company-name-row').hide(); + $('#cd-company-id-row').hide(); + } + $('#cd-phone1').text(c.phone_number_1 || '-'); $('#cd-phone2').text(c.phone_number_2 || '-'); $('#cd-email').text(c.user.email || '-'); @@ -689,9 +774,12 @@ 'customer-id': customer.id, 'id_first_name': customer.first_name, 'id_last_name': customer.last_name, + 'user-type-select': customer.user_type, 'id_phone_number_1': customer.phone_number_1, 'id_phone_number_2': customer.phone_number_2, 'id_national_code': customer.national_code, + 'id_company_name': customer.company_name, + 'id_company_national_id': customer.company_national_id, 'id_card_number': customer.card_number, 'id_account_number': customer.account_number, 'id_address': customer.address, @@ -711,6 +799,14 @@ if (customer.bank_name !== undefined && customer.bank_name !== null) { $('#id_bank_name').val(customer.bank_name); } + + // Ensure user type is applied and toggle company fields + if (customer.user_type !== undefined && customer.user_type !== null) { + $('#user-type-select').val(customer.user_type); + } + + // Toggle company fields based on user type + toggleCompanyFields(); // Open modal $('#add-new-record').offcanvas('show'); @@ -753,8 +849,39 @@ $('.is-invalid').removeClass('is-invalid'); $('.invalid-feedback').remove(); + // Reset user type to individual and hide company fields + $('#user-type-select').val('individual'); + toggleCompanyFields(); + // Open modal $('#add-new-record').offcanvas('show'); } + + function toggleCompanyFields() { + const userType = $('#user-type-select').val(); + const companyFields = $('.company-fields'); + + if (userType === 'legal') { + companyFields.show(); + // Make company fields required + $('input[name="company_name"]').attr('required', true); + $('input[name="company_national_id"]').attr('required', true); + } else { + companyFields.hide(); + // Remove required attribute from company fields + $('input[name="company_name"]').removeAttr('required').val(''); + $('input[name="company_national_id"]').removeAttr('required').val(''); + // Clear any validation errors for company fields + $('.company-fields .is-invalid').removeClass('is-invalid'); + $('.company-fields .invalid-feedback').remove(); + } + } + + // Initialize user type toggle functionality + $(document).ready(function() { + $('#user-type-select').on('change', toggleCompanyFields); + // Initialize on page load + toggleCompanyFields(); + }); {% endblock %} \ No newline at end of file diff --git a/accounts/views.py b/accounts/views.py index fea6375..ab85e27 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -41,7 +41,7 @@ def dashboard(request): @login_required -@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT]) +@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER]) def customer_list(request): # Get all profiles that have customer role base = Profile.objects.filter(roles__slug=UserRoles.CUSTOMER.value, is_deleted=False).select_related('user') @@ -56,7 +56,7 @@ def customer_list(request): @require_POST @login_required -@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT]) +@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER]) def add_customer_ajax(request): """AJAX endpoint for adding customers""" form = CustomerForm(request.POST, request.FILES) @@ -96,7 +96,7 @@ def add_customer_ajax(request): @require_POST @login_required -@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT]) +@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER]) def edit_customer_ajax(request, customer_id): customer = get_object_or_404(Profile, id=customer_id) form = CustomerForm(request.POST, request.FILES, instance=customer) @@ -148,9 +148,12 @@ def get_customer_data(request, customer_id): form_html = { 'first_name': str(form['first_name']), 'last_name': str(form['last_name']), + 'user_type': str(form['user_type']), 'phone_number_1': str(form['phone_number_1']), 'phone_number_2': str(form['phone_number_2']), 'national_code': str(form['national_code']), + 'company_name': str(form['company_name']), + 'company_national_id': str(form['company_national_id']), 'card_number': str(form['card_number']), 'account_number': str(form['account_number']), 'address': str(form['address']), @@ -163,9 +166,12 @@ def get_customer_data(request, customer_id): 'id': customer.id, 'first_name': customer.user.first_name, 'last_name': customer.user.last_name, + 'user_type': customer.user_type or 'individual', 'phone_number_1': customer.phone_number_1 or '', 'phone_number_2': customer.phone_number_2 or '', 'national_code': customer.national_code or '', + 'company_name': customer.company_name or '', + 'company_national_id': customer.company_national_id or '', 'card_number': customer.card_number or '', 'account_number': customer.account_number or '', 'address': customer.address or '', @@ -177,7 +183,7 @@ def get_customer_data(request, customer_id): @require_GET @login_required -@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT]) +@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER]) def get_customer_details(request, customer_id): """جزئیات کامل مشترک برای نمایش در مدال""" customer = get_object_or_404( @@ -196,6 +202,9 @@ def get_customer_details(request, customer_id): 'date_joined': customer.jcreated_date() if customer.user.date_joined else '', }, 'national_code': customer.national_code or '', + 'user_type': customer.user_type or 'individual', + 'company_name': customer.company_name or '', + 'company_national_id': customer.company_national_id or '', 'phone_number_1': customer.phone_number_1 or '', 'phone_number_2': customer.phone_number_2 or '', 'card_number': customer.card_number or '', @@ -229,7 +238,7 @@ def get_customer_details(request, customer_id): @require_GET @login_required -@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT]) +@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER]) def get_customer_wells(request, customer_id): """چاه‌های مرتبط با یک مشترک""" customer = get_object_or_404(Profile, id=customer_id) @@ -262,7 +271,7 @@ def get_customer_wells(request, customer_id): @require_GET @login_required -@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT]) +@allowed_roles([UserRoles.ADMIN, UserRoles.BROKER, UserRoles.MANAGER, UserRoles.ACCOUNTANT, UserRoles.WATER_RESOURCE_MANAGER]) def get_customer_requests(request, customer_id): """درخواست‌های مرتبط با یک مشترک""" customer = get_object_or_404(Profile, id=customer_id) diff --git a/certificates/migrations/0002_certificateinstance_hologram_code.py b/certificates/migrations/0002_certificateinstance_hologram_code.py new file mode 100644 index 0000000..38f81b2 --- /dev/null +++ b/certificates/migrations/0002_certificateinstance_hologram_code.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-09-27 15:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('certificates', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='certificateinstance', + name='hologram_code', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='کد یکتا هولوگرام'), + ), + ] diff --git a/certificates/models.py b/certificates/models.py index 1b3dcaf..a7afe72 100644 --- a/certificates/models.py +++ b/certificates/models.py @@ -28,6 +28,7 @@ class CertificateInstance(BaseModel): issued_at = models.DateField(auto_now_add=True, verbose_name='تاریخ صدور') approved = models.BooleanField(default=False, verbose_name='تایید شده') approved_at = models.DateTimeField(null=True, blank=True, verbose_name='تاریخ تایید') + hologram_code = models.CharField(max_length=50, null=True, blank=True, verbose_name='کد یکتا هولوگرام') class Meta: verbose_name = 'گواهی' diff --git a/certificates/templates/certificates/print.html b/certificates/templates/certificates/print.html index d5ef11f..39b028a 100644 --- a/certificates/templates/certificates/print.html +++ b/certificates/templates/certificates/print.html @@ -18,19 +18,20 @@
-
+
+
کد یکتا هولوگرام: {{ cert.hologram_code|default:'-' }}
شماره درخواست: {{ instance.code }}
تاریخ: {{ cert.jissued_at }}
@@ -38,10 +39,7 @@
- {% if template.company and template.company.logo %} - logo - {% endif %} -

{{ cert.rendered_title }}

+

{{ cert.rendered_title }}

{% if template.company %}
{{ template.company.name }}
{% endif %} @@ -51,17 +49,41 @@
{{ cert.rendered_body|safe }}
- - -
-
-
مهر و امضای تایید کننده
-
{{ template.company.name }}
- {% if template.company and template.company.signature %} - seal - {% endif %} +
مشخصات چاه و کنتور هوشمند
+
+
+
موقعیت مکانی (UTM): {{ latest_report.utm_x|default:'-' }} , {{ latest_report.utm_y|default:'-' }}
+
نیرو محرکه چاه: {{ latest_report.driving_force|default:'-' }}
+
نوع کنتور: {{ latest_report.get_meter_type_display|default:'-' }}
+
قطر لوله آبده (اینچ): {{ latest_report.discharge_pipe_diameter|default:'-' }}
+
نوع مصرف: {{ latest_report.get_usage_type_display|default:'-' }}
+
شماره سیم‌کارت: {{ latest_report.sim_number|default:'-' }}
+
+
سایز کنتور: {{ latest_report.meter_size|default:'-' }}
+
شماره پروانه بهره‌برداری چاه: {{ latest_report.exploitation_license_number|default:'-' }}
+
قدرت موتور: {{ latest_report.motor_power|default:'-' }}
+
دبی قبل از کالیبراسیون: {{ latest_report.pre_calibration_flow_rate|default:'-' }}
+
دبی بعد از کالیبراسیون: {{ latest_report.post_calibration_flow_rate|default:'-' }}
+
نام شرکت کنتورساز: {{ latest_report.water_meter_manufacturer.name|default:'-' }}
+
شماره سریال کنتور: {{ instance.well.water_meter_serial_number|default:'-' }}
+
+
+ +
+
+
مهر و امضای تایید کننده
+
{{ template.company.name }}
+ {% if template.company and template.company.signature %} + seal + {% endif %} +
+
+
+
+ +
{% endblock %} diff --git a/processes/templatetags/processes_tags.py b/processes/templatetags/processes_tags.py index 5ee7b2f..5087f4e 100644 --- a/processes/templatetags/processes_tags.py +++ b/processes/templatetags/processes_tags.py @@ -28,9 +28,11 @@ def stepper_header(instance, current_step=None): status = step_id_to_status.get(step.id, 'pending') # بررسی دسترسی به مرحله (UI navigation constraints): - # can_access = instance.can_access_step(step) + can_access = instance.can_access_step(step) + # فقط مراحل تکمیل‌شده یا مرحله جاری قابل کلیک هستند - can_access = (step_id_to_status.get(step.id) == 'completed') or (instance.current_step and step.id == instance.current_step.id) + # can_access = (step_id_to_status.get(step.id) == 'completed') or (instance.current_step and step.id == instance.current_step.id) + # مرحله انتخاب‌شده (نمایش فعلی) is_selected = bool(current_step and step.id == current_step.id) # مرحله‌ای که باید انجام شود (مرحله جاری در instance) diff --git a/processes/urls.py b/processes/urls.py index a58ebab..0e1aaa5 100644 --- a/processes/urls.py +++ b/processes/urls.py @@ -6,6 +6,7 @@ app_name = 'processes' urlpatterns = [ # Requests UI path('requests/', views.request_list, name='request_list'), + path('requests/export/excel/', views.export_requests_excel, name='export_requests_excel'), path('requests/create/', views.create_request_with_entities, name='create_request_with_entities'), path('requests/lookup/well/', views.lookup_well_by_subscription, name='lookup_well_by_subscription'), path('requests/lookup/representative/', views.lookup_representative_by_national_code, name='lookup_representative_by_national_code'), diff --git a/processes/utils.py b/processes/utils.py index 951398e..717c0fc 100644 --- a/processes/utils.py +++ b/processes/utils.py @@ -20,7 +20,7 @@ def scope_instances_queryset(user, queryset=None): return qs.filter(id__in=assign_ids) if profile.has_role(UserRoles.BROKER): return qs.filter(broker=profile.broker) - if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER): + if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER) or profile.has_role(UserRoles.WATER_RESOURCE_MANAGER): return qs.filter(broker__affairs__county=profile.county) if profile.has_role(UserRoles.ADMIN): return qs @@ -69,7 +69,7 @@ def scope_wells_queryset(user, queryset=None): return qs if profile.has_role(UserRoles.BROKER): return qs.filter(broker=profile.broker) - if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER): + if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER) or profile.has_role(UserRoles.WATER_RESOURCE_MANAGER): return qs.filter(broker__affairs__county=profile.county) if profile.has_role(UserRoles.INSTALLER): # Wells that have instances assigned to this installer @@ -102,7 +102,7 @@ def scope_customers_queryset(user, queryset=None): return qs if profile.has_role(UserRoles.BROKER): return qs.filter(broker=profile.broker) - if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER): + if profile.has_role(UserRoles.ACCOUNTANT) or profile.has_role(UserRoles.MANAGER) or profile.has_role(UserRoles.WATER_RESOURCE_MANAGER): return qs.filter(county=profile.county) if profile.has_role(UserRoles.INSTALLER): # Customers that are representatives of instances assigned to this installer diff --git a/processes/views.py b/processes/views.py index 38ad946..d96a86b 100644 --- a/processes/views.py +++ b/processes/views.py @@ -3,13 +3,19 @@ from django.urls import reverse from django.contrib.auth.decorators import login_required from django.contrib import messages -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponse from django.views.decorators.http import require_POST, require_GET +from django.utils import timezone from django.db import transaction from django.contrib.auth import get_user_model +import openpyxl +from openpyxl.styles import Font, Alignment, PatternFill +from openpyxl.utils import get_column_letter +from datetime import datetime +from _helpers.utils import persian_converter3 from .models import Process, ProcessInstance, StepInstance, ProcessStep from .utils import scope_instances_queryset, get_scoped_instance_or_404 -from installations.models import InstallationAssignment +from installations.models import InstallationAssignment, InstallationReport from wells.models import Well from accounts.models import Profile, Broker from locations.models import Affairs @@ -65,18 +71,65 @@ def request_list(request): steps_list = ProcessStep.objects.select_related('process').all().order_by('process__name', 'order') manufacturers = WaterMeterManufacturer.objects.all().order_by('name') - # Calculate progress for each instance + # Prepare installation assignments map (scheduled date by instance) + try: + instance_ids = list(instances.values_list('id', flat=True)) + except Exception: + instance_ids = [] + assignments_map = {} + reports_map = {} + if instance_ids: + try: + ass_qs = InstallationAssignment.objects.filter(process_instance_id__in=instance_ids).values('process_instance_id', 'scheduled_date') + for row in ass_qs: + assignments_map[row['process_instance_id']] = row['scheduled_date'] + except Exception: + assignments_map = {} + # latest report per instance (visited_date) + try: + rep_qs = InstallationReport.objects.filter(assignment__process_instance_id__in=instance_ids).order_by('-created').values('assignment__process_instance_id', 'visited_date') + for row in rep_qs: + pid = row['assignment__process_instance_id'] + if pid not in reports_map: + reports_map[pid] = row['visited_date'] + except Exception: + reports_map = {} + + # Calculate progress for each instance and attach install schedule info instances_with_progress = [] for instance in instances: total_steps = instance.process.steps.count() completed_steps = instance.step_instances.filter(status='completed').count() progress_percentage = (completed_steps / total_steps * 100) if total_steps > 0 else 0 - + sched_date = assignments_map.get(instance.id) + overdue_days = 0 + reference_date = None + if sched_date: + # Reference date: until installer submits a report, use today; otherwise use visited_date + try: + visited_date = reports_map.get(instance.id) + if visited_date: + reference_date = visited_date + else: + try: + reference_date = timezone.localdate() + except Exception: + from datetime import date as _date + reference_date = _date.today() + if reference_date > sched_date: + overdue_days = (reference_date - sched_date).days + except Exception: + overdue_days = 0 + reference_date = None + + installation_scheduled_date = reference_date if reference_date and reference_date > sched_date else sched_date instances_with_progress.append({ 'instance': instance, 'progress_percentage': round(progress_percentage), 'completed_steps': completed_steps, 'total_steps': total_steps, + 'installation_scheduled_date': installation_scheduled_date, + 'installation_overdue_days': overdue_days, }) # Summary stats for header cards @@ -160,7 +213,10 @@ def lookup_representative_by_national_code(request): 'last_name': user.last_name, 'full_name': user.get_full_name(), 'profile': { + 'user_type': profile.user_type, 'national_code': profile.national_code, + 'company_name': profile.company_name, + 'company_national_id': profile.company_national_id, 'phone_number_1': profile.phone_number_1, 'phone_number_2': profile.phone_number_2, 'card_number': profile.card_number, @@ -240,6 +296,7 @@ def create_request_with_entities(request): well = existing well_data = request.POST.copy() + print(well_data) # Ensure representative set from created/selected user if not provided if representative_user and not well_data.get('representative'): well_data['representative'] = str(representative_user.id) @@ -366,12 +423,12 @@ def step_detail(request, instance_id, step_id): return redirect('processes:instance_summary', instance_id=instance.id) # جلوگیری از پرش به مراحل آینده: فقط اجازه نمایش مرحله جاری یا مراحل تکمیل‌شده - try: - if instance.current_step and step.order > instance.current_step.order: - messages.error(request, 'ابتدا مراحل قبلی را تکمیل کنید.') - return redirect('processes:step_detail', instance_id=instance.id, step_id=instance.current_step.id) - except Exception: - pass + # try: + # if instance.current_step and step.order > instance.current_step.order: + # messages.error(request, 'ابتدا مراحل قبلی را تکمیل کنید.') + # return redirect('processes:step_detail', instance_id=instance.id, step_id=instance.current_step.id) + # except Exception: + # pass # بررسی دسترسی به مرحله if not instance.can_access_step(step): @@ -471,4 +528,365 @@ def instance_summary(request, instance_id): 'latest_report': latest_report, 'certificate': certificate, }) + + +def format_date_jalali(date_obj): + """Convert date to Jalali format without time""" + if not date_obj: + return "" + try: + # If it's a datetime, get just the date part + if hasattr(date_obj, 'date'): + date_obj = date_obj.date() + return persian_converter3(date_obj) + except Exception: + return "" + +def format_datetime_jalali(datetime_obj): + """Convert datetime to Jalali format without time""" + if not datetime_obj: + return "" + try: + # Get just the date part + date_part = datetime_obj.date() if hasattr(datetime_obj, 'date') else datetime_obj + return persian_converter3(date_part) + except Exception: + return "" + +@login_required +def export_requests_excel(request): + """Export filtered requests to Excel""" + + # Get the same queryset as request_list view (with filters) + instances = ProcessInstance.objects.select_related( + 'process', 'current_step', 'representative', 'well', 'well__county', 'well__affairs' + ).prefetch_related('step_instances') + + # Apply scoping + instances = scope_instances_queryset(request.user, instances) + + # Apply filters (same logic as request_list view) + filter_status = request.GET.get('status', '').strip() + if filter_status: + instances = instances.filter(status=filter_status) + + filter_affairs = request.GET.get('affairs', '').strip() + if filter_affairs and filter_affairs.isdigit(): + instances = instances.filter(well__affairs_id=filter_affairs) + + filter_broker = request.GET.get('broker', '').strip() + if filter_broker and filter_broker.isdigit(): + instances = instances.filter(well__broker_id=filter_broker) + + filter_step = request.GET.get('step', '').strip() + if filter_step and filter_step.isdigit(): + instances = instances.filter(current_step_id=filter_step) + + # Get installation data + assignment_ids = list(instances.values_list('id', flat=True)) + assignments_map = {} + reports_map = {} + installers_map = {} + + if assignment_ids: + assignments = InstallationAssignment.objects.filter( + process_instance_id__in=assignment_ids + ).select_related('process_instance', 'installer') + assignments_map = {a.process_instance_id: a.scheduled_date for a in assignments} + installers_map = {a.process_instance_id: a.installer for a in assignments} + + reports = InstallationReport.objects.filter( + assignment__process_instance_id__in=assignment_ids + ).select_related('assignment') + reports_map = {r.assignment.process_instance_id: r for r in reports} + + # Get quotes and payments data + from invoices.models import Quote, Payment, Invoice + quotes_map = {} + payments_map = {} + settlement_dates_map = {} + approval_dates_map = {} + approval_users_map = {} + + if assignment_ids: + # Get quotes + quotes = Quote.objects.filter( + process_instance_id__in=assignment_ids + ).select_related('process_instance') + quotes_map = {q.process_instance_id: q for q in quotes} + + # Get payments with reference numbers + payments = Payment.objects.filter( + invoice__process_instance_id__in=assignment_ids, + is_deleted=False + ).select_related('invoice__process_instance').order_by('created') + + for payment in payments: + if payment.invoice.process_instance_id not in payments_map: + payments_map[payment.invoice.process_instance_id] = [] + payments_map[payment.invoice.process_instance_id].append(payment) + + # Get final invoices to check settlement dates + invoices = Invoice.objects.filter( + process_instance_id__in=assignment_ids + ).select_related('process_instance') + + for invoice in invoices: + if invoice.remaining_amount == 0: # Fully settled + # Find the last payment date for this invoice + last_payment = Payment.objects.filter( + invoice__process_instance=invoice.process_instance, + is_deleted=False + ).order_by('-created').first() + if last_payment: + settlement_dates_map[invoice.process_instance_id] = last_payment.created + + # Get installation approval data + from processes.models import StepInstance, StepApproval + installation_steps = StepInstance.objects.filter( + process_instance_id__in=assignment_ids, + step__slug='installation_report', # Assuming this is the slug for installation step + status='completed' + ).select_related('process_instance') + + for step_instance in installation_steps: + # Get the approval that completed this step + approval = StepApproval.objects.filter( + step_instance=step_instance, + decision='approved', + is_deleted=False + ).select_related('approved_by').order_by('-created').first() + + if approval: + approval_dates_map[step_instance.process_instance_id] = approval.created + approval_users_map[step_instance.process_instance_id] = approval.approved_by + + # Calculate progress and installation data + instances_with_progress = [] + for instance in instances: + total_steps = instance.process.steps.count() + completed_steps = instance.step_instances.filter(status='completed').count() + progress_percentage = (completed_steps / total_steps * 100) if total_steps > 0 else 0 + + sched_date = assignments_map.get(instance.id) + overdue_days = 0 + reference_date = None + + if sched_date: + try: + report = reports_map.get(instance.id) + if report and report.visited_date: + reference_date = report.visited_date + else: + try: + reference_date = timezone.localdate() + except Exception: + from datetime import date as _date + reference_date = _date.today() + if reference_date > sched_date: + overdue_days = (reference_date - sched_date).days + except Exception: + overdue_days = 0 + + installation_scheduled_date = reference_date if reference_date and reference_date > sched_date else sched_date + + instances_with_progress.append({ + 'instance': instance, + 'progress_percentage': round(progress_percentage), + 'completed_steps': completed_steps, + 'total_steps': total_steps, + 'installation_scheduled_date': installation_scheduled_date, + 'installation_overdue_days': overdue_days, + }) + + # Create Excel workbook + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "لیست درخواست‌ها" + + # Set RTL (Right-to-Left) direction + ws.sheet_view.rightToLeft = True + + # Define column headers + headers = [ + 'شناسه', + 'تاریخ ایجاد درخواست', + 'نام نماینده', + 'نام خانوادگی نماینده', + 'کد ملی نماینده', + 'نام شرکت', + 'شناسه شرکت', + 'سریال کنتور', + 'سریال کنتور جدید', + 'شماره اشتراک آب', + 'شماره اشتراک برق', + 'قدرت چاه', + 'شماره تماس ۱', + 'شماره تماس ۲', + 'آدرس', + 'مبلغ پیش‌فاکتور', + 'تاریخ واریزی‌ها و کدهای رهگیری', + 'تاریخ مراجعه نصاب', + 'تاخیر نصاب', + 'نام نصاب', + 'تاریخ تایید نصب توسط مدیر', + 'نام تایید کننده نصب', + 'تاریخ تسویه' + ] + + # Write headers + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = Font(bold=True) + cell.alignment = Alignment(horizontal='center') + cell.fill = PatternFill(start_color="CCCCCC", end_color="CCCCCC", fill_type="solid") + + # Write data rows + for row_num, item in enumerate(instances_with_progress, 2): + instance = item['instance'] + + # Get representative info + rep_first_name = "" + rep_last_name = "" + rep_national_code = "" + rep_phone_1 = "" + rep_phone_2 = "" + rep_address = "" + company_name = "" + company_national_id = "" + + if instance.representative: + rep_first_name = instance.representative.first_name or "" + rep_last_name = instance.representative.last_name or "" + if hasattr(instance.representative, 'profile') and instance.representative.profile: + profile = instance.representative.profile + rep_national_code = profile.national_code or "" + rep_phone_1 = profile.phone_number_1 or "" + rep_phone_2 = profile.phone_number_2 or "" + rep_address = profile.address or "" + if profile.user_type == 'legal': + company_name = profile.company_name or "" + company_national_id = profile.company_national_id or "" + + # Get well info + water_subscription = "" + electricity_subscription = "" + well_power = "" + old_meter_serial = "" + if instance.well: + water_subscription = instance.well.water_subscription_number or "" + electricity_subscription = instance.well.electricity_subscription_number or "" + well_power = str(instance.well.well_power) if instance.well.well_power else "" + old_meter_serial = instance.well.water_meter_serial_number or "" + + # Get new meter serial from installation report + new_meter_serial = "" + installer_visit_date = "" + report = reports_map.get(instance.id) + if report: + new_meter_serial = report.new_water_meter_serial or "" + installer_visit_date = format_date_jalali(report.visited_date) + + # Get quote amount + quote_amount = "" + quote = quotes_map.get(instance.id) + if quote: + quote_amount = str(quote.final_amount) if quote.final_amount else "" + + # Get payments info + payments_info = "" + payments = payments_map.get(instance.id, []) + if payments: + payment_strings = [] + for payment in payments: + date_str = format_datetime_jalali(payment.created) + reference_number = payment.reference_number or "بدون کد" + payment_strings.append(f"{date_str} - {reference_number}") + payments_info = " | ".join(payment_strings) + + # Get installer name + installer_name = "" + installer = installers_map.get(instance.id) + if installer: + installer_name = installer.get_full_name() or str(installer) + + # Get overdue days + overdue_days = "" + if item['installation_overdue_days'] and item['installation_overdue_days'] > 0: + overdue_days = str(item['installation_overdue_days']) + + # Get approval info + approval_date = "" + approval_user = "" + approval_date_obj = approval_dates_map.get(instance.id) + approval_user_obj = approval_users_map.get(instance.id) + if approval_date_obj: + approval_date = format_datetime_jalali(approval_date_obj) + if approval_user_obj: + approval_user = approval_user_obj.get_full_name() or str(approval_user_obj) + + # Get settlement date + settlement_date = "" + settlement_date_obj = settlement_dates_map.get(instance.id) + if settlement_date_obj: + settlement_date = format_datetime_jalali(settlement_date_obj) + + row_data = [ + instance.code, # شناسه + format_datetime_jalali(instance.created), # تاریخ ایجاد درخواست + rep_first_name, # نام نماینده + rep_last_name, # نام خانوادگی نماینده + rep_national_code, # کد ملی نماینده + company_name, # نام شرکت + company_national_id, # شناسه شرکت + old_meter_serial, # سریال کنتور + new_meter_serial, # سریال کنتور جدید + water_subscription, # شماره اشتراک آب + electricity_subscription, # شماره اشتراک برق + well_power, # قدرت چاه + rep_phone_1, # شماره تماس ۱ + rep_phone_2, # شماره تماس ۲ + rep_address, # آدرس + quote_amount, # مبلغ پیش‌فاکتور + payments_info, # تاریخ واریزی‌ها و کدهای رهگیری + installer_visit_date, # تاریخ مراجعه نصاب + overdue_days, # تاخیر نصاب + installer_name, # نام نصاب + approval_date, # تاریخ تایید نصب توسط مدیر + approval_user, # نام تایید کننده نصب + settlement_date # تاریخ تسویه + ] + + for col, value in enumerate(row_data, 1): + cell = ws.cell(row=row_num, column=col, value=value) + # Set right alignment for Persian text + cell.alignment = Alignment(horizontal='right') + + # Auto-adjust column widths + for col in range(1, len(headers) + 1): + column_letter = get_column_letter(col) + max_length = 0 + for row in ws[column_letter]: + try: + if len(str(row.value)) > max_length: + max_length = len(str(row.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # Prepare response + response = HttpResponse( + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + + # Generate filename with current date + current_date = datetime.now().strftime('%Y%m%d_%H%M') + filename = f'requests_export_{current_date}.xlsx' + response['Content-Disposition'] = f'attachment; filename="{filename}"' + + # Save workbook to response + wb.save(response) + + return response \ No newline at end of file diff --git a/templates/sidebars/admin.html b/templates/sidebars/admin.html index 066c177..3e0d49a 100644 --- a/templates/sidebars/admin.html +++ b/templates/sidebars/admin.html @@ -114,7 +114,7 @@ - {% if request.user|is_admin or request.user|is_broker or request.user|is_manager or request.user|is_accountant %} + {% if request.user|is_admin or request.user|is_broker or request.user|is_manager or request.user|is_accountant or request.user|is_water_resource_manager %}