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

@ -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">
@ -213,41 +232,149 @@
{% if user_is_installer %}
<!-- Installation Report Form -->
<form method="post" enctype="multipart/form-data" id="installation-report-form">
{% csrf_token %}
{% csrf_token %}
<div class="mb-3">
<div class="">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">تاریخ مراجعه</label>
<input type="text" id="id_visited_date_display" class="form-control" placeholder="انتخاب تاریخ" {% 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,
})