complete first version of main proccess

This commit is contained in:
aminhashemi92 2025-08-27 07:11:26 +03:30
parent 6ff4740d04
commit f2fc2362a7
61 changed files with 3280 additions and 28 deletions

View file

39
installations/admin.py Normal file
View file

@ -0,0 +1,39 @@
from django.contrib import admin
from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange
@admin.register(InstallationAssignment)
class InstallationAssignmentAdmin(admin.ModelAdmin):
list_display = ('process_instance', 'installer', 'scheduled_date', 'created')
search_fields = ('process_instance__code', 'installer__username', 'installer__first_name', 'installer__last_name')
list_filter = ('scheduled_date',)
class InstallationPhotoInline(admin.TabularInline):
model = InstallationPhoto
extra = 0
class InstallationItemChangeInline(admin.TabularInline):
model = InstallationItemChange
extra = 0
@admin.register(InstallationReport)
class InstallationReportAdmin(admin.ModelAdmin):
list_display = ('assignment', 'visited_date', 'new_water_meter_serial', 'seal_number', 'is_meter_suspicious', 'approved', 'created')
list_filter = ('is_meter_suspicious', 'approved', 'visited_date')
search_fields = ('assignment__process_instance__code', 'new_water_meter_serial', 'seal_number')
inlines = [InstallationPhotoInline, InstallationItemChangeInline]
@admin.register(InstallationPhoto)
class InstallationPhotoAdmin(admin.ModelAdmin):
list_display = ('report', 'created')
@admin.register(InstallationItemChange)
class InstallationItemChangeAdmin(admin.ModelAdmin):
list_display = ('report', 'item', 'change_type', 'quantity', 'unit_price', 'total_price', 'created')
list_filter = ('change_type',)

8
installations/apps.py Normal file
View file

@ -0,0 +1,8 @@
from django.apps import AppConfig
class InstallationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'installations'
verbose_name = 'نصب'

View file

@ -0,0 +1,106 @@
# Generated by Django 5.2.4 on 2025-08-21 08:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('invoices', '0002_historicalpayment_receipt_image_and_more'),
('processes', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='InstallationAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('scheduled_date', models.DateField(blank=True, null=True, verbose_name='تاریخ مراجعه')),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigner_installations', to=settings.AUTH_USER_MODEL, verbose_name='اختصاص\u200cدهنده')),
('installer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_installations', to=settings.AUTH_USER_MODEL, verbose_name='نصاب')),
('process_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='installation_assignments', to='processes.processinstance', verbose_name='نمونه فرآیند')),
],
options={
'verbose_name': 'اختصاص نصاب',
'verbose_name_plural': 'اختصاص\u200cهای نصاب',
'ordering': ['-created'],
},
),
migrations.CreateModel(
name='InstallationReport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('visited_date', models.DateField(blank=True, null=True, verbose_name='تاریخ مراجعه')),
('new_water_meter_serial', models.CharField(blank=True, max_length=50, null=True, verbose_name='سریال کنتور جدید')),
('seal_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='شماره پلمپ')),
('is_meter_suspicious', models.BooleanField(default=False, verbose_name='کنتور مشکوک است؟')),
('utm_x', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='UTM X')),
('utm_y', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='UTM Y')),
('description', models.TextField(blank=True, verbose_name='توضیحات')),
('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='installations.installationassignment', verbose_name='اختصاص')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='ایجادکننده')),
],
options={
'verbose_name': 'گزارش نصب',
'verbose_name_plural': 'گزارش\u200cهای نصب',
'ordering': ['-created'],
},
),
migrations.CreateModel(
name='InstallationPhoto',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('image', models.ImageField(upload_to='installations/photos/%Y/%m/%d/', verbose_name='عکس')),
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='installations.installationreport', verbose_name='گزارش')),
],
options={
'verbose_name': 'عکس نصب',
'verbose_name_plural': 'عکس\u200cهای نصب',
'ordering': ['created'],
},
),
migrations.CreateModel(
name='InstallationItemChange',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('change_type', models.CharField(choices=[('add', 'افزودن'), ('remove', 'حذف')], max_length=6, verbose_name='نوع تغییر')),
('quantity', models.PositiveIntegerField(verbose_name='تعداد')),
('unit_price', models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True, verbose_name='قیمت واحد')),
('total_price', models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True, verbose_name='قیمت کل')),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='invoices.item', verbose_name='آیتم')),
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_changes', to='installations.installationreport', verbose_name='گزارش')),
],
options={
'verbose_name': 'تغییر آیتم نصب',
'verbose_name_plural': 'تغییرات آیتم\u200cهای نصب',
'ordering': ['created'],
},
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-21 09:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('installations', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='installationreport',
name='approved',
field=models.BooleanField(default=False, verbose_name='تایید شده'),
),
migrations.AddField(
model_name='installationreport',
name='approved_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید'),
),
]

View file

111
installations/models.py Normal file
View file

@ -0,0 +1,111 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
from common.models import BaseModel
User = get_user_model()
class InstallationAssignment(BaseModel):
"""انتخاب نصاب و زمان مراجعه برای یک درخواست"""
process_instance = models.ForeignKey(
'processes.ProcessInstance', on_delete=models.CASCADE,
related_name='installation_assignments', verbose_name='نمونه فرآیند'
)
installer = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name='assigned_installations', verbose_name='نصاب'
)
scheduled_date = models.DateField(null=True, blank=True, verbose_name='تاریخ مراجعه')
notes = models.TextField(blank=True, verbose_name='یادداشت')
assigned_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name='assigner_installations', verbose_name='اختصاص‌دهنده'
)
class Meta:
verbose_name = 'اختصاص نصاب'
verbose_name_plural = 'اختصاص‌های نصاب'
ordering = ['-created']
def __str__(self):
return f"Assignment for {self.process_instance.code} to {getattr(self.installer, 'username', '-') }"
class InstallationReport(BaseModel):
"""گزارش نصب توسط نصاب"""
assignment = models.ForeignKey(
InstallationAssignment, on_delete=models.CASCADE,
related_name='reports', verbose_name='اختصاص'
)
visited_date = models.DateField(null=True, blank=True, verbose_name='تاریخ مراجعه')
new_water_meter_serial = models.CharField(max_length=50, null=True, blank=True, verbose_name='سریال کنتور جدید')
seal_number = models.CharField(max_length=50, null=True, blank=True, verbose_name='شماره پلمپ')
is_meter_suspicious = models.BooleanField(default=False, verbose_name='کنتور مشکوک است؟')
utm_x = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True, verbose_name='UTM X')
utm_y = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True, verbose_name='UTM Y')
description = models.TextField(blank=True, verbose_name='توضیحات')
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='ایجادکننده')
approved = models.BooleanField(default=False, verbose_name='تایید شده')
approved_at = models.DateTimeField(null=True, blank=True, verbose_name='تاریخ تایید')
class Meta:
verbose_name = 'گزارش نصب'
verbose_name_plural = 'گزارش‌های نصب'
ordering = ['-created']
def __str__(self):
return f"Report for {self.assignment.process_instance.code}"
def save(self, *args, **kwargs):
# set approved time
if self.approved and self.approved_at is None:
self.approved_at = timezone.now()
super().save(*args, **kwargs)
# if approved, propagate UTM to well
try:
if self.approved and self.assignment and self.assignment.process_instance and self.assignment.process_instance.well:
well = self.assignment.process_instance.well
changed = False
if self.utm_x is not None:
well.utm_x = self.utm_x
changed = True
if self.utm_y is not None:
well.utm_y = self.utm_y
changed = True
if changed:
well.save()
except Exception:
pass
class InstallationPhoto(BaseModel):
report = models.ForeignKey(InstallationReport, on_delete=models.CASCADE, related_name='photos', verbose_name='گزارش')
image = models.ImageField(upload_to='installations/photos/%Y/%m/%d/', verbose_name='عکس')
class Meta:
verbose_name = 'عکس نصب'
verbose_name_plural = 'عکس‌های نصب'
ordering = ['created']
class InstallationItemChange(BaseModel):
"""تغییرات اقلام در مرحله نصب (افزودن/حذف نسبت به اقلام مرحله ۱)"""
CHANGE_CHOICES = [
('add', 'افزودن'),
('remove', 'حذف'),
]
report = models.ForeignKey(InstallationReport, on_delete=models.CASCADE, related_name='item_changes', verbose_name='گزارش')
item = models.ForeignKey('invoices.Item', on_delete=models.CASCADE, verbose_name='آیتم')
change_type = models.CharField(max_length=6, choices=CHANGE_CHOICES, verbose_name='نوع تغییر')
quantity = models.PositiveIntegerField(verbose_name='تعداد')
unit_price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='قیمت واحد', null=True, blank=True)
total_price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='قیمت کل', null=True, blank=True)
notes = models.TextField(blank=True, verbose_name='یادداشت')
class Meta:
verbose_name = 'تغییر آیتم نصب'
verbose_name_plural = 'تغییرات آیتم‌های نصب'
ordering = ['created']

View file

@ -0,0 +1,159 @@
{% extends '_base.html' %}
{% load static %}
{% load processes_tags %}
{% load humanize %}
{% block sidebar %}
{% include 'sidebars/admin.html' %}
{% endblock sidebar %}
{% block navbar %}
{% include 'navbars/admin.html' %}
{% endblock navbar %}
{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
{% block style %}
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
<!-- Persian Date Picker CSS -->
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
{% endblock %}
{% block content %}
{% include '_toasts.html' %}
<div class="container-xxl flex-grow-1 container-p-y">
<div class="row">
<div class="col-12 mb-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small>
</div>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</div>
<div class="bs-stepper wizard-vertical vertical mt-2">
{% stepper_header instance step %}
<div class="bs-stepper-content">
<form method="post">
{% csrf_token %}
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">نصاب</label>
<select name="installer_id" class="form-select" required>
<option value="">انتخاب کنید...</option>
{% for p in installers %}
<option value="{{ p.user.id }}" {% if assignment.installer and p.user.id == assignment.installer.id %}selected{% endif %}>{{ p.user.get_full_name }} ({{ p.user.username }})</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">تاریخ مراجعه نصاب</label>
<input type="text" id="id_scheduled_date_display" class="form-control" placeholder="انتخاب تاریخ" readonly required value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y/m/d' }}{% endif %}">
<input type="hidden" id="id_scheduled_date" name="scheduled_date" value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y-m-d' }}{% endif %}">
</div>
</div>
<div class="d-flex justify-content-between mt-4">
{% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %}
<span></span>
{% endif %}
<button class="btn btn-primary" type="submit">ثبت و ادامه</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<!-- Persian Date Picker JS -->
<script src="https://unpkg.com/persian-date@latest/dist/persian-date.min.js"></script>
<script src="https://unpkg.com/persian-datepicker@latest/dist/js/persian-datepicker.min.js"></script>
<script>
(function(){
function convertPersianToEnglishNumbers(str) {
const persianNumbers = '۰۱۲۳۴۵۶۷۸۹';
const englishNumbers = '0123456789';
return String(str || '').split('').map(function(char){
const index = persianNumbers.indexOf(char);
return index !== -1 ? englishNumbers[index] : char;
}).join('');
}
function initPersianDatePicker() {
if ($.fn.persianDatepicker && $('#id_scheduled_date_display').length) {
try {
var $display = $('#id_scheduled_date_display');
var $hidden = $('#id_scheduled_date');
// Prefill from hidden Gregorian to visible Jalali
var initialGregorian = $hidden.val();
if (initialGregorian) {
try {
var initialJalali = new window.persianDate(new Date(initialGregorian)).format('YYYY/MM/DD');
$display.val(initialJalali);
} catch (e) {}
}
$display.persianDatepicker({
calendarType: 'persian',
altField: '#id_scheduled_date',
format: 'YYYY/MM/DD',
altFormat: 'YYYY-MM-DD',
observer: true,
autoClose: true,
initialValue: false,
calendar:{ persian: { leapYearMode: 'astronomical' } },
onSelect: function (unixDate) {
var g = new window.persianDate(unixDate).toCalendar('gregorian').format('YYYY-MM-DD');
g = convertPersianToEnglishNumbers(g);
$hidden.val(g);
}
});
} catch (e) {
console.error('Error initializing Persian Date Picker:', e);
}
}
}
document.addEventListener('DOMContentLoaded', initPersianDatePicker);
})();
// Require date and show success toast on submit
(function(){
const form = document.querySelector('form');
if (!form) return;
form.addEventListener('submit', function(ev){
const display = document.getElementById('id_scheduled_date_display');
const hidden = document.getElementById('id_scheduled_date');
if (!display.value || !hidden.value) {
ev.preventDefault(); ev.stopPropagation();
if (typeof showToast === 'function') showToast('تاریخ مراجعه نصاب را انتخاب کنید', 'danger');
display.scrollIntoView({behavior:'smooth', block:'center'});
return false;
}
try { sessionStorage.setItem('assign_saved', '1'); } catch(_) {}
}, false);
try {
if (sessionStorage.getItem('assign_saved') === '1') {
sessionStorage.removeItem('assign_saved');
if (typeof showToast === 'function') showToast('با موفقیت ثبت شد', 'success');
}
} catch(_) {}
})();
</script>
{% endblock %}

View file

@ -0,0 +1,448 @@
{% extends '_base.html' %}
{% load static %}
{% load processes_tags %}
{% load common_tags %}
{% load humanize %}
{% block sidebar %}
{% include 'sidebars/admin.html' %}
{% endblock sidebar %}
{% block navbar %}
{% include 'navbars/admin.html' %}
{% endblock navbar %}
{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
{% block style %}
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
<!-- Persian Date Picker CSS -->
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
{% endblock %}
{% block content %}
{% include '_toasts.html' %}
<div class="container-xxl flex-grow-1 container-p-y">
<div class="row">
<div class="col-12 mb-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small>
</div>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</div>
<div class="bs-stepper wizard-vertical vertical mt-2">
{% stepper_header instance step %}
<div class="bs-stepper-content">
{% if report and not edit_mode %}
<div class="card mb-3 border">
<div class="card-header d-flex justify-content-end">
<a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<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>
</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>
</div>
</div>
{% if report.description %}
<div class="mt-2">
<p class="mb-0"><i class="bx bx-text bx-sm me-2"></i><strong>توضیحات:</strong></p>
<div class="text-muted">{{ report.description|default:'-' }}</div>
</div>
{% endif %}
<hr>
<h6>عکس‌ها</h6>
<div class="row">
{% for p in report.photos.all %}
<div class="col-6 col-md-3 mb-2"><img class="img-fluid rounded border" src="{{ p.image.url }}" alt="photo"></div>
{% empty %}
<div class="text-muted">بدون عکس</div>
{% endfor %}
</div>
<hr>
<div class="row g-3">
<div class="col-12">
<h6 class="mb-2">اقلام</h6>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th style="width:40px">نوع</th>
<th>آیتم</th>
<th>تعداد</th>
<th>قیمت واحد</th>
<th>قیمت کل</th>
</tr>
</thead>
<tbody>
{% for ch in report.item_changes.all %}
<tr>
<td>{% if ch.change_type == 'add' %}<span class="text-success"><i class="bx bx-plus"></i></span>{% else %}<span class="text-danger"><i class="bx bx-minus"></i></span>{% endif %}</td>
<td>{{ ch.item.name }}</td>
<td>{{ ch.quantity }}</td>
<td>{% if ch.unit_price %}{{ ch.unit_price|floatformat:0|intcomma:False }}{% else %}-{% endif %}</td>
<td>
{% if ch.total_price %}
{{ ch.total_price|floatformat:0|intcomma:False }}
{% elif ch.unit_price %}
{{ ch.unit_price|floatformat:0|intcomma:False }}
{% else %}-{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="5" class="text-center text-muted">تغییری ثبت نشده است</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Persistent nav in edit mode (outside cards) -->
<div class="d-flex justify-content-between mt-3">
{% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %}
<span></span>
{% endif %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
{% endif %}
</div>
{% else %}
<form method="post" enctype="multipart/form-data">
{% 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="انتخاب تاریخ" 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 %}">
</div>
<div class="col-md-3">
<label class="form-label">سریال کنتور جدید</label>
<input type="text" class="form-control" name="new_water_meter_serial">
</div>
<div class="col-md-3">
<label class="form-label">شماره پلمپ</label>
<input type="text" class="form-control" name="seal_number">
</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">
<label class="form-check-label" for="id_is_meter_suspicious">کنتور مشکوک است</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">UTM X</label>
<input type="number" step="0.000001" class="form-control" name="utm_x" value="{% if instance.well.utm_x %}{{ instance.well.utm_x }}{% endif %}">
</div>
<div class="col-md-3">
<label class="form-label">UTM Y</label>
<input type="number" step="0.000001" class="form-control" name="utm_y" value="{% if instance.well.utm_y %}{{ instance.well.utm_y }}{% endif %}">
</div>
</div>
<div class="my-3">
<label class="form-label">توضیحات (اختیاری)</label>
<textarea class="form-control" rows="3" name="description"></textarea>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<label class="form-label mb-0">عکس‌ها</label>
<button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button>
</div>
{% if report %}
<div class="row mt-2">
{% for p in report.photos.all %}
<div class="col-6 col-md-3 mb-2" id="existing-photo-{{ p.id }}">
<div class="position-relative border rounded p-1">
<img class="img-fluid rounded" src="{{ p.image.url }}" alt="photo">
<button type="button" class="btn btn-sm btn-danger position-absolute" style="top:6px; left:6px;" onclick="markDeletePhoto({{ p.id }})" title="حذف/برگردان"><i class='bx bx-trash'></i></button>
<input type="hidden" name="del_photo_{{ p.id }}" id="del-photo-{{ p.id }}" value="0">
</div>
</div>
{% empty %}
{% endfor %}
</div>
{% endif %}
<div class="row mt-2" id="photosPreview"></div>
<div id="photoInputs" class="d-none"></div>
</div>
</div>
</div>
<div class="card border">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">اقلام</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 mb-4">
<h6 class="mb-2">اقلام انتخاب‌شده قبلی <small class="text-muted">(برای حذف در نصب تیک بزنید)</small></h6>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th style="width:40px">حذف</th>
<th>آیتم</th>
<th>قیمت واحد</th>
<th style="width:140px">تعداد</th>
</tr>
</thead>
<tbody>
{% for qi in quote_items %}
<tr>
<td>
<input type="checkbox" class="form-check-input" name="rem_{{ qi.item.id }}_type" value="remove" title="حذف در نصب" {% if removed_qty|get_item:qi.item.id %}checked{% endif %}>
<input type="hidden" name="rem_{{ qi.item.id }}_qty" value="{% if removed_qty|get_item:qi.item.id %}{{ removed_qty|get_item:qi.item.id }}{% else %}{{ qi.quantity }}{% endif %}">
</td>
<td>
<div class="d-flex flex-column">
<span class="fw-semibold">{{ qi.item.name }}</span>
{% if qi.item.description %}<small class="text-muted">{{ qi.item.description }}</small>{% endif %}
</div>
</td>
<td>{{ qi.unit_price|floatformat:0|intcomma:False }} تومان</td>
<td>
<span class="text-muted">{% if removed_qty|get_item:qi.item.id %}{{ removed_qty|get_item:qi.item.id }}{% else %}{{ qi.quantity }}{% endif %}</span>
</td>
</tr>
{% empty %}
<tr><td colspan="4" class="text-center text-muted">اقلامی ثبت نشده است</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<hr>
<div class="col-12">
<h6 class="mb-2">افزودن اقلام جدید</h6>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th style="width:40px"></th>
<th>آیتم</th>
<th>قیمت واحد</th>
<th style="width:140px">تعداد</th>
</tr>
</thead>
<tbody>
{% for it in all_items %}
<tr>
<td>
{% with add_entry=added_map|get_item:it.id %}
<input type="checkbox" name="add_{{ it.id }}_type" value="add" class="form-check-input" {% if add_entry %}checked{% endif %}>
<input type="hidden" name="add_{{ it.id }}_price" value="{{ it.unit_price }}">
{% endwith %}
</td>
<td>
<div class="d-flex flex-column">
<span class="fw-semibold">{{ it.name }}</span>
{% if it.description %}<small class="text-muted">{{ it.description }}</small>{% endif %}
</div>
</td>
<td>{{ it.unit_price|floatformat:0|intcomma:False }} تومان</td>
<td>
{% with add_entry=added_map|get_item:it.id %}
<input class="form-control form-control-sm" type="number" min="1" name="add_{{ it.id }}_qty" value="{% if add_entry %}{{ add_entry.qty }}{% endif %}">
{% endwith %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</form>
<div class="mt-3 d-flex justify-content-between">
{% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %}
<span></span>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">ثبت گزارش</button>
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-success">بعدی</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<!-- Persian Date Picker JS -->
<script src="https://unpkg.com/persian-date@latest/dist/persian-date.min.js"></script>
<script src="https://unpkg.com/persian-datepicker@latest/dist/js/persian-datepicker.min.js"></script>
<script>
// Persian datepicker for visited_date (exact pattern like sample: display + altField)
(function(){
function convertPersianToEnglishNumbers(str) {
const persianNumbers = '۰۱۲۳۴۵۶۷۸۹';
const englishNumbers = '0123456789';
return String(str || '').split('').map(function(char){
const index = persianNumbers.indexOf(char);
return index !== -1 ? englishNumbers[index] : char;
}).join('');
}
if (window.$ && $.fn.persianDatepicker && $('#id_visited_date_display').length) {
try {
var $display = $('#id_visited_date_display');
var $hidden = $('#id_visited_date');
// Prefill from hidden Gregorian to visible Jalali
var initialGregorian = $hidden.val();
if (initialGregorian) {
try {
var initialJalali = new window.persianDate(new Date(initialGregorian)).format('YYYY/MM/DD');
$display.val(initialJalali);
} catch (e) {}
}
// Initialize datepicker with altField exactly like the sample
var picker = $display.persianDatepicker({
calendarType: 'persian',
altField: '#id_visited_date',
format: 'YYYY/MM/DD',
altFormat: 'YYYY-MM-DD',
observer: true,
autoClose: true,
initialValue: false,
calendar:{ persian: { leapYearMode: 'astronomical' } },
onSelect: function (unixDate) {
var g = new window.persianDate(unixDate).toCalendar('gregorian').format('YYYY-MM-DD');
g = convertPersianToEnglishNumbers(g);
$hidden.val(g);
}
});
} catch (e) { console.error('Error initializing Persian Date Picker:', e); }
}
})();
// Require date and show success toast on submit (persist across redirect)
(function(){
const form = document.querySelector('form[enctype]') || document.querySelector('form');
if (!form) return;
form.addEventListener('submit', function(ev){
const display = document.getElementById('id_visited_date_display');
const hidden = document.getElementById('id_visited_date');
if (!display || !hidden) return;
if (!display.value || !hidden.value) {
ev.preventDefault(); ev.stopPropagation();
showToast('تاریخ مراجعه را انتخاب کنید', 'danger');
display.scrollIntoView({behavior:'smooth', block:'center'});
return false;
}
try { sessionStorage.setItem('install_report_saved', '1'); } catch(_) {}
}, false);
// on load, if saved flag exists, show toast
try {
if (sessionStorage.getItem('install_report_saved') === '1') {
sessionStorage.removeItem('install_report_saved');
showToast('گزارش نصب با موفقیت ثبت شد', 'success');
}
} catch(_) {}
})();
// Dynamic photo add/remove
(function(){
const photoInputs = document.getElementById('photoInputs');
const photosPreview = document.getElementById('photosPreview');
const btnAddPhoto = document.getElementById('btnAddPhoto');
let photoCounter = 0;
function createPhotoInput() {
photoCounter += 1;
const input = document.createElement('input');
input.type = 'file';
input.name = 'photos';
input.accept = 'image/*';
input.className = 'd-none';
input.dataset.key = String(photoCounter);
input.addEventListener('change', function(){
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(){
const col = document.createElement('div');
col.className = 'col-6 col-md-3 mb-2';
col.id = 'photo-preview-' + input.dataset.key;
col.innerHTML = `
<div class="position-relative border rounded p-1">
<img src="${reader.result}" class="img-fluid rounded" alt="photo">
<button type="button" class="btn btn-sm btn-danger position-absolute" style="top:6px; left:6px;" data-key="${input.dataset.key}"><i class=\"bx bx-trash\"></i></button>
</div>
`;
photosPreview.appendChild(col);
col.querySelector('button').addEventListener('click', function(ev){
const key = ev.currentTarget.getAttribute('data-key');
const preview = document.getElementById('photo-preview-' + key);
if (preview) preview.remove();
const inp = photoInputs.querySelector(`input[data-key="${key}"]`);
if (inp) inp.remove();
});
};
reader.readAsDataURL(file);
});
photoInputs.appendChild(input);
input.click();
}
if (btnAddPhoto) btnAddPhoto.addEventListener('click', createPhotoInput);
})();
// Mark delete for existing photos
function markDeletePhoto(id){
const hidden = document.getElementById('del-photo-' + id);
const wrap = document.getElementById('existing-photo-' + id);
if (hidden && wrap){
// toggle behavior
if (hidden.value === '1') {
hidden.value = '0';
wrap.style.opacity = '1';
// update button title back to delete
const btn = wrap.querySelector('button');
if (btn) btn.title = 'حذف';
} else {
hidden.value = '1';
wrap.style.opacity = '0.5';
wrap.style.position = 'relative';
// update button title to undo
const btn = wrap.querySelector('button');
if (btn) btn.title = 'انصراف از حذف';
}
}
}
</script>
{% endblock %}

11
installations/urls.py Normal file
View file

@ -0,0 +1,11 @@
from django.urls import path
from . import views
app_name = 'installations'
urlpatterns = [
path('instance/<int:instance_id>/step/<int:step_id>/assign/', views.installation_assign_step, name='installation_assign_step'),
path('instance/<int:instance_id>/step/<int:step_id>/report/', views.installation_report_step, name='installation_report_step'),
]

255
installations/views.py Normal file
View file

@ -0,0 +1,255 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.urls import reverse
from django.utils import timezone
from accounts.models import Profile
from common.consts import UserRoles
from processes.models import ProcessInstance, StepInstance
from invoices.models import Item, Quote, QuoteItem
from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange
from decimal import Decimal, InvalidOperation
@login_required
def installation_assign_step(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first()
# Installers list (profiles that have installer role)
installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value).select_related('user').all()
assignment, _ = InstallationAssignment.objects.get_or_create(process_instance=instance)
if request.method == 'POST':
installer_id = request.POST.get('installer_id')
scheduled_date = (request.POST.get('scheduled_date') or '').strip()
assignment.installer_id = installer_id or None
if scheduled_date:
assignment.scheduled_date = scheduled_date.replace('/', '-')
assignment.assigned_by = request.user
assignment.save()
# complete step
StepInstance.objects.update_or_create(
process_instance=instance,
step=step,
defaults={'status': 'completed', 'completed_at': timezone.now()}
)
if next_step:
instance.current_step = next_step
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:request_list')
return render(request, 'installations/installation_assign_step.html', {
'instance': instance,
'step': step,
'assignment': assignment,
'installers': installers,
'previous_step': previous_step,
'next_step': next_step,
})
@login_required
def installation_report_step(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first()
assignment = InstallationAssignment.objects.filter(process_instance=instance).first()
existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first()
edit_mode = True if request.GET.get('edit') == '1' else False
# current quote items baseline
quote = Quote.objects.filter(process_instance=instance).first()
quote_items = list(quote.items.select_related('item').all()) if quote else []
quote_price_map = {qi.item_id: qi.unit_price for qi in quote_items}
items = Item.objects.all().order_by('name')
if request.method == 'POST':
description = (request.POST.get('description') or '').strip()
visited_date = (request.POST.get('visited_date') or '').strip()
if '/' in visited_date:
visited_date = visited_date.replace('/', '-')
new_serial = (request.POST.get('new_water_meter_serial') or '').strip()
seal_number = (request.POST.get('seal_number') or '').strip()
is_suspicious = True if request.POST.get('is_meter_suspicious') == 'on' else False
utm_x = request.POST.get('utm_x') or None
utm_y = request.POST.get('utm_y') or None
# Build maps from form fields: remove and add
remove_map = {}
add_map = {}
for key in request.POST.keys():
if key.startswith('rem_') and key.endswith('_type'):
# rem_{id}_type = 'remove'
try:
item_id = int(key.split('_')[1])
except Exception:
continue
if request.POST.get(key) != 'remove':
continue
qty_val = request.POST.get(f'rem_{item_id}_qty') or '1'
try:
qty = int(qty_val)
except Exception:
qty = 1
remove_map[item_id] = qty
if key.startswith('add_') and key.endswith('_type'):
try:
item_id = int(key.split('_')[1])
except Exception:
continue
if request.POST.get(key) != 'add':
continue
qty_val = request.POST.get(f'add_{item_id}_qty') or '1'
price_val = request.POST.get(f'add_{item_id}_price')
try:
qty = int(qty_val)
except Exception:
qty = 1
# resolve unit price
unit_price = None
if price_val:
try:
unit_price = Decimal(price_val)
except InvalidOperation:
unit_price = None
if unit_price is None:
item_obj = Item.objects.filter(id=item_id).first()
unit_price = item_obj.unit_price if item_obj else None
add_map[item_id] = {'qty': qty, 'price': unit_price}
# اجازهٔ ثبت همزمان حذف و افزودن برای یک قلم (بدون محدودیت و ادغام)
if existing_report and edit_mode:
report = existing_report
report.description = description
report.visited_date = visited_date or None
report.new_water_meter_serial = new_serial or None
report.seal_number = seal_number or None
report.is_meter_suspicious = is_suspicious
report.utm_x = utm_x
report.utm_y = utm_y
report.save()
# delete selected existing photos
for key, val in request.POST.items():
if key.startswith('del_photo_') and val == '1':
try:
pid = int(key.split('_')[-1])
InstallationPhoto.objects.filter(id=pid, report=report).delete()
except Exception:
continue
# append new photos
for f in request.FILES.getlist('photos'):
InstallationPhoto.objects.create(report=report, image=f)
# replace item changes with new submission
report.item_changes.all().delete()
for item_id, qty in remove_map.items():
up = quote_price_map.get(item_id)
total = (up * qty) if up is not None else None
InstallationItemChange.objects.create(
report=report,
item_id=item_id,
change_type='remove',
quantity=qty,
unit_price=up,
total_price=total,
)
for item_id, data in add_map.items():
unit_price = data.get('price')
qty = data.get('qty') or 1
total = (unit_price * qty) if (unit_price is not None) else None
InstallationItemChange.objects.create(
report=report,
item_id=item_id,
change_type='add',
quantity=qty,
unit_price=unit_price,
total_price=total,
)
else:
report = InstallationReport.objects.create(
assignment=assignment,
description=description,
visited_date=visited_date or None,
new_water_meter_serial=new_serial or None,
seal_number=seal_number or None,
is_meter_suspicious=is_suspicious,
utm_x=utm_x,
utm_y=utm_y,
created_by=request.user,
)
# photos
for f in request.FILES.getlist('photos'):
InstallationPhoto.objects.create(report=report, image=f)
# item changes
for item_id, qty in remove_map.items():
up = quote_price_map.get(item_id)
total = (up * qty) if up is not None else None
InstallationItemChange.objects.create(
report=report,
item_id=item_id,
change_type='remove',
quantity=qty,
unit_price=up,
total_price=total,
)
for item_id, data in add_map.items():
unit_price = data.get('price')
qty = data.get('qty') or 1
total = (unit_price * qty) if (unit_price is not None) else None
InstallationItemChange.objects.create(
report=report,
item_id=item_id,
change_type='add',
quantity=qty,
unit_price=unit_price,
total_price=total,
)
# complete step
StepInstance.objects.update_or_create(
process_instance=instance,
step=step,
defaults={'status': 'completed', 'completed_at': timezone.now()}
)
if next_step:
instance.current_step = next_step
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
return redirect('processes:request_list')
# Build prefill maps from existing report changes
removed_ids = set()
removed_qty = {}
added_map = {}
if existing_report:
for ch in existing_report.item_changes.all():
if ch.change_type == 'remove':
removed_ids.add(ch.item_id)
removed_qty[ch.item_id] = ch.quantity
elif ch.change_type == 'add':
added_map[ch.item_id] = {'qty': ch.quantity, 'price': ch.unit_price}
return render(request, 'installations/installation_report_step.html', {
'instance': instance,
'step': step,
'assignment': assignment,
'report': existing_report,
'edit_mode': edit_mode,
'quote': quote,
'quote_items': quote_items,
'all_items': items,
'removed_ids': removed_ids,
'removed_qty': removed_qty,
'added_map': added_map,
'previous_step': previous_step,
'next_step': next_step,
})