Compare commits

..

5 commits

27 changed files with 498 additions and 161 deletions

View file

@ -39,7 +39,8 @@ class CustomerForm(forms.ModelForm):
}), }),
'phone_number_1': forms.TextInput(attrs={ 'phone_number_1': forms.TextInput(attrs={
'class': 'form-control', 'class': 'form-control',
'placeholder': '09123456789' 'placeholder': '09123456789',
'required': True
}), }),
'phone_number_2': forms.TextInput(attrs={ 'phone_number_2': forms.TextInput(attrs={
'class': 'form-control', 'class': 'form-control',

View file

@ -0,0 +1,25 @@
# 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,
),
]

View file

@ -78,9 +78,7 @@ class Profile(BaseModel):
) )
phone_number_1 = models.CharField( phone_number_1 = models.CharField(
max_length=11, max_length=11,
null=True,
verbose_name="شماره تماس ۱", verbose_name="شماره تماس ۱",
blank=True
) )
phone_number_2 = models.CharField( phone_number_2 = models.CharField(
max_length=11, max_length=11,

View file

@ -4,6 +4,8 @@ from django.contrib import messages
from django.http import JsonResponse from django.http import JsonResponse
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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 processes.models import ProcessInstance, StepInstance
from invoices.models import Invoice from invoices.models import Invoice
@ -28,20 +30,33 @@ def _render_template(template: CertificateTemplate, instance: ProcessInstance):
well = instance.well well = instance.well
rep = instance.representative rep = instance.representative
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first() 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 = { ctx = {
'today_jalali': _to_jalali(timezone.now().date()), 'today_jalali': mark_safe(f"<span class=\"fw-bold\">{_to_jalali(timezone.now().date())}</span>"),
'request_code': instance.code, 'request_code': mark_safe(f"<span class=\"fw-bold\">{instance.code}</span>"),
'company_name': (template.company.name if template.company else '') or '', 'company_name': mark_safe(f"<span class=\"fw-bold\">{(template.company.name if template.company else '') or ''}</span>"),
'customer_full_name': rep.get_full_name() if rep else '', 'customer_full_name': mark_safe(f"<span class=\"fw-bold\">{rep.get_full_name() if rep else ''}</span>"),
'water_subscription_number': getattr(well, 'water_subscription_number', '') or '', 'water_subscription_number': mark_safe(f"<span class=\"fw-bold\">{getattr(well, 'water_subscription_number', '') or ''}</span>"),
'address': getattr(well, 'county', '') or '', 'address': mark_safe(f"<span class=\"fw-bold\">{getattr(well, 'county', '') or ''}</span>"),
'visit_date_jalali': _to_jalali(getattr(latest_report, 'visited_date', None)) if latest_report else '', 'visit_date_jalali': mark_safe(f"<span class=\"fw-bold\">{_to_jalali(getattr(latest_report, 'visited_date', None)) if latest_report else ''}</span>"),
'city': mark_safe(f"<span class=\"fw-bold\">{city or ''}</span>"),
'county': mark_safe(f"<span class=\"fw-bold\">{county or ''}</span>"),
'customer_company_name': mark_safe(f"<span class=\"fw-bold\">{customer_company_name or ''}</span>"),
'individual': individual,
} }
title = (template.title or '').format(**ctx)
body = (template.body or '') # Render title using Django template engine
# Render body placeholders with bold values title_template = Template(template.title or '')
for k, v in ctx.items(): title = title_template.render(Context(ctx))
body = body.replace(f"{{{{ {k} }}}}", f"<strong>{str(v)}</strong>")
# Render body using Django template engine
body_template = Template(template.body or '')
body = body_template.render(Context(ctx))
return title, body return title, body
@ -63,7 +78,7 @@ def certificate_step(request, instance_id, step_id):
if inv: if inv:
if prev_si and not prev_si.status == 'approved': if prev_si and not prev_si.status == 'approved':
inv.calculate_totals() inv.calculate_totals()
if inv.remaining_amount != 0: if inv.get_remaining_amount() != 0:
messages.error(request, 'مانده فاکتور باید صفر باشد') messages.error(request, 'مانده فاکتور باید صفر باشد')
return redirect('processes:request_list') return redirect('processes:request_list')

Binary file not shown.

View file

@ -75,7 +75,6 @@
<i class="bx bx-error-circle me-2"></i> <i class="bx bx-error-circle me-2"></i>
<div> <div>
<div><strong>این گزارش رد شده است.</strong></div> <div><strong>این گزارش رد شده است.</strong></div>
<div class="mt-1 small">علت رد: {{ step_instance.get_latest_rejection.reason }}</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -99,9 +98,9 @@
<p class="text-nowrap mb-2"><i class="bx bx-map-pin bx-sm me-2"></i>UTM Y: {{ report.utm_y|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-map-pin bx-sm me-2"></i>UTM Y: {{ report.utm_y|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-category bx-sm me-2"></i>نوع مصرف: {{ report.get_usage_type_display|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-category bx-sm me-2"></i>نوع مصرف: {{ report.get_usage_type_display|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-id-card bx-sm me-2"></i>شماره پروانه بهره‌برداری: {{ report.exploitation_license_number|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-id-card bx-sm me-2"></i>شماره پروانه بهره‌برداری: {{ report.exploitation_license_number|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-bolt-circle bx-sm me-2"></i>(کیلووات ساعت)قدرت موتور: {{ report.motor_power|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-bolt-circle bx-sm me-2"></i>قدرت موتور(کیلووات ساعت): {{ report.motor_power|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-water bx-sm me-2"></i>(لیتر/ثانیه) دبی قبل کالیبراسیون: {{ report.pre_calibration_flow_rate|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-water bx-sm me-2"></i>دبی قبل کالیبراسیون(لیتر/ثانیه): {{ report.pre_calibration_flow_rate|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-water bx-sm me-2"></i>(لیتر/ثانیه) دبی بعد کالیبراسیون: {{ report.post_calibration_flow_rate|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-water bx-sm me-2"></i>دبی بعد کالیبراسیون(لیتر/ثانیه): {{ report.post_calibration_flow_rate|default:'-' }}</p>
</div> </div>
</div> </div>

View file

@ -320,15 +320,29 @@ def installation_report_step(request, instance_id, step_id):
can_approve_reject = False can_approve_reject = False
user_can_approve = can_approve_reject user_can_approve = can_approve_reject
approvals_list = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False)) 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} approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [ 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, 'role': r.role,
'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None), 'status': status,
'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''), 'reason': reason,
} })
for r in reqs
]
# Determine if current user has already approved/rejected (to disable buttons) # Determine if current user has already approved/rejected (to disable buttons)
current_user_has_decided = False current_user_has_decided = False
@ -356,10 +370,11 @@ def installation_report_step(request, instance_id, step_id):
if action == 'approve': if action == 'approve':
# Record this user's approval for their role # Record this user's approval for their role
StepApproval.objects.update_or_create( StepApproval.objects.create(
step_instance=step_instance, step_instance=step_instance,
role=matching_role, role=matching_role,
defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''} approved_by=request.user,
reason=''
) )
# Only mark report approved when ALL required roles have approved # Only mark report approved when ALL required roles have approved
if step_instance.is_fully_approved(): if step_instance.is_fully_approved():
@ -386,12 +401,8 @@ def installation_report_step(request, instance_id, step_id):
if not reason: if not reason:
messages.error(request, 'لطفاً علت رد شدن را وارد کنید.') messages.error(request, 'لطفاً علت رد شدن را وارد کنید.')
return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id)
StepApproval.objects.update_or_create( # Only create StepRejection for rejections, not StepApproval
step_instance=step_instance, StepRejection.objects.create(step_instance=step_instance, role=matching_role, rejected_by=request.user, reason=reason)
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.approved = False
existing_report.save() existing_report.save()
# If current step moved ahead of this step, reset it back for correction (align with invoices) # If current step moved ahead of this step, reset it back for correction (align with invoices)

View file

@ -44,11 +44,11 @@ class PaymentInline(admin.TabularInline):
@admin.register(Invoice) @admin.register(Invoice)
class InvoiceAdmin(SimpleHistoryAdmin): class InvoiceAdmin(SimpleHistoryAdmin):
list_display = ['name', 'process_instance', 'customer', 'status_display', 'final_amount', 'paid_amount', 'remaining_amount', 'due_date'] list_display = ['name', 'process_instance', 'customer', 'status_display', 'final_amount', 'paid_amount_display', 'remaining_amount_display', 'due_date']
list_filter = ['status', 'created', 'due_date', 'process_instance__process'] list_filter = ['status', 'created', 'due_date', 'process_instance__process']
search_fields = ['name', 'customer__username', 'customer__first_name', 'customer__last_name', 'notes'] search_fields = ['name', 'customer__username', 'customer__first_name', 'customer__last_name', 'notes']
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
readonly_fields = ['deleted_at', 'created', 'updated', 'total_amount', 'discount_amount', 'final_amount', 'paid_amount', 'remaining_amount'] readonly_fields = ['deleted_at', 'created', 'updated', 'total_amount', 'discount_amount', 'final_amount', 'paid_amount_display', 'remaining_amount_display']
inlines = [InvoiceItemInline, PaymentInline] inlines = [InvoiceItemInline, PaymentInline]
ordering = ['-created'] ordering = ['-created']
@ -56,6 +56,16 @@ class InvoiceAdmin(SimpleHistoryAdmin):
return mark_safe(obj.get_status_display_with_color()) return mark_safe(obj.get_status_display_with_color())
status_display.short_description = "وضعیت" 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('<span style="color: {};">{:,.0f} تومان</span>', color, amount)
remaining_amount_display.short_description = "مبلغ باقی‌مانده"
@admin.register(Payment) @admin.register(Payment)
class PaymentAdmin(SimpleHistoryAdmin): class PaymentAdmin(SimpleHistoryAdmin):
list_display = ['invoice', 'amount', 'payment_method', 'payment_date', 'created_by'] list_display = ['invoice', 'amount', 'payment_method', 'payment_date', 'created_by']

View file

@ -0,0 +1,29 @@
# 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',
),
]

View file

@ -228,18 +228,6 @@ class Invoice(NameSlugModel):
default=0, default=0,
verbose_name="مبلغ نهایی" 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="تاریخ سررسید") due_date = models.DateField(verbose_name="تاریخ سررسید")
notes = models.TextField(verbose_name="یادداشت‌ها", blank=True) notes = models.TextField(verbose_name="یادداشت‌ها", blank=True)
created_by = models.ForeignKey( created_by = models.ForeignKey(
@ -278,22 +266,31 @@ class Invoice(NameSlugModel):
vat_amount = base_amount * vat_rate vat_amount = base_amount * vat_rate
self.final_amount = base_amount + vat_amount self.final_amount = base_amount + vat_amount
# خالص مانده به نفع شرکت (مثبت) یا به نفع مشتری (منفی) # وضعیت بر اساس مانده خالص (استفاده از تابع‌ها)
net_due = self.final_amount - self.paid_amount paid = self.get_paid_amount()
self.remaining_amount = net_due net_due = self.final_amount - paid
# وضعیت بر اساس مانده خالص
if net_due == 0: if net_due == 0:
self.status = 'paid' self.status = 'paid'
elif net_due > 0: elif net_due > 0:
# مشتری هنوز باید پرداخت کند # مشتری هنوز باید پرداخت کند
self.status = 'partially_paid' if self.paid_amount > 0 else 'sent' self.status = 'partially_paid' if paid > 0 else 'sent'
else: else:
# شرکت باید به مشتری پرداخت کند # شرکت باید به مشتری پرداخت کند
self.status = 'partially_paid' self.status = 'partially_paid'
self.save() 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): def get_status_display_with_color(self):
"""نمایش وضعیت با رنگ""" """نمایش وضعیت با رنگ"""
@ -373,17 +370,13 @@ class Payment(BaseModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""بروزرسانی مبالغ فاکتور""" """بروزرسانی مبالغ فاکتور"""
super().save(*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() self.invoice.calculate_totals()
def delete(self, using=None, keep_parents=False): def delete(self, using=None, keep_parents=False):
"""حذف نرم و بروزرسانی مبالغ فاکتور پس از حذف""" """حذف نرم و بروزرسانی مبالغ فاکتور پس از حذف"""
result = super().delete(using=using, keep_parents=keep_parents) result = super().delete(using=using, keep_parents=keep_parents)
try: 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() self.invoice.calculate_totals()
except Exception: except Exception:
pass pass

View file

@ -159,11 +159,11 @@
</tr> </tr>
<tr class="total-section"> <tr class="total-section">
<td colspan="5" class="text-end"><strong>پرداختی‌ها(تومان):</strong></td> <td colspan="5" class="text-end"><strong>پرداختی‌ها(تومان):</strong></td>
<td><strong">{{ invoice.paid_amount|floatformat:0|intcomma:False }}</strong></td> <td><strong">{{ invoice.get_paid_amount|floatformat:0|intcomma:False }}</strong></td>
</tr> </tr>
<tr class="total-section"> <tr class="total-section">
<td colspan="5" class="text-end"><strong>مانده(تومان):</strong></td> <td colspan="5" class="text-end"><strong>مانده(تومان):</strong></td>
<td><strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }}</strong></td> <td><strong>{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }}</strong></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>

View file

@ -74,17 +74,17 @@
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<div class="border rounded p-3 h-100"> <div class="border rounded p-3 h-100">
<div class="small text-muted">پرداختی‌ها</div> <div class="small text-muted">پرداختی‌ها</div>
<div class="h5 mt-1 text-success">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</div> <div class="h5 mt-1 text-success">{{ invoice.get_paid_amount|floatformat:0|intcomma:False }} تومان</div>
</div> </div>
</div> </div>
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<div class="border rounded p-3 h-100"> <div class="border rounded p-3 h-100">
<div class="small text-muted">مانده</div> <div class="small text-muted">مانده</div>
<div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div> <div class="h5 mt-1 {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان</div>
</div> </div>
</div> </div>
<div class="col-6 col-md-3 d-flex align-items-center"> <div class="col-6 col-md-3 d-flex align-items-center">
{% if invoice.remaining_amount <= 0 %} {% if invoice.get_remaining_amount <= 0 %}
<span class="badge bg-success">تسویه کامل</span> <span class="badge bg-success">تسویه کامل</span>
{% else %} {% else %}
<span class="badge bg-warning text-dark">باقی‌مانده دارد</span> <span class="badge bg-warning text-dark">باقی‌مانده دارد</span>
@ -165,11 +165,11 @@
</tr> </tr>
<tr> <tr>
<th colspan="6" class="text-end">پرداختی‌ها</th> <th colspan="6" class="text-end">پرداختی‌ها</th>
<th class="text-end">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</th> <th class="text-end">{{ invoice.get_paid_amount|floatformat:0|intcomma:False }} تومان</th>
</tr> </tr>
<tr> <tr>
<th colspan="6" class="text-end">مانده</th> <th colspan="6" class="text-end">مانده</th>
<th class="text-end {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</th> <th class="text-end {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان</th>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>

View file

@ -42,7 +42,7 @@
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"> <a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer me-2"></i> پرینت <i class="bx bx-printer me-2"></i> پرینت
</a> </a>
{% if request.user|is_manager and step_instance.status != 'approved' and step_instance.status != 'completed' and invoice.remaining_amount != 0 %} {% if request.user|is_manager and step_instance.status != 'approved' and step_instance.status != 'completed' and invoice.get_remaining_amount != 0 %}
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#forceApproveModal"> <button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#forceApproveModal">
<i class="bx bx-bolt-circle me-1"></i> تایید اضطراری <i class="bx bx-bolt-circle me-1"></i> تایید اضطراری
</button> </button>
@ -128,17 +128,17 @@
<div class="col-6 col-md-4"> <div class="col-6 col-md-4">
<div class="border rounded p-3 h-100"> <div class="border rounded p-3 h-100">
<div class="small text-muted">پرداختی‌ها</div> <div class="small text-muted">پرداختی‌ها</div>
<div class="h5 mt-1 text-success">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</div> <div class="h5 mt-1 text-success">{{ invoice.get_paid_amount|floatformat:0|intcomma:False }} تومان</div>
</div> </div>
</div> </div>
<div class="col-6 col-md-4"> <div class="col-6 col-md-4">
<div class="border rounded p-3 h-100"> <div class="border rounded p-3 h-100">
<div class="small text-muted">مانده</div> <div class="small text-muted">مانده</div>
<div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div> <div class="h5 mt-1 {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان</div>
</div> </div>
</div> </div>
<div class="col-6 d-flex align-items-center"> <div class="col-6 d-flex align-items-center">
{% if invoice.remaining_amount <= 0 %} {% if invoice.get_remaining_amount <= 0 %}
<span class="badge bg-success">تسویه کامل</span> <span class="badge bg-success">تسویه کامل</span>
{% else %} {% else %}
<span class="badge bg-warning text-dark">باقی‌مانده دارد</span> <span class="badge bg-warning text-dark">باقی‌مانده دارد</span>
@ -318,9 +318,9 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{% if invoice.remaining_amount != 0 %} {% if invoice.get_remaining_amount != 0 %}
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
مانده فاکتور: <strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br> مانده فاکتور: <strong>{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br>
امکان تایید تا تسویه کامل فاکتور وجود ندارد. امکان تایید تا تسویه کامل فاکتور وجود ندارد.
</div> </div>
{% else %} {% else %}
@ -329,7 +329,7 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button> <button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-success" {% if invoice.remaining_amount != 0 %}disabled{% endif %}>تایید</button> <button type="submit" class="btn btn-success" {% if invoice.get_remaining_amount != 0 %}disabled{% endif %}>تایید</button>
</div> </div>
</form> </form>
</div> </div>

View file

@ -145,7 +145,17 @@
<!-- Customer & Well Info (compact to match preview) --> <!-- Customer & Well Info (compact to match preview) -->
<div class="row mb-3"> <div class="row mb-3">
<div class="col-6"> <div class="col-6">
<h6 class="fw-bold mb-2">اطلاعات مشترک</h6> <h6 class="fw-bold mb-2">
{% if instance.representative.profile.user_type == 'legal' %}
اطلاعات مشترک (حقوقی)
{% else %}
اطلاعات مشترک (حقیقی)
{% endif %}
</h6>
{% if instance.representative.profile.user_type == 'legal' %}
<div class="small mb-1"><span class="text-muted">نام شرکت:</span> {{ instance.representative.profile.company_name|default:"-" }}</div>
<div class="small mb-1"><span class="text-muted">شناسه ملی:</span> {{ instance.representative.profile.company_national_id|default:"-" }}</div>
{% endif %}
<div class="small mb-1"><span class="text-muted">نام:</span> {{ quote.customer.get_full_name }}</div> <div class="small mb-1"><span class="text-muted">نام:</span> {{ quote.customer.get_full_name }}</div>
{% if instance.representative.profile and instance.representative.profile.national_code %} {% if instance.representative.profile and instance.representative.profile.national_code %}
<div class="small mb-1"><span class="text-muted">کد ملی:</span> {{ instance.representative.profile.national_code }}</div> <div class="small mb-1"><span class="text-muted">کد ملی:</span> {{ instance.representative.profile.national_code }}</div>

View file

@ -57,7 +57,7 @@
<div class="alert alert-info"> <div class="alert alert-info">
<h6>پیش‌فاکتور موجود</h6> <h6>پیش‌فاکتور موجود</h6>
<span class="mb-1">{{ existing_quote.name }} | </span> <span class="mb-1">{{ existing_quote.name }} | </span>
<span class="mb-1">مبلغ کل: {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان | </span> <span class="mb-1">مبلغ کل (با احتساب مالیات): {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان | </span>
<span class="mb-0">وضعیت: {{ existing_quote.get_status_display_with_color|safe }}</span> <span class="mb-0">وضعیت: {{ existing_quote.get_status_display_with_color|safe }}</span>
</div> </div>
</div> </div>

View file

@ -358,14 +358,28 @@ def quote_payment_step(request, instance_id, step_id):
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None) 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 [] 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)) 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} 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 = [] approver_statuses = []
for r in reqs: for r in reqs:
appr = approvals_by_role.get(r.role_id) 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.append({
'role': r.role, 'role': r.role,
'status': (appr.decision if appr else None), 'status': status,
'reason': (appr.reason if appr else ''), 'reason': reason,
}) })
# dynamic permission: who can approve/reject this step (based on requirements) # dynamic permission: who can approve/reject this step (based on requirements)
@ -398,10 +412,11 @@ def quote_payment_step(request, instance_id, step_id):
action = request.POST.get('action') action = request.POST.get('action')
if action == 'approve': if action == 'approve':
StepApproval.objects.update_or_create( StepApproval.objects.create(
step_instance=step_instance, step_instance=step_instance,
role=matching_role, role=matching_role,
defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''} approved_by=request.user,
reason=''
) )
if step_instance.is_fully_approved(): if step_instance.is_fully_approved():
step_instance.status = 'completed' step_instance.status = 'completed'
@ -422,12 +437,12 @@ def quote_payment_step(request, instance_id, step_id):
if not reason: if not reason:
messages.error(request, 'علت رد شدن را وارد کنید') messages.error(request, 'علت رد شدن را وارد کنید')
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id) return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
StepApproval.objects.update_or_create( StepRejection.objects.create(
step_instance=step_instance, step_instance=step_instance,
role=matching_role, role=matching_role,
defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason} rejected_by=request.user,
) 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 # If current step is ahead of this step, reset it back to this step
try: try:
if instance.current_step and instance.current_step.order > step.order: if instance.current_step and instance.current_step.order > step.order:
@ -927,16 +942,30 @@ def final_settlement_step(request, instance_id, step_id):
# Build approver statuses for template (include reason to display in UI) # Build approver statuses for template (include reason to display in UI)
reqs = list(step.approver_requirements.select_related('role').all()) reqs = list(step.approver_requirements.select_related('role').all())
approvals = list(step_instance.approvals.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_by_role = {a.role_id: a for a in approvals} approvals_by_role = {a.role_id: a for a in approvals}
approver_statuses = [ 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({
'role': r.role, 'role': r.role,
'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None), 'status': status,
'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''), 'reason': reason,
} })
for r in reqs
]
# dynamic permission to control approve/reject UI # dynamic permission to control approve/reject UI
try: try:
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()) user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none())
@ -949,8 +978,8 @@ def final_settlement_step(request, instance_id, step_id):
# Compute whether current user has already decided (approved/rejected) # Compute whether current user has already decided (approved/rejected)
current_user_has_decided = False current_user_has_decided = False
try: try:
user_has_approval = step_instance.approvals.filter(approved_by=request.user).exists() 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).exists() user_has_rejection = step_instance.rejections.filter(rejected_by=request.user, is_deleted=False).exists()
current_user_has_decided = bool(user_has_approval or user_has_rejection) current_user_has_decided = bool(user_has_approval or user_has_rejection)
except Exception: except Exception:
current_user_has_decided = False current_user_has_decided = False
@ -968,13 +997,14 @@ def final_settlement_step(request, instance_id, step_id):
if action == 'approve': if action == 'approve':
# enforce zero remaining # enforce zero remaining
invoice.calculate_totals() invoice.calculate_totals()
if invoice.remaining_amount != 0: if invoice.get_remaining_amount() != 0:
messages.error(request, f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})") messages.error(request, f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.get_remaining_amount()})")
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id) return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
StepApproval.objects.update_or_create( StepApproval.objects.create(
step_instance=step_instance, step_instance=step_instance,
role=matching_role, role=matching_role,
defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''} approved_by=request.user,
reason=''
) )
if step_instance.is_fully_approved(): if step_instance.is_fully_approved():
step_instance.status = 'completed' step_instance.status = 'completed'
@ -993,12 +1023,12 @@ def final_settlement_step(request, instance_id, step_id):
if not reason: if not reason:
messages.error(request, 'علت رد شدن را وارد کنید') messages.error(request, 'علت رد شدن را وارد کنید')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id) return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
StepApproval.objects.update_or_create( StepRejection.objects.create(
step_instance=step_instance, step_instance=step_instance,
role=matching_role, role=matching_role,
defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason} rejected_by=request.user,
) 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) # If current step is ahead of this step, reset it back to this step (align behavior with other steps)
try: try:
if instance.current_step and instance.current_step.order > step.order: if instance.current_step and instance.current_step.order > step.order:
@ -1173,8 +1203,8 @@ def add_final_payment(request, instance_id, step_id):
'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]),
'totals': { 'totals': {
'final_amount': str(invoice.final_amount), 'final_amount': str(invoice.final_amount),
'paid_amount': str(invoice.paid_amount), 'paid_amount': str(invoice.get_paid_amount()),
'remaining_amount': str(invoice.remaining_amount), 'remaining_amount': str(invoice.get_remaining_amount()),
} }
}) })
@ -1186,14 +1216,17 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
invoice = get_object_or_404(Invoice, process_instance=instance) invoice = get_object_or_404(Invoice, process_instance=instance)
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
# Only BROKER can delete final settlement payments # Only BROKER can delete final settlement payments
try: try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)): if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.BROKER)):
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
# Delete payment and recalculate invoice totals
payment.hard_delete() payment.hard_delete()
invoice.refresh_from_db() invoice.calculate_totals() # This is what was missing!
# On delete, return to awaiting approval # On delete, return to awaiting approval
try: try:
@ -1201,16 +1234,11 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
si.status = 'in_progress' si.status = 'in_progress'
si.completed_at = None si.completed_at = None
si.save() si.save()
try: # Clear approvals and rejections (like in quote_payment)
for appr in list(si.approvals.all()): for appr in list(si.approvals.all()):
appr.delete() appr.delete()
except Exception: for rej in list(si.rejections.all()):
pass rej.delete()
try:
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
except Exception: except Exception:
pass pass
@ -1244,7 +1272,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': { return JsonResponse({'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'totals': {
'final_amount': str(invoice.final_amount), 'final_amount': str(invoice.final_amount),
'paid_amount': str(invoice.paid_amount), 'paid_amount': str(invoice.get_paid_amount()),
'remaining_amount': str(invoice.remaining_amount), 'remaining_amount': str(invoice.get_remaining_amount()),
}}) }})

View file

@ -162,9 +162,9 @@ class StepInstanceAdmin(SimpleHistoryAdmin):
@admin.register(StepRejection) @admin.register(StepRejection)
class StepRejectionAdmin(SimpleHistoryAdmin): class StepRejectionAdmin(SimpleHistoryAdmin):
list_display = ['step_instance', 'rejected_by', 'reason_short', 'created_at', 'is_deleted'] list_display = ['step_instance', 'role', 'rejected_by', 'reason_short', 'created_at', 'is_deleted']
list_filter = ['rejected_by', 'created_at', 'step_instance__step__process'] list_filter = ['role', 'rejected_by', 'created_at', 'step_instance__step__process']
search_fields = ['step_instance__step__name', 'rejected_by__username', 'reason'] search_fields = ['step_instance__step__name', 'rejected_by__username', 'reason', 'role__name']
readonly_fields = ['created_at'] readonly_fields = ['created_at']
ordering = ['-created_at'] ordering = ['-created_at']
@ -182,6 +182,6 @@ class StepApproverRequirementAdmin(admin.ModelAdmin):
@admin.register(StepApproval) @admin.register(StepApproval)
class StepApprovalAdmin(admin.ModelAdmin): class StepApprovalAdmin(admin.ModelAdmin):
list_display = ("step_instance", "role", "decision", "approved_by", "created_at", "is_deleted") list_display = ("step_instance", "role", "approved_by", "created_at", "is_deleted")
list_filter = ("decision", "role", "step_instance__step__process") list_filter = ("role", "step_instance__step__process")
search_fields = ("step_instance__process_instance__code", "role__name", "approved_by__username") search_fields = ("step_instance__process_instance__code", "role__name", "approved_by__username")

View file

@ -0,0 +1,34 @@
# 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='نقش'),
),
]

View file

@ -0,0 +1,22 @@
# 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='توضیحات'),
),
]

View file

@ -378,7 +378,7 @@ class StepInstance(models.Model):
def get_latest_rejection(self): def get_latest_rejection(self):
"""دریافت آخرین رد شدن""" """دریافت آخرین رد شدن"""
return self.rejections.order_by('-created_at').first() return self.rejections.filter(is_deleted=False).order_by('-created_at').first()
# -------- Multi-role approval helpers -------- # -------- Multi-role approval helpers --------
def required_roles(self): def required_roles(self):
@ -386,8 +386,8 @@ class StepInstance(models.Model):
def approvals_by_role(self): def approvals_by_role(self):
decisions = {} decisions = {}
for a in self.approvals.select_related('role').order_by('created_at'): for a in self.approvals.filter(is_deleted=False).select_related('role').order_by('created_at'):
decisions[a.role_id] = a.decision decisions[a.role_id] = 'approved'
return decisions return decisions
def is_fully_approved(self) -> bool: def is_fully_approved(self) -> bool:
@ -409,6 +409,7 @@ class StepRejection(models.Model):
related_name='rejections', related_name='rejections',
verbose_name="نمونه مرحله" verbose_name="نمونه مرحله"
) )
role = models.ForeignKey(Role, on_delete=models.SET_NULL, blank=True, null=True, verbose_name="نقش")
rejected_by = models.ForeignKey( rejected_by = models.ForeignKey(
User, User,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -431,12 +432,13 @@ class StepRejection(models.Model):
ordering = ['-created_at'] ordering = ['-created_at']
def __str__(self): def __str__(self):
return f"رد شدن {self.step_instance} توسط {self.rejected_by.get_full_name()}" return f"رد شدن {self.step_instance} توسط {self.rejected_by.get_full_name()} ({self.role.name})"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""ذخیره با تغییر وضعیت مرحله""" """ذخیره با تغییر وضعیت مرحله"""
self.step_instance.status = 'rejected' if self.is_deleted == False:
self.step_instance.save() self.step_instance.status = 'rejected'
self.step_instance.save()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def hard_delete(self): def hard_delete(self):
@ -447,7 +449,6 @@ class StepRejection(models.Model):
self.save() self.save()
class StepApproverRequirement(models.Model): class StepApproverRequirement(models.Model):
"""Required approver roles for a step.""" """Required approver roles for a step."""
step = models.ForeignKey(ProcessStep, on_delete=models.CASCADE, related_name='approver_requirements', verbose_name="مرحله") step = models.ForeignKey(ProcessStep, on_delete=models.CASCADE, related_name='approver_requirements', verbose_name="مرحله")
@ -466,15 +467,13 @@ class StepApproverRequirement(models.Model):
class StepApproval(models.Model): class StepApproval(models.Model):
"""Approvals per role for a concrete step instance.""" """Approvals per role for a concrete step instance."""
step_instance = models.ForeignKey(StepInstance, on_delete=models.CASCADE, related_name='approvals', verbose_name="نمونه مرحله") step_instance = models.ForeignKey(StepInstance, on_delete=models.CASCADE, related_name='approvals', verbose_name="نمونه مرحله")
role = models.ForeignKey(Role, on_delete=models.CASCADE, verbose_name="نقش") role = models.ForeignKey(Role, on_delete=models.SET_NULL, blank=True, null=True, verbose_name="نقش")
approved_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="تاییدکننده") approved_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="تاییدکننده")
decision = models.CharField(max_length=8, choices=[('approved', 'تایید'), ('rejected', 'رد')], verbose_name='نتیجه') reason = models.TextField(blank=True, verbose_name='توضیحات')
reason = models.TextField(blank=True, verbose_name='علت (برای رد)')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ') created_at = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ')
is_deleted = models.BooleanField(default=False, verbose_name='حذف شده') is_deleted = models.BooleanField(default=False, verbose_name='حذف شده')
class Meta: class Meta:
unique_together = ('step_instance', 'role')
verbose_name = 'تایید مرحله' verbose_name = 'تایید مرحله'
verbose_name_plural = 'تاییدهای مرحله' verbose_name_plural = 'تاییدهای مرحله'
@ -487,4 +486,4 @@ class StepApproval(models.Model):
def __str__(self): def __str__(self):
return f"{self.step_instance} - {self.role} - {self.decision}" return f"{self.step_instance} - {self.role} - تایید شده"

View file

@ -37,9 +37,9 @@
<i class="bx bx-printer me-2"></i> پرینت فاکتور <i class="bx bx-printer me-2"></i> پرینت فاکتور
</a> </a>
{% endif %} {% endif %}
<a href="{% url 'certificates:certificate_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"> <button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#printHologramModal">
<i class="bx bx-printer me-2"></i> پرینت گواهی <i class="bx bx-printer me-2"></i> پرینت گواهی
</a> </button>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary"> <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i> <i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت بازگشت
@ -57,8 +57,8 @@
{% if invoice %} {% if invoice %}
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">مبلغ نهایی</div><div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div></div></div> <div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">مبلغ نهایی</div><div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div></div></div>
<div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">پرداختی‌ها</div><div class="h5 mt-1 text-success">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</div></div></div> <div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">پرداختی‌ها</div><div class="h5 mt-1 text-success">{{ invoice.get_paid_amount|floatformat:0|intcomma:False }} تومان</div></div></div>
<div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">مانده</div><div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div></div></div> <div class="col-6 col-md-3"><div class="border rounded p-3 h-100"><div class="small text-muted">مانده</div><div class="h5 mt-1 {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان</div></div></div>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped mb-0"> <table class="table table-striped mb-0">
@ -95,32 +95,113 @@
<div class="card border"> <div class="card border">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">گزارش نصب</h6> <h6 class="mb-0">گزارش نصب</h6>
{% if latest_report and latest_report.assignment and latest_report.assignment.installer %} <div class="d-flex align-items-center gap-3">
<span class="small text-muted">نصاب: {{ latest_report.assignment.installer.get_full_name|default:latest_report.assignment.installer.username }}</span> {% if installation_delay_days > 0 %}
{% endif %} <span class="badge bg-warning text-dark">
<i class="bx bx-time-five bx-xs"></i> {{ installation_delay_days }} روز تاخیر
</span>
{% elif installation_assignment and latest_report %}
<span class="badge bg-success">
<i class="bx bx-check bx-xs"></i> به موقع
</span>
{% endif %}
{% if latest_report and latest_report.assignment and latest_report.assignment.installer %}
<span class="small text-muted">نصاب: {{ latest_report.assignment.installer.get_full_name|default:latest_report.assignment.installer.username }}</span>
{% endif %}
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if latest_report %} {% if latest_report %}
<div class="row g-3"> <!-- اطلاعات گزارش نصب -->
<div class="col-12 col-md-6"> <div class="row g-3 mb-3">
<p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ latest_report.visited_date|to_jalali|default:'-' }}</p> <div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-calendar bx-sm me-2"></i>تاریخ مراجعه: {{ latest_report.visited_date|to_jalali|default:'-' }}</p>
</div>
{% if installation_assignment.scheduled_date %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-calendar-star bx-sm me-2"></i>تاریخ برنامه‌ریزی: {{ installation_assignment.scheduled_date|to_jalali }}</p>
</div>
{% endif %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-purchase-tag bx-sm me-2"></i>سریال کنتور جدید: {{ latest_report.new_water_meter_serial|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-purchase-tag bx-sm me-2"></i>سریال کنتور جدید: {{ latest_report.new_water_meter_serial|default:'-' }}</p>
</div>
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-lock-alt bx-sm me-2"></i>شماره پلمپ: {{ latest_report.seal_number|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-lock-alt bx-sm me-2"></i>شماره پلمپ: {{ latest_report.seal_number|default:'-' }}</p>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-help-circle bx-sm me-2"></i>کنتور مشکوک: {{ latest_report.is_meter_suspicious|yesno:'بله,خیر' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-help-circle bx-sm me-2"></i>کنتور مشکوک: {{ latest_report.is_meter_suspicious|yesno:'بله,خیر' }}</p>
</div>
{% if latest_report.sim_number %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-mobile bx-sm me-2"></i>شماره سیمکارت: {{ latest_report.sim_number }}</p>
</div>
{% endif %}
{% if latest_report.meter_type %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-category bx-sm me-2"></i>نوع کنتور: {{ latest_report.get_meter_type_display }}</p>
</div>
{% endif %}
{% if latest_report.meter_size %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-ruler bx-sm me-2"></i>سایز کنتور: {{ latest_report.meter_size }}</p>
</div>
{% endif %}
{% if latest_report.water_meter_manufacturer %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-buildings bx-sm me-2"></i>سازنده: {{ latest_report.water_meter_manufacturer.name }}</p>
</div>
{% endif %}
{% if latest_report.discharge_pipe_diameter %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-shape-circle bx-sm me-2"></i>قطر لوله آبده: {{ latest_report.discharge_pipe_diameter }} اینچ</p>
</div>
{% endif %}
{% if latest_report.usage_type %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-droplet bx-sm me-2"></i>نوع مصرف: {{ latest_report.get_usage_type_display }}</p>
</div>
{% endif %}
{% if latest_report.driving_force %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-car bx-sm me-2"></i>نیرو محرکه: {{ latest_report.driving_force }}</p>
</div>
{% endif %}
{% if latest_report.motor_power %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-tag bx-sm me-2"></i>قدرت موتور: {{ latest_report.motor_power }} کیلووات ساعت</p>
</div>
{% endif %}
{% if latest_report.exploitation_license_number %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-id-card bx-sm me-2"></i>شماره پروانه: {{ latest_report.exploitation_license_number }}</p>
</div>
{% endif %}
{% if latest_report.pre_calibration_flow_rate %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-water bx-sm me-2"></i>دبی قبل از کالیبراسیون: {{ latest_report.pre_calibration_flow_rate }} لیتر/ثانیه</p>
</div>
{% endif %}
{% if latest_report.post_calibration_flow_rate %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-tachometer bx-sm me-2"></i>دبی بعد از کالیبراسیون: {{ latest_report.post_calibration_flow_rate }} لیتر/ثانیه</p>
</div>
{% endif %}
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-map bx-sm me-2"></i>UTM X: {{ latest_report.utm_x|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-map bx-sm me-2"></i>UTM X: {{ latest_report.utm_x|default:'-' }}</p>
</div>
<div class="col-12 col-md-4">
<p class="text-nowrap mb-2"><i class="bx bx-map-pin bx-sm me-2"></i>UTM Y: {{ latest_report.utm_y|default:'-' }}</p> <p class="text-nowrap mb-2"><i class="bx bx-map-pin bx-sm me-2"></i>UTM Y: {{ latest_report.utm_y|default:'-' }}</p>
</div> </div>
</div> </div>
{% if latest_report.description %} {% if latest_report.description %}
<div class="mt-2"> <div class="mb-3">
<p class="mb-0"><i class="bx bx-text bx-sm me-2"></i><strong>توضیحات:</strong></p> <h6 class="text-primary mb-2"><i class="bx bx-text me-1"></i>توضیحات</h6>
<div class="text-muted">{{ latest_report.description }}</div> <div class="text-muted">{{ latest_report.description }}</div>
</div> </div>
{% endif %} {% endif %}
<hr>
<h6>عکس‌ها</h6> <h6 class="text-primary mb-2"><i class="bx bx-image me-1"></i>عکس‌ها</h6>
<div class="row"> <div class="row">
{% for p in latest_report.photos.all %} {% for p in latest_report.photos.all %}
<div class="col-6 col-md-3 mb-2"><img class="img-fluid rounded border" src="{{ p.image.url }}" alt="photo"></div> <div class="col-6 col-md-3 mb-2"><img class="img-fluid rounded border" src="{{ p.image.url }}" alt="photo"></div>
@ -175,6 +256,30 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Print Hologram Modal -->
<div class="modal fade" id="printHologramModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'certificates:certificate_print' instance.id %}" target="_blank">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">کد یکتا هولوگرام</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label class="form-label">کد هولوگرام</label>
<input type="text" class="form-control" name="hologram_code" value="{{ certificate.hologram_code|default:'' }}" placeholder="مثال: 123456" required>
<div class="form-text">این کد باید با کد هولوگرام روی گواهی یکسان باشد.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-primary">ثبت و پرینت</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -37,12 +37,14 @@
<div class="d-md-flex justify-content-between align-items-center dt-layout-end col-md-auto ms-auto mt-0"> <div class="d-md-flex justify-content-between align-items-center dt-layout-end col-md-auto ms-auto mt-0">
<div class="dt-buttons btn-group flex-wrap mb-0"> <div class="dt-buttons btn-group flex-wrap mb-0">
<div class="btn-group"> <div class="btn-group">
{% if not request.user|is_installer %}
<button class="btn btn-label-success me-2" type="button" onclick="exportToExcel()"> <button class="btn btn-label-success me-2" type="button" onclick="exportToExcel()">
<span class="d-flex align-items-center gap-2"> <span class="d-flex align-items-center gap-2">
<i class="bx bx-export me-sm-1"></i> <i class="bx bx-export me-sm-1"></i>
<span class="d-none d-sm-inline-block">خروجی اکسل</span> <span class="d-none d-sm-inline-block">خروجی اکسل</span>
</span> </span>
</button> </button>
{% endif %}
{% if request.user|is_broker %} {% if request.user|is_broker %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#requestModal"> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#requestModal">
<i class="bx bx-plus me-1"></i> <i class="bx bx-plus me-1"></i>

View file

@ -507,13 +507,22 @@ def instance_summary(request, instance_id):
# Collect final invoice, payments, and certificate if any # Collect final invoice, payments, and certificate if any
from invoices.models import Invoice from invoices.models import Invoice
from installations.models import InstallationReport from installations.models import InstallationReport, InstallationAssignment
from certificates.models import CertificateInstance from certificates.models import CertificateInstance
invoice = Invoice.objects.filter(process_instance=instance).first() invoice = Invoice.objects.filter(process_instance=instance).first()
payments = invoice.payments.filter(is_deleted=False).all() if invoice else [] payments = invoice.payments.filter(is_deleted=False).all() if invoice else []
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first() latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
certificate = CertificateInstance.objects.filter(process_instance=instance).order_by('-created').first() certificate = CertificateInstance.objects.filter(process_instance=instance).order_by('-created').first()
# Calculate installation delay
installation_assignment = InstallationAssignment.objects.filter(process_instance=instance).first()
installation_delay_days = 0
if installation_assignment and latest_report:
scheduled_date = installation_assignment.scheduled_date
visited_date = latest_report.visited_date
if scheduled_date and visited_date and visited_date > scheduled_date:
installation_delay_days = (visited_date - scheduled_date).days
# Build rows like final invoice step # Build rows like final invoice step
rows = [] rows = []
if invoice: if invoice:
@ -527,6 +536,8 @@ def instance_summary(request, instance_id):
'rows': rows, 'rows': rows,
'latest_report': latest_report, 'latest_report': latest_report,
'certificate': certificate, 'certificate': certificate,
'installation_assignment': installation_assignment,
'installation_delay_days': installation_delay_days,
}) })
@ -632,7 +643,7 @@ def export_requests_excel(request):
).select_related('process_instance') ).select_related('process_instance')
for invoice in invoices: for invoice in invoices:
if invoice.remaining_amount == 0: # Fully settled if invoice.get_remaining_amount() == 0: # Fully settled
# Find the last payment date for this invoice # Find the last payment date for this invoice
last_payment = Payment.objects.filter( last_payment = Payment.objects.filter(
invoice__process_instance=invoice.process_instance, invoice__process_instance=invoice.process_instance,
@ -641,24 +652,30 @@ def export_requests_excel(request):
if last_payment: if last_payment:
settlement_dates_map[invoice.process_instance_id] = last_payment.created settlement_dates_map[invoice.process_instance_id] = last_payment.created
# Get installation approval data # Get installation approval data by Water Resource Manager role
from processes.models import StepInstance, StepApproval from processes.models import StepInstance, StepApproval
from accounts.models import Role
from common.consts import UserRoles
# Get the Water Resource Manager role
water_manager_role = Role.objects.filter(slug=UserRoles.WATER_RESOURCE_MANAGER.value).first()
installation_steps = StepInstance.objects.filter( installation_steps = StepInstance.objects.filter(
process_instance_id__in=assignment_ids, process_instance_id__in=assignment_ids,
step__slug='installation_report', # Assuming this is the slug for installation step step__order=6, # Installation report step is order 6
status='completed' status='completed'
).select_related('process_instance') ).select_related('process_instance')
for step_instance in installation_steps: for step_instance in installation_steps:
# Get the approval that completed this step # Get the approval by Water Resource Manager role that completed this step
approval = StepApproval.objects.filter( approval = StepApproval.objects.filter(
step_instance=step_instance, step_instance=step_instance,
decision='approved', role=water_manager_role,
is_deleted=False is_deleted=False
).select_related('approved_by').order_by('-created').first() ).select_related('approved_by').order_by('-created_at').first()
if approval: if approval:
approval_dates_map[step_instance.process_instance_id] = approval.created approval_dates_map[step_instance.process_instance_id] = approval.created_at
approval_users_map[step_instance.process_instance_id] = approval.approved_by approval_users_map[step_instance.process_instance_id] = approval.approved_by
# Calculate progress and installation data # Calculate progress and installation data

View file

@ -35,6 +35,7 @@ id="layout-navbar">
<!-- /Language --> <!-- /Language -->
<!-- Quick links --> <!-- Quick links -->
<li class="nav-item dropdown-shortcuts navbar-dropdown dropdown me-2 me-xl-0 d-none"> <li class="nav-item dropdown-shortcuts navbar-dropdown dropdown me-2 me-xl-0 d-none">
<a class="nav-link dropdown-toggle hide-arrow" href="#" data-bs-toggle="dropdown" <a class="nav-link dropdown-toggle hide-arrow" href="#" data-bs-toggle="dropdown"
data-bs-auto-close="outside" aria-expanded="false"> data-bs-auto-close="outside" aria-expanded="false">
@ -144,6 +145,11 @@ id="layout-navbar">
</li> </li>
<!-- / Style Switcher--> <!-- / Style Switcher-->
<li class="nav-item align-items-center">
{% if request.user.profile %}
<p class="text-muted badge bg-label-primary m-0">{{ request.user.profile.roles_str }}</p>
{% endif %}
</li>
<!-- Notification --> <!-- Notification -->
<li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-1 d-none"> <li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-1 d-none">

View file

@ -83,10 +83,12 @@ class WellForm(forms.ModelForm):
'utm_x': forms.NumberInput(attrs={ 'utm_x': forms.NumberInput(attrs={
'class': 'form-control', 'class': 'form-control',
'placeholder': 'X UTM', 'placeholder': 'X UTM',
'required': 'required',
}), }),
'utm_y': forms.NumberInput(attrs={ 'utm_y': forms.NumberInput(attrs={
'class': 'form-control', 'class': 'form-control',
'placeholder': 'Y UTM', 'placeholder': 'Y UTM',
'required': 'required',
}), }),
'utm_zone': forms.NumberInput(attrs={ 'utm_zone': forms.NumberInput(attrs={
'class': 'form-control', 'class': 'form-control',

View file

@ -0,0 +1,35 @@
# Generated by Django 5.2.4 on 2025-10-02 09:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wells', '0004_remove_historicalwell_discharge_pipe_diameter_and_more'),
]
operations = [
migrations.AlterField(
model_name='historicalwell',
name='utm_x',
field=models.DecimalField(decimal_places=0, default=11, max_digits=10, verbose_name='X UTM'),
preserve_default=False,
),
migrations.AlterField(
model_name='historicalwell',
name='utm_y',
field=models.DecimalField(decimal_places=0, default=2, max_digits=10, verbose_name='Y UTM'),
preserve_default=False,
),
migrations.AlterField(
model_name='well',
name='utm_x',
field=models.DecimalField(decimal_places=0, max_digits=10, verbose_name='X UTM'),
),
migrations.AlterField(
model_name='well',
name='utm_y',
field=models.DecimalField(decimal_places=0, max_digits=10, verbose_name='Y UTM'),
),
]

View file

@ -80,15 +80,11 @@ class Well(SluggedModel):
max_digits=10, max_digits=10,
decimal_places=0, decimal_places=0,
verbose_name="X UTM", verbose_name="X UTM",
null=True,
blank=True
) )
utm_y = models.DecimalField( utm_y = models.DecimalField(
max_digits=10, max_digits=10,
decimal_places=0, decimal_places=0,
verbose_name="Y UTM", verbose_name="Y UTM",
null=True,
blank=True
) )
utm_zone = models.PositiveIntegerField( utm_zone = models.PositiveIntegerField(
verbose_name="زون UTM", verbose_name="زون UTM",