This commit is contained in:
aminhashemi92 2025-09-29 17:38:11 +03:30
parent 810c87e2e0
commit b5bf3a5dbe
51 changed files with 2397 additions and 326 deletions

View file

@ -173,3 +173,6 @@ JAZZMIN_SETTINGS = {
"custom_js": None,
}
# VAT / Value Added Tax percent (e.g., 0.09 for 9%)
VAT_RATE = 0.1

View file

@ -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)

View file

@ -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']

View file

@ -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'),

View file

@ -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='نوع کاربر'),
),
]

View file

@ -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(

View file

@ -61,6 +61,7 @@
<tr>
<th>ردیف</th>
<th>کاربر</th>
<th>نوع کاربر</th>
<th>کد ملی</th>
<th>تلفن</th>
<th>آدرس</th>
@ -100,6 +101,27 @@
</div>
</div>
</td>
<td>
{% if customer.user_type == 'legal' %}
<span class="badge bg-label-info">
<i class="bx bx-buildings me-1"></i>حقوقی
</span>
<div class="mt-1">
{% if customer.company_name %}
<small class="text-muted d-block">{{ customer.company_name|truncatechars:25 }}</small>
{% endif %}
{% if customer.company_national_id %}
<small class="text-muted d-block">
<i class="bx bx-id-card me-1"></i>{{ customer.company_national_id }}
</small>
{% endif %}
</div>
{% else %}
<span class="badge bg-label-primary">
<i class="bx bx-user me-1"></i>حقیقی
</span>
{% endif %}
</td>
<td>{{ customer.national_code|default:"کد ملی ثبت نشده" }}</td>
<td>
<div class="d-flex flex-column">
@ -205,6 +227,16 @@
<input type="hidden" id="customer-id" name="customer_id" value="">
<!-- User Information -->
<div class="col-sm-12">
<label class="form-label fw-bold" for="{{ form.user_type.id_for_label }}">{{ form.user_type.label }}</label>
<div class="input-group input-group-merge">
<span class="input-group-text"><i class="bx bx-user-circle"></i></span>
{{ form.user_type }}
</div>
{% if form.user_type.errors %}
<div class="invalid-feedback d-block">{{ form.user_type.errors.0 }}</div>
{% endif %}
</div>
<div class="col-sm-6">
<label class="form-label fw-bold" for="{{ form.first_name.id_for_label }}">{{ form.first_name.label }}</label>
@ -261,6 +293,29 @@
{% endif %}
</div>
<!-- Company Information (for legal entities) -->
<div class="col-sm-12 company-fields" style="display: none;">
<label class="form-label fw-bold" for="{{ form.company_name.id_for_label }}">{{ form.company_name.label }}</label>
<div class="input-group input-group-merge">
<span class="input-group-text"><i class="bx bx-buildings"></i></span>
{{ form.company_name }}
</div>
{% if form.company_name.errors %}
<div class="invalid-feedback d-block">{{ form.company_name.errors.0 }}</div>
{% endif %}
</div>
<div class="col-sm-12 company-fields" style="display: none;">
<label class="form-label fw-bold" for="{{ form.company_national_id.id_for_label }}">{{ form.company_national_id.label }}</label>
<div class="input-group input-group-merge">
<span class="input-group-text"><i class="bx bx-id-card"></i></span>
{{ form.company_national_id }}
</div>
{% if form.company_national_id.errors %}
<div class="invalid-feedback d-block">{{ form.company_national_id.errors.0 }}</div>
{% endif %}
</div>
<div class="col-sm-12">
<label class="form-label fw-bold" for="{{ form.bank_name.id_for_label }}">{{ form.bank_name.label }}</label>
<div class="input-group input-group-merge">
@ -347,6 +402,18 @@
<td class="text-muted"><i class="bx bx-fingerprint me-1"></i>کد ملی</td>
<td><strong id="cd-national-code">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-user-circle me-1"></i>نوع کاربر</td>
<td><strong id="cd-user-type">-</strong></td>
</tr>
<tr id="cd-company-name-row" style="display: none;">
<td class="text-muted"><i class="bx bx-buildings me-1"></i>نام شرکت</td>
<td><strong id="cd-company-name">-</strong></td>
</tr>
<tr id="cd-company-id-row" style="display: none;">
<td class="text-muted"><i class="bx bx-id-card me-1"></i>شناسه ملی شرکت</td>
<td><strong id="cd-company-id">-</strong></td>
</tr>
<tr>
<td class="text-muted"><i class="bx bx-phone me-1"></i>شماره تلفن اول</td>
<td><strong id="cd-phone1">-</strong></td>
@ -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,
@ -712,6 +800,14 @@
$('#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');
} else {
@ -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();
});
</script>
{% endblock %}

View file

@ -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)

View file

@ -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='کد یکتا هولوگرام'),
),
]

View file

@ -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 = 'گواهی'

View file

@ -18,19 +18,20 @@
<link rel="stylesheet" href="{% static 'assets/css/persian-fonts.css' %}">
<style>
@page { size: A4; margin: 1cm; }
@page { size: A4 landscape; margin: 1cm; }
@media print { body { print-color-adjust: exact; } .no-print { display: none !important; } }
.header { border-bottom: 1px solid #dee2e6; padding-bottom: 16px; margin-bottom: 24px; }
.header { border-bottom: 0px solid #dee2e6; padding-bottom: 10px; margin-bottom: 10px; }
.company-name { font-weight: 600; }
.body-text { white-space: pre-line; line-height: 1.9; }
.signature-section { margin-top: 40px; border-top: 1px solid #dee2e6; padding-top: 24px; }
.signature-section { margin-top: 40px; border-top: 0px solid #dee2e6; padding-top: 24px; }
</style>
</head>
<body>
<div class="container-fluid py-3">
<!-- Top-left request info -->
<div class="d-flex mb-2">
<div class="d-flex">
<div class="ms-auto text-end">
<div class="">کد یکتا هولوگرام: {{ cert.hologram_code|default:'-' }}</div>
<div class="">شماره درخواست: {{ instance.code }}</div>
<div class="">تاریخ: {{ cert.jissued_at }}</div>
</div>
@ -38,10 +39,7 @@
<!-- Header with logo and company -->
<div class="header text-center">
{% if template.company and template.company.logo %}
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:90px">
{% endif %}
<h4 class="mt-2">{{ cert.rendered_title }}</h4>
<h4 class="">{{ cert.rendered_title }}</h4>
{% if template.company %}
<div class="text-muted company-name">{{ template.company.name }}</div>
{% endif %}
@ -51,17 +49,41 @@
<div class="body-text">
{{ cert.rendered_body|safe }}
</div>
<!-- Signature -->
<div class="signature-section d-flex justify-content-end">
<div class="text-center">
<div>مهر و امضای تایید کننده</div>
<div class="text-muted">{{ template.company.name }}</div>
{% if template.company and template.company.signature %}
<img src="{{ template.company.signature.url }}" alt="seal" style="max-height:200px">
{% endif %}
<h6 class="my-2">مشخصات چاه و کنتور هوشمند</h6>
<div class="row" style="font-size: 14px;">
<div class="col-4">
<div>موقعیت مکانی (UTM): {{ latest_report.utm_x|default:'-' }} , {{ latest_report.utm_y|default:'-' }}</div>
<div>نیرو محرکه چاه: {{ latest_report.driving_force|default:'-' }}</div>
<div>نوع کنتور: {{ latest_report.get_meter_type_display|default:'-' }}</div>
<div>قطر لوله آبده (اینچ): {{ latest_report.discharge_pipe_diameter|default:'-' }}</div>
<div>نوع مصرف: {{ latest_report.get_usage_type_display|default:'-' }}</div>
<div>شماره سیم‌کارت: {{ latest_report.sim_number|default:'-' }}</div>
</div>
<div class="col-4">
<div>سایز کنتور: {{ latest_report.meter_size|default:'-' }}</div>
<div>شماره پروانه بهره‌برداری چاه: {{ latest_report.exploitation_license_number|default:'-' }}</div>
<div>قدرت موتور: {{ latest_report.motor_power|default:'-' }}</div>
<div>دبی قبل از کالیبراسیون: {{ latest_report.pre_calibration_flow_rate|default:'-' }}</div>
<div>دبی بعد از کالیبراسیون: {{ latest_report.post_calibration_flow_rate|default:'-' }}</div>
<div>نام شرکت کنتورساز: {{ latest_report.water_meter_manufacturer.name|default:'-' }}</div>
<div>شماره سریال کنتور: {{ instance.well.water_meter_serial_number|default:'-' }}</div>
</div>
<div class="col-4">
<!-- Signature -->
<div class="signature-section d-flex justify-content-end">
<div class="text-center">
<div>مهر و امضای تایید کننده</div>
<div class="text-muted">{{ template.company.name }}</div>
{% if template.company and template.company.signature %}
<img src="{{ template.company.signature.url }}" alt="seal" style="max-height:200px">
{% endif %}
</div>
</div>
</div>
</div>
</div>
<script>

View file

@ -38,9 +38,9 @@
</small>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" target="_blank" href="{% url 'certificates:certificate_print' instance.id %}">
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="modal" data-bs-target="#printHologramModal">
<i class="bx bx-printer me-2"></i> پرینت
</a>
</button>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
@ -61,16 +61,33 @@
<div>تاریخ: {{ cert.jissued_at }}</div>
</div>
</div>
<div class="text-center mb-3">
{% if template.company and template.company.logo %}
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:80px">
{% endif %}
<div class="text-center">
<h5 class="mt-2">{{ cert.rendered_title }}</h5>
{% if template.company %}<div class="text-muted">{{ template.company.name }}</div>{% endif %}
</div>
<div class="mt-3" style="white-space:pre-line; line-height:1.9;">
<div class="mb-3" style="white-space:pre-line; line-height:1.9;">
{{ cert.rendered_body|safe }}
</div>
<h6 class="mb-2">مشخصات چاه و کنتور هوشمند</h6>
<div class="row g-2 small">
<div class="col-12 col-md-6">
<div class="d-flex gap-2"><span class="text-muted">موقعیت مکانی (UTM):</span><span class="fw-medium">{{ latest_report.utm_x|default:'-' }} , {{ latest_report.utm_y|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">نیرو محرکه چاه:</span><span class="fw-medium">{{ latest_report.driving_force|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">نوع کنتور:</span><span class="fw-medium">{{ latest_report.get_meter_type_display|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">قطر لوله آبده (اینچ):</span><span class="fw-medium">{{ latest_report.discharge_pipe_diameter|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">نوع مصرف:</span><span class="fw-medium">{{ latest_report.get_usage_type_display|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">شماره سیم‌کارت:</span><span class="fw-medium">{{ latest_report.sim_number|default:'-' }}</span></div>
</div>
<div class="col-12 col-md-6">
<div class="d-flex gap-2"><span class="text-muted">سایز کنتور:</span><span class="fw-medium">{{ latest_report.meter_size|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">شماره پروانه بهره‌برداری چاه:</span><span class="fw-medium">{{ latest_report.exploitation_license_number|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">قدرت موتور:</span><span class="fw-medium">{{ latest_report.motor_power|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">دبی قبل از کالیبراسیون:</span><span class="fw-medium">{{ latest_report.pre_calibration_flow_rate|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">دبی بعد از کالیبراسیون:</span><span class="fw-medium">{{ latest_report.post_calibration_flow_rate|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">نام شرکت کنتورساز:</span><span class="fw-medium">{{ latest_report.water_meter_manufacturer.name|default:'-' }}</span></div>
<div class="d-flex gap-2"><span class="text-muted">شماره سریال کنتور:</span><span class="fw-medium">{{ instance.well.water_meter_serial_number|default:'-' }}</span></div>
</div>
</div>
<div class="signature-section d-flex justify-content-end">
<div class="text-center">
<div>مهر و امضای تایید کننده</div>
@ -103,6 +120,29 @@
</div>
</div>
</div>
<!-- Print Hologram Modal -->
<div class="modal fade" id="printHologramModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'certificates:certificate_print' instance.id %}" target="_blank">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">کد یکتا هولوگرام</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label class="form-label">کد هولوگرام</label>
<input type="text" class="form-control" name="hologram_code" value="{{ cert.hologram_code|default:'' }}" placeholder="مثال: 123456" required>
<div class="form-text">این کد باید با کد هولوگرام روی گواهی یکسان باشد.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-primary">ثبت و پرینت</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -52,16 +52,20 @@ def certificate_step(request, instance_id, step_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()
previous_step = instance.process.steps.filter(order__lt=instance.current_step.order).last() if instance.current_step else None
prev_si = StepInstance.objects.filter(process_instance=instance, step=previous_step).first() if previous_step else None
if incomplete:
if incomplete and not prev_si.status == 'approved':
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')
if prev_si and not prev_si.status == 'approved':
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:
@ -117,6 +121,8 @@ def certificate_step(request, instance_id, step_id):
instance.save()
return redirect('processes:instance_summary', instance_id=instance.id)
# latest installation report for details
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
return render(request, 'certificates/step.html', {
'instance': instance,
'template': template,
@ -124,6 +130,7 @@ def certificate_step(request, instance_id, step_id):
'previous_step': previous_step,
'next_step': next_step,
'step': step,
'latest_report': latest_report,
})
@ -131,11 +138,32 @@ def certificate_step(request, instance_id, step_id):
def certificate_print(request, instance_id):
instance = get_scoped_instance_or_404(request, instance_id)
cert = CertificateInstance.objects.filter(process_instance=instance).order_by('-created').first()
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
if request.method == 'POST':
# Save/update hologram code then print
code = (request.POST.get('hologram_code') or '').strip()
if cert:
if code:
cert.hologram_code = code
cert.save(update_fields=['hologram_code'])
else:
template = CertificateTemplate.objects.filter(is_active=True).order_by('-created').first()
if template:
title, body = _render_template(template, instance)
cert = CertificateInstance.objects.create(process_instance=instance, template=template, rendered_title=title, rendered_body=body, hologram_code=code or None)
# proceed to rendering page after saving code
return render(request, 'certificates/print.html', {
'instance': instance,
'cert': cert,
'template': cert.template if cert else None,
'latest_report': latest_report,
})
template = cert.template if cert else None
return render(request, 'certificates/print.html', {
'instance': instance,
'cert': cert,
'template': template,
'latest_report': latest_report,
})

View file

@ -13,6 +13,11 @@ class UserRoles(Enum):
HEADQUARTER = "hdq" # ستاد آب منطقه‌ای
USER_TYPE_CHOICES = [
('individual', 'حقیقی'),
('legal', 'حقوقی'),
]
BANK_CHOICES = [
('mellat', 'بانک ملت'),
('saman', 'بانک سامان'),

View file

@ -96,9 +96,8 @@
<span></span>
{% endif %}
{% if next_step %}
{% if is_broker %}
<button type="submit" class="btn btn-primary">تایید و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
{% if is_broker and step_instance.status != 'completed' %}
<button type="submit" class="btn btn-primary">تایید
</button>
{% else %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">

View file

@ -1,3 +1,4 @@
from django.db.models.query import FlatValuesListIterable
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.urls import reverse
@ -28,6 +29,9 @@ def build_contract_context(instance: ProcessInstance) -> dict:
except Exception:
latest_payment_date = None
individual = True if profile and profile.user_type == 'individual' else False
company_national_id = profile.company_national_id if profile and profile.user_type == 'legal' else None
company_name = profile.company_name if profile and profile.user_type == 'legal' else None
return {
'customer_full_name': mark_safe(f"<span class=\"fw-bold\">{representative.get_full_name() if representative else ''}</span>"),
'registration_number': mark_safe(f"<span class=\"fw-bold\">{instance.broker.company.registration_number if instance.broker and instance.broker.company else ''}</span>"),
@ -48,6 +52,11 @@ def build_contract_context(instance: ProcessInstance) -> dict:
'bank_name': mark_safe(f"<span class=\"fw-bold\">{instance.representative.profile.get_bank_name_display() if instance.representative else ''}</span>"),
'prepayment_amount': mark_safe(f"<span class=\"fw-bold\">{int(total_paid):,}</span>"),
'prepayment_date': mark_safe(f"<span class=\"fw-bold\">{jalali_converter2(latest_payment_date)}</span>") if latest_payment_date else '',
'user_type': mark_safe(f"<span>{profile.get_user_type_display() if profile else ''}</span>"),
'individual': individual,
'company_national_id': mark_safe(f"<span class=\"fw-bold\">{company_national_id if company_national_id else ''}</span>"),
'company_name': mark_safe(f"<span class=\"fw-bold\">{company_name if company_name else ''}</span>"),
}
@ -59,6 +68,8 @@ def contract_step(request, instance_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()
step_instance = StepInstance.objects.filter(process_instance=instance, step=step).first()
profile = getattr(request.user, 'profile', None)
is_broker = False
can_view_contract_body = True
@ -93,15 +104,16 @@ def contract_step(request, instance_id, step_id):
if request.method == 'POST':
if not is_broker:
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
StepInstance.objects.update_or_create(
step_instance, _ = 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.current_step = next_step
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
# 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', {
@ -113,6 +125,7 @@ def contract_step(request, instance_id, step_id):
'next_step': next_step,
'is_broker': is_broker,
'can_view_contract_body': can_view_contract_body,
'step_instance': step_instance,
})

Binary file not shown.

View file

@ -21,10 +21,40 @@ class InstallationItemChangeInline(admin.TabularInline):
@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')
list_display = (
'assignment', 'visited_date', 'meter_type', 'meter_size', 'water_meter_manufacturer',
'discharge_pipe_diameter', 'usage_type', 'exploitation_license_number',
'motor_power', 'pre_calibration_flow_rate', 'post_calibration_flow_rate',
'new_water_meter_serial', 'seal_number', 'sim_number',
'is_meter_suspicious', 'approved', 'created'
)
list_filter = ('is_meter_suspicious', 'approved', 'visited_date', 'meter_type', 'usage_type', 'water_meter_manufacturer')
search_fields = (
'assignment__process_instance__code', 'new_water_meter_serial', 'seal_number', 'exploitation_license_number', 'sim_number'
)
inlines = [InstallationPhotoInline, InstallationItemChangeInline]
fieldsets = (
('زمان و تایید', {
'fields': ('visited_date', 'approved', 'approved_at')
}),
('کنتور و سازنده', {
'fields': (
'meter_type', 'meter_size', 'water_meter_manufacturer', 'new_water_meter_serial', 'seal_number', 'is_meter_suspicious', 'sim_number'
)
}),
('مشخصات هیدرولیکی', {
'fields': ('discharge_pipe_diameter', 'pre_calibration_flow_rate', 'post_calibration_flow_rate')
}),
('کاربری و مجوز', {
'fields': ('usage_type', 'exploitation_license_number')
}),
('توان و محرکه', {
'fields': ('driving_force', 'motor_power')
}),
('توضیحات', {
'fields': ('description',)
}),
)
@admin.register(InstallationPhoto)

208
installations/forms.py Normal file
View file

@ -0,0 +1,208 @@
from django import forms
from django.core.exceptions import ValidationError
from .models import InstallationReport
from wells.models import WaterMeterManufacturer
class InstallationReportForm(forms.ModelForm):
# Additional fields for manufacturer handling
new_manufacturer = forms.CharField(
max_length=100,
required=False,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'شرکت سازنده جدید',
'style': 'display:none;'
})
)
class Meta:
model = InstallationReport
fields = [
'visited_date', 'new_water_meter_serial', 'seal_number',
'utm_x', 'utm_y', 'meter_type', 'meter_size',
'discharge_pipe_diameter', 'usage_type', 'exploitation_license_number',
'motor_power', 'pre_calibration_flow_rate', 'post_calibration_flow_rate',
'water_meter_manufacturer', 'sim_number', 'driving_force',
'is_meter_suspicious', 'description'
]
widgets = {
'visited_date': forms.DateInput(attrs={
'type': 'date',
'class': 'form-control',
'required': True
}),
'new_water_meter_serial': forms.TextInput(attrs={
'class': 'form-control'
}),
'seal_number': forms.TextInput(attrs={
'class': 'form-control'
}),
'utm_x': forms.NumberInput(attrs={
'class': 'form-control',
'step': '1'
}),
'utm_y': forms.NumberInput(attrs={
'class': 'form-control',
'step': '1'
}),
'meter_type': forms.Select(attrs={
'class': 'form-select'
}, choices=[
('', 'انتخاب کنید'),
('smart', 'هوشمند (آبی/برق)'),
('volumetric', 'حجمی')
]),
'meter_size': forms.TextInput(attrs={
'class': 'form-control'
}),
'discharge_pipe_diameter': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0',
'step': '1'
}),
'usage_type': forms.Select(attrs={
'class': 'form-select'
}, choices=[
('', 'انتخاب کنید'),
('domestic', 'شرب و خدمات'),
('agriculture', 'کشاورزی'),
('industrial', 'صنعتی')
]),
'exploitation_license_number': forms.TextInput(attrs={
'class': 'form-control',
'required': True
}),
'motor_power': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0',
'step': '1'
}),
'pre_calibration_flow_rate': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0',
'step': '0.01'
}),
'post_calibration_flow_rate': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0',
'step': '0.01'
}),
'water_meter_manufacturer': forms.Select(attrs={
'class': 'form-select',
'id': 'id_water_meter_manufacturer'
}),
'sim_number': forms.TextInput(attrs={
'class': 'form-control'
}),
'driving_force': forms.TextInput(attrs={
'class': 'form-control'
}),
'is_meter_suspicious': forms.CheckboxInput(attrs={
'class': 'form-check-input',
'id': 'id_is_meter_suspicious'
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3
})
}
labels = {
'visited_date': 'تاریخ مراجعه',
'new_water_meter_serial': 'سریال کنتور جدید',
'seal_number': 'شماره پلمپ',
'utm_x': 'UTM X',
'utm_y': 'UTM Y',
'meter_type': 'نوع کنتور',
'meter_size': 'سایز کنتور',
'discharge_pipe_diameter': 'قطر لوله آبده (اینچ)',
'usage_type': 'نوع مصرف',
'exploitation_license_number': 'شماره پروانه بهره‌برداری',
'motor_power': 'قدرت موتور (کیلووات ساعت)',
'pre_calibration_flow_rate': 'دبی قبل از کالیبراسیون (لیتر بر ثانیه)',
'post_calibration_flow_rate': 'دبی بعد از کالیبراسیون (لیتر بر ثانیه)',
'water_meter_manufacturer': 'شرکت سازنده کنتور',
'sim_number': 'شماره سیمکارت',
'driving_force': 'نیرو محرکه چاه',
'is_meter_suspicious': 'کنتور مشکوک است',
'description': 'توضیحات'
}
def __init__(self, *args, **kwargs):
self.user_is_installer = kwargs.pop('user_is_installer', False)
self.instance_well = kwargs.pop('instance_well', None)
super().__init__(*args, **kwargs)
# Set manufacturer choices
manufacturers = WaterMeterManufacturer.objects.filter(is_deleted=False)
manufacturer_choices = [('', 'انتخاب شرکت سازنده')]
manufacturer_choices.extend([(m.id, m.name) for m in manufacturers])
self.fields['water_meter_manufacturer'].choices = manufacturer_choices
# Pre-fill UTM from well if available and no existing report data
if self.instance_well and not self.instance.pk:
if self.instance_well.utm_x:
self.initial['utm_x'] = self.instance_well.utm_x
if self.instance_well.utm_y:
self.initial['utm_y'] = self.instance_well.utm_y
# Disable fields for non-installers
if not self.user_is_installer:
for field_name, field in self.fields.items():
if field_name != 'new_manufacturer': # Keep this always disabled via CSS
field.widget.attrs['readonly'] = True
if isinstance(field.widget, (forms.Select, forms.CheckboxInput)):
field.widget.attrs['disabled'] = True
def clean(self):
cleaned_data = super().clean()
# Handle new manufacturer creation
new_manufacturer = cleaned_data.get('new_manufacturer')
water_meter_manufacturer = cleaned_data.get('water_meter_manufacturer')
if new_manufacturer and not water_meter_manufacturer:
# Create new manufacturer
manufacturer, created = WaterMeterManufacturer.objects.get_or_create(
name=new_manufacturer,
defaults={'is_deleted': False}
)
cleaned_data['water_meter_manufacturer'] = manufacturer
return cleaned_data
def clean_visited_date(self):
visited_date = self.cleaned_data.get('visited_date')
if not visited_date:
raise ValidationError('تاریخ مراجعه الزامی است.')
return visited_date
def clean_exploitation_license_number(self):
license_number = self.cleaned_data.get('exploitation_license_number')
if not license_number or not license_number.strip():
raise ValidationError('شماره پروانه بهره‌برداری الزامی است.')
return license_number.strip()
def validate_photos(self, request_files, existing_photos, deleted_photo_ids):
"""
Validate that at least one photo is present (either existing or newly uploaded)
This method should be called from the view after form.is_valid()
"""
# Count existing photos that are not deleted
kept_existing = 0
if existing_photos:
for photo in existing_photos:
if str(photo.id) not in deleted_photo_ids:
kept_existing += 1
# Count new photos
new_photos = len(request_files.getlist('photos')) if request_files else 0
total_photos = kept_existing + new_photos
if total_photos <= 0:
raise ValidationError('بارگذاری حداقل یک عکس الزامی است.')
return True

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-09-21 07:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='utm_x',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='UTM X'),
),
migrations.AlterField(
model_name='installationreport',
name='utm_y',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='UTM Y'),
),
]

View file

@ -0,0 +1,70 @@
# Generated by Django 5.2.4 on 2025-09-24 11:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0002_alter_installationreport_utm_x_and_more'),
('wells', '0004_remove_historicalwell_discharge_pipe_diameter_and_more'),
]
operations = [
migrations.AddField(
model_name='installationreport',
name='discharge_pipe_diameter',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='قطر لوله آبده (اینچ)'),
),
migrations.AddField(
model_name='installationreport',
name='driving_force',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='نیرو محرکه چاه'),
),
migrations.AddField(
model_name='installationreport',
name='exploitation_license_number',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='شماره پروانه بهره\u200cبرداری چاه'),
),
migrations.AddField(
model_name='installationreport',
name='meter_size',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='سایز کنتور'),
),
migrations.AddField(
model_name='installationreport',
name='meter_type',
field=models.CharField(blank=True, choices=[('smart', 'هوشمند (آبی/برق)'), ('volumetric', 'حجمی')], max_length=20, null=True, verbose_name='نوع کنتور'),
),
migrations.AddField(
model_name='installationreport',
name='motor_power',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='(کیلووات ساعت)قدرت موتور'),
),
migrations.AddField(
model_name='installationreport',
name='post_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی بعد از کالیبراسیون'),
),
migrations.AddField(
model_name='installationreport',
name='pre_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
),
migrations.AddField(
model_name='installationreport',
name='sim_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='شماره سیمکارت'),
),
migrations.AddField(
model_name='installationreport',
name='usage_type',
field=models.CharField(blank=True, choices=[('domestic', 'شرب و خدمات'), ('agriculture', 'کشاورزی'), ('industrial', 'صنعتی')], max_length=20, null=True, verbose_name='نوع مصرف'),
),
migrations.AddField(
model_name='installationreport',
name='water_meter_manufacturer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='wells.watermetermanufacturer', verbose_name='شرکت سازنده کنتور آب'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-27 06:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0003_installationreport_discharge_pipe_diameter_and_more'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='motor_power',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='(کیلووات ساعت) قدرت موتور'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-29 10:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0004_alter_installationreport_motor_power'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='usage_type',
field=models.CharField(choices=[('domestic', 'شرب و خدمات'), ('agriculture', 'کشاورزی'), ('industrial', 'صنعتی')], max_length=20, null=True, verbose_name='نوع مصرف'),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 5.2.4 on 2025-09-29 10:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0005_alter_installationreport_usage_type'),
]
operations = [
migrations.AlterField(
model_name='installationreport',
name='exploitation_license_number',
field=models.CharField(default=1, max_length=50, verbose_name='شماره پروانه بهره\u200cبرداری چاه'),
preserve_default=False,
),
]

View file

@ -42,6 +42,26 @@ class InstallationReport(BaseModel):
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='کنتور مشکوک است؟')
METER_TYPE_CHOICES = [
('smart', 'هوشمند (آبی/برق)'),
('volumetric', 'حجمی'),
]
meter_type = models.CharField(max_length=20, choices=METER_TYPE_CHOICES, null=True, blank=True, verbose_name='نوع کنتور')
meter_size = models.CharField(max_length=50, null=True, blank=True, verbose_name='سایز کنتور')
discharge_pipe_diameter = models.PositiveIntegerField(null=True, blank=True, verbose_name='قطر لوله آبده (اینچ)')
USAGE_TYPE_CHOICES = [
('domestic', 'شرب و خدمات'),
('agriculture', 'کشاورزی'),
('industrial', 'صنعتی'),
]
usage_type = models.CharField(max_length=20, choices=USAGE_TYPE_CHOICES, null=True, verbose_name='نوع مصرف')
exploitation_license_number = models.CharField(max_length=50, verbose_name='شماره پروانه بهره‌برداری چاه')
motor_power = models.PositiveIntegerField(null=True, blank=True, verbose_name='(کیلووات ساعت) قدرت موتور')
pre_calibration_flow_rate = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name='(لیتر بر ثانیه)دبی قبل از کالیبراسیون')
post_calibration_flow_rate = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name='(لیتر بر ثانیه)دبی بعد از کالیبراسیون')
water_meter_manufacturer = models.ForeignKey('wells.WaterMeterManufacturer', on_delete=models.SET_NULL, null=True, blank=True, verbose_name='شرکت سازنده کنتور آب')
sim_number = models.CharField(max_length=20, null=True, blank=True, verbose_name='شماره سیمکارت')
driving_force = models.CharField(max_length=50, null=True, blank=True, verbose_name='نیرو محرکه چاه')
utm_x = models.DecimalField(max_digits=10, decimal_places=0, null=True, blank=True, verbose_name='UTM X')
utm_y = models.DecimalField(max_digits=10, decimal_places=0, null=True, blank=True, verbose_name='UTM Y')
description = models.TextField(blank=True, verbose_name='توضیحات')

View file

@ -63,7 +63,7 @@
<div class="bs-stepper-content">
{% if report and not edit_mode %}
<div class="mb-3 text-end">
{% if user_is_installer %}
{% if user_is_installer and not report.approved %}
<a href="?edit=1" class="btn btn-primary">
<i class="bx bx-edit bx-sm me-2"></i>
ویرایش گزارش نصب
@ -86,11 +86,23 @@
<p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-purchase-tag bx-sm me-2"></i>سریال جدید: {{ report.new_water_meter_serial|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-lock-alt bx-sm me-2"></i>شماره پلمپ: {{ report.seal_number|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-chip bx-sm me-2"></i>نوع کنتور: {{ report.get_meter_type_display|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-ruler bx-sm me-2"></i>سایز کنتور: {{ report.meter_size|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-tachometer bx-sm me-2"></i>قطر لوله آبده (اینچ): {{ report.discharge_pipe_diameter|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-building bx-sm me-2"></i>سازنده کنتور: {{ report.water_meter_manufacturer|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-sim-card bx-sm me-2"></i>شماره سیمکارت: {{ report.sim_number|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-cog bx-sm me-2"></i>نیرو محرکه چاه: {{ report.driving_force|default:'-' }}</p>
</div>
<div class="col-md-6">
<p class="text-nowrap mb-2"><i class="bx bx-help-circle bx-sm me-2"></i>کنتور مشکوک: {{ report.is_meter_suspicious|yesno:'بله,خیر' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-map bx-sm me-2"></i>UTM X: {{ report.utm_x|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-map-pin bx-sm me-2"></i>UTM Y: {{ report.utm_y|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-category bx-sm me-2"></i>نوع مصرف: {{ report.get_usage_type_display|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-id-card bx-sm me-2"></i>شماره پروانه بهره‌برداری: {{ report.exploitation_license_number|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-bolt-circle bx-sm me-2"></i>(کیلووات ساعت)قدرت موتور: {{ report.motor_power|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-water bx-sm me-2"></i>(لیتر/ثانیه) دبی قبل کالیبراسیون: {{ report.pre_calibration_flow_rate|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-water bx-sm me-2"></i>(لیتر/ثانیه) دبی بعد کالیبراسیون: {{ report.post_calibration_flow_rate|default:'-' }}</p>
</div>
</div>
{% if report.description %}
@ -155,11 +167,18 @@
<div class="card border mt-2">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">وضعیت تاییدها</h6>
{% if user_can_approve %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveModal">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectModal">رد</button>
</div>
{% if can_approve_reject %}
{% if current_user_has_decided %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" disabled>تایید</button>
<button type="button" class="btn btn-danger btn-sm" disabled>رد</button>
</div>
{% else %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveModal">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectModal">رد</button>
</div>
{% endif %}
{% endif %}
</div>
<div class="card-body py-3">
@ -218,36 +237,144 @@
<div class="">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">تاریخ مراجعه</label>
<input type="text" id="id_visited_date_display" class="form-control" placeholder="انتخاب تاریخ" {% if not user_is_installer %}disabled{% endif %} readonly required value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y/m/d' }}{% endif %}">
<input type="hidden" id="id_visited_date" name="visited_date" value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y-m-d' }}{% endif %}">
{{ form.visited_date.label_tag }}
<!-- Custom date picker handling -->
<input type="text" id="id_visited_date_display" class="form-control{% if form.visited_date.errors %} is-invalid{% endif %}" placeholder="انتخاب تاریخ" {% if not user_is_installer %}disabled{% endif %} readonly required value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y/m/d' }}{% elif form.visited_date.value %}{{ form.visited_date.value|date:'Y/m/d' }}{% endif %}">
<input type="hidden" id="id_visited_date" name="visited_date" value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y-m-d' }}{% elif form.visited_date.value %}{{ form.visited_date.value }}{% endif %}">
{% if form.visited_date.errors %}
<div class="invalid-feedback">{{ form.visited_date.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label class="form-label">سریال کنتور جدید</label>
<input type="text" class="form-control" name="new_water_meter_serial" value="{% if report and edit_mode %}{{ report.new_water_meter_serial|default_if_none:'' }}{% endif %}" {% if not user_is_installer %}readonly{% endif %}>
{{ form.new_water_meter_serial.label_tag }}
{{ form.new_water_meter_serial }}
{% if form.new_water_meter_serial.errors %}
<div class="invalid-feedback">{{ form.new_water_meter_serial.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label class="form-label">شماره پلمپ</label>
<input type="text" class="form-control" name="seal_number" value="{% if report and edit_mode %}{{ report.seal_number|default_if_none:'' }}{% endif %}" {% if not user_is_installer %}readonly{% endif %}>
{{ form.seal_number.label_tag }}
{{ form.seal_number }}
{% if form.seal_number.errors %}
<div class="invalid-feedback">{{ form.seal_number.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.utm_x.label_tag }}
{{ form.utm_x }}
{% if form.utm_x.errors %}
<div class="invalid-feedback">{{ form.utm_x.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.utm_y.label_tag }}
{{ form.utm_y }}
{% if form.utm_y.errors %}
<div class="invalid-feedback">{{ form.utm_y.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.meter_type.label_tag }}
{{ form.meter_type }}
{% if form.meter_type.errors %}
<div class="invalid-feedback">{{ form.meter_type.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.meter_size.label_tag }}
{{ form.meter_size }}
{% if form.meter_size.errors %}
<div class="invalid-feedback">{{ form.meter_size.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.discharge_pipe_diameter.label_tag }}
{{ form.discharge_pipe_diameter }}
{% if form.discharge_pipe_diameter.errors %}
<div class="invalid-feedback">{{ form.discharge_pipe_diameter.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.usage_type.label_tag }}
{{ form.usage_type }}
{% if form.usage_type.errors %}
<div class="invalid-feedback">{{ form.usage_type.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.exploitation_license_number.label_tag }}
{{ form.exploitation_license_number }}
{% if form.exploitation_license_number.errors %}
<div class="invalid-feedback">{{ form.exploitation_license_number.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.motor_power.label_tag }}
{{ form.motor_power }}
{% if form.motor_power.errors %}
<div class="invalid-feedback">{{ form.motor_power.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.pre_calibration_flow_rate.label_tag }}
{{ form.pre_calibration_flow_rate }}
{% if form.pre_calibration_flow_rate.errors %}
<div class="invalid-feedback">{{ form.pre_calibration_flow_rate.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.post_calibration_flow_rate.label_tag }}
{{ form.post_calibration_flow_rate }}
{% if form.post_calibration_flow_rate.errors %}
<div class="invalid-feedback">{{ form.post_calibration_flow_rate.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.water_meter_manufacturer.label_tag }}
<div class="input-group">
{{ form.water_meter_manufacturer }}
{{ form.new_manufacturer }}
{% if user_is_installer %}
<button class="btn btn-outline-primary" type="button" id="btnToggleManufacturer"><i class="bx bx-plus"></i></button>
{% endif %}
</div>
{% if form.water_meter_manufacturer.errors %}
<div class="invalid-feedback">{{ form.water_meter_manufacturer.errors.0 }}</div>
{% endif %}
{% if form.new_manufacturer.errors %}
<div class="invalid-feedback">{{ form.new_manufacturer.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.sim_number.label_tag }}
{{ form.sim_number }}
{% if form.sim_number.errors %}
<div class="invalid-feedback">{{ form.sim_number.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.driving_force.label_tag }}
{{ form.driving_force }}
{% if form.driving_force.errors %}
<div class="invalid-feedback">{{ form.driving_force.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_meter_suspicious" id="id_is_meter_suspicious" {% if not user_is_installer %}disabled{% endif %} {% if report and edit_mode and report.is_meter_suspicious %}checked{% endif %}>
<label class="form-check-label" for="id_is_meter_suspicious">کنتور مشکوک است</label>
{{ form.is_meter_suspicious }}
{{ form.is_meter_suspicious.label_tag }}
</div>
</div>
<div class="col-md-3">
<label class="form-label">UTM X</label>
<input type="number" step="1" class="form-control" name="utm_x" value="{% if report and edit_mode and report.utm_x %}{{ report.utm_x }}{% elif instance.well.utm_x %}{{ instance.well.utm_x }}{% endif %}" {% if not user_is_installer %}readonly{% endif %}>
</div>
<div class="col-md-3">
<label class="form-label">UTM Y</label>
<input type="number" step="1" class="form-control" name="utm_y" value="{% if report and edit_mode and report.utm_y %}{{ report.utm_y }}{% elif instance.well.utm_y %}{{ instance.well.utm_y }}{% endif %}" {% if not user_is_installer %}readonly{% endif %}>
{% if form.is_meter_suspicious.errors %}
<div class="invalid-feedback">{{ form.is_meter_suspicious.errors.0 }}</div>
{% endif %}
</div>
</div>
<div class="my-3">
<label class="form-label">توضیحات (اختیاری)</label>
<textarea class="form-control" rows="3" name="description" {% if not user_is_installer %}readonly{% endif %}>{% if report and edit_mode %}{{ report.description|default_if_none:'' }}{% endif %}</textarea>
{{ form.description.label_tag }}
{{ form.description }}
{% if form.description.errors %}
<div class="invalid-feedback">{{ form.description.errors.0 }}</div>
{% endif %}
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center">
@ -284,7 +411,7 @@
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 mb-4">
<div class="col-12 mb-4 d-none">
<h6 class="mb-2">اقلام انتخاب‌شده قبلی <small class="text-muted">(برای حذف در نصب تیک بزنید)</small></h6>
<div class="table-responsive">
<table class="table table-sm align-middle">
@ -321,7 +448,6 @@
</table>
</div>
</div>
<hr>
<div class="col-12">
<h6 class="mb-2">افزودن اقلام جدید</h6>
<div class="table-responsive">
@ -513,6 +639,20 @@
display.scrollIntoView({behavior:'smooth', block:'center'});
return false;
}
// Require at least one photo: either existing (not marked for deletion) or newly added
try {
var keptExisting = 0;
document.querySelectorAll('input[id^="del-photo-"]').forEach(function(inp){
if (String(inp.value) !== '1') keptExisting += 1;
});
var newFiles = document.querySelectorAll('#photoInputs input[type="file"]').length;
if ((keptExisting + newFiles) <= 0) {
ev.preventDefault(); ev.stopPropagation();
showToast('بارگذاری حداقل یک عکس الزامی است', 'danger');
(document.getElementById('btnAddPhoto') || form).scrollIntoView({behavior:'smooth', block:'center'});
return false;
}
} catch(_) {}
try { sessionStorage.setItem('install_report_saved', '1'); } catch(_) {}
}, false);
// on load, if saved flag exists, show toast
@ -568,6 +708,36 @@
if (btnAddPhoto) btnAddPhoto.addEventListener('click', createPhotoInput);
})();
// Toggle manufacturer select/input (like request_list)
(function(){
const $select = $('#id_water_meter_manufacturer');
const $input = $('#id_new_manufacturer');
const $btn = $('#btnToggleManufacturer');
if (!$select.length || !$btn.length) return;
$btn.on('click', function(){
if ($select.is(':visible')) {
$select.hide();
$input.show().focus();
$btn.html('<i class="bx bx-check"></i>');
} else {
// When switching back, if input has value, append it as selected option
const val = ($input.val() || '').trim();
if (val) {
// Add a temporary option with value prefixed 'new:' to be handled server-side
const exists = $select.find('option').filter(function(){ return $(this).text().trim() === val; }).length > 0;
if (!exists) {
const opt = $('<option></option>').val('').text(val);
$select.append(opt);
}
$select.val('');
}
$input.hide();
$select.show();
$btn.html('<i class="bx bx-plus"></i>');
}
});
})();
// Mark delete for existing photos
function markDeletePhoto(id){
const hidden = document.getElementById('del-photo-' + id);

View file

@ -3,12 +3,15 @@ from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.urls import reverse
from django.utils import timezone
from django.core.exceptions import ValidationError
from accounts.models import Profile
from common.consts import UserRoles
from processes.models import ProcessInstance, StepInstance, StepRejection, StepApproval
from accounts.models import Role
from invoices.models import Item, Quote, QuoteItem
from wells.models import WaterMeterManufacturer
from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange
from .forms import InstallationReportForm
from decimal import Decimal, InvalidOperation
from processes.utils import get_scoped_instance_or_404
@ -122,12 +125,9 @@ def installation_report_step(request, instance_id, step_id):
is_assigned_installer = bool(assignment and assignment.installer_id == request.user.id)
user_is_installer = bool(has_installer_role and is_assigned_installer)
edit_mode = True if (request.GET.get('edit') == '1' and user_is_installer) 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.filter(is_active=True, is_special=False, is_deleted=False).order_by('name')
# Prevent edit mode if an approved report exists
if existing_report and existing_report.approved:
edit_mode = False
# Ensure a StepInstance exists for this step
step_instance, _ = StepInstance.objects.get_or_create(
@ -136,6 +136,177 @@ def installation_report_step(request, instance_id, step_id):
defaults={'status': 'in_progress'}
)
# 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.filter(is_active=True, is_special=False, is_deleted=False).order_by('name')
manufacturers = WaterMeterManufacturer.objects.all().order_by('name')
# Initialize the form
form = None
if request.method == 'POST' and request.POST.get('action') not in ['approve', 'reject']:
# Handle form submission for report creation/editing
if not user_is_installer:
messages.error(request, 'شما مجوز ثبت/ویرایش گزارش نصب را ندارید')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
# Block editing approved reports
if existing_report and existing_report.approved:
messages.error(request, 'این گزارش قبلا تایید شده و قابل ویرایش نیست')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
form = InstallationReportForm(
request.POST,
instance=existing_report if edit_mode else None,
user_is_installer=user_is_installer,
instance_well=instance.well
)
if form.is_valid():
# Validate photos
photo_validation_passed = False
try:
deleted_photo_ids = []
for key, val in request.POST.items():
if key.startswith('del_photo_') and val == '1':
try:
pid = key.split('_')[-1]
deleted_photo_ids.append(pid)
except Exception:
continue
existing_photos = existing_report.photos.all() if existing_report else None
form.validate_photos(request.FILES, existing_photos, deleted_photo_ids)
photo_validation_passed = True
except ValidationError as e:
form.add_error(None, str(e))
# Re-render form with photo validation error
photo_validation_passed = False
# Always clear approvals/rejections when form is submitted (even if photo validation fails)
# Reset step status and clear approvals/rejections
step_instance.status = 'in_progress'
step_instance.completed_at = None
step_instance.save()
try:
for appr in list(step_instance.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(step_instance.rejections.all()):
rej.delete()
except Exception:
pass
# Reopen subsequent steps
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance and subsequent_step_instance.status == 'completed':
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
try:
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
pass
# Reset current step if needed
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
# Only save the report if photo validation passed
if photo_validation_passed:
# Save the form
report = form.save(commit=False)
if not existing_report:
report.assignment = assignment
report.created_by = request.user
report.approved = False # Reset approval status
report.save()
# Handle photo uploads and deletions
if existing_report and edit_mode:
# 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
# Add new photos
for f in request.FILES.getlist('photos'):
InstallationPhoto.objects.create(report=report, image=f)
# Handle item changes (this logic remains the same)
remove_map = {}
add_map = {}
for key in request.POST.keys():
if key.startswith('rem_') and key.endswith('_type'):
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}
# Replace item changes with new submission
if existing_report and edit_mode:
report.item_changes.all().delete()
create_item_changes_for_report(report, remove_map, add_map, quote_price_map)
messages.success(request, 'گزارش ثبت شد و در انتظار تایید است.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
else:
# GET request or approval/rejection actions - initialize form for display
form = InstallationReportForm(
instance=existing_report if existing_report else None,
user_is_installer=user_is_installer,
instance_well=instance.well
)
# Build approver requirements/status for UI
reqs = list(step.approver_requirements.select_related('role').all())
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
@ -148,7 +319,7 @@ def installation_report_step(request, instance_id, step_id):
except Exception:
can_approve_reject = False
user_can_approve = can_approve_reject
approvals_list = list(step_instance.approvals.select_related('role').all())
approvals_list = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False))
approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [
{
@ -159,6 +330,15 @@ def installation_report_step(request, instance_id, step_id):
for r in reqs
]
# Determine if current user has already approved/rejected (to disable buttons)
current_user_has_decided = False
try:
user_has_approval = step_instance.approvals.filter(approved_by=request.user, is_deleted=False).exists()
user_has_rejection = step_instance.rejections.filter(rejected_by=request.user, is_deleted=False).exists()
current_user_has_decided = bool(user_has_approval or user_has_rejection)
except Exception:
current_user_has_decided = False
# Manager approval/rejection actions
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
action = request.POST.get('action')
@ -175,14 +355,16 @@ def installation_report_step(request, instance_id, step_id):
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
if action == 'approve':
existing_report.approved = True
existing_report.save()
# Record this user's approval for their role
StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''}
)
# Only mark report approved when ALL required roles have approved
if step_instance.is_fully_approved():
existing_report.approved = True
existing_report.save()
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
@ -191,6 +373,11 @@ def installation_report_step(request, instance_id, step_id):
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:request_list')
else:
# Not fully approved yet; keep report as not approved
if existing_report.approved:
existing_report.approved = False
existing_report.save(update_fields=['approved'])
messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقش‌ها.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
@ -217,160 +404,6 @@ def installation_report_step(request, instance_id, step_id):
messages.success(request, 'گزارش رد شد و برای اصلاح به نصاب بازگشت.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
if request.method == 'POST':
# Only installers can submit or edit reports (non-approval actions)
if request.POST.get('action') not in ['approve', 'reject'] and not user_is_installer:
messages.error(request, 'شما مجوز ثبت/ویرایش گزارش نصب را ندارید')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
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
# Normalize UTM to integer meters
if utm_x is not None and utm_x != '':
try:
utm_x = int(Decimal(str(utm_x)))
except InvalidOperation:
utm_x = None
else:
utm_x = None
if utm_y is not None and utm_y != '':
try:
utm_y = int(Decimal(str(utm_y)))
except InvalidOperation:
utm_y = None
else:
utm_y = 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.approved = False # back to awaiting approval after edits
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()
create_item_changes_for_report(report, remove_map, add_map, quote_price_map)
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
create_item_changes_for_report(report, remove_map, add_map, quote_price_map)
# After installer submits/edits, set step back to in_progress and clear approvals
step_instance.status = 'in_progress'
step_instance.completed_at = None
step_instance.save()
try:
step_instance.approvals.all().delete()
except Exception:
pass
# If the report was edited, ensure downstream steps reopen like invoices flow
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance and subsequent_step_instance.status == 'completed':
# Reopen the step
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
# Clear previous approvals if any
try:
subsequent_step_instance.approvals.all().delete()
except Exception:
pass
except Exception:
pass
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
messages.success(request, 'گزارش ثبت شد و در انتظار تایید است.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
# Build prefill maps from existing report changes
removed_ids = set()
@ -389,11 +422,13 @@ def installation_report_step(request, instance_id, step_id):
'step': step,
'assignment': assignment,
'report': existing_report,
'form': form,
'edit_mode': edit_mode,
'user_is_installer': user_is_installer,
'quote': quote,
'quote_items': quote_items,
'all_items': items,
'manufacturers': manufacturers,
'removed_ids': removed_ids,
'removed_qty': removed_qty,
'added_map': added_map,
@ -403,6 +438,7 @@ def installation_report_step(request, instance_id, step_id):
'approver_statuses': approver_statuses,
'user_can_approve': user_can_approve,
'can_approve_reject': can_approve_reject,
'current_user_has_decided': current_user_has_decided,
})

View file

@ -106,7 +106,6 @@ class Quote(NameSlugModel):
def calculate_totals(self):
"""محاسبه مبالغ کل"""
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
# محاسبه تخفیف
@ -115,7 +114,14 @@ class Quote(NameSlugModel):
else:
self.discount_amount = 0
self.final_amount = self.total_amount - self.discount_amount
# محاسبه مبلغ نهایی با احتساب مالیات
base_amount = self.total_amount - self.discount_amount
try:
vat_rate = Decimal(str(getattr(settings, 'VAT_RATE', 0)))
except Exception:
vat_rate = Decimal('0')
vat_amount = base_amount * vat_rate
self.final_amount = base_amount + vat_amount
self.save()
def get_status_display_with_color(self):
@ -263,7 +269,15 @@ class Invoice(NameSlugModel):
else:
self.discount_amount = 0
self.final_amount = self.total_amount - self.discount_amount
# محاسبه مبلغ نهایی با احتساب مالیات
base_amount = self.total_amount - self.discount_amount
try:
vat_rate = Decimal(str(getattr(settings, 'VAT_RATE', 0)))
except Exception:
vat_rate = Decimal('0')
vat_amount = base_amount * vat_rate
self.final_amount = base_amount + vat_amount
# خالص مانده به نفع شرکت (مثبت) یا به نفع مشتری (منفی)
net_due = self.final_amount - self.paid_amount
self.remaining_amount = net_due
@ -280,6 +294,7 @@ class Invoice(NameSlugModel):
self.save()
def get_status_display_with_color(self):
"""نمایش وضعیت با رنگ"""
status_colors = {

View file

@ -90,7 +90,11 @@
<!-- Customer & Well Info -->
<div class="row mb-3">
<div class="col-6">
<h6 class="fw-bold mb-2">اطلاعات مشترک</h6>
<h6 class="fw-bold mb-2">اطلاعات مشترک {% if instance.representative.profile and instance.representative.profile.user_type == 'legal' %}(حقوقی){% else %}(حقیقی){% endif %}</h6>
{% if instance.representative.profile and instance.representative.profile.user_type == 'legal' %}
<div class="small mb-1"><span class="text-muted">نام شرکت:</span> {{ instance.representative.profile.company_name|default:"-" }}</div>
<div class="small mb-1"><span class="text-muted">شناسه ملی:</span> {{ instance.representative.profile.company_national_id|default:"-" }}</div>
{% endif %}
<div class="small mb-1"><span class="text-muted">نام:</span> {{ invoice.customer.get_full_name|default:instance.representative.get_full_name }}</div>
{% if instance.representative.profile and instance.representative.profile.national_code %}
<div class="small mb-1"><span class="text-muted">کد ملی:</span> {{ instance.representative.profile.national_code }}</div>
@ -150,7 +154,7 @@
</tr>
{% endif %}
<tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>مبلغ نهایی (شامل مالیات)(تومان):</strong></td>
<td><strong>{{ invoice.final_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section">

View file

@ -67,7 +67,7 @@
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="border rounded p-3 h-100">
<div class="small text-muted">مبلغ نهایی</div>
<div class="small text-muted">مبلغ نهایی (با مالیات)</div>
<div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
@ -106,7 +106,7 @@
</thead>
<tbody>
{% for r in rows %}
<tr>
<tr class="{% if r.is_removed %}table-light text-muted{% endif %}">
<td>
<div class="d-flex flex-column">
<span class="fw-semibold">{{ r.item.name }}</span>
@ -118,7 +118,13 @@
<td class="text-center text-danger">{{ r.removed_qty }}</td>
<td class="text-center">{{ r.quantity }}</td>
<td class="text-end">{{ r.unit_price|floatformat:0|intcomma:False }}</td>
<td class="text-end">{{ r.total_price|floatformat:0|intcomma:False }}</td>
<td class="text-end">
{% if r.is_removed %}
<span class="text-muted">-</span>
{% else %}
{{ r.total_price|floatformat:0|intcomma:False }}
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="7" class="text-center text-muted">آیتمی یافت نشد</td></tr>
@ -154,7 +160,7 @@
<th class="text-end">{{ invoice.discount_amount|floatformat:0|intcomma:False }} تومان</th>
</tr>
<tr>
<th colspan="6" class="text-end">مبلغ نهایی</th>
<th colspan="6" class="text-end">مبلغ نهایی (با مالیات)</th>
<th class="text-end">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</th>
</tr>
<tr>

View file

@ -42,6 +42,11 @@
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer me-2"></i> پرینت
</a>
{% if request.user|is_manager and step_instance.status != 'approved' and step_instance.status != 'completed' and invoice.remaining_amount != 0 %}
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#forceApproveModal">
<i class="bx bx-bolt-circle me-1"></i> تایید اضطراری
</button>
{% endif %}
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
@ -106,13 +111,17 @@
<div class="col-12 {% if is_broker %}col-lg-7{% else %}col-lg-12{% endif %}">
<div class="card mb-3 border">
<div class="card-header d-flex justify-content-between">
<h5 class="mb-0">وضعیت فاکتور</h5>
<h5 class="mb-0">وضعیت فاکتور
{% if step_instance.status == 'approved' %}
<span class="badge bg-warning">تایید اضطراری</span>
{% endif %}
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-muted">مبلغ نهایی</div>
<div class="small text-muted">مبلغ نهایی (با مالیات)</div>
<div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
@ -189,10 +198,17 @@
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">وضعیت تاییدها</h6>
{% if can_approve_reject %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveFinalSettleModal">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
</div>
{% if current_user_has_decided %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" disabled>تایید</button>
<button type="button" class="btn btn-danger btn-sm" disabled>رد</button>
</div>
{% else %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveFinalSettleModal">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
</div>
{% endif %}
{% endif %}
</div>
<div class="card-body py-3">
@ -243,6 +259,32 @@
</div>
</div>
</div>
<!-- Force Approve Modal -->
<div class="modal fade" id="forceApproveModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="force_approve">
<div class="modal-header">
<h5 class="modal-title">تایید اضطراری تسویه</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning" role="alert">
با تایید اضطراری ممکن است هنوز پرداخت کامل نشده باشد و این مرحله به صورت استثنا تایید می‌شود.
</div>
آیا از تایید اضطراری این مرحله اطمینان دارید؟
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-warning">تایید اضطراری</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal (final settlement payments) -->

View file

@ -75,7 +75,7 @@
<input type="number" min="1" class="form-control" name="amount" id="id_amount" required>
</div>
<div class="mb-3">
<label class="form-label">تاریخ پرداخت</label>
<label class="form-label">تاریخ پرداخت/سررسید چک</label>
<input type="text" class="form-control" id="id_payment_date" name="payment_date" placeholder="انتخاب تاریخ" readonly required>
</div>
<div class="mb-3">
@ -89,7 +89,7 @@
</select>
</div>
<div class="mb-3">
<label class="form-label">شماره مرجع/چک</label>
<label class="form-label">شماره پیگیری/شماره صیادی چک</label>
<input type="text" class="form-control" name="reference_number" id="id_reference_number" placeholder="..." required>
</div>
<div class="mb-3">
@ -116,7 +116,7 @@
<div class="row g-3">
<div class="col-6">
<div class="border rounded p-3">
<div class="small text-muted">مبلغ نهایی پیش‌فاکتور</div>
<div class="small text-muted">مبلغ نهایی پیش‌فاکتور (با مالیات)</div>
<div class="h5 mt-1">{{ totals.final_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
@ -154,9 +154,9 @@
<thead>
<tr>
<th>مبلغ</th>
<th>تاریخ</th>
<th>تاریخ پرداخت/سررسید چک</th>
<th>روش</th>
<th>شماره مرجع/چک</th>
<th>شماره پیگیری/شماره صیادی چک</th>
<th>عملیات</th>
</tr>
</thead>
@ -197,10 +197,17 @@
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">وضعیت تاییدها</h6>
{% if can_approve_reject %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
</div>
{% if current_user_has_decided %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" disabled>تایید</button>
<button type="button" class="btn btn-danger btn-sm" disabled>رد</button>
</div>
{% else %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
</div>
{% endif %}
{% endif %}
</div>
<div class="card-body py-3">

View file

@ -114,8 +114,23 @@
<div class="">
<div class="card-body p-3">
<h6 class="card-title text-primary mb-2">
<i class="bx bx-user me-1"></i>اطلاعات مشترک
<i class="bx bx-user me-1"></i>
{% if instance.representative.profile.user_type == 'legal' %}
اطلاعات مشترک (حقوقی)
{% else %}
اطلاعات مشترک (حقیقی)
{% endif %}
</h6>
{% if instance.representative.profile.user_type == 'legal' %}
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">نام شرکت:</span>
<span class="fw-medium small">{{ instance.representative.profile.company_name|default:"-" }}</span>
</div>
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">شناسه ملی:</span>
<span class="fw-medium small">{{ instance.representative.profile.company_national_id|default:"-" }}</span>
</div>
{% endif %}
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">نام:</span>
<span class="fw-medium small">{{ quote.customer.get_full_name }}</span>
@ -198,7 +213,7 @@
{% if quote.discount_amount > 0 %}
<p class="mb-2">تخفیف:</p>
{% endif %}
<p class="mb-0 fw-bold">مبلغ نهایی:</p>
<p class="mb-0 fw-bold">مبلغ نهایی (شامل مالیات):</p>
</td>
<td class="px-4 py-5">
<p class="fw-medium mb-2">{{ quote.total_amount|floatformat:0|intcomma:False }} تومان</p>

View file

@ -203,7 +203,7 @@
</tr>
{% endif %}
<tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>مبلغ نهایی (با مالیات)(تومان):</strong></td>
<td><strong>{{ quote.final_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
</tfoot>

View file

@ -5,6 +5,7 @@ from django.contrib import messages
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.utils import timezone
from django.conf import settings
from django.urls import reverse
from decimal import Decimal, InvalidOperation
import json
@ -356,16 +357,16 @@ def quote_payment_step(request, instance_id, step_id):
reqs = list(step.approver_requirements.select_related('role').all())
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
approvals_list = list(step_instance.approvals.select_related('role').all())
approvals_list = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False))
approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [
{
approver_statuses = []
for r in reqs:
appr = approvals_by_role.get(r.role_id)
approver_statuses.append({
'role': r.role,
'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None),
'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''),
}
for r in reqs
]
'status': (appr.decision if appr else None),
'reason': (appr.reason if appr else ''),
})
# dynamic permission: who can approve/reject this step (based on requirements)
try:
@ -375,6 +376,15 @@ def quote_payment_step(request, instance_id, step_id):
except Exception:
can_approve_reject = False
# Compute whether current user has already decided (approved/rejected)
current_user_has_decided = False
try:
user_has_approval = step_instance.approvals.filter(approved_by=request.user, is_deleted=False).exists()
user_has_rejection = step_instance.rejections.filter(rejected_by=request.user, is_deleted=False).exists()
current_user_has_decided = bool(user_has_approval or user_has_rejection)
except Exception:
current_user_has_decided = False
# Accountant/Admin approval and rejection via POST (multi-role)
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
@ -452,6 +462,7 @@ def quote_payment_step(request, instance_id, step_id):
'is_broker': is_broker,
'is_accountant': is_accountant,
'can_approve_reject': can_approve_reject,
'current_user_has_decided': current_user_has_decided,
})
@ -537,7 +548,17 @@ def add_quote_payment(request, instance_id, step_id):
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
except Exception:
pass
try:
for appr in list(si.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
@ -554,7 +575,8 @@ def add_quote_payment(request, instance_id, step_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
@ -596,7 +618,7 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
try:
# soft delete using project's BaseModel delete override
payment.delete()
payment.hard_delete()
except Exception:
return JsonResponse({'success': False, 'message': 'خطا در حذف فیش'})
# On delete, return to awaiting approval
@ -605,7 +627,10 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
for appr in list(si.approvals.all()):
appr.delete()
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
@ -622,7 +647,8 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
@ -707,16 +733,15 @@ def final_invoice_step(request, instance_id, step_id):
if ch.unit_price:
row['base_price'] = _to_decimal(ch.unit_price)
# Compute final invoice lines
# Compute final invoice lines (include fully removed items for display)
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
line_total = Decimal(final_qty) * unit_price_dec if final_qty > 0 else Decimal('0')
if final_qty > 0:
total_amount += line_total
rows.append({
'item': r['item'],
'quantity': final_qty,
@ -725,6 +750,7 @@ def final_invoice_step(request, instance_id, step_id):
'base_qty': r['base_qty'],
'added_qty': r['added_qty'],
'removed_qty': r['removed_qty'],
'is_removed': True if final_qty == 0 else False,
})
# Create or reuse final invoice
@ -745,6 +771,8 @@ def final_invoice_step(request, instance_id, step_id):
except Exception:
qs.delete()
for r in rows:
if r['quantity'] <= 0:
continue
from .models import InvoiceItem
InvoiceItem.objects.create(
invoice=invoice,
@ -918,12 +946,21 @@ def final_settlement_step(request, instance_id, step_id):
except Exception:
can_approve_reject = False
# Compute whether current user has already decided (approved/rejected)
current_user_has_decided = False
try:
user_has_approval = step_instance.approvals.filter(approved_by=request.user).exists()
user_has_rejection = step_instance.rejections.filter(rejected_by=request.user).exists()
current_user_has_decided = bool(user_has_approval or user_has_rejection)
except Exception:
current_user_has_decided = False
# Accountant/Admin approval and rejection (multi-role)
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject', 'force_approve']:
req_roles = [req.role for req in step.approver_requirements.select_related('role').all()]
user_roles = list(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).all())
matching_role = next((r for r in user_roles if r in req_roles), None)
if matching_role is None:
if matching_role is None and request.POST.get('action') != 'force_approve':
messages.error(request, 'شما دسترسی لازم برای تایید/رد این مرحله را ندارید.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
@ -972,6 +1009,24 @@ def final_settlement_step(request, instance_id, step_id):
messages.success(request, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
if action == 'force_approve':
# Only MANAGER can force approve
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
messages.error(request, 'فقط مدیر مجاز به تایید اضطراری است.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
except Exception:
messages.error(request, 'فقط مدیر مجاز به تایید اضطراری است.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
# Mark step completed regardless of remaining amount/approvals
step_instance.status = 'approved'
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')
# broker flag for payment management permission
profile = getattr(request.user, 'profile', None)
is_broker = False
@ -991,6 +1046,8 @@ def final_settlement_step(request, instance_id, step_id):
'approver_statuses': approver_statuses,
'can_approve_reject': can_approve_reject,
'is_broker': is_broker,
'current_user_has_decided': current_user_has_decided,
'is_manager': bool(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).filter(slug=UserRoles.MANAGER.value).exists()) if getattr(request.user, 'profile', None) else False,
})
@ -1065,10 +1122,20 @@ def add_final_payment(request, instance_id, step_id):
# On delete, return to awaiting approval
try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress'
if si.status != 'approved':
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
try:
for appr in list(si.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
except Exception:
pass
@ -1085,7 +1152,8 @@ def add_final_payment(request, instance_id, step_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
@ -1124,7 +1192,7 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
payment.delete()
payment.hard_delete()
invoice.refresh_from_db()
# On delete, return to awaiting approval
@ -1133,7 +1201,16 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
try:
for appr in list(si.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
except Exception:
pass
@ -1150,7 +1227,8 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:

View file

@ -0,0 +1,19 @@
# Generated by Django 5.2.4 on 2025-09-21 07:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('locations', '0003_remove_broker_company'),
]
operations = [
migrations.AlterField(
model_name='county',
name='city',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='locations.city', verbose_name='استان'),
),
]

View file

@ -162,7 +162,7 @@ class StepInstanceAdmin(SimpleHistoryAdmin):
@admin.register(StepRejection)
class StepRejectionAdmin(SimpleHistoryAdmin):
list_display = ['step_instance', 'rejected_by', 'reason_short', 'created_at']
list_display = ['step_instance', 'rejected_by', 'reason_short', 'created_at', 'is_deleted']
list_filter = ['rejected_by', 'created_at', 'step_instance__step__process']
search_fields = ['step_instance__step__name', 'rejected_by__username', 'reason']
readonly_fields = ['created_at']
@ -182,6 +182,6 @@ class StepApproverRequirementAdmin(admin.ModelAdmin):
@admin.register(StepApproval)
class StepApprovalAdmin(admin.ModelAdmin):
list_display = ("step_instance", "role", "decision", "approved_by", "created_at")
list_display = ("step_instance", "role", "decision", "approved_by", "created_at", "is_deleted")
list_filter = ("decision", "role", "step_instance__step__process")
search_fields = ("step_instance__process_instance__code", "role__name", "approved_by__username")

View file

@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2025-09-27 06:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('processes', '0003_historicalstepinstance_edit_count_and_more'),
]
operations = [
migrations.AddField(
model_name='historicalsteprejection',
name='is_deleted',
field=models.BooleanField(default=False, verbose_name='حذف شده'),
),
migrations.AddField(
model_name='stepapproval',
name='is_deleted',
field=models.BooleanField(default=False, verbose_name='حذف شده'),
),
migrations.AddField(
model_name='steprejection',
name='is_deleted',
field=models.BooleanField(default=False, verbose_name='حذف شده'),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-09-27 15:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('processes', '0004_historicalsteprejection_is_deleted_and_more'),
]
operations = [
migrations.AlterField(
model_name='historicalstepinstance',
name='status',
field=models.CharField(choices=[('pending', 'در انتظار'), ('in_progress', 'در حال انجام'), ('completed', 'تکمیل شده'), ('skipped', 'رد شده'), ('blocked', 'مسدود شده'), ('rejected', 'رد شده و نیاز به اصلاح'), ('approved', 'تایید اضطراری')], default='pending', max_length=20, verbose_name='وضعیت'),
),
migrations.AlterField(
model_name='stepinstance',
name='status',
field=models.CharField(choices=[('pending', 'در انتظار'), ('in_progress', 'در حال انجام'), ('completed', 'تکمیل شده'), ('skipped', 'رد شده'), ('blocked', 'مسدود شده'), ('rejected', 'رد شده و نیاز به اصلاح'), ('approved', 'تایید اضطراری')], default='pending', max_length=20, verbose_name='وضعیت'),
),
]

View file

@ -290,6 +290,10 @@ class ProcessInstance(SluggedModel):
dependencies = step.get_dependencies()
for dependency_id in dependencies:
step_instance = self.step_instances.filter(step_id=dependency_id).first()
if step_instance and step_instance.status == 'in_progress' and step_instance.step.order == 3 and step.order == 4:
return True
if step_instance and step_instance.status == 'approved' and step_instance.step.order == 8 and step.order == 9:
return True
if not step_instance or step_instance.status != 'completed':
return False
return True
@ -320,6 +324,7 @@ class StepInstance(models.Model):
('skipped', 'رد شده'),
('blocked', 'مسدود شده'),
('rejected', 'رد شده و نیاز به اصلاح'),
('approved', 'تایید اضطراری'),
],
default='pending',
verbose_name="وضعیت"
@ -417,6 +422,7 @@ class StepRejection(models.Model):
blank=True
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ رد شدن")
is_deleted = models.BooleanField(default=False, verbose_name='حذف شده')
history = HistoricalRecords()
class Meta:
@ -433,6 +439,14 @@ class StepRejection(models.Model):
self.step_instance.save()
super().save(*args, **kwargs)
def hard_delete(self):
super().delete()
def delete(self, *args, **kwargs):
self.is_deleted = True
self.save()
class StepApproverRequirement(models.Model):
"""Required approver roles for a step."""
@ -457,11 +471,20 @@ class StepApproval(models.Model):
decision = models.CharField(max_length=8, choices=[('approved', 'تایید'), ('rejected', 'رد')], verbose_name='نتیجه')
reason = models.TextField(blank=True, verbose_name='علت (برای رد)')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ')
is_deleted = models.BooleanField(default=False, verbose_name='حذف شده')
class Meta:
unique_together = ('step_instance', 'role')
verbose_name = 'تایید مرحله'
verbose_name_plural = 'تاییدهای مرحله'
def delete(self, *args, **kwargs):
self.is_deleted = True
self.save()
def hard_delete(self):
super().delete()
def __str__(self):
return f"{self.step_instance} - {self.role} - {self.decision}"

View file

@ -1,6 +1,7 @@
{% extends '_base.html' %}
{% load static %}
{% load accounts_tags %}
{% load common_tags %}
{% block sidebar %}
{% include 'sidebars/admin.html' %}
@ -36,12 +37,10 @@
<div class="d-md-flex justify-content-between align-items-center dt-layout-end col-md-auto ms-auto mt-0">
<div class="dt-buttons btn-group flex-wrap mb-0">
<div class="btn-group">
<button class="btn buttons-collection btn-label-primary dropdown-toggle me-4 d-none" type="button">
<span>
<span class="d-flex align-items-center gap-2">
<i class="icon-base bx bx-export me-sm-1"></i>
<span class="d-none d-sm-inline-block">خروجی</span>
</span>
<button class="btn btn-label-success me-2" type="button" onclick="exportToExcel()">
<span class="d-flex align-items-center gap-2">
<i class="bx bx-export me-sm-1"></i>
<span class="d-none d-sm-inline-block">خروجی اکسل</span>
</span>
</button>
{% if request.user|is_broker %}
@ -212,6 +211,7 @@
<th>امور</th>
<th>پیشرفت</th>
<th>وضعیت</th>
<th>تاریخ نصب/تاخیر</th>
<th>تاریخ ایجاد</th>
<th>عملیات</th>
</tr>
@ -244,6 +244,20 @@
</div>
</td>
<td>{{ item.instance.get_status_display_with_color|safe }}</td>
<td>
{% if item.installation_scheduled_date %}
<div>
<span title="{{ item.installation_scheduled_date|date:'Y-m-d' }}">{{ item.installation_scheduled_date | to_jalali }}</span>
</div>
{% if item.installation_overdue_days and item.installation_overdue_days > 0 %}
<small class="text-danger d-block">{{ item.installation_overdue_days }} روز تاخیر</small>
{% else %}
<small class="text-muted d-block">بدون تاخیر</small>
{% endif %}
{% else %}
<span class="text-muted">تاریخ نصب تعیین نشده</span>
{% endif %}
</td>
<td>{{ item.instance.jcreated_date }}</td>
<td>
<div class="d-inline-block">
@ -287,6 +301,7 @@
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
{% endfor %}
</tbody>
@ -419,6 +434,10 @@
<div id="repNewFields" class="col-sm-12" style="display:none;">
<div class="row g-3">
<div class="col-sm-12">
<label class="form-label" for="id_user_type">{{ customer_form.user_type.label }}</label>
{{ customer_form.user_type }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_first_name">{{ customer_form.first_name.label }}</label>
{{ customer_form.first_name }}
@ -439,6 +458,15 @@
<label class="form-label" for="id_national_code">{{ customer_form.national_code.label }}</label>
{{ customer_form.national_code }}
</div>
<!-- Company fields for legal entities -->
<div class="col-sm-6 company-fields" style="display:none;">
<label class="form-label" for="id_company_name">{{ customer_form.company_name.label }}</label>
{{ customer_form.company_name }}
</div>
<div class="col-sm-6 company-fields" style="display:none;">
<label class="form-label" for="id_company_national_id">{{ customer_form.company_national_id.label }}</label>
{{ customer_form.company_national_id }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_card_number">{{ customer_form.card_number.label }}</label>
{{ customer_form.card_number }}
@ -717,6 +745,21 @@
.fail(function(){ setStatus('#wellStatus', 'خطا در بررسی چاه', 'danger'); });
});
function toggleRepCompanyFields() {
const userType = $('#user-type-select').val();
if (userType === 'legal') {
$('#repNewFields .company-fields').show();
$('input[name="company_name"]').attr('required', true);
$('input[name="company_national_id"]').attr('required', true);
} else {
$('#repNewFields .company-fields').hide();
$('input[name="company_name"]').removeAttr('required');
$('input[name="company_national_id"]').removeAttr('required');
}
}
$('#user-type-select').on('change', toggleRepCompanyFields);
$('#btnLookupRep').on('click', function() {
const nc = $('#rep_national_code').val().trim();
if (!nc) { setStatus('#repStatus', 'لطفا کد ملی نماینده را وارد کنید', 'danger'); return; }
@ -732,36 +775,47 @@
$('#id_first_name').val(resp.user.first_name || '');
$('#id_last_name').val(resp.user.last_name || '');
if (resp.user.profile) {
$('#user-type-select').val(resp.user.profile.user_type || 'individual');
$('#id_national_code').val(resp.user.profile.national_code || nc);
$('#id_phone_number_1').val(resp.user.profile.phone_number_1 || '');
$('#id_phone_number_2').val(resp.user.profile.phone_number_2 || '');
$('#id_company_name').val(resp.user.profile.company_name || '');
$('#id_company_national_id').val(resp.user.profile.company_national_id || '');
$('#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 {
$('#user-type-select').val('individual');
$('#id_national_code').val(nc);
$('#id_phone_number_1').val('');
$('#id_phone_number_2').val('');
$('#id_company_name').val('');
$('#id_company_national_id').val('');
$('#id_card_number').val('');
$('#id_account_number').val('');
$('#id_bank_name').val('');
$('#id_address').val('');
}
toggleRepCompanyFields();
setStatus('#repStatus', 'نماینده یافت شد.', 'success');
} else {
currentRepId = null;
$('#repNewFields').show();
// Clear form and prefill national code
$('#user-type-select').val('individual');
$('#id_first_name').val('');
$('#id_last_name').val('');
$('#id_national_code').val(nc);
$('#id_phone_number_1').val('');
$('#id_phone_number_2').val('');
$('#id_company_name').val('');
$('#id_company_national_id').val('');
$('#id_card_number').val('');
$('#id_account_number').val('');
$('#id_bank_name').val('');
$('#id_address').val('');
toggleRepCompanyFields();
setStatus('#repStatus', 'نماینده یافت نشد. لطفا اطلاعات را تکمیل کنید.', 'danger');
}
})
@ -954,6 +1008,45 @@
});
};
};
// Export to Excel function
window.exportToExcel = function() {
// Get current filter parameters from the form
const form = document.querySelector('form[method="get"]');
const formData = new FormData(form);
// Build query string
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value.trim()) {
params.append(key, value);
}
}
// Create export URL with current filters
const exportUrl = '{% url "processes:export_requests_excel" %}' + '?' + params.toString();
// Show loading state
const btn = document.querySelector('button[onclick="exportToExcel()"]');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="bx bx-loader-circle bx-spin me-1"></i>در حال تولید...';
btn.disabled = true;
// Create invisible link and trigger download
const link = document.createElement('a');
link.href = exportUrl;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Reset button after a short delay
setTimeout(() => {
btn.innerHTML = originalText;
btn.disabled = false;
showToast('فایل اکسل آماده دانلود است', 'success');
}, 1000);
};
});
</script>
{% endblock %}

View file

@ -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)

View file

@ -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'),

View file

@ -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

View file

@ -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):
@ -472,3 +529,364 @@ def instance_summary(request, instance_id):
'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

View file

@ -114,7 +114,7 @@
</a>
</li>
{% 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 %}
<!-- Customers -->
<li class="menu-header small text-uppercase">
<span class="menu-header-text">مشترک‌ها</span>

View file

@ -104,7 +104,8 @@ class WellForm(forms.ModelForm):
}),
'reference_letter_number': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'شماره معرفی نامه'
'placeholder': 'شماره معرفی نامه',
'required': True
}),
'reference_letter_date': forms.DateInput(attrs={
'class': 'form-control',

View file

@ -0,0 +1,33 @@
# Generated by Django 5.2.4 on 2025-09-21 07:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wells', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='historicalwell',
name='utm_x',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='X UTM'),
),
migrations.AlterField(
model_name='historicalwell',
name='utm_y',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='Y UTM'),
),
migrations.AlterField(
model_name='well',
name='utm_x',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='X UTM'),
),
migrations.AlterField(
model_name='well',
name='utm_y',
field=models.DecimalField(blank=True, decimal_places=0, max_digits=10, null=True, verbose_name='Y UTM'),
),
]

View file

@ -0,0 +1,113 @@
# Generated by Django 5.2.4 on 2025-09-24 11:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wells', '0002_alter_historicalwell_utm_x_and_more'),
]
operations = [
migrations.AddField(
model_name='historicalwell',
name='discharge_pipe_diameter',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='قطر لوله آبده (اینچ)'),
),
migrations.AddField(
model_name='historicalwell',
name='driving_force',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='نیرو محرکه چاه'),
),
migrations.AddField(
model_name='historicalwell',
name='exploitation_license_number',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='شماره پروانه بهره\u200cبرداری چاه'),
),
migrations.AddField(
model_name='historicalwell',
name='meter_size',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='سایز کنتور'),
),
migrations.AddField(
model_name='historicalwell',
name='meter_type',
field=models.CharField(blank=True, choices=[('smart', 'هوشمند (آبی/برق)'), ('volumetric', 'حجمی')], max_length=20, null=True, verbose_name='نوع کنتور'),
),
migrations.AddField(
model_name='historicalwell',
name='motor_power',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='(کیلووات ساعت)قدرت موتور'),
),
migrations.AddField(
model_name='historicalwell',
name='post_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی بعد از کالیبراسیون'),
),
migrations.AddField(
model_name='historicalwell',
name='pre_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
),
migrations.AddField(
model_name='historicalwell',
name='sim_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='شماره سیمکارت'),
),
migrations.AddField(
model_name='historicalwell',
name='usage_type',
field=models.CharField(blank=True, choices=[('domestic', 'شرب و خدمات'), ('agriculture', 'کشاورزی'), ('industrial', 'صنعتی')], max_length=20, null=True, verbose_name='نوع مصرف'),
),
migrations.AddField(
model_name='well',
name='discharge_pipe_diameter',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='قطر لوله آبده (اینچ)'),
),
migrations.AddField(
model_name='well',
name='driving_force',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='نیرو محرکه چاه'),
),
migrations.AddField(
model_name='well',
name='exploitation_license_number',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='شماره پروانه بهره\u200cبرداری چاه'),
),
migrations.AddField(
model_name='well',
name='meter_size',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='سایز کنتور'),
),
migrations.AddField(
model_name='well',
name='meter_type',
field=models.CharField(blank=True, choices=[('smart', 'هوشمند (آبی/برق)'), ('volumetric', 'حجمی')], max_length=20, null=True, verbose_name='نوع کنتور'),
),
migrations.AddField(
model_name='well',
name='motor_power',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='(کیلووات ساعت)قدرت موتور'),
),
migrations.AddField(
model_name='well',
name='post_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی بعد از کالیبراسیون'),
),
migrations.AddField(
model_name='well',
name='pre_calibration_flow_rate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='(لیتر بر ثانیه)دبی قبل از کالیبراسیون'),
),
migrations.AddField(
model_name='well',
name='sim_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='شماره سیمکارت'),
),
migrations.AddField(
model_name='well',
name='usage_type',
field=models.CharField(blank=True, choices=[('domestic', 'شرب و خدمات'), ('agriculture', 'کشاورزی'), ('industrial', 'صنعتی')], max_length=20, null=True, verbose_name='نوع مصرف'),
),
]

View file

@ -0,0 +1,93 @@
# Generated by Django 5.2.4 on 2025-09-24 11:15
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('wells', '0003_historicalwell_discharge_pipe_diameter_and_more'),
]
operations = [
migrations.RemoveField(
model_name='historicalwell',
name='discharge_pipe_diameter',
),
migrations.RemoveField(
model_name='historicalwell',
name='driving_force',
),
migrations.RemoveField(
model_name='historicalwell',
name='exploitation_license_number',
),
migrations.RemoveField(
model_name='historicalwell',
name='meter_size',
),
migrations.RemoveField(
model_name='historicalwell',
name='meter_type',
),
migrations.RemoveField(
model_name='historicalwell',
name='motor_power',
),
migrations.RemoveField(
model_name='historicalwell',
name='post_calibration_flow_rate',
),
migrations.RemoveField(
model_name='historicalwell',
name='pre_calibration_flow_rate',
),
migrations.RemoveField(
model_name='historicalwell',
name='sim_number',
),
migrations.RemoveField(
model_name='historicalwell',
name='usage_type',
),
migrations.RemoveField(
model_name='well',
name='discharge_pipe_diameter',
),
migrations.RemoveField(
model_name='well',
name='driving_force',
),
migrations.RemoveField(
model_name='well',
name='exploitation_license_number',
),
migrations.RemoveField(
model_name='well',
name='meter_size',
),
migrations.RemoveField(
model_name='well',
name='meter_type',
),
migrations.RemoveField(
model_name='well',
name='motor_power',
),
migrations.RemoveField(
model_name='well',
name='post_calibration_flow_rate',
),
migrations.RemoveField(
model_name='well',
name='pre_calibration_flow_rate',
),
migrations.RemoveField(
model_name='well',
name='sim_number',
),
migrations.RemoveField(
model_name='well',
name='usage_type',
),
]

View file

@ -15,7 +15,7 @@ from processes.utils import scope_wells_queryset
from processes.models import ProcessInstance
@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 well_list(request):
"""نمایش لیست چاه‌ها"""
base = Well.objects.select_related(
@ -40,7 +40,7 @@ def well_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_well_ajax(request):
"""AJAX endpoint for adding wells"""
try:
@ -98,7 +98,7 @@ def add_well_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_well_ajax(request, well_id):
"""AJAX endpoint for editing wells"""
well = get_object_or_404(Well, id=well_id)
@ -154,7 +154,7 @@ def edit_well_ajax(request, well_id):
@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 delete_well(request, well_id):
"""حذف چاه"""
well = get_object_or_404(Well, id=well_id)
@ -199,7 +199,7 @@ def get_well_data(request, well_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_well_details(request, well_id):
"""جزئیات کامل چاه برای نمایش در مدال"""
well = get_object_or_404(
@ -260,7 +260,7 @@ def get_well_details(request, well_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_well_requests(request, well_id):
"""سوابق درخواست‌های مرتبط با یک چاه"""
# Scoped access: reuse base scoping by filtering on ProcessInstance via broker/affairs of current user if needed