From 6ff4740d04c466d587ac0898157e99f22da96651 Mon Sep 17 00:00:00 2001 From: aminhashemi92 Date: Thu, 21 Aug 2025 09:18:51 +0330 Subject: [PATCH] Add qoute step. --- _base/urls.py | 1 + accounts/migrations/0001_initial.py | 45 +- ..._profile_broker_profile_county_and_more.py | 75 -- invoices/migrations/0001_initial.py | 2 +- ...istoricalpayment_receipt_image_and_more.py | 23 + invoices/models.py | 19 + .../invoices/quote_payment_step.html | 402 +++++++++ .../invoices/quote_preview_step.html | 287 +++++++ invoices/templates/invoices/quote_print.html | 283 +++++++ invoices/templates/invoices/quote_step.html | 204 +++++ invoices/urls.py | 24 + invoices/views.py | 416 +++++++++- locations/migrations/0001_initial.py | 2 +- processes/admin.py | 100 ++- processes/forms.py | 11 +- processes/migrations/0001_initial.py | 51 +- processes/models.py | 179 +++- .../processes/includes/stepper_header.html | 54 ++ .../templates/processes/request_list.html | 774 ++++++++++++++++++ .../templates/processes/step_detail.html | 97 +++ processes/templatetags/__init__.py | 0 processes/templatetags/processes_tags.py | 48 ++ processes/urls.py | 14 +- processes/views.py | 337 +++++++- templates/sidebars/admin.html | 12 + wells/forms.py | 3 +- wells/migrations/0001_initial.py | 54 +- ...acturer_historicalwell_affairs_and_more.py | 202 ----- wells/models.py | 2 - wells/templates/wells/well_list.html | 17 +- 30 files changed, 3362 insertions(+), 376 deletions(-) delete mode 100644 accounts/migrations/0002_profile_affairs_profile_broker_profile_county_and_more.py create mode 100644 invoices/migrations/0002_historicalpayment_receipt_image_and_more.py create mode 100644 invoices/templates/invoices/quote_payment_step.html create mode 100644 invoices/templates/invoices/quote_preview_step.html create mode 100644 invoices/templates/invoices/quote_print.html create mode 100644 invoices/templates/invoices/quote_step.html create mode 100644 invoices/urls.py create mode 100644 processes/templates/processes/includes/stepper_header.html create mode 100644 processes/templates/processes/request_list.html create mode 100644 processes/templates/processes/step_detail.html create mode 100644 processes/templatetags/__init__.py create mode 100644 processes/templatetags/processes_tags.py delete mode 100644 wells/migrations/0002_watermetermanufacturer_historicalwell_affairs_and_more.py 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 %} +
+ +
+ {% csrf_token %} +
+
+
{{ step.name }}
+ ثبت فیش‌های واریزی برای پیش‌فاکتور +
+ +
+
+
+
+
ثبت فیش جدید
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+
وضعیت پیش‌فاکتور
+ مشاهده پیش‌فاکتور +
+
+
+
+
+
مبلغ نهایی پیش‌فاکتور
+
{{ totals.final_amount|floatformat:0|intcomma:False }} تومان
+
+
+
+
+
مبلغ پرداخت‌شده
+
{{ totals.paid_amount|floatformat:0|intcomma:False }} تومان
+
+
+
+
+
مانده
+
{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان
+
+
+
+ {% if totals.is_fully_paid %} + تسویه کامل + {% else %} + باقی‌مانده دارد + {% endif %} +
+
+
+
+ +
+
+
فیش‌های ثبت شده
+
+
+ + + + + + + + + + + + + {% for p in payments %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
مبلغتاریخروششماره مرجعتصویرعملیات
{{ p.amount|floatformat:0|intcomma:False }} تومان{{ p.payment_date|date:'Y/m/d' }}{{ p.get_payment_method_display }}{{ p.reference_number|default:'-' }} + {% if p.receipt_image %} + + + + {% else %} + - + {% endif %} + +
+ + +
+
تا کنون فیشی ثبت نشده است
+
+
+
+ +
+ {% if previous_step %} + + + قبلی + + {% else %} + + {% endif %} + +
+
+
+
+
+
+
+
+
+{% 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 %} + + + + + + + + {% endfor %} + + + + + + +
آیتمتوضیحاتقیمت واحدتعدادقیمت کل
{{ 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 }} تومان
+

+ صادر کننده: + {{ 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 }} + + + + + + + +
+ +
+ + +
+ + +
+
+
+ +
+

دفتر مرکزی، خیابان اصلی

+

تهران، ایران

+

تلفن: ۰۲۱-۱۲۳۴۵۶۷۸

+

ایمیل: info@watercompany.ir

+
+
+
+
پیش‌فاکتور
+
+ + + + + + + + + + + + + + + + + +
شماره پیش‌فاکتور:{{ quote.name }}
کد درخواست:{{ instance.code }}
تاریخ صدور:{{ quote.created|date:"Y/m/d" }}
معتبر تا:{{ quote.valid_until|date:"Y/m/d" }}
+
+
+
+
+ + +
+
+
مشخصات مشترک:
+ + + + + + {% if instance.representative.profile %} + + + + + + + + + + + + + {% endif %} +
نام و نام خانوادگی:{{ quote.customer.get_full_name }}
کد ملی:{{ instance.representative.profile.national_code }}
تلفن:{{ instance.representative.profile.phone_number_1|default:"-" }}
آدرس:{{ instance.representative.profile.address|default:"آدرس نامشخص" }}
+
+
+
مشخصات چاه:
+ + + + + + + + + + + + + + + + + +
شماره اشتراک آب:{{ 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 %} + + + + + + + + + {% endfor %} + + + + + + + {% if quote.discount_amount > 0 %} + + + + + {% endif %} + + + + + +
ردیفشرح کالا/خدماتتوضیحاتتعدادقیمت واحد (تومان)قیمت کل (تومان)
{{ 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 }}
جمع کل:{{ quote.total_amount|floatformat:0 }} تومان
تخفیف:{{ quote.discount_amount|floatformat:0 }} تومان
مبلغ نهایی:{{ 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 %} + +
+
+ {% csrf_token %} +
+
+
{{ step.name }}
+ {{ step.description|default:' ' }} +
+ +
+ {% if existing_quote %} +
+
+
پیش‌فاکتور موجود
+ نام: {{ existing_quote.name }} | + مبلغ کل: {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان | + وضعیت: {{ existing_quote.get_status_display_with_color|safe }} +
+
+ {% endif %} + +
+
+ + + + + + + + + + + {% for item in items %} + {% with selected_qty=existing_quote_items|get_item:item.id %} + + + + + + + {% endwith %} + {% endfor %} + +
آیتمقیمت واحدتعداد
+ + +
+ {{ item.name }} + {% if item.is_default_in_quotes %} + پیش‌فرض + {% endif %} + + + {% if item.description %}{{ item.description }}{% endif %} +
+
{{ item.unit_price|floatformat:0|intcomma:False }} تومان + +
+
+ + +
+ +
+ {% 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/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 %} + +
+ {% 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 %} + + {% endwith %} + {% if not forloop.last %}
{% endif %} + {% endfor %} +
+ + 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 %} + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
شناسهفرآیندشماره اشتراک آبنمایندهدرخواست‌کنندهاولویتوضعیتتاریخ ایجادعملیات
{{ 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 }} + +
موردی ثبت نشده است
+
+
+ + + +
+ + + +{% 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 %} + +
+
+
+
{{ step.name }}
+ {{ step.description|default:' ' }} +
+ +
+
+
+
وضعیت مرحله: + {% 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 @@ + + + + +