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 @@
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 installation_delay_days > 0 %} - - {{ installation_delay_days }} روز تاخیر - - {% elif installation_assignment and latest_report %} - - به موقع - - {% endif %} - {% if latest_report and latest_report.assignment and latest_report.assignment.installer %} - نصاب: {{ latest_report.assignment.installer.get_full_name|default:latest_report.assignment.installer.username }} - {% endif %} -
+ {% if latest_report and latest_report.assignment and latest_report.assignment.installer %} + نصاب: {{ latest_report.assignment.installer.get_full_name|default:latest_report.assignment.installer.username }} + {% endif %}
{% 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 %}
photo
@@ -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 @@
- {% if not request.user|is_installer %} - {% endif %} {% if request.user|is_broker %}