diff --git a/accounts/forms.py b/accounts/forms.py
index e5f3b90..a5d493b 100644
--- a/accounts/forms.py
+++ b/accounts/forms.py
@@ -39,8 +39,7 @@ class CustomerForm(forms.ModelForm):
}),
'phone_number_1': forms.TextInput(attrs={
'class': 'form-control',
- 'placeholder': '09123456789',
- 'required': True
+ 'placeholder': '09123456789'
}),
'phone_number_2': forms.TextInput(attrs={
'class': 'form-control',
diff --git a/accounts/migrations/0008_alter_historicalprofile_phone_number_1_and_more.py b/accounts/migrations/0008_alter_historicalprofile_phone_number_1_and_more.py
deleted file mode 100644
index a005ff8..0000000
--- a/accounts/migrations/0008_alter_historicalprofile_phone_number_1_and_more.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# Generated by Django 5.2.4 on 2025-10-02 09:32
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('accounts', '0007_historicalprofile_company_name_and_more'),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='historicalprofile',
- name='phone_number_1',
- field=models.CharField(default=1, max_length=11, verbose_name='شماره تماس ۱'),
- preserve_default=False,
- ),
- migrations.AlterField(
- model_name='profile',
- name='phone_number_1',
- field=models.CharField(default=1, max_length=11, verbose_name='شماره تماس ۱'),
- preserve_default=False,
- ),
- ]
diff --git a/accounts/models.py b/accounts/models.py
index f6ccf3d..348304e 100644
--- a/accounts/models.py
+++ b/accounts/models.py
@@ -78,7 +78,9 @@ class Profile(BaseModel):
)
phone_number_1 = models.CharField(
max_length=11,
+ null=True,
verbose_name="شماره تماس ۱",
+ blank=True
)
phone_number_2 = models.CharField(
max_length=11,
diff --git a/certificates/views.py b/certificates/views.py
index 5149334..9a8f1de 100644
--- a/certificates/views.py
+++ b/certificates/views.py
@@ -4,8 +4,6 @@ from django.contrib import messages
from django.http import JsonResponse
from django.urls import reverse
from django.utils import timezone
-from django.template import Template, Context
-from django.utils.safestring import mark_safe
from processes.models import ProcessInstance, StepInstance
from invoices.models import Invoice
@@ -30,33 +28,20 @@ def _render_template(template: CertificateTemplate, instance: ProcessInstance):
well = instance.well
rep = instance.representative
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
- individual = True if rep.profile and rep.profile.user_type == 'individual' else False
- customer_company_name = rep.profile.company_name if rep.profile and rep.profile.user_type == 'legal' else None
- city = template.company.broker.affairs.county.city.name if template.company and template.company.broker and template.company.broker.affairs and template.company.broker.affairs.county and template.company.broker.affairs.county.city else None
- county = template.company.broker.affairs.county.name if template.company and template.company.broker and template.company.broker.affairs and template.company.broker.affairs.county else None
-
ctx = {
- 'today_jalali': mark_safe(f"{_to_jalali(timezone.now().date())}"),
- 'request_code': mark_safe(f"{instance.code}"),
- 'company_name': mark_safe(f"{(template.company.name if template.company else '') or ''}"),
- 'customer_full_name': mark_safe(f"{rep.get_full_name() if rep else ''}"),
- 'water_subscription_number': mark_safe(f"{getattr(well, 'water_subscription_number', '') or ''}"),
- 'address': mark_safe(f"{getattr(well, 'county', '') or ''}"),
- 'visit_date_jalali': mark_safe(f"{_to_jalali(getattr(latest_report, 'visited_date', None)) if latest_report else ''}"),
- 'city': mark_safe(f"{city or ''}"),
- 'county': mark_safe(f"{county or ''}"),
- 'customer_company_name': mark_safe(f"{customer_company_name or ''}"),
- 'individual': individual,
+ 'today_jalali': _to_jalali(timezone.now().date()),
+ 'request_code': instance.code,
+ 'company_name': (template.company.name if template.company else '') or '',
+ 'customer_full_name': rep.get_full_name() if rep else '',
+ 'water_subscription_number': getattr(well, 'water_subscription_number', '') or '',
+ 'address': getattr(well, 'county', '') or '',
+ 'visit_date_jalali': _to_jalali(getattr(latest_report, 'visited_date', None)) if latest_report else '',
}
-
- # Render title using Django template engine
- title_template = Template(template.title or '')
- title = title_template.render(Context(ctx))
-
- # Render body using Django template engine
- body_template = Template(template.body or '')
- body = body_template.render(Context(ctx))
-
+ title = (template.title or '').format(**ctx)
+ body = (template.body or '')
+ # Render body placeholders with bold values
+ for k, v in ctx.items():
+ body = body.replace(f"{{{{ {k} }}}}", f"{str(v)}")
return title, body
@@ -78,7 +63,7 @@ def certificate_step(request, instance_id, step_id):
if inv:
if prev_si and not prev_si.status == 'approved':
inv.calculate_totals()
- if inv.get_remaining_amount() != 0:
+ if inv.remaining_amount != 0:
messages.error(request, 'مانده فاکتور باید صفر باشد')
return redirect('processes:request_list')
diff --git a/db.sqlite3 b/db.sqlite3
index 02dd5e4..194dde7 100644
Binary files a/db.sqlite3 and b/db.sqlite3 differ
diff --git a/installations/templates/installations/installation_report_step.html b/installations/templates/installations/installation_report_step.html
index d6a7cfc..df12a29 100644
--- a/installations/templates/installations/installation_report_step.html
+++ b/installations/templates/installations/installation_report_step.html
@@ -75,6 +75,7 @@
این گزارش رد شده است.
+
علت رد: {{ step_instance.get_latest_rejection.reason }}
{% endif %}
@@ -98,9 +99,9 @@
UTM Y: {{ report.utm_y|default:'-' }}
نوع مصرف: {{ report.get_usage_type_display|default:'-' }}
شماره پروانه بهرهبرداری: {{ report.exploitation_license_number|default:'-' }}
- قدرت موتور(کیلووات ساعت): {{ report.motor_power|default:'-' }}
- دبی قبل کالیبراسیون(لیتر/ثانیه): {{ report.pre_calibration_flow_rate|default:'-' }}
- دبی بعد کالیبراسیون(لیتر/ثانیه): {{ report.post_calibration_flow_rate|default:'-' }}
+ (کیلووات ساعت)قدرت موتور: {{ report.motor_power|default:'-' }}
+ (لیتر/ثانیه) دبی قبل کالیبراسیون: {{ report.pre_calibration_flow_rate|default:'-' }}
+ (لیتر/ثانیه) دبی بعد کالیبراسیون: {{ report.post_calibration_flow_rate|default:'-' }}
diff --git a/installations/views.py b/installations/views.py
index 367edac..177ccc3 100644
--- a/installations/views.py
+++ b/installations/views.py
@@ -320,29 +320,15 @@ def installation_report_step(request, instance_id, step_id):
can_approve_reject = False
user_can_approve = can_approve_reject
approvals_list = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False))
- rejections_list = list(step_instance.rejections.select_related('role', 'rejected_by').filter(is_deleted=False))
approvals_by_role = {a.role_id: a for a in approvals_list}
- rejections_by_role = {r.role_id: r for r in rejections_list}
- approver_statuses = []
- for r in reqs:
- appr = approvals_by_role.get(r.role_id)
- rejection = rejections_by_role.get(r.role_id)
-
- if appr:
- status = 'approved'
- reason = appr.reason
- elif rejection:
- status = 'rejected'
- reason = rejection.reason
- else:
- status = None
- reason = ''
-
- approver_statuses.append({
+ approver_statuses = [
+ {
'role': r.role,
- 'status': status,
- 'reason': reason,
- })
+ 'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None),
+ 'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''),
+ }
+ for r in reqs
+ ]
# Determine if current user has already approved/rejected (to disable buttons)
current_user_has_decided = False
@@ -370,11 +356,10 @@ def installation_report_step(request, instance_id, step_id):
if action == 'approve':
# Record this user's approval for their role
- StepApproval.objects.create(
+ StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
- approved_by=request.user,
- reason=''
+ defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''}
)
# Only mark report approved when ALL required roles have approved
if step_instance.is_fully_approved():
@@ -401,8 +386,12 @@ def installation_report_step(request, instance_id, step_id):
if not reason:
messages.error(request, 'لطفاً علت رد شدن را وارد کنید.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
- # Only create StepRejection for rejections, not StepApproval
- StepRejection.objects.create(step_instance=step_instance, role=matching_role, rejected_by=request.user, reason=reason)
+ StepApproval.objects.update_or_create(
+ step_instance=step_instance,
+ role=matching_role,
+ defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
+ )
+ StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
existing_report.approved = False
existing_report.save()
# If current step moved ahead of this step, reset it back for correction (align with invoices)
diff --git a/invoices/admin.py b/invoices/admin.py
index 72df296..f8a46cb 100644
--- a/invoices/admin.py
+++ b/invoices/admin.py
@@ -44,11 +44,11 @@ class PaymentInline(admin.TabularInline):
@admin.register(Invoice)
class InvoiceAdmin(SimpleHistoryAdmin):
- list_display = ['name', 'process_instance', 'customer', 'status_display', 'final_amount', 'paid_amount_display', 'remaining_amount_display', 'due_date']
+ list_display = ['name', 'process_instance', 'customer', 'status_display', 'final_amount', 'paid_amount', 'remaining_amount', 'due_date']
list_filter = ['status', 'created', 'due_date', 'process_instance__process']
search_fields = ['name', 'customer__username', 'customer__first_name', 'customer__last_name', 'notes']
prepopulated_fields = {'slug': ('name',)}
- readonly_fields = ['deleted_at', 'created', 'updated', 'total_amount', 'discount_amount', 'final_amount', 'paid_amount_display', 'remaining_amount_display']
+ readonly_fields = ['deleted_at', 'created', 'updated', 'total_amount', 'discount_amount', 'final_amount', 'paid_amount', 'remaining_amount']
inlines = [InvoiceItemInline, PaymentInline]
ordering = ['-created']
@@ -56,16 +56,6 @@ class InvoiceAdmin(SimpleHistoryAdmin):
return mark_safe(obj.get_status_display_with_color())
status_display.short_description = "وضعیت"
- def paid_amount_display(self, obj):
- return f"{obj.get_paid_amount():,.0f} تومان"
- paid_amount_display.short_description = "مبلغ پرداخت شده"
-
- def remaining_amount_display(self, obj):
- amount = obj.get_remaining_amount()
- color = "green" if amount <= 0 else "red"
- return format_html('{:,.0f} تومان', color, amount)
- remaining_amount_display.short_description = "مبلغ باقیمانده"
-
@admin.register(Payment)
class PaymentAdmin(SimpleHistoryAdmin):
list_display = ['invoice', 'amount', 'payment_method', 'payment_date', 'created_by']
diff --git a/invoices/migrations/0002_remove_historicalinvoice_paid_amount_and_more.py b/invoices/migrations/0002_remove_historicalinvoice_paid_amount_and_more.py
deleted file mode 100644
index 4617568..0000000
--- a/invoices/migrations/0002_remove_historicalinvoice_paid_amount_and_more.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# Generated by Django 5.2.4 on 2025-10-04 08:16
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('invoices', '0001_initial'),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name='historicalinvoice',
- name='paid_amount',
- ),
- migrations.RemoveField(
- model_name='historicalinvoice',
- name='remaining_amount',
- ),
- migrations.RemoveField(
- model_name='invoice',
- name='paid_amount',
- ),
- migrations.RemoveField(
- model_name='invoice',
- name='remaining_amount',
- ),
- ]
diff --git a/invoices/models.py b/invoices/models.py
index 4a48c8c..854f46b 100644
--- a/invoices/models.py
+++ b/invoices/models.py
@@ -228,6 +228,18 @@ class Invoice(NameSlugModel):
default=0,
verbose_name="مبلغ نهایی"
)
+ paid_amount = models.DecimalField(
+ max_digits=15,
+ decimal_places=2,
+ default=0,
+ verbose_name="مبلغ پرداخت شده"
+ )
+ remaining_amount = models.DecimalField(
+ max_digits=15,
+ decimal_places=2,
+ default=0,
+ verbose_name="مبلغ باقیمانده"
+ )
due_date = models.DateField(verbose_name="تاریخ سررسید")
notes = models.TextField(verbose_name="یادداشتها", blank=True)
created_by = models.ForeignKey(
@@ -266,31 +278,22 @@ class Invoice(NameSlugModel):
vat_amount = base_amount * vat_rate
self.final_amount = base_amount + vat_amount
- # وضعیت بر اساس مانده خالص (استفاده از تابعها)
- paid = self.get_paid_amount()
- net_due = self.final_amount - paid
-
+ # خالص مانده به نفع شرکت (مثبت) یا به نفع مشتری (منفی)
+ net_due = self.final_amount - self.paid_amount
+ self.remaining_amount = net_due
+
+ # وضعیت بر اساس مانده خالص
if net_due == 0:
self.status = 'paid'
elif net_due > 0:
# مشتری هنوز باید پرداخت کند
- self.status = 'partially_paid' if paid > 0 else 'sent'
+ self.status = 'partially_paid' if self.paid_amount > 0 else 'sent'
else:
# شرکت باید به مشتری پرداخت کند
self.status = 'partially_paid'
self.save()
- def get_paid_amount(self):
- """مبلغ پرداخت شده بر اساس پرداختها (مثل Quote)"""
- return sum((p.amount if p.direction == 'in' else -p.amount) for p in self.payments.filter(is_deleted=False).all())
-
- def get_remaining_amount(self):
- """مبلغ باقیمانده بر اساس پرداختها (مثل Quote)"""
- paid = self.get_paid_amount()
- remaining = self.final_amount - paid
- return remaining
-
def get_status_display_with_color(self):
"""نمایش وضعیت با رنگ"""
@@ -370,13 +373,17 @@ class Payment(BaseModel):
def save(self, *args, **kwargs):
"""بروزرسانی مبالغ فاکتور"""
super().save(*args, **kwargs)
- # فقط مجدداً calculate_totals را صدا کن (مثل Quote)
+ # بروزرسانی مبلغ پرداخت شده فاکتور
+ total_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in self.invoice.payments.filter(is_deleted=False).all())
+ self.invoice.paid_amount = total_paid
self.invoice.calculate_totals()
def delete(self, using=None, keep_parents=False):
"""حذف نرم و بروزرسانی مبالغ فاکتور پس از حذف"""
result = super().delete(using=using, keep_parents=keep_parents)
try:
+ total_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in self.invoice.payments.filter(is_deleted=False).all())
+ self.invoice.paid_amount = total_paid
self.invoice.calculate_totals()
except Exception:
pass
diff --git a/invoices/templates/invoices/final_invoice_print.html b/invoices/templates/invoices/final_invoice_print.html
index d9c8333..36f0ffd 100644
--- a/invoices/templates/invoices/final_invoice_print.html
+++ b/invoices/templates/invoices/final_invoice_print.html
@@ -159,11 +159,11 @@
| پرداختیها(تومان): |
- {{ invoice.get_paid_amount|floatformat:0|intcomma:False }} |
+ {{ invoice.paid_amount|floatformat:0|intcomma:False }} |
| مانده(تومان): |
- {{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} |
+ {{ invoice.remaining_amount|floatformat:0|intcomma:False }} |
diff --git a/invoices/templates/invoices/final_invoice_step.html b/invoices/templates/invoices/final_invoice_step.html
index 1d99072..9fc706c 100644
--- a/invoices/templates/invoices/final_invoice_step.html
+++ b/invoices/templates/invoices/final_invoice_step.html
@@ -74,17 +74,17 @@
پرداختیها
-
{{ invoice.get_paid_amount|floatformat:0|intcomma:False }} تومان
+
{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان
مانده
-
{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان
+
{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان
- {% if invoice.get_remaining_amount <= 0 %}
+ {% if invoice.remaining_amount <= 0 %}
تسویه کامل
{% else %}
باقیمانده دارد
@@ -165,11 +165,11 @@
| پرداختیها |
- {{ invoice.get_paid_amount|floatformat:0|intcomma:False }} تومان |
+ {{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان |
| مانده |
- {{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان |
+ {{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان |
diff --git a/invoices/templates/invoices/final_settlement_step.html b/invoices/templates/invoices/final_settlement_step.html
index a4767d4..a1514c9 100644
--- a/invoices/templates/invoices/final_settlement_step.html
+++ b/invoices/templates/invoices/final_settlement_step.html
@@ -42,7 +42,7 @@
پرینت
- {% if request.user|is_manager and step_instance.status != 'approved' and step_instance.status != 'completed' and invoice.get_remaining_amount != 0 %}
+ {% if request.user|is_manager and step_instance.status != 'approved' and step_instance.status != 'completed' and invoice.remaining_amount != 0 %}
@@ -128,17 +128,17 @@
پرداختیها
-
{{ invoice.get_paid_amount|floatformat:0|intcomma:False }} تومان
+
{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان
مانده
-
{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان
+
{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان
- {% if invoice.get_remaining_amount <= 0 %}
+ {% if invoice.remaining_amount <= 0 %}
تسویه کامل
{% else %}
باقیمانده دارد
@@ -318,9 +318,9 @@
- {% if invoice.get_remaining_amount != 0 %}
+ {% if invoice.remaining_amount != 0 %}
- مانده فاکتور: {{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان
+ مانده فاکتور: {{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان
امکان تایید تا تسویه کامل فاکتور وجود ندارد.
{% else %}
@@ -329,7 +329,7 @@
diff --git a/invoices/templates/invoices/quote_print.html b/invoices/templates/invoices/quote_print.html
index fc445a6..bbf7a97 100644
--- a/invoices/templates/invoices/quote_print.html
+++ b/invoices/templates/invoices/quote_print.html
@@ -145,17 +145,7 @@
-
- {% if instance.representative.profile.user_type == 'legal' %}
- اطلاعات مشترک (حقوقی)
- {% else %}
- اطلاعات مشترک (حقیقی)
- {% endif %}
-
- {% if instance.representative.profile.user_type == 'legal' %}
-
نام شرکت: {{ instance.representative.profile.company_name|default:"-" }}
-
شناسه ملی: {{ instance.representative.profile.company_national_id|default:"-" }}
- {% endif %}
+
اطلاعات مشترک
نام: {{ quote.customer.get_full_name }}
{% if instance.representative.profile and instance.representative.profile.national_code %}
کد ملی: {{ instance.representative.profile.national_code }}
diff --git a/invoices/templates/invoices/quote_step.html b/invoices/templates/invoices/quote_step.html
index 853f250..404cf14 100644
--- a/invoices/templates/invoices/quote_step.html
+++ b/invoices/templates/invoices/quote_step.html
@@ -57,7 +57,7 @@
پیشفاکتور موجود
{{ existing_quote.name }} |
- مبلغ کل (با احتساب مالیات): {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان |
+ مبلغ کل: {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان |
وضعیت: {{ existing_quote.get_status_display_with_color|safe }}
diff --git a/invoices/views.py b/invoices/views.py
index 778d837..6e467b9 100644
--- a/invoices/views.py
+++ b/invoices/views.py
@@ -358,28 +358,14 @@ def quote_payment_step(request, instance_id, step_id):
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
approvals_list = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False))
- rejections_list = list(step_instance.rejections.select_related('role', 'rejected_by').filter(is_deleted=False))
approvals_by_role = {a.role_id: a for a in approvals_list}
- rejections_by_role = {r.role_id: r for r in rejections_list}
approver_statuses = []
for r in reqs:
appr = approvals_by_role.get(r.role_id)
- rejection = rejections_by_role.get(r.role_id)
-
- if appr:
- status = 'approved'
- reason = appr.reason
- elif rejection:
- status = 'rejected'
- reason = rejection.reason
- else:
- status = None
- reason = ''
-
approver_statuses.append({
'role': r.role,
- 'status': status,
- 'reason': reason,
+ 'status': (appr.decision if appr else None),
+ 'reason': (appr.reason if appr else ''),
})
# dynamic permission: who can approve/reject this step (based on requirements)
@@ -412,11 +398,10 @@ def quote_payment_step(request, instance_id, step_id):
action = request.POST.get('action')
if action == 'approve':
- StepApproval.objects.create(
+ StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
- approved_by=request.user,
- reason=''
+ defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''}
)
if step_instance.is_fully_approved():
step_instance.status = 'completed'
@@ -437,12 +422,12 @@ def quote_payment_step(request, instance_id, step_id):
if not reason:
messages.error(request, 'علت رد شدن را وارد کنید')
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
- StepRejection.objects.create(
+ StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
- rejected_by=request.user,
- reason=reason
- )
+ defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
+ )
+ StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
@@ -942,30 +927,16 @@ def final_settlement_step(request, instance_id, step_id):
# Build approver statuses for template (include reason to display in UI)
reqs = list(step.approver_requirements.select_related('role').all())
- approvals = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False))
- rejections = list(step_instance.rejections.select_related('role', 'rejected_by').filter(is_deleted=False))
+ approvals = list(step_instance.approvals.select_related('role').all())
approvals_by_role = {a.role_id: a for a in approvals}
- rejections_by_role = {r.role_id: r for r in rejections}
- approver_statuses = []
- for r in reqs:
- appr = approvals_by_role.get(r.role_id)
- rejection = rejections_by_role.get(r.role_id)
-
- if appr:
- status = 'approved'
- reason = appr.reason
- elif rejection:
- status = 'rejected'
- reason = rejection.reason
- else:
- status = None
- reason = ''
-
- approver_statuses.append({
+ approver_statuses = [
+ {
'role': r.role,
- 'status': status,
- 'reason': reason,
- })
+ 'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None),
+ 'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''),
+ }
+ for r in reqs
+ ]
# dynamic permission to control approve/reject UI
try:
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none())
@@ -978,8 +949,8 @@ def final_settlement_step(request, instance_id, step_id):
# Compute whether current user has already decided (approved/rejected)
current_user_has_decided = False
try:
- user_has_approval = step_instance.approvals.filter(approved_by=request.user, is_deleted=False).exists()
- user_has_rejection = step_instance.rejections.filter(rejected_by=request.user, is_deleted=False).exists()
+ user_has_approval = step_instance.approvals.filter(approved_by=request.user).exists()
+ user_has_rejection = step_instance.rejections.filter(rejected_by=request.user).exists()
current_user_has_decided = bool(user_has_approval or user_has_rejection)
except Exception:
current_user_has_decided = False
@@ -997,14 +968,13 @@ def final_settlement_step(request, instance_id, step_id):
if action == 'approve':
# enforce zero remaining
invoice.calculate_totals()
- if invoice.get_remaining_amount() != 0:
- messages.error(request, f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.get_remaining_amount()})")
+ if invoice.remaining_amount != 0:
+ messages.error(request, f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})")
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
- StepApproval.objects.create(
+ StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
- approved_by=request.user,
- reason=''
+ defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''}
)
if step_instance.is_fully_approved():
step_instance.status = 'completed'
@@ -1023,12 +993,12 @@ def final_settlement_step(request, instance_id, step_id):
if not reason:
messages.error(request, 'علت رد شدن را وارد کنید')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
- StepRejection.objects.create(
+ StepApproval.objects.update_or_create(
step_instance=step_instance,
role=matching_role,
- rejected_by=request.user,
- reason=reason
- )
+ defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
+ )
+ StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
# If current step is ahead of this step, reset it back to this step (align behavior with other steps)
try:
if instance.current_step and instance.current_step.order > step.order:
@@ -1203,8 +1173,8 @@ def add_final_payment(request, instance_id, step_id):
'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]),
'totals': {
'final_amount': str(invoice.final_amount),
- 'paid_amount': str(invoice.get_paid_amount()),
- 'remaining_amount': str(invoice.get_remaining_amount()),
+ 'paid_amount': str(invoice.paid_amount),
+ 'remaining_amount': str(invoice.remaining_amount),
}
})
@@ -1216,17 +1186,14 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance)
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
-
# Only BROKER can delete final settlement payments
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)):
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
-
- # Delete payment and recalculate invoice totals
payment.hard_delete()
- invoice.calculate_totals() # This is what was missing!
+ invoice.refresh_from_db()
# On delete, return to awaiting approval
try:
@@ -1234,11 +1201,16 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
si.status = 'in_progress'
si.completed_at = None
si.save()
- # Clear approvals and rejections (like in quote_payment)
- for appr in list(si.approvals.all()):
- appr.delete()
- for rej in list(si.rejections.all()):
- rej.delete()
+ try:
+ for appr in list(si.approvals.all()):
+ appr.delete()
+ except Exception:
+ pass
+ try:
+ for rej in list(si.rejections.all()):
+ rej.delete()
+ except Exception:
+ pass
except Exception:
pass
@@ -1272,7 +1244,7 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'totals': {
'final_amount': str(invoice.final_amount),
- 'paid_amount': str(invoice.get_paid_amount()),
- 'remaining_amount': str(invoice.get_remaining_amount()),
+ 'paid_amount': str(invoice.paid_amount),
+ 'remaining_amount': str(invoice.remaining_amount),
}})
diff --git a/processes/admin.py b/processes/admin.py
index 42d4bca..4739742 100644
--- a/processes/admin.py
+++ b/processes/admin.py
@@ -162,9 +162,9 @@ class StepInstanceAdmin(SimpleHistoryAdmin):
@admin.register(StepRejection)
class StepRejectionAdmin(SimpleHistoryAdmin):
- list_display = ['step_instance', 'role', 'rejected_by', 'reason_short', 'created_at', 'is_deleted']
- list_filter = ['role', 'rejected_by', 'created_at', 'step_instance__step__process']
- search_fields = ['step_instance__step__name', 'rejected_by__username', 'reason', 'role__name']
+ list_display = ['step_instance', 'rejected_by', 'reason_short', 'created_at', 'is_deleted']
+ list_filter = ['rejected_by', 'created_at', 'step_instance__step__process']
+ search_fields = ['step_instance__step__name', 'rejected_by__username', 'reason']
readonly_fields = ['created_at']
ordering = ['-created_at']
@@ -182,6 +182,6 @@ class StepApproverRequirementAdmin(admin.ModelAdmin):
@admin.register(StepApproval)
class StepApprovalAdmin(admin.ModelAdmin):
- list_display = ("step_instance", "role", "approved_by", "created_at", "is_deleted")
- list_filter = ("role", "step_instance__step__process")
+ list_display = ("step_instance", "role", "decision", "approved_by", "created_at", "is_deleted")
+ list_filter = ("decision", "role", "step_instance__step__process")
search_fields = ("step_instance__process_instance__code", "role__name", "approved_by__username")
diff --git a/processes/migrations/0006_alter_stepapproval_unique_together_and_more.py b/processes/migrations/0006_alter_stepapproval_unique_together_and_more.py
deleted file mode 100644
index f4cb896..0000000
--- a/processes/migrations/0006_alter_stepapproval_unique_together_and_more.py
+++ /dev/null
@@ -1,34 +0,0 @@
-# Generated by Django 5.2.4 on 2025-10-02 09:32
-
-import django.db.models.deletion
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('accounts', '0008_alter_historicalprofile_phone_number_1_and_more'),
- ('processes', '0005_alter_historicalstepinstance_status_and_more'),
- ]
-
- operations = [
- migrations.AlterUniqueTogether(
- name='stepapproval',
- unique_together=set(),
- ),
- migrations.AddField(
- model_name='historicalsteprejection',
- name='role',
- field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='accounts.role', verbose_name='نقش'),
- ),
- migrations.AddField(
- model_name='steprejection',
- name='role',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.role', verbose_name='نقش'),
- ),
- migrations.AlterField(
- model_name='stepapproval',
- name='role',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.role', verbose_name='نقش'),
- ),
- ]
diff --git a/processes/migrations/0007_remove_stepapproval_decision_and_more.py b/processes/migrations/0007_remove_stepapproval_decision_and_more.py
deleted file mode 100644
index d97d16b..0000000
--- a/processes/migrations/0007_remove_stepapproval_decision_and_more.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# Generated by Django 5.2.4 on 2025-10-02 09:50
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('processes', '0006_alter_stepapproval_unique_together_and_more'),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name='stepapproval',
- name='decision',
- ),
- migrations.AlterField(
- model_name='stepapproval',
- name='reason',
- field=models.TextField(blank=True, verbose_name='توضیحات'),
- ),
- ]
diff --git a/processes/models.py b/processes/models.py
index 9cd0ddd..391fd26 100644
--- a/processes/models.py
+++ b/processes/models.py
@@ -378,7 +378,7 @@ class StepInstance(models.Model):
def get_latest_rejection(self):
"""دریافت آخرین رد شدن"""
- return self.rejections.filter(is_deleted=False).order_by('-created_at').first()
+ return self.rejections.order_by('-created_at').first()
# -------- Multi-role approval helpers --------
def required_roles(self):
@@ -386,8 +386,8 @@ class StepInstance(models.Model):
def approvals_by_role(self):
decisions = {}
- for a in self.approvals.filter(is_deleted=False).select_related('role').order_by('created_at'):
- decisions[a.role_id] = 'approved'
+ for a in self.approvals.select_related('role').order_by('created_at'):
+ decisions[a.role_id] = a.decision
return decisions
def is_fully_approved(self) -> bool:
@@ -409,7 +409,6 @@ class StepRejection(models.Model):
related_name='rejections',
verbose_name="نمونه مرحله"
)
- role = models.ForeignKey(Role, on_delete=models.SET_NULL, blank=True, null=True, verbose_name="نقش")
rejected_by = models.ForeignKey(
User,
on_delete=models.CASCADE,
@@ -432,13 +431,12 @@ class StepRejection(models.Model):
ordering = ['-created_at']
def __str__(self):
- return f"رد شدن {self.step_instance} توسط {self.rejected_by.get_full_name()} ({self.role.name})"
+ return f"رد شدن {self.step_instance} توسط {self.rejected_by.get_full_name()}"
def save(self, *args, **kwargs):
"""ذخیره با تغییر وضعیت مرحله"""
- if self.is_deleted == False:
- self.step_instance.status = 'rejected'
- self.step_instance.save()
+ self.step_instance.status = 'rejected'
+ self.step_instance.save()
super().save(*args, **kwargs)
def hard_delete(self):
@@ -449,6 +447,7 @@ class StepRejection(models.Model):
self.save()
+
class StepApproverRequirement(models.Model):
"""Required approver roles for a step."""
step = models.ForeignKey(ProcessStep, on_delete=models.CASCADE, related_name='approver_requirements', verbose_name="مرحله")
@@ -467,13 +466,15 @@ class StepApproverRequirement(models.Model):
class StepApproval(models.Model):
"""Approvals per role for a concrete step instance."""
step_instance = models.ForeignKey(StepInstance, on_delete=models.CASCADE, related_name='approvals', verbose_name="نمونه مرحله")
- role = models.ForeignKey(Role, on_delete=models.SET_NULL, blank=True, null=True, verbose_name="نقش")
+ role = models.ForeignKey(Role, on_delete=models.CASCADE, verbose_name="نقش")
approved_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="تاییدکننده")
- reason = models.TextField(blank=True, verbose_name='توضیحات')
+ decision = models.CharField(max_length=8, choices=[('approved', 'تایید'), ('rejected', 'رد')], verbose_name='نتیجه')
+ reason = models.TextField(blank=True, verbose_name='علت (برای رد)')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ')
is_deleted = models.BooleanField(default=False, verbose_name='حذف شده')
class Meta:
+ unique_together = ('step_instance', 'role')
verbose_name = 'تایید مرحله'
verbose_name_plural = 'تاییدهای مرحله'
@@ -486,4 +487,4 @@ class StepApproval(models.Model):
def __str__(self):
- return f"{self.step_instance} - {self.role} - تایید شده"
+ return f"{self.step_instance} - {self.role} - {self.decision}"
diff --git a/processes/templates/processes/instance_summary.html b/processes/templates/processes/instance_summary.html
index 52b0bf2..ad84b59 100644
--- a/processes/templates/processes/instance_summary.html
+++ b/processes/templates/processes/instance_summary.html
@@ -37,9 +37,9 @@
پرینت فاکتور
{% endif %}
-
+
بازگشت
@@ -57,8 +57,8 @@
{% if invoice %}
مبلغ نهایی
{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان
-
پرداختیها
{{ invoice.get_paid_amount|floatformat:0|intcomma:False }} تومان
-
مانده
{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان
+
پرداختیها
{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان
+
مانده
{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان
@@ -95,113 +95,32 @@
{% if latest_report %}
-
-
-
-
تاریخ مراجعه: {{ latest_report.visited_date|to_jalali|default:'-' }}
-
- {% if installation_assignment.scheduled_date %}
-
-
تاریخ برنامهریزی: {{ installation_assignment.scheduled_date|to_jalali }}
-
- {% endif %}
-
+
+
+
تاریخ مراجعه: {{ latest_report.visited_date|to_jalali|default:'-' }}
سریال کنتور جدید: {{ latest_report.new_water_meter_serial|default:'-' }}
-
-
شماره پلمپ: {{ latest_report.seal_number|default:'-' }}
-
+
کنتور مشکوک: {{ latest_report.is_meter_suspicious|yesno:'بله,خیر' }}
-
- {% if latest_report.sim_number %}
-
-
شماره سیمکارت: {{ latest_report.sim_number }}
-
- {% endif %}
- {% if latest_report.meter_type %}
-
-
نوع کنتور: {{ latest_report.get_meter_type_display }}
-
- {% endif %}
- {% if latest_report.meter_size %}
-
-
سایز کنتور: {{ latest_report.meter_size }}
-
- {% endif %}
- {% if latest_report.water_meter_manufacturer %}
-
-
سازنده: {{ latest_report.water_meter_manufacturer.name }}
-
- {% endif %}
- {% if latest_report.discharge_pipe_diameter %}
-
-
قطر لوله آبده: {{ latest_report.discharge_pipe_diameter }} اینچ
-
- {% endif %}
- {% if latest_report.usage_type %}
-
-
نوع مصرف: {{ latest_report.get_usage_type_display }}
-
- {% endif %}
- {% if latest_report.driving_force %}
-
-
نیرو محرکه: {{ latest_report.driving_force }}
-
- {% endif %}
- {% if latest_report.motor_power %}
-
-
قدرت موتور: {{ latest_report.motor_power }} کیلووات ساعت
-
- {% endif %}
- {% if latest_report.exploitation_license_number %}
-
-
شماره پروانه: {{ latest_report.exploitation_license_number }}
-
- {% endif %}
- {% if latest_report.pre_calibration_flow_rate %}
-
-
دبی قبل از کالیبراسیون: {{ latest_report.pre_calibration_flow_rate }} لیتر/ثانیه
-
- {% endif %}
- {% if latest_report.post_calibration_flow_rate %}
-
-
دبی بعد از کالیبراسیون: {{ latest_report.post_calibration_flow_rate }} لیتر/ثانیه
-
- {% endif %}
-
UTM X: {{ latest_report.utm_x|default:'-' }}
-
-
UTM Y: {{ latest_report.utm_y|default:'-' }}
-
{% if latest_report.description %}
-
-
توضیحات
+
+
توضیحات:
{{ latest_report.description }}
{% endif %}
-
-
عکسها
+
+
عکسها
{% for p in latest_report.photos.all %}
@@ -256,30 +175,6 @@
-
-
-
{% endblock %}
diff --git a/processes/templates/processes/request_list.html b/processes/templates/processes/request_list.html
index 9ec7e92..5640425 100644
--- a/processes/templates/processes/request_list.html
+++ b/processes/templates/processes/request_list.html
@@ -37,14 +37,12 @@