Add qoute step.

This commit is contained in:
aminhashemi92 2025-08-21 09:18:51 +03:30
parent b71ea45681
commit 6ff4740d04
30 changed files with 3362 additions and 376 deletions

View file

@ -24,6 +24,7 @@ urlpatterns = [
path('', include('accounts.urls')), path('', include('accounts.urls')),
path('wells/', include('wells.urls')), path('wells/', include('wells.urls')),
path('processes/', include('processes.urls')), path('processes/', include('processes.urls')),
path('invoices/', include('invoices.urls')),
] ]
if settings.DEBUG: if settings.DEBUG:

View file

@ -1,7 +1,8 @@
# Generated by Django 5.2.4 on 2025-08-07 09:08 # Generated by Django 5.2.4 on 2025-08-14 09:02
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion
import simple_history.models
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -11,10 +12,47 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('locations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel(
name='HistoricalProfile',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('national_code', models.CharField(blank=True, max_length=10, null=True, validators=[django.core.validators.RegexValidator(code='invalid_national_code', message='کد ملی باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='کد ملی')),
('address', models.TextField(blank=True, null=True, verbose_name='آدرس')),
('card_number', models.CharField(blank=True, max_length=16, null=True, validators=[django.core.validators.RegexValidator(code='invalid_card_number', message='شماره کارت باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره کارت')),
('account_number', models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب')),
('phone_number_1', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۱')),
('phone_number_2', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۲')),
('pic', models.TextField(default='../static/sample_images/profile.jpg', max_length=100, verbose_name='تصویر')),
('is_completed', models.BooleanField(default=False, verbose_name='پروفایل تکمیل شده')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('affairs', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.affairs', verbose_name='امور')),
('broker', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.broker', verbose_name='کارگزار')),
('county', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.county', verbose_name='شهرستان')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('owner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='ایجاد کننده')),
('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='کاربر')),
],
options={
'verbose_name': 'historical پروفایل',
'verbose_name_plural': 'historical پروفایل\u200cها',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel( migrations.CreateModel(
name='Role', name='Role',
fields=[ fields=[
@ -48,8 +86,11 @@ class Migration(migrations.Migration):
('account_number', models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب')), ('account_number', models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب')),
('phone_number_1', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۱')), ('phone_number_1', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۱')),
('phone_number_2', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۲')), ('phone_number_2', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۲')),
('pic', models.ImageField(default='../static/dist/img/profile.jpg', upload_to='profile_images', verbose_name='تصویر')), ('pic', models.ImageField(default='../static/sample_images/profile.jpg', upload_to='profile_images', verbose_name='تصویر')),
('is_completed', models.BooleanField(default=False, verbose_name='پروفایل تکمیل شده')), ('is_completed', models.BooleanField(default=False, verbose_name='پروفایل تکمیل شده')),
('affairs', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.affairs', verbose_name='امور')),
('broker', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.broker', verbose_name='کارگزار')),
('county', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.county', verbose_name='شهرستان')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_profiles', to=settings.AUTH_USER_MODEL, verbose_name='ایجاد کننده')), ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_profiles', to=settings.AUTH_USER_MODEL, verbose_name='ایجاد کننده')),
('user', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='کاربر')), ('user', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='کاربر')),
('roles', models.ManyToManyField(blank=True, related_name='profiles', to='accounts.role', verbose_name='نقش\u200cها')), ('roles', models.ManyToManyField(blank=True, related_name='profiles', to='accounts.role', verbose_name='نقش\u200cها')),

View file

@ -1,75 +0,0 @@
# Generated by Django 5.2.4 on 2025-08-07 14:29
import django.core.validators
import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
('locations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='profile',
name='affairs',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.affairs', verbose_name='امور'),
),
migrations.AddField(
model_name='profile',
name='broker',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.broker', verbose_name='کارگزار'),
),
migrations.AddField(
model_name='profile',
name='county',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.county', verbose_name='شهرستان'),
),
migrations.AlterField(
model_name='profile',
name='pic',
field=models.ImageField(default='../static/sample_images/profile.jpg', upload_to='profile_images', verbose_name='تصویر'),
),
migrations.CreateModel(
name='HistoricalProfile',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('national_code', models.CharField(blank=True, max_length=10, null=True, validators=[django.core.validators.RegexValidator(code='invalid_national_code', message='کد ملی باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='کد ملی')),
('address', models.TextField(blank=True, null=True, verbose_name='آدرس')),
('card_number', models.CharField(blank=True, max_length=16, null=True, validators=[django.core.validators.RegexValidator(code='invalid_card_number', message='شماره کارت باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره کارت')),
('account_number', models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب')),
('phone_number_1', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۱')),
('phone_number_2', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس ۲')),
('pic', models.TextField(default='../static/sample_images/profile.jpg', max_length=100, verbose_name='تصویر')),
('is_completed', models.BooleanField(default=False, verbose_name='پروفایل تکمیل شده')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('affairs', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.affairs', verbose_name='امور')),
('broker', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.broker', verbose_name='کارگزار')),
('county', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.county', verbose_name='شهرستان')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('owner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='ایجاد کننده')),
('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='کاربر')),
],
options={
'verbose_name': 'historical پروفایل',
'verbose_name_plural': 'historical پروفایل\u200cها',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-08-07 09:08 # Generated by Django 5.2.4 on 2025-08-14 09:02
import django.db.models.deletion import django.db.models.deletion
import simple_history.models import simple_history.models

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-16 04:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoices', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='historicalpayment',
name='receipt_image',
field=models.TextField(blank=True, max_length=100, null=True, verbose_name='تصویر فیش'),
),
migrations.AddField(
model_name='payment',
name='receipt_image',
field=models.ImageField(blank=True, null=True, upload_to='payments/%Y/%m/%d/', verbose_name='تصویر فیش'),
),
]

View file

@ -4,6 +4,9 @@ from common.models import NameSlugModel, BaseModel
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from decimal import Decimal from decimal import Decimal
from django.utils import timezone
from django.core.validators import MinValueValidator
from django.conf import settings
User = get_user_model() User = get_user_model()
@ -123,6 +126,21 @@ class Quote(NameSlugModel):
color = status_colors.get(self.status, 'secondary') color = status_colors.get(self.status, 'secondary')
return '<span class="badge bg-{}">{}</span>'.format(color, self.get_status_display()) return '<span class="badge bg-{}">{}</span>'.format(color, self.get_status_display())
def get_paid_amount(self):
"""مبلغ پرداخت شده برای این پیش‌فاکتور بر اساس پرداخت‌های فاکتور مرتبط"""
invoice = Invoice.objects.filter(quote=self).first()
if not invoice:
return Decimal('0')
return sum(p.amount for p in invoice.payments.filter(is_deleted=False).all())
def get_remaining_amount(self):
"""مبلغ باقی‌مانده بر اساس پرداخت‌ها"""
paid = self.get_paid_amount()
remaining = self.final_amount - paid
if remaining < 0:
remaining = Decimal('0')
return remaining
class QuoteItem(BaseModel): class QuoteItem(BaseModel):
"""مدل آیتم‌های پیش‌فاکتور""" """مدل آیتم‌های پیش‌فاکتور"""
quote = models.ForeignKey(Quote, on_delete=models.CASCADE, related_name='items', verbose_name="پیش‌فاکتور") quote = models.ForeignKey(Quote, on_delete=models.CASCADE, related_name='items', verbose_name="پیش‌فاکتور")
@ -311,6 +329,7 @@ class Payment(BaseModel):
reference_number = models.CharField(max_length=100, verbose_name="شماره مرجع", blank=True) reference_number = models.CharField(max_length=100, verbose_name="شماره مرجع", blank=True)
payment_date = models.DateField(verbose_name="تاریخ پرداخت") payment_date = models.DateField(verbose_name="تاریخ پرداخت")
notes = models.TextField(verbose_name="یادداشت‌ها", blank=True) notes = models.TextField(verbose_name="یادداشت‌ها", blank=True)
receipt_image = models.ImageField(upload_to='payments/%Y/%m/%d/', null=True, blank=True, verbose_name="تصویر فیش")
created_by = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="ثبت کننده") created_by = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="ثبت کننده")
history = HistoricalRecords() history = HistoricalRecords()

View file

@ -0,0 +1,402 @@
{% 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">
<style>
@media print {
.no-print { display: none !important; }
}
</style>
{% endblock %}
{% block content %}
{% include '_toasts.html' %}
{% csrf_token %}
<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 no-print">
<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>
<div class="d-flex gap-2">
<a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer"></i> پرینت
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</div>
</div>
<div class="bs-stepper wizard-vertical vertical mt-2 no-print">
{% stepper_header instance step %}
<div class="bs-stepper-content">
<form id="formAddPayment" enctype="multipart/form-data" onsubmit="return false;">
{% csrf_token %}
<div class="content active dstepper-block">
<div class="content-header mb-3">
<h6 class="mb-0">{{ step.name }}</h6>
<small>ثبت فیش‌های واریزی برای پیش‌فاکتور</small>
</div>
<div class="row g-3">
<div class="col-12 col-lg-5">
<div class="card h-100 border">
<div class="card-header">
<h5 class="card-title mb-0">ثبت فیش جدید</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">مبلغ (تومان)</label>
<input type="number" min="1" class="form-control" name="amount" id="id_amount" required>
</div>
<div class="mb-3">
<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">
<label class="form-label">روش پرداخت</label>
<select class="form-select" name="payment_method" id="id_payment_method" required>
<option value="bank_transfer">انتقال بانکی</option>
<option value="card">کارت بانکی</option>
<option value="cash">نقدی</option>
<option value="check">چک</option>
<option value="other">سایر</option>
</select>
</div>
<div class="mb-3">
<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">
<label class="form-label">تصویر فیش</label>
<input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
</div>
<div class="mb-3">
<label class="form-label">توضیحات</label>
<textarea class="form-control" rows="2" name="notes" id="id_notes"></textarea>
</div>
<div class="d-flex justify-content-end">
<button type="button" id="btnAddPayment" class="btn btn-primary">افزودن فیش</button>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-7">
<div class="card mb-3 border">
<div class="card-header d-flex justify-content-between">
<h5 class="card-title mb-0">وضعیت پیش‌فاکتور</h5>
<a href="{% url 'invoices:quote_preview_step' instance.id step.id|add:'-1' %}" class="btn btn-sm btn-label-secondary">مشاهده پیش‌فاکتور</a>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<div class="border rounded p-3">
<div class="small text-muted">مبلغ نهایی پیش‌فاکتور</div>
<div class="h5 mt-1">{{ totals.final_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
<div class="col-6">
<div class="border rounded p-3">
<div class="small text-muted">مبلغ پرداخت‌شده</div>
<div class="h5 mt-1 text-success">{{ totals.paid_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
<div class="col-6">
<div class="border rounded p-3">
<div class="small text-muted">مانده</div>
<div class="h5 mt-1 {% if totals.is_fully_paid %}text-success{% else %}text-danger{% endif %}">{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
<div class="col-6 d-flex align-items-center">
{% if totals.is_fully_paid %}
<span class="badge bg-success">تسویه کامل</span>
{% else %}
<span class="badge bg-warning text-dark">باقی‌مانده دارد</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card border">
<div class="card-header">
<h5 class="card-title mb-0">فیش‌های ثبت شده</h5>
</div>
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>مبلغ</th>
<th>تاریخ</th>
<th>روش</th>
<th>شماره مرجع</th>
<th>تصویر</th>
<th style="width:120px">عملیات</th>
</tr>
</thead>
<tbody>
{% for p in payments %}
<tr>
<td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
<td>{{ p.payment_date|date:'Y/m/d' }}</td>
<td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td>
<td>
{% if p.receipt_image %}
<a href="{{ p.receipt_image.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="مشاهده" aria-label="مشاهده">
<i class="bx bx-show"></i>
</a>
{% else %}
-
{% endif %}
</td>
<td>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="editPayment({{ p.id }})" title="ویرایش" aria-label="ویرایش">
<i class="bx bx-edit"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal({{ p.id }})" title="حذف" aria-label="حذف">
<i class="bx bx-trash"></i>
</button>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center text-muted">تا کنون فیشی ثبت نشده است</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-12 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">
<i class="bx bx-chevron-right bx-sm me-sm-2"></i>
قبلی
</a>
{% else %}
<span></span>
{% endif %}
<button type="button" id="btnApprovePayments" class="btn btn-primary">
تایید پرداخت‌ها
<i class="bx bx-chevron-left bx-sm ms-sm-2"></i>
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
const isFullyPaid = {{ totals.is_fully_paid|yesno:'true,false' }};
function buildFormData(form) {
const fd = new FormData(form);
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
return fd;
}
document.getElementById('btnAddPayment').addEventListener('click', function() {
// Front-end validation
const amount = document.getElementById('id_amount').value.trim();
const payDate = document.getElementById('id_payment_date').value.trim();
const method = document.getElementById('id_payment_method').value.trim();
const ref = document.getElementById('id_reference_number').value.trim();
const img = document.getElementById('id_receipt_image').files[0];
const notes = document.getElementById('id_notes').value.trim();
if (!amount || !payDate || !method || !ref || !img) {
showToast('همه فیلدها الزامی است', 'danger');
return;
}
const form = document.getElementById('formAddPayment');
const fd = buildFormData(form);
fetch('{% url "invoices:add_quote_payment" instance.id step.id %}', {
method: 'POST',
body: fd
}).then(r => r.json()).then(resp => {
if (resp.success) {
showToast('فیش با موفقیت ثبت شد', 'success');
if (resp.redirect) {
setTimeout(() => { window.location.href = resp.redirect; }, 700);
}
} else {
showToast(resp.message || 'خطا در ثبت فیش', 'danger');
}
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
});
let deleteTargetId = null;
function openDeleteModal(id) {
deleteTargetId = id;
const modal = new bootstrap.Modal(document.getElementById('deletePaymentModal'));
modal.show();
}
function confirmDeletePayment() {
if (!deleteTargetId) return;
const fd = new FormData();
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
fetch(`{% url "invoices:delete_quote_payment" instance.id step.id 0 %}`.replace('/0/', `/${deleteTargetId}/`), {
method: 'POST',
body: fd
}).then(r => r.json()).then(resp => {
if (resp.success && resp.redirect) {
showToast('فیش با موفقیت حذف شد', 'success');
setTimeout(() => { window.location.href = resp.redirect; }, 700);
} else {
showToast(resp.message || 'خطا در حذف فیش', 'danger');
}
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
}
function editPayment(id) {
// برای سادگی، همین فرم را استفاده نمی‌کنیم؛ می‌توانید مدال ویرایش اضافه کنید
alert('ویرایش فیش را بعدا با مدال تکمیل می‌کنیم. فعلا حذف و افزودن مجدد انجام دهید.');
}
function performApprovePayments() {
const fd = new FormData();
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
fetch('{% url "invoices:approve_payments" instance.id step.id %}', {
method: 'POST',
body: fd
}).then(r => r.json()).then(resp => {
if (resp.success && resp.redirect) {
showToast(resp.message, 'success');
setTimeout(() => { window.location.href = resp.redirect; }, 600);
} else {
showToast(resp.message || 'خطا در تایید پرداخت‌ها', 'danger');
}
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
}
function openApproveModal() {
const el = document.getElementById('approvePaymentsModal');
const remEl = document.getElementById('remainingAmountText');
if (remEl) {
remEl.textContent = '{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان';
}
// Prefer jQuery plugin if available to avoid namespace issues
if (window.$ && typeof $(el).modal === 'function') {
$(el).modal('show');
} else if (window.bootstrap && window.bootstrap.Modal) {
const modal = new window.bootstrap.Modal(el);
modal.show();
} else {
// fallback: force display
el.classList.add('show');
el.style.display = 'block';
el.removeAttribute('aria-hidden');
}
}
document.getElementById('btnApprovePayments').addEventListener('click', function() {
if (isFullyPaid) {
performApprovePayments();
} else {
openApproveModal();
}
});
</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 initPersianDatePicker() {
if (window.$ && $.fn.persianDatepicker && $('#id_payment_date').length) {
try {
$('#id_payment_date').persianDatepicker({
format: 'YYYY/MM/DD',
initialValue: false,
autoClose: true,
persianDigit: false,
observer: true,
calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } },
onSelect: function(unix) {
const gregorianDate = new Date(unix);
const year = gregorianDate.getFullYear();
const month = String(gregorianDate.getMonth() + 1).padStart(2, '0');
const day = String(gregorianDate.getDate()).padStart(2, '0');
const gregorianDateString = `${year}-${month}-${day}`;
if (window.persianDate) {
const persianDate = new window.persianDate(unix);
const persianDateString = persianDate.format('YYYY/MM/DD');
$('#id_payment_date').val(persianDateString);
} else {
$('#id_payment_date').val(gregorianDateString);
}
$('#id_payment_date').attr('data-gregorian', gregorianDateString);
}
});
} catch (e) { console.error('Error initializing Persian Date Picker:', e); }
}
})();
</script>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deletePaymentModal" tabindex="-1" aria-labelledby="deletePaymentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deletePaymentModalLabel">تایید حذف فیش</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
آیا از حذف این فیش مطمئن هستید؟ این عمل قابل بازگشت نیست.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-danger" onclick="confirmDeletePayment()" data-bs-dismiss="modal">حذف</button>
</div>
</div>
</div>
</div>
<!-- Approve Confirmation Modal (shown when remaining amount > 0) -->
<div class="modal fade" id="approvePaymentsModal" tabindex="-1" aria-labelledby="approvePaymentsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="approvePaymentsModalLabel">تایید نهایی پرداخت‌ها</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
مبلغی از پیش‌فاکتور هنوز پرداخت نشده است.
<div class="mt-2">مانده: <strong id="remainingAmountText"></strong></div>
آیا مطمئن هستید که می‌خواهید مرحله را تایید و به مرحله بعد بروید؟
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="performApprovePayments()">بله، تایید</button>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,287 @@
{% 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' %}">
<style>
@media print {
.no-print { display: none !important; }
}
</style>
{% endblock %}
{% block content %}
{% include '_toasts.html' %}
{% csrf_token %}
<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 no-print">
<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>
<div class="d-flex gap-2">
<a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer"></i> پرینت
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</div>
</div>
<div class="bs-stepper wizard-vertical vertical mt-2 no-print">
{% stepper_header instance step %}
<div class="bs-stepper-content">
<!-- Invoice Preview Card -->
<div class="card invoice-preview-card mt-4">
<div class="card-body">
<div class="d-flex justify-content-between flex-xl-row flex-md-column flex-sm-row flex-column p-sm-3 p-0">
<div class="mb-xl-0 mb-4">
<div class="d-flex svg-illustration mb-3 gap-2">
<span class="app-brand-logo demo">
<svg width="25" viewBox="0 0 25 42" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<path d="M13.7918663,0.358365126 L3.39788168,7.44174259 C0.566865006,9.69408886 -0.379795268,12.4788597 0.557900856,15.7960551 C0.68998853,16.2305145 1.09562888,17.7872135 3.12357076,19.2293357 C3.8146334,19.7207684 5.32369333,20.3834223 7.65075054,21.2172976 L7.59773219,21.2525164 L2.63468769,24.5493413 C0.445452254,26.3002124 0.0884951797,28.5083815 1.56381646,31.1738486 C2.83770406,32.8170431 5.20850219,33.2640127 7.09180128,32.5391577 C8.347334,32.0559211 11.4559176,30.0011079 16.4175519,26.3747182 C18.0338572,24.4997857 18.6973423,22.4544883 18.4080071,20.2388261 C17.963753,17.5346866 16.1776345,15.5799961 13.0496516,14.3747546 L10.9194936,13.4715819 L18.6192054,7.984237 L13.7918663,0.358365126 Z" id="path-1"></path>
<path d="M5.47320593,6.00457225 C4.05321814,8.216144 4.36334763,10.0722806 6.40359441,11.5729822 C8.61520715,12.571656 10.0999176,13.2171421 10.8577257,13.5094407 L15.5088241,14.433041 L18.6192054,7.984237 C15.5364148,3.11535317 13.9273018,0.573395879 13.7918663,0.358365126 C13.5790555,0.511491653 10.8061687,2.3935607 5.47320593,6.00457225 Z" id="path-3"></path>
<path d="M7.50063644,21.2294429 L12.3234468,23.3159332 C14.1688022,24.7579751 14.397098,26.4880487 13.008334,28.506154 C11.6195701,30.5242593 10.3099883,31.790241 9.07958868,32.3040991 C5.78142938,33.4346997 4.13234973,34 4.13234973,34 C4.13234973,34 2.75489982,33.0538207 2.37032616e-14,31.1614621 C-0.55822714,27.8186216 -0.55822714,26.0572515 -4.05231404e-15,25.8773518 C0.83734071,25.6075023 2.77988457,22.8248993 3.3049379,22.52991 C3.65497346,22.3332504 5.05353963,21.8997614 7.50063644,21.2294429 Z" id="path-4"></path>
<path d="M20.6,7.13333333 L25.6,13.8 C26.2627417,14.6836556 26.0836556,15.9372583 25.2,16.6 C24.8538077,16.8596443 24.4327404,17 24,17 L14,17 C12.8954305,17 12,16.1045695 12,15 C12,14.5672596 12.1403557,14.1461923 12.4,13.8 L17.4,7.13333333 C18.0627417,6.24967773 19.3163444,6.07059163 20.2,6.73333333 C20.3516113,6.84704183 20.4862915,6.981722 20.6,7.13333333 Z" id="path-5"></path>
</defs>
<g id="g-app-brand" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Brand-Logo" transform="translate(-27.000000, -15.000000)">
<g id="Icon" transform="translate(27.000000, 15.000000)">
<g id="Mask" transform="translate(0.000000, 8.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use fill="#696cff" xlink:href="#path-1"></use>
<g id="Path-3" mask="url(#mask-2)">
<use fill="#696cff" xlink:href="#path-3"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-3"></use>
</g>
<g id="Path-4" mask="url(#mask-2)">
<use fill="#696cff" xlink:href="#path-4"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-4"></use>
</g>
</g>
<g id="Triangle" transform="translate(19.000000, 11.000000) rotate(-300.000000) translate(-19.000000, -11.000000) ">
<use fill="#696cff" xlink:href="#path-5"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-5"></use>
</g>
</g>
</g>
</g>
</svg>
</span>
<span class="app-brand-text demo text-body fw-bold">شرکت آب منطقه‌ای</span>
</div>
<p class="mb-1">دفتر مرکزی، خیابان اصلی</p>
<p class="mb-1">تهران، ایران</p>
<p class="mb-0">۰۲۱-۱۲۳۴۵۶۷۸</p>
</div>
<div>
<h4>پیش‌فاکتور #{{ quote.name }}</h4>
<div class="mb-2">
<span class="me-1">تاریخ صدور:</span>
<span class="fw-medium">{{ quote.jcreated }}</span>
</div>
<div>
<span class="me-1">معتبر تا:</span>
<span class="fw-medium">{{ quote.valid_until|date:"Y/m/d" }}</span>
</div>
</div>
</div>
</div>
<hr class="my-0">
<div class="card-body">
<div class="row p-sm-3 p-0">
<div class="col-xl-6 col-md-12 col-sm-5 col-12 mb-xl-0 mb-md-4 mb-sm-0 mb-4">
<h6 class="pb-2">صادر شده برای:</h6>
<p class="mb-1">{{ quote.customer.get_full_name }}</p>
{% if instance.representative.profile %}
<p class="mb-1">کد ملی: {{ instance.representative.profile.national_code }}</p>
<p class="mb-1">{{ instance.representative.profile.address|default:"آدرس نامشخص" }}</p>
<p class="mb-1">{{ instance.representative.profile.phone_number_1|default:"" }}</p>
{% endif %}
</div>
<div class="col-xl-6 col-md-12 col-sm-7 col-12">
<h6 class="pb-2">اطلاعات چاه:</h6>
<table>
<tbody>
<tr>
<td class="pe-3">شماره اشتراک آب:</td>
<td>{{ instance.well.water_subscription_number }}</td>
</tr>
<tr>
<td class="pe-3">شماره اشتراک برق:</td>
<td>{{ instance.well.electricity_subscription_number|default:"-" }}</td>
</tr>
<tr>
<td class="pe-3">سریال کنتور:</td>
<td>{{ instance.well.water_meter_serial_number|default:"-" }}</td>
</tr>
<tr>
<td class="pe-3">قدرت چاه:</td>
<td>{{ instance.well.well_power|default:"-" }}</td>
</tr>
<tr>
<td class="pe-3">کد درخواست:</td>
<td>{{ instance.code }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table border-top m-0">
<thead>
<tr>
<th>آیتم</th>
<th>توضیحات</th>
<th>قیمت واحد</th>
<th>تعداد</th>
<th>قیمت کل</th>
</tr>
</thead>
<tbody>
{% for quote_item in quote.items.all %}
<tr>
<td class="text-nowrap">{{ quote_item.item.name }}</td>
<td class="text-nowrap">{{ quote_item.item.description|default:"-" }}</td>
<td>{{ quote_item.unit_price|floatformat:0|intcomma:False }} تومان</td>
<td>{{ quote_item.quantity }}</td>
<td>{{ quote_item.total_price|floatformat:0|intcomma:False }} تومان</td>
</tr>
{% endfor %}
<tr>
<td colspan="3" class="align-top px-4 py-5">
<p class="mb-2">
<span class="me-1 fw-medium">صادر کننده:</span>
<span>{{ quote.created_by.get_full_name }}</span>
</p>
<span>با تشکر از انتخاب شما</span>
</td>
<td class="text-end px-4 py-5">
<p class="mb-2">جمع کل:</p>
{% if quote.discount_amount > 0 %}
<p class="mb-2">تخفیف:</p>
{% endif %}
<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>
{% if quote.discount_amount > 0 %}
<p class="fw-medium mb-2">{{ quote.discount_amount|floatformat:0|intcomma:False }} تومان</p>
{% endif %}
<p class="fw-bold mb-0">{{ quote.final_amount|floatformat:0|intcomma:False }} تومان</p>
</td>
</tr>
</tbody>
</table>
</div>
{% if quote.notes %}
<div class="card-body">
<div class="row">
<div class="col-12">
<span class="fw-medium">یادداشت:</span>
<span>{{ quote.notes }}</span>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Action Buttons -->
<div class="row mt-4 no-print">
<div class="col-12">
<div class="d-flex justify-content-between">
{% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}"
class="btn btn-label-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
<span class="align-middle d-sm-inline-block d-none">ویرایش اقلام</span>
</a>
{% else %}
<span></span>
{% endif %}
{% if step_instance.status == 'completed' %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}"
class="btn btn-primary">
<span class="align-middle d-sm-inline-block d-none me-sm-1">بعدی</span>
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% else %}
<button class="btn btn-success" type="button">اتمام</button>
{% endif %}
{% else %}
<button type="button" class="btn btn-primary" id="btnApproveQuote">
تایید پیش‌فاکتور
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script src="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Quote approval
const btnApproveQuote = document.getElementById('btnApproveQuote');
if (btnApproveQuote) {
btnApproveQuote.addEventListener('click', function() {
btnApproveQuote.disabled = true;
fetch('{% url "invoices:approve_quote" instance.id step.id %}', {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
'Content-Type': 'application/x-www-form-urlencoded',
}
}).then(r => r.json()).then(resp => {
if (resp.success) {
showToast('پیش‌فاکتور با موفقیت تایید شد', 'success');
if (resp.redirect) {
window.location.href = resp.redirect;
return;
}
setTimeout(() => { window.location.reload(); }, 800);
} else {
showToast(resp.message || 'خطا در تایید پیش‌فاکتور', 'danger');
btnApproveQuote.disabled = false;
}
}).catch(() => {
showToast('خطا در ارتباط با سرور', 'danger');
btnApproveQuote.disabled = false;
});
});
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>پیش‌فاکتور {{ quote.name }} - {{ instance.code }}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
@page {
size: A4;
margin: 1cm;
}
body {
font-family: 'Vazirmatn', sans-serif;
font-size: 14px;
line-height: 1.6;
}
@media print {
body { print-color-adjust: exact; }
.page-break { page-break-before: always; }
.no-print { display: none !important; }
}
.invoice-header {
border-bottom: 2px solid #696cff;
padding-bottom: 20px;
margin-bottom: 30px;
}
.company-logo {
font-size: 24px;
font-weight: bold;
color: #696cff;
}
.invoice-title {
font-size: 28px;
font-weight: bold;
color: #333;
}
.info-table td {
padding: 5px 10px;
border: none;
}
.items-table {
border: 1px solid #dee2e6;
}
.items-table th {
background-color: #f8f9fa;
border-bottom: 2px solid #dee2e6;
font-weight: bold;
text-align: center;
}
.items-table td {
border-bottom: 1px solid #dee2e6;
text-align: center;
}
.total-section {
background-color: #f8f9fa;
font-weight: bold;
}
.signature-section {
margin-top: 50px;
border-top: 1px solid #dee2e6;
padding-top: 30px;
}
.signature-box {
border: 1px dashed #ccc;
height: 80px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
</style>
</head>
<body>
<div class="container-fluid">
<!-- Print Button (hidden in print) -->
<div class="no-print mb-3">
<button onclick="window.print()" class="btn btn-primary">
<i class="bi bi-printer"></i> پرینت
</button>
<button onclick="window.close()" class="btn btn-secondary">
بستن
</button>
</div>
<!-- Invoice Header -->
<div class="invoice-header">
<div class="row">
<div class="col-6">
<div class="company-logo mb-3">
شرکت آب منطقه‌ای
</div>
<div class="company-info">
<p class="mb-1">دفتر مرکزی، خیابان اصلی</p>
<p class="mb-1">تهران، ایران</p>
<p class="mb-1">تلفن: ۰۲۱-۱۲۳۴۵۶۷۸</p>
<p class="mb-0">ایمیل: info@watercompany.ir</p>
</div>
</div>
<div class="col-6 text-end">
<div class="invoice-title">پیش‌فاکتور</div>
<div class="mt-3">
<table class="info-table">
<tr>
<td><strong>شماره پیش‌فاکتور:</strong></td>
<td>{{ quote.name }}</td>
</tr>
<tr>
<td><strong>کد درخواست:</strong></td>
<td>{{ instance.code }}</td>
</tr>
<tr>
<td><strong>تاریخ صدور:</strong></td>
<td>{{ quote.created|date:"Y/m/d" }}</td>
</tr>
<tr>
<td><strong>معتبر تا:</strong></td>
<td>{{ quote.valid_until|date:"Y/m/d" }}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<!-- Customer & Well Info -->
<div class="row mb-4">
<div class="col-6">
<h6 class="fw-bold mb-3">مشخصات مشترک:</h6>
<table class="info-table">
<tr>
<td><strong>نام و نام خانوادگی:</strong></td>
<td>{{ quote.customer.get_full_name }}</td>
</tr>
{% if instance.representative.profile %}
<tr>
<td><strong>کد ملی:</strong></td>
<td>{{ instance.representative.profile.national_code }}</td>
</tr>
<tr>
<td><strong>تلفن:</strong></td>
<td>{{ instance.representative.profile.phone_number_1|default:"-" }}</td>
</tr>
<tr>
<td><strong>آدرس:</strong></td>
<td>{{ instance.representative.profile.address|default:"آدرس نامشخص" }}</td>
</tr>
{% endif %}
</table>
</div>
<div class="col-6">
<h6 class="fw-bold mb-3">مشخصات چاه:</h6>
<table class="info-table">
<tr>
<td><strong>شماره اشتراک آب:</strong></td>
<td>{{ instance.well.water_subscription_number }}</td>
</tr>
<tr>
<td><strong>شماره اشتراک برق:</strong></td>
<td>{{ instance.well.electricity_subscription_number|default:"-" }}</td>
</tr>
<tr>
<td><strong>سریال کنتور:</strong></td>
<td>{{ instance.well.water_meter_serial_number|default:"-" }}</td>
</tr>
<tr>
<td><strong>قدرت چاه:</strong></td>
<td>{{ instance.well.well_power|default:"-" }}</td>
</tr>
</table>
</div>
</div>
<!-- Items Table -->
<div class="mb-4">
<table class="table items-table">
<thead>
<tr>
<th style="width: 5%">ردیف</th>
<th style="width: 30%">شرح کالا/خدمات</th>
<th style="width: 30%">توضیحات</th>
<th style="width: 10%">تعداد</th>
<th style="width: 12.5%">قیمت واحد (تومان)</th>
<th style="width: 12.5%">قیمت کل (تومان)</th>
</tr>
</thead>
<tbody>
{% for quote_item in quote.items.all %}
<tr>
<td>{{ forloop.counter }}</td>
<td class="text-start">{{ quote_item.item.name }}</td>
<td class="text-start">{{ quote_item.item.description|default:"-" }}</td>
<td>{{ quote_item.quantity }}</td>
<td>{{ quote_item.unit_price|floatformat:0 }}</td>
<td>{{ quote_item.total_price|floatformat:0 }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>جمع کل:</strong></td>
<td><strong>{{ quote.total_amount|floatformat:0 }} تومان</strong></td>
</tr>
{% if quote.discount_amount > 0 %}
<tr class="total-section">
<td colspan="5" class="text-end"><strong>تخفیف:</strong></td>
<td><strong>{{ quote.discount_amount|floatformat:0 }} تومان</strong></td>
</tr>
{% endif %}
<tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی:</strong></td>
<td><strong>{{ quote.final_amount|floatformat:0 }} تومان</strong></td>
</tr>
</tfoot>
</table>
</div>
<!-- Notes -->
{% if quote.notes %}
<div class="mb-4">
<h6 class="fw-bold">یادداشت:</h6>
<p>{{ quote.notes }}</p>
</div>
{% endif %}
<!-- Additional Info -->
<div class="mb-4">
<p><strong>صادر کننده:</strong> {{ quote.created_by.get_full_name }}</p>
<p class="text-muted">این پیش‌فاکتور تا تاریخ {{ quote.valid_until|date:"Y/m/d" }} معتبر است.</p>
</div>
<!-- Signature Section -->
<div class="signature-section">
<div class="row">
<div class="col-6">
<div class="text-center">
<p class="mb-2"><strong>امضای مشترک</strong></p>
<div class="signature-box">
امضا و مهر
</div>
<p class="mt-2 small">تاریخ: ____/____/____</p>
</div>
</div>
<div class="col-6">
<div class="text-center">
<p class="mb-2"><strong>امضای شرکت</strong></p>
<div class="signature-box">
امضا و مهر
</div>
<p class="mt-2 small">تاریخ: ____/____/____</p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center mt-4 small text-muted">
این پیش‌فاکتور توسط سیستم مدیریت فرآیندها تولید شده است.
</div>
</div>
<script>
// Auto print on load (optional)
// window.onload = function() { window.print(); }
</script>
</body>
</html>

View file

@ -0,0 +1,204 @@
{% 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' %}">
{% 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>
{% csrf_token %}
<div class="content active dstepper-block">
<div class="content-header mb-3">
<h6 class="mb-0">{{ step.name }}</h6>
<small>{{ step.description|default:' ' }}</small>
</div>
<div class="row g-3">
{% if existing_quote %}
<div class="col-12 mb-3">
<div class="alert alert-info">
<h6>پیش‌فاکتور موجود</h6>
<span class="mb-1">نام: {{ existing_quote.name }} | </span>
<span class="mb-1">مبلغ کل: {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان | </span>
<span class="mb-0">وضعیت: {{ existing_quote.get_status_display_with_color|safe }}</span>
</div>
</div>
{% endif %}
<div class="col-12">
<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 item in items %}
{% with selected_qty=existing_quote_items|get_item:item.id %}
<tr>
<td>
<input class="form-check-input quote-item-check" type="checkbox"
data-item-id="{{ item.id }}"
data-is-default="{% if item.is_default_in_quotes %}1{% else %}0{% endif %}"
{% if selected_qty %}checked{% elif item.is_default_in_quotes %}checked{% endif %}
{% if item.is_default_in_quotes %}disabled title="آیتم پیش‌فرض است و قابل حذف نیست"{% endif %}>
</td>
<td>
<div class="d-flex flex-column">
<span class="fw-semibold">{{ item.name }}
{% if item.is_default_in_quotes %}
<span class="badge bg-label-primary me-2">پیش‌فرض</span>
{% endif %}
</span>
{% if item.description %}<small class="text-muted">{{ item.description }}</small>{% endif %}
</div>
</td>
<td>{{ item.unit_price|floatformat:0|intcomma:False }} تومان</td>
<td>
<input type="number" class="form-control form-control-sm quote-item-qty" min="1"
data-item-id="{{ item.id }}"
value="{% if selected_qty %}{{ selected_qty }}{% else %}{{ item.default_quantity }}{% endif %}">
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-12 d-flex justify-content-between">
{% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}"
class="btn btn-label-secondary">
<i class="bx bx-chevron-left bx-sm ms-sm-n2"></i>
<span class="align-middle d-sm-inline-block d-none">قبلی</span>
</a>
{% else %}
<span></span>
{% endif %}
{% if step_instance.status == 'completed' %}
{% if next_step %}
<div class="d-flex justify-content-end mt-3">
<button type="button" class="btn btn-primary" id="btnCreateQuote">
{% if existing_quote %}بروزرسانی پیش‌فاکتور{% else %}ثبت پیش‌فاکتور{% endif %}
و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button>
</div>
{% else %}
<button class="btn btn-success" type="button">اتمام</button>
{% endif %}
{% else %}
<button type="button" class="btn btn-primary" id="btnCreateQuote">
{% if existing_quote %}بروزرسانی پیش‌فاکتور{% else %}ثبت پیش‌فاکتور{% endif %}
و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button>
{% endif %}
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script src="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Quote creation
const btnCreateQuote = document.getElementById('btnCreateQuote');
if (btnCreateQuote) {
btnCreateQuote.addEventListener('click', function() {
const selections = [];
document.querySelectorAll('.quote-item-check').forEach(chk => {
if (chk.checked) {
const id = chk.getAttribute('data-item-id');
const isDefault = chk.getAttribute('data-is-default') === '1';
const qtyInput = document.querySelector(`.quote-item-qty[data-item-id="${id}"]`);
const qty = qtyInput ? parseInt(qtyInput.value || '1', 10) : 1;
selections.push({ id, qty, isDefault });
}
});
if (selections.length === 0) {
showToast('حداقل یک آیتم را انتخاب کنید', 'danger');
return;
}
const payload = new FormData();
payload.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
payload.append('items', JSON.stringify(selections));
btnCreateQuote.disabled = true;
fetch('{% url "invoices:create_quote" instance.id step.id %}', {
method: 'POST',
body: payload
}).then(r => r.json()).then(resp => {
if (resp.success) {
showToast('پیش‌فاکتور با موفقیت ثبت شد', 'success');
if (resp.redirect) {
window.location.href = resp.redirect;
} else {
setTimeout(() => { window.location.reload(); }, 800);
}
} else {
showToast(resp.message || 'خطا در ثبت پیش‌فاکتور', 'danger');
btnCreateQuote.disabled = false;
}
}).catch(() => {
showToast('خطا در ارتباط با سرور', 'danger');
btnCreateQuote.disabled = false;
});
});
}
});
</script>
{% endblock %}

24
invoices/urls.py Normal file
View file

@ -0,0 +1,24 @@
from django.urls import path
from . import views
app_name = 'invoices'
urlpatterns = [
# Quote step for process instances
path('instance/<int:instance_id>/step/<int:step_id>/quote/', views.quote_step, name='quote_step'),
path('instance/<int:instance_id>/step/<int:step_id>/quote/create/', views.create_quote, name='create_quote'),
# Quote preview step (step 2)
path('instance/<int:instance_id>/step/<int:step_id>/quote-preview/', views.quote_preview_step, name='quote_preview_step'),
path('instance/<int:instance_id>/step/<int:step_id>/approve/', views.approve_quote, name='approve_quote'),
# Quote payments step (step 3)
path('instance/<int:instance_id>/step/<int:step_id>/payments/', views.quote_payment_step, name='quote_payment_step'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/add/', views.add_quote_payment, name='add_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/update/', views.update_quote_payment, name='update_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/delete/', views.delete_quote_payment, name='delete_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/approve/', views.approve_payments, name='approve_payments'),
# Quote print
path('instance/<int:instance_id>/quote/print/', views.quote_print, name='quote_print'),
]

View file

@ -1,3 +1,415 @@
from django.shortcuts import render from django.shortcuts import render, get_object_or_404, redirect
import logging
from django.contrib.auth.decorators import login_required
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.urls import reverse
from decimal import Decimal, InvalidOperation
import json
# Create your views here. from processes.models import ProcessInstance, ProcessStep, StepInstance
from .models import Item, Quote, QuoteItem, Payment, Invoice
@login_required
def quote_step(request, instance_id, step_id):
"""مرحله انتخاب اقلام و ساخت پیش‌فاکتور"""
instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id
)
step = get_object_or_404(instance.process.steps, id=step_id)
# بررسی دسترسی به مرحله
if not instance.can_access_step(step):
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
return redirect('processes:request_list')
# دریافت آیتم‌ها
items = Item.objects.all().order_by('name')
existing_quote = Quote.objects.filter(process_instance=instance).first()
existing_quote_items = {}
if existing_quote:
existing_quote_items = {qi.item_id: qi.quantity for qi in existing_quote.items.all()}
step_instance = instance.step_instances.filter(step=step).first()
# Navigation logic
previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first()
return render(request, 'invoices/quote_step.html', {
'instance': instance,
'step': step,
'step_instance': step_instance,
'items': items,
'existing_quote_items': existing_quote_items,
'existing_quote': existing_quote,
'previous_step': previous_step,
'next_step': next_step,
})
@require_POST
@login_required
def create_quote(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)
try:
items_payload = json.loads(request.POST.get('items') or '[]')
except json.JSONDecodeError:
return JsonResponse({'success': False, 'message': 'داده‌های اقلام نامعتبر است'})
# اطمینان از حضور اقلام پیش‌فرض حتی اگر کلاینت ارسال نکرده باشد
payload_by_id = {}
for entry in items_payload:
try:
iid = int(entry.get('id'))
payload_by_id[iid] = int(entry.get('qty') or 1)
except Exception:
continue
default_item_ids = set(Item.objects.filter(is_default_in_quotes=True).values_list('id', flat=True))
if default_item_ids:
for default_id in default_item_ids:
if default_id not in payload_by_id:
# مقدار پیش فرض را قرار بده
default_qty = Item.objects.filter(id=default_id).values_list('default_quantity', flat=True).first() or 1
payload_by_id[default_id] = int(default_qty)
# بازسازی payload نهایی معتبر
items_payload = [{'id': iid, 'qty': qty} for iid, qty in payload_by_id.items() if qty and qty > 0]
if not items_payload:
return JsonResponse({'success': False, 'message': 'هیچ آیتمی انتخاب نشده است'})
# Create or reuse quote
quote, _ = Quote.objects.get_or_create(
process_instance=instance,
defaults={
'name': f"پیش‌فاکتور {instance.code}",
'customer': instance.representative or request.user,
'valid_until': timezone.now().date(),
'created_by': request.user,
}
)
# Replace quote items with submitted ones
quote.items.all().delete()
for entry in items_payload:
try:
item_id = int(entry.get('id'))
qty = int(entry.get('qty') or 1)
except (TypeError, ValueError):
continue
if qty <= 0:
continue
item = Item.objects.filter(id=item_id).first()
if not item:
continue
QuoteItem.objects.create(
quote=quote,
item=item,
quantity=qty,
unit_price=item.unit_price,
total_price=item.unit_price * qty,
)
quote.calculate_totals()
# تکمیل مرحله
step_instance, created = StepInstance.objects.get_or_create(
process_instance=instance,
step=step
)
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
# انتقال به مرحله بعدی
next_step = instance.process.steps.filter(order__gt=step.order).first()
redirect_url = None
if next_step:
instance.current_step = next_step
instance.save()
# هدایت مستقیم به مرحله پیش‌نمایش پیش‌فاکتور
redirect_url = reverse('invoices:quote_preview_step', args=[instance.id, next_step.id])
return JsonResponse({'success': True, 'quote_id': quote.id, 'redirect': redirect_url})
@login_required
def quote_preview_step(request, instance_id, step_id):
"""مرحله صدور پیش‌فاکتور - نمایش و تایید فاکتور"""
instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id
)
step = get_object_or_404(instance.process.steps, id=step_id)
# بررسی دسترسی به مرحله
if not instance.can_access_step(step):
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
return redirect('processes:request_list')
# دریافت پیش‌فاکتور
quote = get_object_or_404(Quote, process_instance=instance)
step_instance = instance.step_instances.filter(step=step).first()
# Navigation logic
previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first()
return render(request, 'invoices/quote_preview_step.html', {
'instance': instance,
'step': step,
'step_instance': step_instance,
'quote': quote,
'previous_step': previous_step,
'next_step': next_step,
})
@login_required
def quote_print(request, instance_id):
"""صفحه پرینت پیش‌فاکتور"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
quote = get_object_or_404(Quote, process_instance=instance)
return render(request, 'invoices/quote_print.html', {
'instance': instance,
'quote': quote,
})
@require_POST
@login_required
def approve_quote(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)
quote = get_object_or_404(Quote, process_instance=instance)
# تایید پیش‌فاکتور
quote.status = 'sent'
quote.save()
# تکمیل مرحله
step_instance, created = StepInstance.objects.get_or_create(
process_instance=instance,
step=step
)
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
# انتقال به مرحله بعدی
next_step = instance.process.steps.filter(order__gt=step.order).first()
redirect_url = None
if next_step:
instance.current_step = next_step
instance.save()
redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
else:
# در صورت نبود مرحله بعدی، بازگشت به لیست درخواست‌ها
redirect_url = reverse('processes:request_list')
messages.success(request, 'پیش‌فاکتور با موفقیت تایید شد.')
return JsonResponse({'success': True, 'message': 'پیش‌فاکتور با موفقیت تایید شد.', 'redirect': redirect_url})
@login_required
def quote_payment_step(request, instance_id, step_id):
"""مرحله سوم: ثبت فیش‌های واریزی پیش‌فاکتور"""
instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id
)
step = get_object_or_404(instance.process.steps, id=step_id)
# بررسی دسترسی
if not instance.can_access_step(step):
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
return redirect('processes:request_list')
quote = get_object_or_404(Quote, process_instance=instance)
invoice = Invoice.objects.filter(quote=quote).first()
payments = invoice.payments.select_related('created_by').filter(is_deleted=False).all() if invoice else []
previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first()
totals = {
'final_amount': quote.final_amount,
'paid_amount': quote.get_paid_amount(),
'remaining_amount': quote.get_remaining_amount(),
'is_fully_paid': quote.get_remaining_amount() <= 0,
}
step_instance = instance.step_instances.filter(step=step).first()
return render(request, 'invoices/quote_payment_step.html', {
'instance': instance,
'step': step,
'step_instance': step_instance,
'quote': quote,
'payments': payments,
'totals': totals,
'previous_step': previous_step,
'next_step': next_step,
})
@require_POST
@login_required
def add_quote_payment(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)
quote = get_object_or_404(Quote, process_instance=instance)
invoice, _ = Invoice.objects.get_or_create(
process_instance=instance,
quote=quote,
defaults={
'name': f"Invoice {quote.name}",
'customer': quote.customer,
'due_date': timezone.now().date(),
'created_by': request.user,
}
)
logger = logging.getLogger(__name__)
try:
amount = (request.POST.get('amount') or '').strip()
payment_date = (request.POST.get('payment_date') or '').strip()
payment_method = (request.POST.get('payment_method') or '').strip()
reference_number = (request.POST.get('reference_number') or '').strip()
notes = (request.POST.get('notes') or '').strip()
receipt_image = request.FILES.get('receipt_image')
# Server-side validation for required fields
if not amount:
return JsonResponse({'success': False, 'message': 'مبلغ را وارد کنید'})
if not payment_date:
return JsonResponse({'success': False, 'message': 'تاریخ پرداخت را وارد کنید'})
if not payment_method:
return JsonResponse({'success': False, 'message': 'روش پرداخت را انتخاب کنید'})
if not reference_number:
return JsonResponse({'success': False, 'message': 'شماره مرجع را وارد کنید'})
if not receipt_image:
return JsonResponse({'success': False, 'message': 'تصویر فیش را بارگذاری کنید'})
# Normalize date to YYYY-MM-DD (accept YYYY/MM/DD from Persian datepicker)
if '/' in payment_date:
payment_date = payment_date.replace('/', '-')
# Prevent overpayment
try:
amount_dec = Decimal(amount)
except InvalidOperation:
return JsonResponse({'success': False, 'message': 'مبلغ نامعتبر است'})
remaining = quote.get_remaining_amount()
if amount_dec > remaining:
return JsonResponse({'success': False, 'message': 'مبلغ فیش بیشتر از مانده پیش‌فاکتور است'})
Payment.objects.create(
invoice=invoice,
amount=amount_dec,
payment_date=payment_date,
payment_method=payment_method,
reference_number=reference_number,
receipt_image=receipt_image,
notes=notes,
created_by=request.user,
)
except Exception as e:
logger.exception('Error adding quote payment (instance=%s, step=%s)', instance_id, step_id)
return JsonResponse({'success': False, 'message': 'خطا در ثبت فیش', 'error': str(e)})
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@require_POST
@login_required
def update_quote_payment(request, instance_id, step_id, payment_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance)
invoice = Invoice.objects.filter(quote=quote).first()
if not invoice:
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
try:
amount = request.POST.get('amount')
payment_date = request.POST.get('payment_date') or payment.payment_date
payment_method = request.POST.get('payment_method') or payment.payment_method
reference_number = request.POST.get('reference_number') or ''
notes = request.POST.get('notes') or ''
receipt_image = request.FILES.get('receipt_image')
if amount:
payment.amount = amount
payment.payment_date = payment_date
payment.payment_method = payment_method
payment.reference_number = reference_number
payment.notes = notes
# اگر نیاز به ذخیره عکس در Payment دارید، فیلد آن اضافه شده است
if receipt_image:
payment.receipt_image = receipt_image
payment.save()
except Exception:
return JsonResponse({'success': False, 'message': 'خطا در ویرایش فیش'})
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@require_POST
@login_required
def delete_quote_payment(request, instance_id, step_id, payment_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance)
invoice = Invoice.objects.filter(quote=quote).first()
if not invoice:
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
try:
# soft delete using project's BaseModel delete override
payment.delete()
except Exception:
return JsonResponse({'success': False, 'message': 'خطا در حذف فیش'})
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@require_POST
@login_required
def approve_payments(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)
quote = get_object_or_404(Quote, process_instance=instance)
is_fully_paid = quote.get_remaining_amount() <= 0
# تکمیل مرحله
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
# حرکت به مرحله بعد
next_step = instance.process.steps.filter(order__gt=step.order).first()
redirect_url = reverse('processes:request_list')
if next_step:
instance.current_step = next_step
instance.save()
redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
msg = 'پرداخت‌ها تایید شد'
if is_fully_paid:
msg += ' - مبلغ پیش‌فاکتور به طور کامل پرداخت شده است.'
else:
msg += ' - توجه: مبلغ پیش‌فاکتور به طور کامل پرداخت نشده است.'
return JsonResponse({'success': True, 'message': msg, 'redirect': redirect_url, 'is_fully_paid': is_fully_paid})

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-08-07 09:08 # Generated by Django 5.2.4 on 2025-08-14 09:02
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View file

@ -46,13 +46,86 @@ class StepDependencyAdmin(admin.ModelAdmin):
@admin.register(ProcessInstance) @admin.register(ProcessInstance)
class ProcessInstanceAdmin(SimpleHistoryAdmin): class ProcessInstanceAdmin(SimpleHistoryAdmin):
list_display = ['name', 'process', 'requester', 'current_step', 'status', 'started_at', 'progress_display'] verbose_name = "درخواست"
list_filter = ['process', 'status', 'started_at'] verbose_name_plural = "درخواست‌ها"
search_fields = ['name', 'process__name', 'requester__username', 'requester__first_name'] list_display = [
readonly_fields = ['deleted_at', 'started_at', 'completed_at'] 'code',
ordering = ['-started_at'] 'slug',
'well_display',
'representative',
'requester',
'process',
'status_display',
'priority_display',
'created',
'progress_display'
]
list_filter = [
'process',
'status',
'priority',
'created',
'well__representative'
]
search_fields = [
'code',
'slug',
'process__name',
'requester__username',
'requester__first_name',
'well__water_subscription_number',
'representative__username'
]
readonly_fields = [
'deleted_at',
'created',
'updated',
'completed_at'
]
autocomplete_fields = [
'well',
'representative',
'requester',
'process',
'current_step'
]
ordering = ['-created']
fieldsets = (
('اطلاعات اصلی', {
'fields': ('code', 'slug', 'description', 'process')
}),
('اطلاعات چاه', {
'fields': ('well', 'representative')
}),
('اطلاعات درخواست', {
'fields': ('requester', 'priority')
}),
('وضعیت و پیشرفت', {
'fields': ('status', 'current_step')
}),
('تاریخ‌ها', {
'fields': ('created', 'updated', 'completed_at'),
'classes': ('collapse',)
}),
)
def well_display(self, obj):
if obj.well:
return f"{obj.well.water_subscription_number}"
return "-"
well_display.short_description = "چاه"
def status_display(self, obj):
return mark_safe(obj.get_status_display_with_color())
status_display.short_description = "وضعیت"
def priority_display(self, obj):
return mark_safe(obj.get_priority_display_with_color())
priority_display.short_description = "اولویت"
def progress_display(self, obj): def progress_display(self, obj):
if obj.process:
total_steps = obj.process.steps.count() total_steps = obj.process.steps.count()
completed_steps = obj.step_instances.filter(status='completed').count() completed_steps = obj.step_instances.filter(status='completed').count()
percentage = (completed_steps / total_steps * 100) if total_steps > 0 else 0 percentage = (completed_steps / total_steps * 100) if total_steps > 0 else 0
@ -61,6 +134,7 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
'<div class="progress" style="width: 100px;"><div class="progress-bar" style="width: {}%">{}/{} ({}%)</div></div>', '<div class="progress" style="width: 100px;"><div class="progress-bar" style="width: {}%">{}/{} ({}%)</div></div>',
percentage_int, completed_steps, total_steps, percentage_int percentage_int, completed_steps, total_steps, percentage_int
) )
return "-"
progress_display.short_description = "پیشرفت" progress_display.short_description = "پیشرفت"
@admin.register(StepInstance) @admin.register(StepInstance)

View file

@ -4,9 +4,16 @@ from .models import ProcessInstance, StepInstance
class ProcessInstanceForm(forms.ModelForm): class ProcessInstanceForm(forms.ModelForm):
class Meta: class Meta:
model = ProcessInstance model = ProcessInstance
fields = ['name'] fields = ['description', 'process', 'well', 'representative', 'requester', 'priority', 'status', 'current_step']
widgets = { widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}) 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'process': forms.Select(attrs={'class': 'form-control'}),
'well': forms.Select(attrs={'class': 'form-control'}),
'representative': forms.Select(attrs={'class': 'form-control'}),
'requester': forms.Select(attrs={'class': 'form-control'}),
'priority': forms.Select(attrs={'class': 'form-control'}),
'status': forms.Select(attrs={'class': 'form-control'}),
'current_step': forms.Select(attrs={'class': 'form-control'}),
} }
class StepInstanceForm(forms.ModelForm): class StepInstanceForm(forms.ModelForm):

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.4 on 2025-08-07 09:08 # Generated by Django 5.2.4 on 2025-08-14 09:02
import django.db.models.deletion import django.db.models.deletion
import simple_history.models import simple_history.models
@ -11,6 +11,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('wells', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@ -130,18 +131,21 @@ class Migration(migrations.Migration):
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')), ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')), ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')), ('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')),
('name', models.CharField(max_length=100, verbose_name='نام')), ('code', models.CharField(help_text='کد ۵ رقمی یکتا برای هر درخواست', max_length=5, unique=True, verbose_name='کد درخواست')),
('description', models.TextField(blank=True, null=True, verbose_name='توضیحات درخواست')),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('in_progress', 'در حال انجام'), ('completed', 'تکمیل شده'), ('cancelled', 'لغو شده'), ('rejected', 'رد شده')], default='pending', max_length=20, verbose_name='وضعیت')), ('status', models.CharField(choices=[('pending', 'در انتظار'), ('in_progress', 'در حال انجام'), ('completed', 'تکمیل شده'), ('cancelled', 'لغو شده'), ('rejected', 'رد شده')], default='pending', max_length=20, verbose_name='وضعیت')),
('started_at', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ شروع')), ('priority', models.CharField(choices=[('low', 'کم'), ('medium', 'متوسط'), ('high', 'زیاد'), ('urgent', 'فوری')], default='medium', max_length=20, verbose_name='اولویت')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تکمیل')), ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تکمیل')),
('process', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='instances', to='processes.process', verbose_name='فرآیند')), ('process', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='instances', to='processes.process', verbose_name='فرآیند')),
('requester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='درخواست کننده')), ('representative', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='representative_instances', to=settings.AUTH_USER_MODEL, verbose_name='نماینده چاه')),
('requester', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='درخواست کننده')),
('well', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='process_instances', to='wells.well', verbose_name='چاه')),
('current_step', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='processes.processstep', verbose_name='مرحله فعلی')), ('current_step', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='processes.processstep', verbose_name='مرحله فعلی')),
], ],
options={ options={
'verbose_name': 'نمونه فرآیند', 'verbose_name': 'درخواست',
'verbose_name_plural': 'نمونه\u200cهای فرآیند', 'verbose_name_plural': 'درخواست\u200cها',
'ordering': ['-started_at'], 'ordering': ['-created'],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
@ -169,37 +173,6 @@ class Migration(migrations.Migration):
}, },
bases=(simple_history.models.HistoricalChanges, models.Model), bases=(simple_history.models.HistoricalChanges, models.Model),
), ),
migrations.CreateModel(
name='HistoricalProcessInstance',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('created', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ ایجاد')),
('updated', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ بروزرسانی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('slug', models.SlugField(max_length=100, verbose_name='اسلاگ')),
('name', models.CharField(max_length=100, verbose_name='نام')),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('in_progress', 'در حال انجام'), ('completed', 'تکمیل شده'), ('cancelled', 'لغو شده'), ('rejected', 'رد شده')], default='pending', max_length=20, verbose_name='وضعیت')),
('started_at', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ شروع')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تکمیل')),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('requester', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='درخواست کننده')),
('process', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='processes.process', verbose_name='فرآیند')),
('current_step', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='processes.processstep', verbose_name='مرحله فعلی')),
],
options={
'verbose_name': 'historical نمونه فرآیند',
'verbose_name_plural': 'historical نمونه\u200cهای فرآیند',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel( migrations.CreateModel(
name='StepInstance', name='StepInstance',
fields=[ fields=[

View file

@ -1,8 +1,11 @@
from django.db import models from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from common.models import NameSlugModel from common.models import NameSlugModel, SluggedModel
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils import timezone
from _helpers.utils import generate_unique_slug
import random
User = get_user_model() User = get_user_model()
@ -21,6 +24,7 @@ class Process(NameSlugModel):
def __str__(self): def __str__(self):
return self.name return self.name
class ProcessStep(NameSlugModel): class ProcessStep(NameSlugModel):
"""مدل مراحل فرآیند""" """مدل مراحل فرآیند"""
process = models.ForeignKey(Process, on_delete=models.CASCADE, related_name='steps', verbose_name="فرآیند") process = models.ForeignKey(Process, on_delete=models.CASCADE, related_name='steps', verbose_name="فرآیند")
@ -95,35 +99,169 @@ class StepDependency(models.Model):
if self.dependent_step.order <= self.dependency_step.order: if self.dependent_step.order <= self.dependency_step.order:
raise ValidationError("مرحله وابسته باید بعد از مرحله مورد نیاز باشد") raise ValidationError("مرحله وابسته باید بعد از مرحله مورد نیاز باشد")
class ProcessInstance(NameSlugModel):
class ProcessInstance(SluggedModel):
code = models.CharField(
max_length=5,
unique=True,
verbose_name="کد درخواست",
help_text="کد ۵ رقمی یکتا برای هر درخواست"
)
"""مدل نمونه فرآیند (برای هر درخواست)""" """مدل نمونه فرآیند (برای هر درخواست)"""
process = models.ForeignKey(Process, on_delete=models.CASCADE, related_name='instances', verbose_name="فرآیند")
requester = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="درخواست کننده") PRIORITY_CHOICES = [
current_step = models.ForeignKey('ProcessStep', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="مرحله فعلی")
status = models.CharField( ('low', 'کم'),
max_length=20, ('medium', 'متوسط'),
choices=[ ('high', 'زیاد'),
('urgent', 'فوری'),
]
STATUS_CHOICES = [
('pending', 'در انتظار'), ('pending', 'در انتظار'),
('in_progress', 'در حال انجام'), ('in_progress', 'در حال انجام'),
('completed', 'تکمیل شده'), ('completed', 'تکمیل شده'),
('cancelled', 'لغو شده'), ('cancelled', 'لغو شده'),
('rejected', 'رد شده'), ('rejected', 'رد شده'),
], ]
description = models.TextField(
verbose_name="توضیحات درخواست",
blank=True,
null=True
)
process = models.ForeignKey(
Process,
on_delete=models.CASCADE,
related_name='instances',
verbose_name="فرآیند",
null=True,
blank=True
)
well = models.ForeignKey(
'wells.Well',
on_delete=models.CASCADE,
related_name='process_instances',
verbose_name="چاه",
)
representative = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name='representative_instances',
verbose_name="نماینده چاه",
null=True,
)
requester = models.ForeignKey(
User,
on_delete=models.SET_NULL,
verbose_name="درخواست کننده",
null=True,
blank=True
)
current_step = models.ForeignKey(
ProcessStep,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="مرحله فعلی",
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='pending', default='pending',
verbose_name="وضعیت" verbose_name="وضعیت"
) )
started_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ شروع")
completed_at = models.DateTimeField(null=True, blank=True, verbose_name="تاریخ تکمیل") priority = models.CharField(
history = HistoricalRecords() max_length=20,
choices=PRIORITY_CHOICES,
default='medium',
verbose_name="اولویت"
)
completed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="تاریخ تکمیل"
)
class Meta: class Meta:
verbose_name = "نمونه فرآیند" verbose_name = "درخواست"
verbose_name_plural = "نمونه‌های فرآیند" verbose_name_plural = "درخواست‌ها"
ordering = ['-started_at'] ordering = ['-created']
def __str__(self): def __str__(self):
if self.well:
return f"{self.process.name} - {self.well.water_subscription_number}"
return f"{self.process.name} - {self.requester.get_full_name()}" return f"{self.process.name} - {self.requester.get_full_name()}"
def clean(self):
"""اعتبارسنجی مدل"""
if self.well and self.representative and self.well.representative != self.representative:
raise ValidationError("نماینده درخواست باید همان نماینده ثبت شده در چاه باشد")
if self.well and self.representative and self.requester == self.representative:
raise ValidationError("درخواست کننده نمی‌تواند نماینده چاه باشد")
def save(self, *args, **kwargs):
# Generate unique 5-digit numeric code if missing
if not self.code:
# Try a few times to avoid rare collisions
for _ in range(10):
candidate = f"{random.randint(10000, 99999)}"
if not ProcessInstance.objects.filter(code=candidate).exists():
self.code = candidate
break
# As a fallback if collision persists (very unlikely)
if not self.code:
self.code = f"{random.randint(10000, 99999)}"
if not self.slug:
slug_text = f"{self.process.name}-{self.well.water_subscription_number if self.well else 'unknown'}-{timezone.now().strftime('%Y%m%d')}"
self.slug = generate_unique_slug(slug_text)
if self.status == 'completed' and not self.completed_at:
self.completed_at = timezone.now()
super().save(*args, **kwargs)
def get_status_display_with_color(self):
"""نمایش وضعیت با رنگ"""
status_colors = {
'pending': 'info',
'in_progress': 'primary',
'completed': 'success',
'rejected': 'danger',
'cancelled': 'warning',
}
color = status_colors.get(self.status, 'secondary')
return '<span class="badge bg-{}">{}</span>'.format(color, self.get_status_display())
def get_priority_display_with_color(self):
"""نمایش اولویت با رنگ"""
priority_colors = {
'low': 'success',
'medium': 'info',
'high': 'warning',
'urgent': 'danger',
}
color = priority_colors.get(self.priority, 'secondary')
return '<span class="badge bg-{}">{}</span>'.format(color, self.get_priority_display())
def can_edit(self, user):
"""بررسی امکان ویرایش درخواست"""
if self.status == 'pending' and self.requester == user:
return True
if self.representative == user and self.status in ['pending']:
return True
return False
def get_available_steps(self): def get_available_steps(self):
"""دریافت مراحل قابل دسترس""" """دریافت مراحل قابل دسترس"""
available_steps = [] available_steps = []
@ -134,7 +272,6 @@ class ProcessInstance(NameSlugModel):
def can_access_step(self, step): def can_access_step(self, step):
"""بررسی امکان دسترسی به مرحله""" """بررسی امکان دسترسی به مرحله"""
# بررسی وابستگی‌ها
dependencies = step.get_dependencies() dependencies = step.get_dependencies()
for dependency_id in dependencies: for dependency_id in dependencies:
step_instance = self.step_instances.filter(step_id=dependency_id).first() step_instance = self.step_instances.filter(step_id=dependency_id).first()
@ -144,7 +281,6 @@ class ProcessInstance(NameSlugModel):
def can_edit_step(self, step): def can_edit_step(self, step):
"""بررسی امکان ویرایش مرحله""" """بررسی امکان ویرایش مرحله"""
# اگر مرحله مسدود کننده باشد و مراحل بعدی تکمیل شده باشند
if step.blocks_previous: if step.blocks_previous:
later_steps = self.step_instances.filter( later_steps = self.step_instances.filter(
step__order__gt=step.order, step__order__gt=step.order,
@ -187,12 +323,10 @@ class StepInstance(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""ذخیره با اعتبارسنجی""" """ذخیره با اعتبارسنجی"""
# بررسی وابستگی‌ها
if self.status == 'in_progress' or self.status == 'completed': if self.status == 'in_progress' or self.status == 'completed':
if not self.process_instance.can_access_step(self.step): if not self.process_instance.can_access_step(self.step):
raise ValidationError("مراحل وابسته تکمیل نشده‌اند") raise ValidationError("مراحل وابسته تکمیل نشده‌اند")
# بررسی امکان ویرایش
if self.status == 'completed' and not self.process_instance.can_edit_step(self.step): if self.status == 'completed' and not self.process_instance.can_edit_step(self.step):
raise ValidationError("این مرحله قابل ویرایش نیست") raise ValidationError("این مرحله قابل ویرایش نیست")
@ -252,7 +386,6 @@ class StepRejection(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""ذخیره با تغییر وضعیت مرحله""" """ذخیره با تغییر وضعیت مرحله"""
# تغییر وضعیت مرحله به رد شده
self.step_instance.status = 'rejected' self.step_instance.status = 'rejected'
self.step_instance.save() self.step_instance.save()
super().save(*args, **kwargs) super().save(*args, **kwargs)

View file

@ -0,0 +1,54 @@
{% load static %}
<div class="bs-stepper-header">
{% for item in steps_context %}
{% with step=item.step status=item.status can_access=item.can_access is_selected=item.is_selected is_todo=item.is_todo %}
<div class="step
{% if not can_access %}disabled
{% elif status == 'completed' %}completed
{% elif is_todo %}active
{% endif %}
{% if is_selected %} selected{% endif %}"
data-target="#step-{{ step.id }}">
{% if can_access %}
<a href="{% url 'processes:step_detail' instance.id step.id %}"
class="step-trigger text-decoration-none p-0"
aria-selected="{% if is_selected %}true{% else %}false{% endif %}">
{% else %}
<span class="step-trigger">
{% endif %}
<span class="bs-stepper-circle">{{ forloop.counter }}</span>
<span class="bs-stepper-label mt-1">
<span class="bs-stepper-title">{{ step.name }}</span>
<span class="bs-stepper-subtitle">{{ step.description|default:' ' }}</span>
</span>
{% if can_access %}
</a>
{% else %}
</span>
{% endif %}
</div>
{% endwith %}
{% if not forloop.last %}<div class="line"></div>{% endif %}
{% endfor %}
</div>
<style>
.step.disabled .step-trigger { opacity: 0.5; cursor: not-allowed; }
.step.disabled .bs-stepper-circle { background-color: #6c757d; }
/* تکمیل شده */
.step.completed .bs-stepper-circle { background-color: #28a745 !important; color: #fff !important; border-color: #28a745 !important; }
.step.completed .bs-stepper-title { color: #28a745 !important; }
/* مرحله‌ای که باید انجام شود (فعلی سیستم) */
.step.active .bs-stepper-circle { background-color: #696cff; color: #fff; }
.step.active .bs-stepper-title { color: #696cff; }
/* مرحله انتخاب‌شده (نمایش فعلی صفحه) */
.step.selected { outline: 1px solid #696cff; border-radius: 8px; }
.step.selected .bs-stepper-circle { box-shadow: 0 0 0 3px rgba(13,202,240,.25); }
</style>

View file

@ -0,0 +1,774 @@
{% extends '_base.html' %}
{% load static %}
{% block sidebar %}
{% include 'sidebars/admin.html' %}
{% endblock sidebar %}
{% block navbar %}
{% include 'navbars/admin.html' %}
{% endblock navbar %}
{% block title %}درخواست‌ها{% endblock %}
{% block style %}
<!-- DataTables CSS -->
<link rel="stylesheet" href="{% static 'assets/vendor/libs/datatables-bs5/datatables.bootstrap5.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/libs/datatables-responsive-bs5/responsive.bootstrap5.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/libs/datatables-buttons-bs5/buttons.bootstrap5.css' %}">
<!-- Persian Date Picker CSS -->
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
{% endblock style %}
{% block content %}
{% include '_toasts.html' %}
<div class="container-xxl flex-grow-1 container-p-y">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">درخواست‌ها</h4>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#requestModal">
<i class="bx bx-plus"></i>
درخواست جدید
</button>
</div>
<div class="card">
<div class="table-responsive">
<table id="requestsTable" class="table table-striped">
<thead>
<tr>
<th>شناسه</th>
<th>فرآیند</th>
<th>شماره اشتراک آب</th>
<th>نماینده</th>
<th>درخواست‌کننده</th>
<th>اولویت</th>
<th>وضعیت</th>
<th>تاریخ ایجاد</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
{% for inst in instances %}
<tr>
<td>{{ inst.code }}</td>
<td>{{ inst.process.name }}</td>
<td>{{ inst.well.water_subscription_number }}</td>
<td>{% if inst.representative %}{{ inst.representative.get_full_name }}{% else %}-{% endif %}</td>
<td>{% if inst.requester %}{{ inst.requester.get_full_name }}{% else %}-{% endif %}</td>
<td>{{ inst.get_priority_display }}</td>
<td>{{ inst.get_status_display }}</td>
<td>{{ inst.jcreated }}</td>
<td>
<div class="d-inline-block">
<a href="javascript:;" class="btn btn-icon dropdown-toggle hide-arrow" data-bs-toggle="dropdown">
<i class="icon-base bx bx-dots-vertical-rounded"></i>
</a>
<ul class="dropdown-menu dropdown-menu-end m-0">
<li>
<a href="{% url 'processes:instance_steps' inst.id %}" class="dropdown-item">
<i class="bx bx-show me-1"></i>مشاهده جزئیات
</a>
</li>
<div class="dropdown-divider"></div>
<li>
<a href="#" class="dropdown-item text-danger" data-instance-id="{{ inst.id }}" data-instance-code="{{ inst.code }}" onclick="deleteRequest(this.getAttribute('data-instance-id'), this.getAttribute('data-instance-code'))">
<i class="bx bx-trash me-1"></i>حذف
</a>
</li>
</ul>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="9" class="text-center text-muted">موردی ثبت نشده است</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="requestModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<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">
<form id="requestForm">
{% csrf_token %}
<div class="row g-3">
<div class="col-sm-12">
<label class="form-label">فرآیند</label>
<select class="form-select" name="process" id="req_process" required>
{% for process in processes %}
<option value="{{ process.id }}">{{ process.name }}</option>
{% endfor %}
</select>
</div>
<hr class="mt-3 border border-dashed">
<div class="col-sm-12">
<label class="form-label">شماره اشتراک آب</label>
<div class="input-group">
<input type="text" class="form-control" id="req_water_sub" placeholder="مثال: 12345" required>
<button class="btn btn-outline-secondary" type="button" id="btnLookupWell">
بررسی/افزودن چاه
</button>
</div>
<div class="form-text" id="wellStatus"></div>
</div>
<!-- Well form fields (from WellForm) -->
<div id="wellFormBlock" class="col-sm-12" style="display:none;">
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label" for="id_electricity_subscription_number">{{ well_form.electricity_subscription_number.label }}</label>
{{ well_form.electricity_subscription_number }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_water_meter_manufacturer">{{ well_form.water_meter_manufacturer.label }}</label>
<div class="input-group">
<select name="water_meter_manufacturer" class="form-select" id="id_water_meter_manufacturer">
<option value="" selected="">انتخاب شرکت سازنده</option>
{% for manufacturer in manufacturers %}
<option value="{{ manufacturer.id }}">{{ manufacturer.name }}</option>
{% endfor %}
</select>
<input type="text" class="form-control" id="id_new_manufacturer" name="new_manufacturer" placeholder="شرکت سازنده جدید" style="display:none;">
<button class="btn btn-outline-primary" type="button" id="btnToggleManufacturer">
<i class="bx bx-plus"></i>
</button>
</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="id_water_meter_serial_number">{{ well_form.water_meter_serial_number.label }}</label>
{{ well_form.water_meter_serial_number }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_water_meter_old_serial_number">{{ well_form.water_meter_old_serial_number.label }}</label>
{{ well_form.water_meter_old_serial_number }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_utm_x">{{ well_form.utm_x.label }}</label>
{{ well_form.utm_x }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_utm_y">{{ well_form.utm_y.label }}</label>
{{ well_form.utm_y }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_utm_zone">{{ well_form.utm_zone.label }}</label>
{{ well_form.utm_zone }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_utm_hemisphere">{{ well_form.utm_hemisphere.label }}</label>
{{ well_form.utm_hemisphere }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_well_power">{{ well_form.well_power.label }}</label>
{{ well_form.well_power }}
</div>
<div class="col-sm-6"></div>
<div class="col-sm-6">
<label class="form-label" for="id_reference_letter_number">{{ well_form.reference_letter_number.label }}</label>
{{ well_form.reference_letter_number }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_reference_letter_date">{{ well_form.reference_letter_date.label }}</label>
<input type="text" class="form-control" id="id_reference_letter_date" name="reference_letter_date" placeholder="انتخاب تاریخ" readonly>
</div>
<div class="col-sm-12">
<label class="form-label" for="id_representative_letter_file">{{ well_form.representative_letter_file.label }}</label>
{{ well_form.representative_letter_file }}
<!-- نمایش فایل موجود -->
<div id="current-file-display" style="display: none; margin-top: 10px;">
<div class="alert alert-info d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<i class="bx bx-file me-2"></i>
<span id="current-file-name" class="text-truncate" style="max-width: 200px;" title=""></span>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeCurrentFile()">
<i class="bx bx-trash me-1"></i>حذف
</button>
</div>
</div>
<input type="hidden" id="remove-file" name="remove_file" value="false">
</div>
</div>
</div>
<hr class="mt-3 border border-dashed">
<div class="col-sm-12">
<label class="form-label">کد ملی نماینده</label>
<div class="input-group">
<input type="text" class="form-control" id="rep_national_code" placeholder="مثال: 0012345678">
<button class="btn btn-outline-secondary" type="button" id="btnLookupRep">
بررسی/افزودن نماینده
</button>
</div>
<div class="form-text" id="repStatus"></div>
</div>
<div id="repNewFields" class="col-sm-12" style="display:none;">
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label" for="id_first_name">{{ customer_form.first_name.label }}</label>
{{ customer_form.first_name }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_last_name">{{ customer_form.last_name.label }}</label>
{{ customer_form.last_name }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_phone_number_1">{{ customer_form.phone_number_1.label }}</label>
{{ customer_form.phone_number_1 }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_phone_number_2">{{ customer_form.phone_number_2.label }}</label>
{{ customer_form.phone_number_2 }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_national_code">{{ customer_form.national_code.label }}</label>
{{ customer_form.national_code }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_card_number">{{ customer_form.card_number.label }}</label>
{{ customer_form.card_number }}
</div>
<div class="col-sm-6">
<label class="form-label" for="id_account_number">{{ customer_form.account_number.label }}</label>
{{ customer_form.account_number }}
</div>
<div class="col-sm-12">
<label class="form-label" for="id_address">{{ customer_form.address.label }}</label>
{{ customer_form.address }}
</div>
</div>
</div>
<hr class="mt-3 border border-dashed">
<div class="col-sm-12">
<label class="form-label">توضیحات</label>
<textarea class="form-control" rows="3" id="req_description"></textarea>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">بستن</button>
<button type="button" class="btn btn-primary" id="btnSaveRequest" disabled>ذخیره</button>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-labelledby="deleteConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteConfirmModalLabel">تایید حذف</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p id="deleteConfirmText">آیا از حذف این درخواست اطمینان دارید؟</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">حذف</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<!-- DataTables JS -->
<script src="{% static 'assets/vendor/libs/datatables-bs5/datatables-bootstrap5.js' %}"></script>
<!-- Persian DataTable defaults -->
<script src="{% static 'assets/js/persian-datatable.js' %}"></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 to initialize Persian Date Picker
function initPersianDatePicker() {
if ($.fn.persianDatepicker && $('#id_reference_letter_date').length) {
try {
$('#id_reference_letter_date').persianDatepicker({
format: 'YYYY/MM/DD',
initialValue: false,
autoClose: true,
persianDigit: false,
observer: true,
calendar: {
persian: {
locale: 'fa',
leapYearMode: 'astronomical'
}
},
onSelect: function(unix) {
// تبدیل تاریخ شمسی به میلادی برای ارسال به سرور
const gregorianDate = new Date(unix);
const year = gregorianDate.getFullYear();
const month = String(gregorianDate.getMonth() + 1).padStart(2, '0');
const day = String(gregorianDate.getDate()).padStart(2, '0');
const gregorianDateString = `${year}-${month}-${day}`;
// نمایش تاریخ شمسی در فیلد
if (window.persianDate) {
const persianDate = new window.persianDate(unix);
const persianDateString = persianDate.format('YYYY/MM/DD');
$('#id_reference_letter_date').val(persianDateString);
} else {
// اگر persianDate در دسترس نبود، تاریخ میلادی را نمایش بده
$('#id_reference_letter_date').val(gregorianDateString);
}
// ذخیره تاریخ میلادی در فیلد مخفی برای ارسال به سرور
$('#id_reference_letter_date').attr('data-gregorian', gregorianDateString);
}
});
} catch (e) {
console.error('Error initializing Persian Date Picker:', e);
}
}
}
$(function() {
// if ($.fn.DataTable) {
// try {
// $('#requestsTable').DataTable({
// pageLength: 10,
// order: [[0, 'desc']]
// });
// } catch (e) {
// console.error('DataTable init failed', e);
// }
// } else {
// console.warn('DataTables library not loaded');
// }
let currentWellId = null;
let currentRepId = null;
let wellChecked = false;
let repChecked = false;
function setStatus(el, text, type) {
$(el).text(text).removeClass('text-danger text-success text-muted').addClass(type ? 'text-' + type : 'text-muted');
}
function checkSaveButton() {
const canSave = wellChecked && repChecked;
$('#btnSaveRequest').prop('disabled', !canSave);
}
// Inline error helpers
function clearInlineErrors() {
$('#requestModal .is-invalid').removeClass('is-invalid');
$('#requestModal .invalid-feedback.inline-error').remove();
}
function applyErrorTo(selector, message) {
const $el = $(selector);
if (!$el.length) return false;
$el.addClass('is-invalid');
const $feedback = $('<div class="invalid-feedback inline-error"></div>').text(message);
const $grp = $el.closest('.input-group');
if ($grp.length) {
$feedback.insertAfter($grp);
} else {
$feedback.insertAfter($el);
}
return true;
}
function mapWellFieldToSelector(field) {
switch (field) {
case 'water_subscription_number': return '#req_water_sub';
case 'electricity_subscription_number': return '#id_electricity_subscription_number';
case 'water_meter_serial_number': return '#id_water_meter_serial_number';
case 'water_meter_old_serial_number': return '#id_water_meter_old_serial_number';
case 'water_meter_manufacturer': return '#id_water_meter_manufacturer';
case 'new_manufacturer': return '#id_new_manufacturer';
case 'utm_x': return '#id_utm_x';
case 'utm_y': return '#id_utm_y';
case 'utm_zone': return '#id_utm_zone';
case 'utm_hemisphere': return '#id_utm_hemisphere';
case 'well_power': return '#id_well_power';
case 'reference_letter_number': return '#id_reference_letter_number';
case 'reference_letter_date': return '#id_reference_letter_date';
case 'representative_letter_file': return '#id_representative_letter_file';
case 'representative': return '#rep_national_code';
default: return '#id_' + field;
}
}
function mapCustomerFieldToSelector(field) {
switch (field) {
case 'national_code': return $('#id_national_code').length ? '#id_national_code' : '#rep_national_code';
case 'first_name': return '#id_first_name';
case 'last_name': return '#id_last_name';
case 'phone_number_1': return '#id_phone_number_1';
case 'phone_number_2': return '#id_phone_number_2';
case 'card_number': return '#id_card_number';
case 'account_number': return '#id_account_number';
case 'address': return '#id_address';
default: return '#id_' + field;
}
}
function showInlineErrors(errors) {
if (!errors) return;
let nonFieldWell = '';
let nonFieldCustomer = '';
if (errors.well) {
for (const key in errors.well) {
const msgs = Array.isArray(errors.well[key]) ? errors.well[key] : [errors.well[key]];
if (key === '__all__' || key === 'non_field_errors') { nonFieldWell = msgs.join('، '); continue; }
const sel = mapWellFieldToSelector(key);
applyErrorTo(sel, msgs[0]);
}
}
if (errors.customer) {
for (const key in errors.customer) {
const msgs = Array.isArray(errors.customer[key]) ? errors.customer[key] : [errors.customer[key]];
if (key === '__all__' || key === 'non_field_errors') { nonFieldCustomer = msgs.join('، '); continue; }
const sel = mapCustomerFieldToSelector(key);
applyErrorTo(sel, msgs[0]);
}
}
if (nonFieldWell) setStatus('#wellStatus', nonFieldWell, 'danger');
if (nonFieldCustomer) setStatus('#repStatus', nonFieldCustomer, 'danger');
}
$('#btnLookupWell').on('click', function() {
const sub = $('#req_water_sub').val().trim();
if (!sub) { setStatus('#wellStatus', 'لطفا شماره اشتراک آب را وارد کنید', 'danger'); return; }
setStatus('#wellStatus', 'در حال بررسی...', 'muted');
wellChecked = true;
checkSaveButton();
$.get('{% url "processes:lookup_well_by_subscription" %}', { water_subscription_number: sub })
.done(function(resp){
if (resp.exists) {
currentWellId = resp.well.id;
$('#wellFormBlock').show();
// Initialize Persian Date Picker after well form is shown
setTimeout(initPersianDatePicker, 100);
// Prefill well form
$('#id_electricity_subscription_number').val(resp.well.electricity_subscription_number || '');
$('#id_water_meter_serial_number').val(resp.well.water_meter_serial_number || '');
$('#id_water_meter_old_serial_number').val(resp.well.water_meter_old_serial_number || '');
$('#id_water_meter_manufacturer').val(resp.well.water_meter_manufacturer || '');
$('#id_utm_x').val(resp.well.utm_x || '');
$('#id_utm_y').val(resp.well.utm_y || '');
$('#id_utm_zone').val(resp.well.utm_zone || '');
$('#id_utm_hemisphere').val(resp.well.utm_hemisphere || '');
$('#id_well_power').val(resp.well.well_power || '');
$('#id_reference_letter_number').val(resp.well.reference_letter_number || '');
// Prefill date: show Persian in input, keep Gregorian in data attribute
if (resp.well.reference_letter_date) {
try {
if (window.persianDate) {
const gregorianDate = new Date(resp.well.reference_letter_date);
const persianDateObj = new window.persianDate(gregorianDate);
const persianDateString = persianDateObj.format('YYYY/MM/DD');
$('#id_reference_letter_date').val(persianDateString);
} else {
$('#id_reference_letter_date').val(resp.well.reference_letter_date);
}
$('#id_reference_letter_date').attr('data-gregorian', resp.well.reference_letter_date);
} catch (e) {
$('#id_reference_letter_date').val(resp.well.reference_letter_date);
}
} else {
$('#id_reference_letter_date').val('');
$('#id_reference_letter_date').removeAttr('data-gregorian');
}
// Existing representative letter file display
if (resp.well.representative_letter_file_url) {
$('#current-file-display').show();
const fileName = resp.well.representative_letter_file_name || 'فایل موجود';
$('#current-file-name').text(fileName).attr('title', fileName);
$('#id_representative_letter_file').hide();
$('#remove-file').val('false');
} else {
$('#current-file-display').hide();
$('#id_representative_letter_file').show();
$('#remove-file').val('false');
}
setStatus('#wellStatus', 'چاه یافت شد', 'success');
} else {
currentWellId = null;
$('#wellFormBlock').show();
$('#wellFormBlock').find('input, select').val('');
$('#id_reference_letter_date').removeAttr('data-gregorian');
// Reset file UI for new well
$('#current-file-display').hide();
$('#id_representative_letter_file').show().val('');
$('#remove-file').val('false');
// Initialize Persian Date Picker after well form is shown
setTimeout(initPersianDatePicker, 100);
setStatus('#wellStatus', 'چاه یافت نشد. با ذخیره، ایجاد خواهد شد.', 'danger');
}
})
.fail(function(){ setStatus('#wellStatus', 'خطا در بررسی چاه', 'danger'); });
});
$('#btnLookupRep').on('click', function() {
const nc = $('#rep_national_code').val().trim();
if (!nc) { setStatus('#repStatus', 'لطفا کد ملی نماینده را وارد کنید', 'danger'); return; }
setStatus('#repStatus', 'در حال بررسی...', 'muted');
repChecked = true;
checkSaveButton();
$.get('{% url "processes:lookup_representative_by_national_code" %}', { national_code: nc })
.done(function(resp){
if (resp.exists) {
currentRepId = resp.user.id;
$('#repNewFields').show();
// Prefill customer form fields for editing
$('#id_first_name').val(resp.user.first_name || '');
$('#id_last_name').val(resp.user.last_name || '');
if (resp.user.profile) {
$('#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_card_number').val(resp.user.profile.card_number || '');
$('#id_account_number').val(resp.user.profile.account_number || '');
$('#id_address').val(resp.user.profile.address || '');
} else {
$('#id_national_code').val(nc);
$('#id_phone_number_1').val('');
$('#id_phone_number_2').val('');
$('#id_card_number').val('');
$('#id_account_number').val('');
$('#id_address').val('');
}
setStatus('#repStatus', 'نماینده یافت شد.', 'success');
} else {
currentRepId = null;
$('#repNewFields').show();
// Clear form and prefill national code
$('#id_first_name').val('');
$('#id_last_name').val('');
$('#id_national_code').val(nc);
$('#id_phone_number_1').val('');
$('#id_phone_number_2').val('');
$('#id_card_number').val('');
$('#id_account_number').val('');
$('#id_address').val('');
setStatus('#repStatus', 'نماینده یافت نشد. لطفا اطلاعات را تکمیل کنید.', 'danger');
}
})
.fail(function(){ setStatus('#repStatus', 'خطا در بررسی نماینده', 'danger'); });
});
$('#btnSaveRequest').on('click', function(){
const formData = new FormData();
formData.append('csrfmiddlewaretoken', $('input[name=csrfmiddlewaretoken]').val());
formData.append('process', $('#req_process').val());
formData.append('description', $('#req_description').val());
formData.append('water_subscription_number', $('#req_water_sub').val().trim());
if (currentWellId) formData.append('well_id', currentWellId);
if (currentRepId) formData.append('representative_id', currentRepId);
// Send fields using CustomerForm names if visible
const ncField = $('#id_national_code').length ? $('#id_national_code').val() : '';
formData.append('national_code', (ncField || $('#rep_national_code').val().trim()));
formData.append('first_name', $('#id_first_name').val() || '');
formData.append('last_name', $('#id_last_name').val() || '');
formData.append('phone_number_1', $('#id_phone_number_1').val() || '');
formData.append('phone_number_2', $('#id_phone_number_2').val() || '');
formData.append('card_number', $('#id_card_number').val() || '');
formData.append('account_number', $('#id_account_number').val() || '');
formData.append('address', $('#id_address').val() || '');
// Include WellForm fields so edits are saved
if ($('#wellFormBlock').is(':visible')) {
formData.append('electricity_subscription_number', $('#id_electricity_subscription_number').val() || '');
formData.append('water_meter_serial_number', $('#id_water_meter_serial_number').val() || '');
formData.append('water_meter_old_serial_number', $('#id_water_meter_old_serial_number').val() || '');
formData.append('water_meter_manufacturer', $('#id_water_meter_manufacturer').is(':visible') ? ($('#id_water_meter_manufacturer').val() || '') : '');
formData.append('new_manufacturer', $('#id_new_manufacturer').is(':visible') ? ($('#id_new_manufacturer').val() || '') : '');
formData.append('utm_x', $('#id_utm_x').val() || '');
formData.append('utm_y', $('#id_utm_y').val() || '');
formData.append('utm_zone', $('#id_utm_zone').val() || '');
formData.append('utm_hemisphere', $('#id_utm_hemisphere').val() || '');
formData.append('well_power', $('#id_well_power').val() || '');
formData.append('reference_letter_number', $('#id_reference_letter_number').val() || '');
// Use gregorian date if available, otherwise use the field value
const gregorianDate = $('#id_reference_letter_date').attr('data-gregorian');
formData.append('reference_letter_date', gregorianDate || $('#id_reference_letter_date').val() || '');
// Remove flag
formData.append('remove_file', $('#remove-file').val() || 'false');
const repFile = document.getElementById('id_representative_letter_file');
if (repFile && repFile.files && repFile.files[0]) {
formData.append('representative_letter_file', repFile.files[0]);
}
}
const $btn = $(this).prop('disabled', true).text('در حال ذخیره...');
$.ajax({
url: '{% url "processes:create_request_with_entities" %}',
method: 'POST',
data: formData,
processData: false,
contentType: false,
}).done(function(resp){
if (resp.ok) {
showToast('درخواست با موفقیت ثبت شد', 'success');
if (resp.redirect) {
setTimeout(function(){ window.location.href = resp.redirect; }, 800);
} else {
setTimeout(function(){ location.reload(); }, 1200);
}
} else {
const msg = buildErrorMessage(resp);
showToast(msg, 'danger');
}
}).fail(function(xhr){
let msg = 'خطا در ذخیره';
try {
const resp = JSON.parse(xhr.responseText);
msg = buildErrorMessage(resp) || msg;
} catch(e) {}
showToast(msg, 'danger');
}).always(function(){
$btn.prop('disabled', false).text('ذخیره');
});
});
function buildErrorMessage(resp){
if (!resp) return '';
if (resp.error) return resp.error;
if (resp.errors) {
// Collect form-related errors
const parts = [];
if (resp.errors.customer) {
parts.push('خطای نماینده: ' + flattenErrors(resp.errors.customer));
}
if (resp.errors.well) {
parts.push('خطای چاه: ' + flattenErrors(resp.errors.well));
}
return parts.join(' | ');
}
return '';
}
function flattenErrors(errorsObj){
if (typeof errorsObj === 'string') return errorsObj;
try {
const parts = [];
for (const k in errorsObj){
const v = errorsObj[k];
if (Array.isArray(v)) parts.push(`${k}: ${v[0]}`);
else if (typeof v === 'string') parts.push(`${k}: ${v}`);
}
return parts.join('، ');
} catch(e){
return '';
}
}
$('#btnToggleManufacturer').on('click', function() {
const $select = $('#id_water_meter_manufacturer');
const $input = $('#id_new_manufacturer');
const $btn = $(this);
if ($select.is(':visible')) {
$select.hide();
$input.show().focus();
$btn.html('<i class="bx bx-check"></i>');
} else {
$input.hide();
$select.show();
$btn.html('<i class="bx bx-plus"></i>');
}
});
$('#requestModal').on('hidden.bs.modal', function(){
$('#requestForm')[0].reset();
$('#wellFormBlock').hide();
$('#repNewFields').hide();
$('#id_reference_letter_date').removeAttr('data-gregorian');
// Reset file UI
$('#current-file-display').hide();
$('#id_representative_letter_file').show().val('');
$('#remove-file').val('false');
setStatus('#wellStatus', '', '');
setStatus('#repStatus', '', '');
currentWellId = null;
currentRepId = null;
wellChecked = false;
repChecked = false;
checkSaveButton();
clearInlineErrors(); // Clear inline errors on modal close
});
// Handle selecting a new file: hide existing file display and cancel removal flag
$('#id_representative_letter_file').on('change', function() {
if (this.files && this.files.length > 0) {
$('#current-file-display').hide();
$('#remove-file').val('false');
}
});
// Expose remove function
window.removeCurrentFile = function() {
$('#current-file-display').hide();
$('#remove-file').val('true');
$('#id_representative_letter_file').show().val('');
};
// Delete request function
window.deleteRequest = function(instanceId, instanceCode) {
// Set modal content
document.getElementById('deleteConfirmText').textContent = `آیا از حذف درخواست ${instanceCode} اطمینان دارید؟`;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
modal.show();
// Handle confirm button click
document.getElementById('confirmDeleteBtn').onclick = function() {
$.ajax({
url: '{% url "processes:delete_request" 0 %}'.replace('0', instanceId),
type: 'POST',
data: {
'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()
},
success: function(response) {
if (response.success) {
showToast(response.message, 'success');
modal.hide();
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
showToast(response.message, 'danger');
}
},
error: function() {
showToast('خطا در ارتباط با سرور', 'danger');
}
});
};
};
});
</script>
{% endblock %}

View file

@ -0,0 +1,97 @@
{% extends '_base.html' %}
{% load static %}
{% load processes_tags %}
{% 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' %}">
{% 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">
<div class="content active dstepper-block">
<div class="content-header mb-3">
<h6 class="mb-0">{{ step.name }}</h6>
<small>{{ step.description|default:' ' }}</small>
</div>
<div class="row g-3">
<div class="col-12">
<div class="alert alert-info">
<h6>وضعیت مرحله:
{% if step_instance %}
{{ step_instance.get_status_display_with_color|safe }}
{% else %}
<span class="badge bg-secondary">در انتظار</span>
{% endif %}
</h6>
<p class="mb-0">فرم این مرحله بعداً پیاده‌سازی می‌شود.</p>
{% if step_instance and step_instance.notes %}
<hr>
<strong>یادداشت‌ها:</strong>
<p class="mb-0">{{ step_instance.notes }}</p>
{% endif %}
</div>
</div>
<div class="col-12 d-flex justify-content-between">
{% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}"
class="btn btn-label-secondary">
<i class="bx bx-chevron-left bx-sm ms-sm-n2"></i>
<span class="align-middle d-sm-inline-block d-none">قبلی</span>
</a>
{% else %}
<span></span>
{% endif %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}"
class="btn btn-primary">
<span class="align-middle d-sm-inline-block d-none me-sm-1">بعدی</span>
<i class="bx bx-chevron-right bx-sm me-sm-n2"></i>
</a>
{% else %}
<button class="btn btn-success" type="button">اتمام</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script src="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.js' %}"></script>
{% endblock %}

View file

View file

@ -0,0 +1,48 @@
from django import template
from django.utils.safestring import mark_safe
from ..models import ProcessInstance, StepInstance
register = template.Library()
@register.filter(name='get_item')
def get_item(mapping, key):
try:
return mapping.get(key)
except Exception:
return None
@register.inclusion_tag('processes/includes/stepper_header.html')
def stepper_header(instance, current_step=None):
"""رندر کردن header مراحل برای instance"""
if not isinstance(instance, ProcessInstance):
return {}
steps = list(instance.process.steps.all().order_by('order'))
step_instances = instance.step_instances.select_related('step').all()
step_id_to_status = {si.step_id: si.status for si in step_instances}
steps_context = []
for step in steps:
step_instance = next((si for si in step_instances if si.step_id == step.id), None)
status = step_id_to_status.get(step.id, 'pending')
# بررسی دسترسی به مرحله
can_access = instance.can_access_step(step)
# مرحله انتخاب‌شده (نمایش فعلی)
is_selected = bool(current_step and step.id == current_step.id)
# مرحله‌ای که باید انجام شود (مرحله جاری در instance)
is_todo = bool(instance.current_step and step.id == instance.current_step.id)
steps_context.append({
'step': step,
'status': status,
'can_access': can_access,
'is_selected': is_selected,
'is_todo': is_todo,
'step_instance': step_instance,
})
return {
'instance': instance,
'steps_context': steps_context,
}

View file

@ -4,6 +4,18 @@ from . import views
app_name = 'processes' app_name = 'processes'
urlpatterns = [ urlpatterns = [
# Requests UI
path('requests/', views.request_list, name='request_list'),
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'),
path('requests/<int:instance_id>/delete/', views.delete_request, name='delete_request'),
# New step-based architecture
path('instance/<int:instance_id>/steps/', views.instance_steps, name='instance_steps'),
path('instance/<int:instance_id>/step/<int:step_id>/', views.step_detail, name='step_detail'),
# Legacy process views
path('', views.process_list, name='process_list'), path('', views.process_list, name='process_list'),
path('<int:process_id>/', views.process_detail, name='process_detail'), path('<int:process_id>/', views.process_detail, name='process_detail'),
path('<int:process_id>/start/', views.start_process, name='start_process'), path('<int:process_id>/start/', views.start_process, name='start_process'),

View file

@ -1,10 +1,21 @@
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
import json
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib import messages from django.contrib import messages
from django.http import JsonResponse from django.http import JsonResponse
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST, require_GET
from django.db import transaction
from django.contrib.auth import get_user_model
from .models import Process, ProcessInstance, StepInstance from .models import Process, ProcessInstance, StepInstance
from wells.models import Well
from accounts.models import Profile
from .forms import ProcessInstanceForm from .forms import ProcessInstanceForm
from accounts.forms import CustomerForm
from wells.forms import WellForm
from wells.models import WaterMeterManufacturer
@login_required @login_required
def process_list(request): def process_list(request):
@ -22,6 +33,272 @@ def process_detail(request, process_id):
'process': process 'process': process
}) })
@login_required
def request_list(request):
"""نمایش لیست درخواست‌ها با جدول و مدال ایجاد"""
instances = ProcessInstance.objects.select_related('well', 'representative', 'requester').filter(is_deleted=False).order_by('-created')
processes = Process.objects.filter(is_active=True)
manufacturers = WaterMeterManufacturer.objects.all().order_by('name')
return render(request, 'processes/request_list.html', {
'instances': instances,
'customer_form': CustomerForm(),
'well_form': WellForm(),
'processes': processes,
'manufacturers': manufacturers
})
@require_GET
@login_required
def lookup_well_by_subscription(request):
sub = request.GET.get('water_subscription_number', '').strip()
if not sub:
return JsonResponse({'ok': False, 'error': 'شماره اشتراک الزامی است'}, status=400)
try:
well = Well.objects.select_related('representative', 'water_meter_manufacturer').get(water_subscription_number=sub)
data = {
'id': well.id,
'water_subscription_number': well.water_subscription_number,
'electricity_subscription_number': well.electricity_subscription_number,
'water_meter_serial_number': well.water_meter_serial_number,
'water_meter_old_serial_number': well.water_meter_old_serial_number,
'water_meter_manufacturer': well.water_meter_manufacturer.id if well.water_meter_manufacturer else None,
'utm_x': str(well.utm_x) if well.utm_x is not None else None,
'utm_y': str(well.utm_y) if well.utm_y is not None else None,
'utm_zone': well.utm_zone,
'utm_hemisphere': well.utm_hemisphere,
'well_power': well.well_power,
'reference_letter_number': well.reference_letter_number,
'reference_letter_date': well.reference_letter_date.isoformat() if well.reference_letter_date else None,
'representative_letter_file_url': well.representative_letter_file.url if well.representative_letter_file else '',
'representative_letter_file_name': well.representative_letter_file.name.split('/')[-1] if well.representative_letter_file else '',
'representative_id': well.representative.id if well.representative else None,
'representative_full_name': well.representative.get_full_name() if well.representative else None,
}
return JsonResponse({'ok': True, 'exists': True, 'well': data})
except Well.DoesNotExist:
return JsonResponse({'ok': True, 'exists': False})
@require_GET
@login_required
def lookup_representative_by_national_code(request):
national_code = request.GET.get('national_code', '').strip()
if not national_code:
return JsonResponse({'ok': False, 'error': 'کد ملی الزامی است'}, status=400)
profile = Profile.objects.select_related('user').filter(national_code=national_code).first()
if not profile:
return JsonResponse({'ok': True, 'exists': False})
user = profile.user
return JsonResponse({
'ok': True,
'exists': True,
'user': {
'id': user.id,
'username': user.username,
'first_name': user.first_name,
'last_name': user.last_name,
'full_name': user.get_full_name(),
'profile': {
'national_code': profile.national_code,
'phone_number_1': profile.phone_number_1,
'phone_number_2': profile.phone_number_2,
'card_number': profile.card_number,
'account_number': profile.account_number,
'address': profile.address,
}
}
})
@require_POST
@login_required
@transaction.atomic
def create_request_with_entities(request):
"""ایجاد/به‌روزرسانی چاه و نماینده و سپس ایجاد درخواست"""
User = get_user_model()
process_id = request.POST.get('process')
process = Process.objects.get(id=process_id)
description = request.POST.get('description', '')
# Well fields
water_subscription_number = request.POST.get('water_subscription_number')
well_id = request.POST.get('well_id') # optional if existing
# Representative fields
representative_id = request.POST.get('representative_id')
# Prefer plain CustomerForm keys; fallback to representative_* keys
representative_national_code = request.POST.get('national_code') or request.POST.get('representative_national_code')
representative_first_name = request.POST.get('first_name') or request.POST.get('representative_first_name')
representative_last_name = request.POST.get('last_name') or request.POST.get('representative_last_name')
representative_username = request.POST.get('username') or request.POST.get('representative_username')
representative_phone_number_1 = request.POST.get('phone_number_1') or request.POST.get('representative_phone_number_1')
representative_phone_number_2 = request.POST.get('phone_number_2') or request.POST.get('representative_phone_number_2')
representative_card_number = request.POST.get('card_number') or request.POST.get('representative_card_number')
representative_account_number = request.POST.get('account_number') or request.POST.get('representative_account_number')
representative_address = request.POST.get('address') or request.POST.get('representative_address')
if not process_id:
return JsonResponse({'ok': False, 'errors': {'request': {'process': ['فرآیند الزامی است']}}}, status=400)
if not water_subscription_number:
return JsonResponse({'ok': False, 'errors': {'well': {'water_subscription_number': ['شماره اشتراک آب الزامی است']}}}, status=400)
if not representative_id and not representative_national_code:
return JsonResponse({'ok': False, 'errors': {'customer': {'national_code': ['کد ملی نماینده را وارد کنید یا دکمه بررسی/افزودن نماینده را بزنید']}}}, status=400)
representative_user = None
representative_profile = None
if representative_id:
representative_profile = Profile.objects.select_related('user').filter(user_id=representative_id).first()
if not representative_profile:
return JsonResponse({'ok': False, 'errors': {'customer': {'__all__': ['نماینده انتخاب‌شده یافت نشد']}}}, status=400)
representative_user = representative_profile.user
# Optionally update if fields provided
changed = False
if representative_first_name:
representative_user.first_name = representative_first_name
changed = True
if representative_last_name:
representative_user.last_name = representative_last_name
changed = True
if representative_username:
representative_user.username = representative_username
changed = True
if changed:
representative_user.save()
if representative_national_code:
representative_profile.national_code = representative_national_code
if representative_phone_number_1 is not None:
representative_profile.phone_number_1 = representative_phone_number_1
if representative_phone_number_2 is not None:
representative_profile.phone_number_2 = representative_phone_number_2
if representative_card_number is not None:
representative_profile.card_number = representative_card_number
if representative_account_number is not None:
representative_profile.account_number = representative_account_number
if representative_address is not None:
representative_profile.address = representative_address
representative_profile.save()
else:
# Use CustomerForm to validate/create/update representative profile by national code
profile_instance = None
if representative_national_code:
profile_instance = Profile.objects.filter(national_code=representative_national_code).first()
customer_data = {
'first_name': representative_first_name or '',
'last_name': representative_last_name or '',
'phone_number_1': representative_phone_number_1 or '',
'phone_number_2': representative_phone_number_2 or '',
'national_code': representative_national_code or '',
'address': representative_address or '',
'card_number': representative_card_number or '',
'account_number': representative_account_number or '',
}
customer_form = CustomerForm(customer_data, instance=profile_instance)
customer_form.request = request
if not customer_form.is_valid():
return JsonResponse({'ok': False, 'errors': {'customer': customer_form.errors}}, status=400)
representative_profile = customer_form.save()
representative_user = representative_profile.user
# Resolve/create/update well
# Build WellForm data from POST
well = None
if well_id:
well = Well.objects.filter(id=well_id).first()
if not well:
return JsonResponse({'ok': False, 'error': 'شناسه چاه نامعتبر است'}, status=400)
else:
existing = Well.objects.filter(water_subscription_number=water_subscription_number).first()
if existing:
well = existing
well_data = request.POST.copy()
# 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)
if not well_data.get('water_subscription_number'):
well_data['water_subscription_number'] = water_subscription_number
# Preserve existing values on partial updates
if well:
for field_name in WellForm.Meta.fields:
if field_name in ('representative_letter_file',):
# File field handled via request.FILES; skip if not provided
continue
incoming = well_data.get(field_name, None)
if incoming is None or incoming == '':
current_value = getattr(well, field_name, None)
if current_value is None:
continue
# Convert FK to id
if hasattr(current_value, 'pk'):
well_data[field_name] = str(current_value.pk)
else:
# Convert dates/decimals/others to string
try:
well_data[field_name] = current_value.isoformat() # dates
except AttributeError:
well_data[field_name] = str(current_value)
well_form = WellForm(well_data, request.FILES, instance=well)
if not well_form.is_valid():
return JsonResponse({'ok': False, 'errors': {'well': well_form.errors}}, status=400)
# Save with ability to remove existing file
well = well_form.save(commit=False)
try:
if request.POST.get('remove_file') == 'true' and getattr(well, 'representative_letter_file', None):
well.representative_letter_file.delete(save=False)
well.representative_letter_file = None
except Exception:
pass
well.save()
# Auto fill geo ownership from current user profile if available
current_profile = getattr(request.user, 'profile', None)
if current_profile:
if hasattr(well, 'affairs'):
well.affairs = current_profile.affairs
if hasattr(well, 'county'):
well.county = current_profile.county
if hasattr(well, 'broker'):
well.broker = current_profile.broker
well.save()
# Create request instance
instance = ProcessInstance.objects.create(
process=process,
description=description,
well=well,
representative=representative_user,
requester=request.user,
status='pending',
priority='medium',
)
# ایجاد نمونه‌های مرحله بر اساس مراحل فرآیند و تنظیم مرحله فعلی
for step in process.steps.all().order_by('order'):
StepInstance.objects.create(
process_instance=instance,
step=step
)
first_step = process.steps.all().order_by('order').first()
if first_step:
instance.current_step = first_step
instance.status = 'in_progress'
instance.save()
redirect_url = reverse('processes:instance_steps', args=[instance.id])
return JsonResponse({'ok': True, 'instance_id': instance.id, 'redirect': redirect_url})
@require_POST
@login_required
def delete_request(request, instance_id):
"""حذف درخواست"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
code = instance.code
instance.delete()
return JsonResponse({
'success': True,
'message': f'درخواست {code} با موفقیت حذف شد'
})
@login_required @login_required
def start_process(request, process_id): def start_process(request, process_id):
"""شروع فرآیند جدید""" """شروع فرآیند جدید"""
@ -67,6 +344,61 @@ def instance_detail(request, instance_id):
'instance': instance 'instance': instance
}) })
@login_required
def step_detail(request, instance_id, step_id):
"""نمایش جزئیات مرحله خاص"""
instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
id=instance_id
)
step = get_object_or_404(instance.process.steps, id=step_id)
# بررسی دسترسی به مرحله
if not instance.can_access_step(step):
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
return redirect('processes:request_list')
# هدایت به view مناسب بر اساس نوع مرحله
if step.order == 1: # مرحله اول - انتخاب اقلام
return redirect('invoices:quote_step', instance_id=instance.id, step_id=step.id)
elif step.order == 2: # مرحله دوم - صدور پیش‌فاکتور
return redirect('invoices:quote_preview_step', instance_id=instance.id, step_id=step.id)
elif step.order == 3: # مرحله سوم - ثبت فیش‌های واریزی
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
# برای سایر مراحل، template عمومی نمایش داده می‌شود
step_instance = instance.step_instances.filter(step=step).first()
# Navigation logic
previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first()
return render(request, 'processes/step_detail.html', {
'instance': instance,
'step': step,
'step_instance': step_instance,
'previous_step': previous_step,
'next_step': next_step,
})
@login_required
def instance_steps(request, instance_id):
"""هدایت به مرحله فعلی instance"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
if not instance.current_step:
# اگر مرحله فعلی تعریف نشده، به اولین مرحله برو
first_step = instance.process.steps.first()
if first_step:
instance.current_step = first_step
instance.save()
return redirect('processes:step_detail', instance_id=instance.id, step_id=first_step.id)
else:
messages.error(request, 'هیچ مرحله‌ای برای این فرآیند تعریف نشده است.')
return redirect('processes:request_list')
return redirect('processes:step_detail', instance_id=instance.id, step_id=instance.current_step.id)
@login_required @login_required
def my_processes(request): def my_processes(request):
"""نمایش فرآیندهای کاربر""" """نمایش فرآیندهای کاربر"""
@ -77,3 +409,4 @@ def my_processes(request):
'my_instances': my_instances, 'my_instances': my_instances,
'assigned_steps': assigned_steps 'assigned_steps': assigned_steps
}) })

View file

@ -98,6 +98,18 @@
</ul> </ul>
</li> </li>
<!-- requests -->
<li class="menu-header small text-uppercase">
<span class="menu-header-text">درخواست‌ها</span>
</li>
<!-- Users -->
<li class="menu-item {% if 'processes' in request.path or 'step' in request.path %}active{% endif %}">
<a href="{% url 'processes:request_list' %}" class="menu-link">
<i class="menu-icon tf-icons bx bx-user"></i>
<div class="text-truncate">درخواست‌ها</div>
</a>
</li>
<!-- Customers --> <!-- Customers -->
<li class="menu-header small text-uppercase"> <li class="menu-header small text-uppercase">

View file

@ -64,7 +64,8 @@ class WellForm(forms.ModelForm):
}), }),
'electricity_subscription_number': forms.TextInput(attrs={ 'electricity_subscription_number': forms.TextInput(attrs={
'class': 'form-control', 'class': 'form-control',
'placeholder': 'شماره اشتراک برق' 'placeholder': 'شماره اشتراک برق',
'required': True
}), }),
'water_meter_serial_number': forms.TextInput(attrs={ 'water_meter_serial_number': forms.TextInput(attrs={
'class': 'form-control', 'class': 'form-control',

View file

@ -1,7 +1,8 @@
# Generated by Django 5.2.4 on 2025-08-07 09:08 # Generated by Django 5.2.4 on 2025-08-14 09:02
import django.db.models.deletion import django.db.models.deletion
import simple_history.models import simple_history.models
import wells.models
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -11,10 +12,27 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('locations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel(
name='WaterMeterManufacturer',
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='تاریخ حذف')),
('name', models.CharField(blank=True, max_length=20, null=True, verbose_name='نام شرکت سازنده کنتور آب')),
],
options={
'verbose_name': 'شرکت سازنده کنتور آب',
'verbose_name_plural': 'شرکت\u200cهای سازنده کنتور آب',
},
),
migrations.CreateModel( migrations.CreateModel(
name='HistoricalWell', name='HistoricalWell',
fields=[ fields=[
@ -25,12 +43,28 @@ class Migration(migrations.Migration):
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')), ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')), ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('slug', models.SlugField(max_length=100, verbose_name='اسلاگ')), ('slug', models.SlugField(max_length=100, verbose_name='اسلاگ')),
('water_subscription_number', models.CharField(db_index=True, max_length=20, verbose_name='شماره اشتراک آب')),
('electricity_subscription_number', models.CharField(db_index=True, max_length=20, null=True, verbose_name='شماره اشتراک برق')),
('water_meter_serial_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='سریال کنتور آب')),
('water_meter_old_serial_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='سریال کنتور قدیمی آب')),
('utm_x', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='X UTM')),
('utm_y', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='Y UTM')),
('utm_zone', models.PositiveIntegerField(blank=True, default=40, null=True, verbose_name='زون UTM')),
('utm_hemisphere', models.CharField(blank=True, choices=[('N', 'شمال'), ('S', 'جنوب')], default='N', max_length=1, null=True, verbose_name='نیمکره UTM')),
('well_power', models.PositiveIntegerField(blank=True, null=True, verbose_name='قدرت چاه')),
('reference_letter_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='شماره معرفی نامه')),
('reference_letter_date', models.DateField(blank=True, null=True, verbose_name='تاریخ معرفی نامه')),
('representative_letter_file', models.TextField(blank=True, max_length=100, null=True, verbose_name='نامه نمایندگی')),
('history_id', models.AutoField(primary_key=True, serialize=False)), ('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)), ('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)), ('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('affairs', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.affairs', verbose_name='امور')),
('broker', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.broker', verbose_name='کارگزار')),
('county', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.county', verbose_name='شهرستان')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('representative', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='نماینده')), ('representative', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='نماینده')),
('water_meter_manufacturer', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='wells.watermetermanufacturer', verbose_name='شرکت سازنده کنتور آب')),
], ],
options={ options={
'verbose_name': 'historical چاه', 'verbose_name': 'historical چاه',
@ -50,7 +84,23 @@ class Migration(migrations.Migration):
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')), ('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')), ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')), ('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')),
('representative', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wells', to=settings.AUTH_USER_MODEL, verbose_name='نماینده')), ('water_subscription_number', models.CharField(max_length=20, unique=True, verbose_name='شماره اشتراک آب')),
('electricity_subscription_number', models.CharField(max_length=20, null=True, unique=True, verbose_name='شماره اشتراک برق')),
('water_meter_serial_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='سریال کنتور آب')),
('water_meter_old_serial_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='سریال کنتور قدیمی آب')),
('utm_x', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='X UTM')),
('utm_y', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='Y UTM')),
('utm_zone', models.PositiveIntegerField(blank=True, default=40, null=True, verbose_name='زون UTM')),
('utm_hemisphere', models.CharField(blank=True, choices=[('N', 'شمال'), ('S', 'جنوب')], default='N', max_length=1, null=True, verbose_name='نیمکره UTM')),
('well_power', models.PositiveIntegerField(blank=True, null=True, verbose_name='قدرت چاه')),
('reference_letter_number', models.CharField(blank=True, max_length=20, null=True, verbose_name='شماره معرفی نامه')),
('reference_letter_date', models.DateField(blank=True, null=True, verbose_name='تاریخ معرفی نامه')),
('representative_letter_file', models.FileField(blank=True, null=True, upload_to=wells.models.Well.path_and_rename, verbose_name='نامه نمایندگی')),
('affairs', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.affairs', verbose_name='امور')),
('broker', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.broker', verbose_name='کارگزار')),
('county', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.county', verbose_name='شهرستان')),
('representative', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wells', to=settings.AUTH_USER_MODEL, verbose_name='نماینده')),
('water_meter_manufacturer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='wells.watermetermanufacturer', verbose_name='شرکت سازنده کنتور آب')),
], ],
options={ options={
'verbose_name': 'چاه', 'verbose_name': 'چاه',

View file

@ -1,202 +0,0 @@
# Generated by Django 5.2.4 on 2025-08-07 14:29
import datetime
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('locations', '0001_initial'),
('wells', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WaterMeterManufacturer',
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='تاریخ حذف')),
('name', models.CharField(blank=True, max_length=20, null=True, verbose_name='نام شرکت سازنده کنتور آب')),
],
options={
'verbose_name': 'شرکت سازنده کنتور آب',
'verbose_name_plural': 'شرکت\u200cهای سازنده کنتور آب',
},
),
migrations.AddField(
model_name='historicalwell',
name='affairs',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.affairs', verbose_name='امور'),
),
migrations.AddField(
model_name='historicalwell',
name='broker',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.broker', verbose_name='کارگزار'),
),
migrations.AddField(
model_name='historicalwell',
name='county',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='locations.county', verbose_name='شهرستان'),
),
migrations.AddField(
model_name='historicalwell',
name='electricity_subscription_number',
field=models.CharField(blank=True, db_index=True, max_length=20, null=True, verbose_name='شماره اشتراک برق'),
),
migrations.AddField(
model_name='historicalwell',
name='reference_letter_date',
field=models.DateField(blank=True, null=True, verbose_name='تاریخ معرفی نامه'),
),
migrations.AddField(
model_name='historicalwell',
name='reference_letter_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='شماره معرفی نامه'),
),
migrations.AddField(
model_name='historicalwell',
name='representative_letter_file',
field=models.TextField(blank=True, max_length=100, null=True, verbose_name='نامه نمایندگی'),
),
migrations.AddField(
model_name='historicalwell',
name='utm_hemisphere',
field=models.CharField(blank=True, choices=[('N', 'شمال'), ('S', 'جنوب')], default='N', max_length=1, null=True, verbose_name='نیمکره UTM'),
),
migrations.AddField(
model_name='historicalwell',
name='utm_x',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='X UTM'),
),
migrations.AddField(
model_name='historicalwell',
name='utm_y',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='Y UTM'),
),
migrations.AddField(
model_name='historicalwell',
name='utm_zone',
field=models.PositiveIntegerField(blank=True, default=40, null=True, verbose_name='زون UTM'),
),
migrations.AddField(
model_name='historicalwell',
name='water_meter_old_serial_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='سریال کنتور قدیمی آب'),
),
migrations.AddField(
model_name='historicalwell',
name='water_meter_serial_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='سریال کنتور آب'),
),
migrations.AddField(
model_name='historicalwell',
name='water_subscription_number',
field=models.CharField(db_index=True, default=datetime.datetime(2025, 8, 7, 14, 29, 15, 340093, tzinfo=datetime.timezone.utc), max_length=20, verbose_name='شماره اشتراک آب'),
preserve_default=False,
),
migrations.AddField(
model_name='historicalwell',
name='well_power',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='قدرت چاه'),
),
migrations.AddField(
model_name='well',
name='affairs',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.affairs', verbose_name='امور'),
),
migrations.AddField(
model_name='well',
name='broker',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.broker', verbose_name='کارگزار'),
),
migrations.AddField(
model_name='well',
name='county',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='locations.county', verbose_name='شهرستان'),
),
migrations.AddField(
model_name='well',
name='electricity_subscription_number',
field=models.CharField(blank=True, max_length=20, null=True, unique=True, verbose_name='شماره اشتراک برق'),
),
migrations.AddField(
model_name='well',
name='reference_letter_date',
field=models.DateField(blank=True, null=True, verbose_name='تاریخ معرفی نامه'),
),
migrations.AddField(
model_name='well',
name='reference_letter_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='شماره معرفی نامه'),
),
migrations.AddField(
model_name='well',
name='representative_letter_file',
field=models.FileField(blank=True, null=True, upload_to='representative_letters/', verbose_name='نامه نمایندگی'),
),
migrations.AddField(
model_name='well',
name='utm_hemisphere',
field=models.CharField(blank=True, choices=[('N', 'شمال'), ('S', 'جنوب')], default='N', max_length=1, null=True, verbose_name='نیمکره UTM'),
),
migrations.AddField(
model_name='well',
name='utm_x',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='X UTM'),
),
migrations.AddField(
model_name='well',
name='utm_y',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='Y UTM'),
),
migrations.AddField(
model_name='well',
name='utm_zone',
field=models.PositiveIntegerField(blank=True, default=40, null=True, verbose_name='زون UTM'),
),
migrations.AddField(
model_name='well',
name='water_meter_old_serial_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='سریال کنتور قدیمی آب'),
),
migrations.AddField(
model_name='well',
name='water_meter_serial_number',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='سریال کنتور آب'),
),
migrations.AddField(
model_name='well',
name='water_subscription_number',
field=models.CharField(default=django.utils.timezone.now, max_length=20, unique=True, verbose_name='شماره اشتراک آب'),
preserve_default=False,
),
migrations.AddField(
model_name='well',
name='well_power',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='قدرت چاه'),
),
migrations.AlterField(
model_name='well',
name='representative',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wells', to=settings.AUTH_USER_MODEL, verbose_name='نماینده'),
),
migrations.AddField(
model_name='historicalwell',
name='water_meter_manufacturer',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='wells.watermetermanufacturer', verbose_name='شرکت سازنده کنتور آب'),
),
migrations.AddField(
model_name='well',
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

@ -42,7 +42,6 @@ class Well(SluggedModel):
verbose_name="نماینده", verbose_name="نماینده",
related_name="wells", related_name="wells",
null=True, null=True,
blank=True
) )
water_subscription_number = models.CharField( water_subscription_number = models.CharField(
@ -54,7 +53,6 @@ class Well(SluggedModel):
max_length=20, max_length=20,
verbose_name="شماره اشتراک برق", verbose_name="شماره اشتراک برق",
null=True, null=True,
blank=True,
unique=True unique=True
) )

View file

@ -387,7 +387,8 @@
observer: true, observer: true,
calendar: { calendar: {
persian: { persian: {
locale: 'fa' locale: 'fa',
leapYearMode: 'astronomical'
} }
}, },
onSelect: function(unix) { onSelect: function(unix) {