diff --git a/_base/urls.py b/_base/urls.py
index f593463..e4a1acb 100644
--- a/_base/urls.py
+++ b/_base/urls.py
@@ -24,6 +24,7 @@ urlpatterns = [
path('', include('accounts.urls')),
path('wells/', include('wells.urls')),
path('processes/', include('processes.urls')),
+ path('invoices/', include('invoices.urls')),
]
if settings.DEBUG:
diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py
index 29de948..de431d4 100644
--- a/accounts/migrations/0001_initial.py
+++ b/accounts/migrations/0001_initial.py
@@ -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.db.models.deletion
+import simple_history.models
from django.conf import settings
from django.db import migrations, models
@@ -11,10 +12,47 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
+ ('locations', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
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(
name='Role',
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='شماره حساب')),
('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.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='پروفایل تکمیل شده')),
+ ('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='ایجاد کننده')),
('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ها')),
diff --git a/accounts/migrations/0002_profile_affairs_profile_broker_profile_county_and_more.py b/accounts/migrations/0002_profile_affairs_profile_broker_profile_county_and_more.py
deleted file mode 100644
index 970e8b2..0000000
--- a/accounts/migrations/0002_profile_affairs_profile_broker_profile_county_and_more.py
+++ /dev/null
@@ -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),
- ),
- ]
diff --git a/invoices/migrations/0001_initial.py b/invoices/migrations/0001_initial.py
index ff3b0f2..abf23d3 100644
--- a/invoices/migrations/0001_initial.py
+++ b/invoices/migrations/0001_initial.py
@@ -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 simple_history.models
diff --git a/invoices/migrations/0002_historicalpayment_receipt_image_and_more.py b/invoices/migrations/0002_historicalpayment_receipt_image_and_more.py
new file mode 100644
index 0000000..d8de4a0
--- /dev/null
+++ b/invoices/migrations/0002_historicalpayment_receipt_image_and_more.py
@@ -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='تصویر فیش'),
+ ),
+ ]
diff --git a/invoices/models.py b/invoices/models.py
index 4e8e60b..015c675 100644
--- a/invoices/models.py
+++ b/invoices/models.py
@@ -4,6 +4,9 @@ from common.models import NameSlugModel, BaseModel
from simple_history.models import HistoricalRecords
from django.core.exceptions import ValidationError
from decimal import Decimal
+from django.utils import timezone
+from django.core.validators import MinValueValidator
+from django.conf import settings
User = get_user_model()
@@ -123,6 +126,21 @@ class Quote(NameSlugModel):
color = status_colors.get(self.status, 'secondary')
return '{}'.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):
"""مدل آیتمهای پیشفاکتور"""
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)
payment_date = models.DateField(verbose_name="تاریخ پرداخت")
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="ثبت کننده")
history = HistoricalRecords()
diff --git a/invoices/templates/invoices/quote_payment_step.html b/invoices/templates/invoices/quote_payment_step.html
new file mode 100644
index 0000000..268aa72
--- /dev/null
+++ b/invoices/templates/invoices/quote_payment_step.html
@@ -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 %}
+
+
+
+
+{% endblock %}
+
+{% block content %}
+{% include '_toasts.html' %}
+{% csrf_token %}
+
+
+
+
+
+
{{ step.name }}: {{ instance.process.name }}
+
+ اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
+ | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
+
+
+
+
+
+
+ {% stepper_header instance step %}
+
+
+
+
+
+{% endblock %}
+
+{% block script %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ آیا از حذف این فیش مطمئن هستید؟ این عمل قابل بازگشت نیست.
+
+
+
+
+
+
+
+
+
+
+
+ مبلغی از پیشفاکتور هنوز پرداخت نشده است.
+
مانده:
+ آیا مطمئن هستید که میخواهید مرحله را تایید و به مرحله بعد بروید؟
+
+
+
+
+
+{% endblock %}
diff --git a/invoices/templates/invoices/quote_preview_step.html b/invoices/templates/invoices/quote_preview_step.html
new file mode 100644
index 0000000..f098634
--- /dev/null
+++ b/invoices/templates/invoices/quote_preview_step.html
@@ -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 %}
+
+
+{% endblock %}
+
+{% block content %}
+{% include '_toasts.html' %}
+{% csrf_token %}
+
+
+
+
+
+
{{ step.name }}: {{ instance.process.name }}
+
+ اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
+ | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
+
+
+
+
+
+
+ {% stepper_header instance step %}
+
+
+
+
+
+
+
+
+
+
+
شرکت آب منطقهای
+
+
دفتر مرکزی، خیابان اصلی
+
تهران، ایران
+
۰۲۱-۱۲۳۴۵۶۷۸
+
+
+
پیشفاکتور #{{ quote.name }}
+
+ تاریخ صدور:
+ {{ quote.jcreated }}
+
+
+ معتبر تا:
+ {{ quote.valid_until|date:"Y/m/d" }}
+
+
+
+
+
+
+
+
+
صادر شده برای:
+
{{ quote.customer.get_full_name }}
+ {% if instance.representative.profile %}
+
کد ملی: {{ instance.representative.profile.national_code }}
+
{{ instance.representative.profile.address|default:"آدرس نامشخص" }}
+
{{ instance.representative.profile.phone_number_1|default:"" }}
+ {% endif %}
+
+
+
اطلاعات چاه:
+
+
+
+ شماره اشتراک آب: |
+ {{ instance.well.water_subscription_number }} |
+
+
+ شماره اشتراک برق: |
+ {{ instance.well.electricity_subscription_number|default:"-" }} |
+
+
+ سریال کنتور: |
+ {{ instance.well.water_meter_serial_number|default:"-" }} |
+
+
+ قدرت چاه: |
+ {{ instance.well.well_power|default:"-" }} |
+
+
+ کد درخواست: |
+ {{ instance.code }} |
+
+
+
+
+
+
+
+
+
+
+ آیتم |
+ توضیحات |
+ قیمت واحد |
+ تعداد |
+ قیمت کل |
+
+
+
+ {% for quote_item in quote.items.all %}
+
+ {{ quote_item.item.name }} |
+ {{ quote_item.item.description|default:"-" }} |
+ {{ quote_item.unit_price|floatformat:0|intcomma:False }} تومان |
+ {{ quote_item.quantity }} |
+ {{ quote_item.total_price|floatformat:0|intcomma:False }} تومان |
+
+ {% endfor %}
+
+
+
+ صادر کننده:
+ {{ quote.created_by.get_full_name }}
+
+ با تشکر از انتخاب شما
+ |
+
+ جمع کل:
+ {% if quote.discount_amount > 0 %}
+ تخفیف:
+ {% endif %}
+ مبلغ نهایی:
+ |
+
+ {{ quote.total_amount|floatformat:0|intcomma:False }} تومان
+ {% if quote.discount_amount > 0 %}
+ {{ quote.discount_amount|floatformat:0|intcomma:False }} تومان
+ {% endif %}
+ {{ quote.final_amount|floatformat:0|intcomma:False }} تومان
+ |
+
+
+
+
+
+ {% if quote.notes %}
+
+
+
+ یادداشت:
+ {{ quote.notes }}
+
+
+
+ {% endif %}
+
+
+
+
+
+
+ {% if previous_step %}
+
+
+ ویرایش اقلام
+
+ {% else %}
+
+ {% endif %}
+
+ {% if step_instance.status == 'completed' %}
+ {% if next_step %}
+
+ بعدی
+
+
+ {% else %}
+
+ {% endif %}
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block script %}
+
+
+{% endblock %}
diff --git a/invoices/templates/invoices/quote_print.html b/invoices/templates/invoices/quote_print.html
new file mode 100644
index 0000000..7296d12
--- /dev/null
+++ b/invoices/templates/invoices/quote_print.html
@@ -0,0 +1,283 @@
+
+
+
+
+
+ پیشفاکتور {{ quote.name }} - {{ instance.code }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
مشخصات مشترک:
+
+
+ نام و نام خانوادگی: |
+ {{ quote.customer.get_full_name }} |
+
+ {% if instance.representative.profile %}
+
+ کد ملی: |
+ {{ instance.representative.profile.national_code }} |
+
+
+ تلفن: |
+ {{ instance.representative.profile.phone_number_1|default:"-" }} |
+
+
+ آدرس: |
+ {{ instance.representative.profile.address|default:"آدرس نامشخص" }} |
+
+ {% endif %}
+
+
+
+
مشخصات چاه:
+
+
+ شماره اشتراک آب: |
+ {{ instance.well.water_subscription_number }} |
+
+
+ شماره اشتراک برق: |
+ {{ instance.well.electricity_subscription_number|default:"-" }} |
+
+
+ سریال کنتور: |
+ {{ instance.well.water_meter_serial_number|default:"-" }} |
+
+
+ قدرت چاه: |
+ {{ instance.well.well_power|default:"-" }} |
+
+
+
+
+
+
+
+
+
+
+ ردیف |
+ شرح کالا/خدمات |
+ توضیحات |
+ تعداد |
+ قیمت واحد (تومان) |
+ قیمت کل (تومان) |
+
+
+
+ {% for quote_item in quote.items.all %}
+
+ {{ forloop.counter }} |
+ {{ quote_item.item.name }} |
+ {{ quote_item.item.description|default:"-" }} |
+ {{ quote_item.quantity }} |
+ {{ quote_item.unit_price|floatformat:0 }} |
+ {{ quote_item.total_price|floatformat:0 }} |
+
+ {% endfor %}
+
+
+
+ جمع کل: |
+ {{ quote.total_amount|floatformat:0 }} تومان |
+
+ {% if quote.discount_amount > 0 %}
+
+ تخفیف: |
+ {{ quote.discount_amount|floatformat:0 }} تومان |
+
+ {% endif %}
+
+ مبلغ نهایی: |
+ {{ quote.final_amount|floatformat:0 }} تومان |
+
+
+
+
+
+
+ {% if quote.notes %}
+
+
یادداشت:
+
{{ quote.notes }}
+
+ {% endif %}
+
+
+
+
صادر کننده: {{ quote.created_by.get_full_name }}
+
این پیشفاکتور تا تاریخ {{ quote.valid_until|date:"Y/m/d" }} معتبر است.
+
+
+
+
+
+
+
+
امضای مشترک
+
+ امضا و مهر
+
+
تاریخ: ____/____/____
+
+
+
+
+
امضای شرکت
+
+ امضا و مهر
+
+
تاریخ: ____/____/____
+
+
+
+
+
+
+
+ این پیشفاکتور توسط سیستم مدیریت فرآیندها تولید شده است.
+
+
+
+
+
+
diff --git a/invoices/templates/invoices/quote_step.html b/invoices/templates/invoices/quote_step.html
new file mode 100644
index 0000000..5219c40
--- /dev/null
+++ b/invoices/templates/invoices/quote_step.html
@@ -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 %}
+
+{% endblock %}
+
+{% block content %}
+{% include '_toasts.html' %}
+
+
+
+
+
+
{{ step.name }}: {{ instance.process.name }}
+
+ اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
+ | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
+
+
+
بازگشت
+
+
+
+ {% stepper_header instance step %}
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block script %}
+
+
+{% endblock %}
diff --git a/invoices/urls.py b/invoices/urls.py
new file mode 100644
index 0000000..2acbd82
--- /dev/null
+++ b/invoices/urls.py
@@ -0,0 +1,24 @@
+from django.urls import path
+from . import views
+
+app_name = 'invoices'
+
+urlpatterns = [
+ # Quote step for process instances
+ path('instance//step//quote/', views.quote_step, name='quote_step'),
+ path('instance//step//quote/create/', views.create_quote, name='create_quote'),
+
+ # Quote preview step (step 2)
+ path('instance//step//quote-preview/', views.quote_preview_step, name='quote_preview_step'),
+ path('instance//step//approve/', views.approve_quote, name='approve_quote'),
+
+ # Quote payments step (step 3)
+ path('instance//step//payments/', views.quote_payment_step, name='quote_payment_step'),
+ path('instance//step//payments/add/', views.add_quote_payment, name='add_quote_payment'),
+ path('instance//step//payments//update/', views.update_quote_payment, name='update_quote_payment'),
+ path('instance//step//payments//delete/', views.delete_quote_payment, name='delete_quote_payment'),
+ path('instance//step//payments/approve/', views.approve_payments, name='approve_payments'),
+
+ # Quote print
+ path('instance//quote/print/', views.quote_print, name='quote_print'),
+]
diff --git a/invoices/views.py b/invoices/views.py
index 91ea44a..096808f 100644
--- a/invoices/views.py
+++ b/invoices/views.py
@@ -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})
diff --git a/locations/migrations/0001_initial.py b/locations/migrations/0001_initial.py
index cb3a5b3..def46a0 100644
--- a/locations/migrations/0001_initial.py
+++ b/locations/migrations/0001_initial.py
@@ -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
from django.db import migrations, models
diff --git a/processes/admin.py b/processes/admin.py
index 5bcdc24..4c83c14 100644
--- a/processes/admin.py
+++ b/processes/admin.py
@@ -46,21 +46,95 @@ class StepDependencyAdmin(admin.ModelAdmin):
@admin.register(ProcessInstance)
class ProcessInstanceAdmin(SimpleHistoryAdmin):
- list_display = ['name', 'process', 'requester', 'current_step', 'status', 'started_at', 'progress_display']
- list_filter = ['process', 'status', 'started_at']
- search_fields = ['name', 'process__name', 'requester__username', 'requester__first_name']
- readonly_fields = ['deleted_at', 'started_at', 'completed_at']
- ordering = ['-started_at']
+ verbose_name = "درخواست"
+ verbose_name_plural = "درخواستها"
+ list_display = [
+ 'code',
+ '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):
- total_steps = obj.process.steps.count()
- completed_steps = obj.step_instances.filter(status='completed').count()
- percentage = (completed_steps / total_steps * 100) if total_steps > 0 else 0
- percentage_int = int(percentage)
- return format_html(
- '',
- percentage_int, completed_steps, total_steps, percentage_int
- )
+ if obj.process:
+ total_steps = obj.process.steps.count()
+ completed_steps = obj.step_instances.filter(status='completed').count()
+ percentage = (completed_steps / total_steps * 100) if total_steps > 0 else 0
+ percentage_int = int(percentage)
+ return format_html(
+ '',
+ percentage_int, completed_steps, total_steps, percentage_int
+ )
+ return "-"
progress_display.short_description = "پیشرفت"
@admin.register(StepInstance)
diff --git a/processes/forms.py b/processes/forms.py
index 6b035eb..534c475 100644
--- a/processes/forms.py
+++ b/processes/forms.py
@@ -4,9 +4,16 @@ from .models import ProcessInstance, StepInstance
class ProcessInstanceForm(forms.ModelForm):
class Meta:
model = ProcessInstance
- fields = ['name']
+ fields = ['description', 'process', 'well', 'representative', 'requester', 'priority', 'status', 'current_step']
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):
diff --git a/processes/migrations/0001_initial.py b/processes/migrations/0001_initial.py
index 6a45e92..8675e70 100644
--- a/processes/migrations/0001_initial.py
+++ b/processes/migrations/0001_initial.py
@@ -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 simple_history.models
@@ -11,6 +11,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
+ ('wells', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@@ -130,18 +131,21 @@ class Migration(migrations.Migration):
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
('deleted_at', models.DateTimeField(blank=True, null=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='وضعیت')),
- ('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='تاریخ تکمیل')),
- ('process', models.ForeignKey(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='درخواست کننده')),
+ ('process', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='instances', to='processes.process', 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='مرحله فعلی')),
],
options={
- 'verbose_name': 'نمونه فرآیند',
- 'verbose_name_plural': 'نمونه\u200cهای فرآیند',
- 'ordering': ['-started_at'],
+ 'verbose_name': 'درخواست',
+ 'verbose_name_plural': 'درخواست\u200cها',
+ 'ordering': ['-created'],
},
),
migrations.CreateModel(
@@ -169,37 +173,6 @@ class Migration(migrations.Migration):
},
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(
name='StepInstance',
fields=[
diff --git a/processes/models.py b/processes/models.py
index 19ef14b..cfbdfa7 100644
--- a/processes/models.py
+++ b/processes/models.py
@@ -1,8 +1,11 @@
from django.db import models
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 django.core.exceptions import ValidationError
+from django.utils import timezone
+from _helpers.utils import generate_unique_slug
+import random
User = get_user_model()
@@ -21,6 +24,7 @@ class Process(NameSlugModel):
def __str__(self):
return self.name
+
class ProcessStep(NameSlugModel):
"""مدل مراحل فرآیند"""
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:
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="درخواست کننده")
- current_step = models.ForeignKey('ProcessStep', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="مرحله فعلی")
+
+ PRIORITY_CHOICES = [
+
+ ('low', 'کم'),
+ ('medium', 'متوسط'),
+ ('high', 'زیاد'),
+ ('urgent', 'فوری'),
+ ]
+
+ STATUS_CHOICES = [
+ ('pending', 'در انتظار'),
+ ('in_progress', 'در حال انجام'),
+ ('completed', 'تکمیل شده'),
+ ('cancelled', 'لغو شده'),
+ ('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=[
- ('pending', 'در انتظار'),
- ('in_progress', 'در حال انجام'),
- ('completed', 'تکمیل شده'),
- ('cancelled', 'لغو شده'),
- ('rejected', 'رد شده'),
- ],
+ choices=STATUS_CHOICES,
default='pending',
verbose_name="وضعیت"
)
- started_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ شروع")
- completed_at = models.DateTimeField(null=True, blank=True, verbose_name="تاریخ تکمیل")
- history = HistoricalRecords()
+
+ priority = models.CharField(
+ max_length=20,
+ choices=PRIORITY_CHOICES,
+ default='medium',
+ verbose_name="اولویت"
+ )
+
+ completed_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ verbose_name="تاریخ تکمیل"
+ )
class Meta:
- verbose_name = "نمونه فرآیند"
- verbose_name_plural = "نمونههای فرآیند"
- ordering = ['-started_at']
+ verbose_name = "درخواست"
+ verbose_name_plural = "درخواستها"
+ ordering = ['-created']
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()}"
+ 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 '{}'.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 '{}'.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):
"""دریافت مراحل قابل دسترس"""
available_steps = []
@@ -134,7 +272,6 @@ class ProcessInstance(NameSlugModel):
def can_access_step(self, step):
"""بررسی امکان دسترسی به مرحله"""
- # بررسی وابستگیها
dependencies = step.get_dependencies()
for dependency_id in dependencies:
step_instance = self.step_instances.filter(step_id=dependency_id).first()
@@ -144,7 +281,6 @@ class ProcessInstance(NameSlugModel):
def can_edit_step(self, step):
"""بررسی امکان ویرایش مرحله"""
- # اگر مرحله مسدود کننده باشد و مراحل بعدی تکمیل شده باشند
if step.blocks_previous:
later_steps = self.step_instances.filter(
step__order__gt=step.order,
@@ -187,12 +323,10 @@ class StepInstance(models.Model):
def save(self, *args, **kwargs):
"""ذخیره با اعتبارسنجی"""
- # بررسی وابستگیها
if self.status == 'in_progress' or self.status == 'completed':
if not self.process_instance.can_access_step(self.step):
raise ValidationError("مراحل وابسته تکمیل نشدهاند")
- # بررسی امکان ویرایش
if self.status == 'completed' and not self.process_instance.can_edit_step(self.step):
raise ValidationError("این مرحله قابل ویرایش نیست")
@@ -252,7 +386,6 @@ class StepRejection(models.Model):
def save(self, *args, **kwargs):
"""ذخیره با تغییر وضعیت مرحله"""
- # تغییر وضعیت مرحله به رد شده
self.step_instance.status = 'rejected'
self.step_instance.save()
super().save(*args, **kwargs)
diff --git a/processes/templates/processes/includes/stepper_header.html b/processes/templates/processes/includes/stepper_header.html
new file mode 100644
index 0000000..bd30927
--- /dev/null
+++ b/processes/templates/processes/includes/stepper_header.html
@@ -0,0 +1,54 @@
+{% load static %}
+
+
+
+
diff --git a/processes/templates/processes/request_list.html b/processes/templates/processes/request_list.html
new file mode 100644
index 0000000..8b175d8
--- /dev/null
+++ b/processes/templates/processes/request_list.html
@@ -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 %}
+
+
+
+
+
+
+
+{% endblock style %}
+
+
+{% block content %}
+{% include '_toasts.html' %}
+
+
+
+
+
درخواستها
+
+
+
+
+
+
+
+
+ شناسه |
+ فرآیند |
+ شماره اشتراک آب |
+ نماینده |
+ درخواستکننده |
+ اولویت |
+ وضعیت |
+ تاریخ ایجاد |
+ عملیات |
+
+
+
+ {% for inst in instances %}
+
+ {{ inst.code }} |
+ {{ inst.process.name }} |
+ {{ inst.well.water_subscription_number }} |
+ {% if inst.representative %}{{ inst.representative.get_full_name }}{% else %}-{% endif %} |
+ {% if inst.requester %}{{ inst.requester.get_full_name }}{% else %}-{% endif %} |
+ {{ inst.get_priority_display }} |
+ {{ inst.get_status_display }} |
+ {{ inst.jcreated }} |
+
+
+ |
+
+ {% empty %}
+
+ موردی ثبت نشده است |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
آیا از حذف این درخواست اطمینان دارید؟
+
+
+
+
+
+{% endblock %}
+
+{% block script %}
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+
diff --git a/processes/templates/processes/step_detail.html b/processes/templates/processes/step_detail.html
new file mode 100644
index 0000000..e1b41ee
--- /dev/null
+++ b/processes/templates/processes/step_detail.html
@@ -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 %}
+
+{% endblock %}
+
+{% block content %}
+{% include '_toasts.html' %}
+
+
+
+
+
+
{{ step.name }}: {{ instance.process.name }}
+
+ اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
+ | نماینده: {{ instance.representative.profile.national_code|default:"-" }}
+
+
+
بازگشت
+
+
+
+ {% stepper_header instance step %}
+
+
+
+
+
+
+
+
+
وضعیت مرحله:
+ {% if step_instance %}
+ {{ step_instance.get_status_display_with_color|safe }}
+ {% else %}
+ در انتظار
+ {% endif %}
+
+
فرم این مرحله بعداً پیادهسازی میشود.
+
+ {% if step_instance and step_instance.notes %}
+
+
یادداشتها:
+
{{ step_instance.notes }}
+ {% endif %}
+
+
+
+
+ {% if previous_step %}
+
+
+ قبلی
+
+ {% else %}
+
+ {% endif %}
+
+ {% if next_step %}
+
+ بعدی
+
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block script %}
+
+{% endblock %}
diff --git a/processes/templatetags/__init__.py b/processes/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/processes/templatetags/processes_tags.py b/processes/templatetags/processes_tags.py
new file mode 100644
index 0000000..e587f75
--- /dev/null
+++ b/processes/templatetags/processes_tags.py
@@ -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,
+ }
diff --git a/processes/urls.py b/processes/urls.py
index 449eb98..56ec7b5 100644
--- a/processes/urls.py
+++ b/processes/urls.py
@@ -4,9 +4,21 @@ from . import views
app_name = 'processes'
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//delete/', views.delete_request, name='delete_request'),
+
+ # New step-based architecture
+ path('instance//steps/', views.instance_steps, name='instance_steps'),
+ path('instance//step//', views.step_detail, name='step_detail'),
+
+ # Legacy process views
path('', views.process_list, name='process_list'),
path('/', views.process_detail, name='process_detail'),
path('/start/', views.start_process, name='start_process'),
path('instance//', views.instance_detail, name='instance_detail'),
path('my-processes/', views.my_processes, name='my_processes'),
-]
\ No newline at end of file
+]
\ No newline at end of file
diff --git a/processes/views.py b/processes/views.py
index 805d071..ddf1b41 100644
--- a/processes/views.py
+++ b/processes/views.py
@@ -1,10 +1,21 @@
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 import messages
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 wells.models import Well
+from accounts.models import Profile
from .forms import ProcessInstanceForm
+from accounts.forms import CustomerForm
+from wells.forms import WellForm
+from wells.models import WaterMeterManufacturer
+
@login_required
def process_list(request):
@@ -22,6 +33,272 @@ def process_detail(request, process_id):
'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
def start_process(request, process_id):
"""شروع فرآیند جدید"""
@@ -67,6 +344,61 @@ def instance_detail(request, instance_id):
'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
def my_processes(request):
"""نمایش فرآیندهای کاربر"""
@@ -76,4 +408,5 @@ def my_processes(request):
return render(request, 'processes/my_processes.html', {
'my_instances': my_instances,
'assigned_steps': assigned_steps
- })
\ No newline at end of file
+ })
+
diff --git a/templates/sidebars/admin.html b/templates/sidebars/admin.html
index 008c9cf..053c6ca 100644
--- a/templates/sidebars/admin.html
+++ b/templates/sidebars/admin.html
@@ -98,6 +98,18 @@
+
+
+
+
+