1420 lines
		
	
	
	
		
			62 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			1420 lines
		
	
	
	
		
			62 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
from django.shortcuts import render, get_object_or_404, redirect
 | 
						|
import logging
 | 
						|
from django.contrib.auth.decorators import login_required
 | 
						|
from django.contrib import messages
 | 
						|
from django.http import JsonResponse
 | 
						|
from django.views.decorators.http import require_POST
 | 
						|
from django.utils import timezone
 | 
						|
from django.conf import settings
 | 
						|
from django.urls import reverse
 | 
						|
from decimal import Decimal, InvalidOperation
 | 
						|
import json
 | 
						|
 | 
						|
from processes.models import ProcessInstance, ProcessStep, StepInstance, StepRejection, StepApproval
 | 
						|
from accounts.models import Role
 | 
						|
from common.consts import UserRoles
 | 
						|
from .models import Item, Quote, QuoteItem, Payment, Invoice, InvoiceItem
 | 
						|
from installations.models import InstallationReport, InstallationItemChange
 | 
						|
from processes.utils import get_scoped_instance_or_404
 | 
						|
 | 
						|
@login_required
 | 
						|
def quote_step(request, instance_id, step_id):
 | 
						|
    """مرحله انتخاب اقلام و ساخت پیشفاکتور"""
 | 
						|
    # Enforce scoped access to prevent URL tampering
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
 | 
						|
    # Enforce scoped access to prevent URL tampering
 | 
						|
    instance = get_object_or_404(
 | 
						|
        ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
 | 
						|
        id=instance_id
 | 
						|
    )
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
    
 | 
						|
    # بررسی دسترسی به مرحله
 | 
						|
    if not instance.can_access_step(step):
 | 
						|
        messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
 | 
						|
        return redirect('processes:request_list')
 | 
						|
    
 | 
						|
    # دریافت آیتمها
 | 
						|
    items = Item.objects.filter(is_active=True, is_special=False, is_deleted=False).order_by('name')
 | 
						|
    existing_quote = Quote.objects.filter(process_instance=instance).first()
 | 
						|
    existing_quote_items = {}
 | 
						|
    if existing_quote:
 | 
						|
        existing_quote_items = {qi.item_id: qi.quantity for qi in existing_quote.items.all()}
 | 
						|
    
 | 
						|
    step_instance = instance.step_instances.filter(step=step).first()
 | 
						|
    
 | 
						|
    # Navigation logic
 | 
						|
    previous_step = instance.process.steps.filter(order__lt=step.order).last()
 | 
						|
    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
						|
    
 | 
						|
    # determine if current user is broker
 | 
						|
    profile = getattr(request.user, 'profile', None)
 | 
						|
    is_broker = False
 | 
						|
    try:
 | 
						|
        is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
 | 
						|
    except Exception:
 | 
						|
        is_broker = False
 | 
						|
 | 
						|
    return render(request, 'invoices/quote_step.html', {
 | 
						|
        'instance': instance,
 | 
						|
        'step': step,
 | 
						|
        'step_instance': step_instance,
 | 
						|
        'items': items,
 | 
						|
        'existing_quote_items': existing_quote_items,
 | 
						|
        'existing_quote': existing_quote,
 | 
						|
        'previous_step': previous_step,
 | 
						|
        'next_step': next_step,
 | 
						|
        'is_broker': is_broker,
 | 
						|
    })
 | 
						|
 | 
						|
 | 
						|
@require_POST
 | 
						|
@login_required
 | 
						|
def create_quote(request, instance_id, step_id):
 | 
						|
    """ساخت/بروزرسانی پیشفاکتور از اقلام انتخابی"""
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
    # enforce permission: only BROKER can create/update quote
 | 
						|
    profile = getattr(request.user, 'profile', None)
 | 
						|
    try:
 | 
						|
        if not (profile and profile.has_role(UserRoles.BROKER)):
 | 
						|
            return JsonResponse({'success': False, 'message': 'شما مجوز ثبت/ویرایش پیشفاکتور را ندارید'})
 | 
						|
    except Exception:
 | 
						|
        return JsonResponse({'success': False, 'message': 'شما مجوز ثبت/ویرایش پیشفاکتور را ندارید'})
 | 
						|
    
 | 
						|
    try:
 | 
						|
        items_payload = json.loads(request.POST.get('items') or '[]')
 | 
						|
    except json.JSONDecodeError:
 | 
						|
        return JsonResponse({'success': False, 'message': 'دادههای اقلام نامعتبر است'})
 | 
						|
 | 
						|
    # اطمینان از حضور اقلام پیشفرض حتی اگر کلاینت ارسال نکرده باشد
 | 
						|
    payload_by_id = {}
 | 
						|
    for entry in items_payload:
 | 
						|
        try:
 | 
						|
            iid = int(entry.get('id'))
 | 
						|
            payload_by_id[iid] = int(entry.get('qty') or 1)
 | 
						|
        except Exception:
 | 
						|
            continue
 | 
						|
 | 
						|
    default_item_ids = set(Item.objects.filter(is_default_in_quotes=True, is_deleted=False, is_special=False).values_list('id', flat=True))
 | 
						|
    if default_item_ids:
 | 
						|
        for default_id in default_item_ids:
 | 
						|
            if default_id not in payload_by_id:
 | 
						|
                # مقدار پیش فرض را قرار بده
 | 
						|
                default_qty = Item.objects.filter(id=default_id).values_list('default_quantity', flat=True).first() or 1
 | 
						|
                payload_by_id[default_id] = int(default_qty)
 | 
						|
 | 
						|
    # بازسازی payload نهایی معتبر
 | 
						|
    items_payload = [{'id': iid, 'qty': qty} for iid, qty in payload_by_id.items() if qty and qty > 0]
 | 
						|
 | 
						|
    if not items_payload:
 | 
						|
        return JsonResponse({'success': False, 'message': 'هیچ آیتمی انتخاب نشده است'})
 | 
						|
 | 
						|
    # Create or reuse quote
 | 
						|
    quote, created_q = Quote.objects.get_or_create(
 | 
						|
        process_instance=instance,
 | 
						|
        defaults={
 | 
						|
            'name': f"پیشفاکتور {instance.code}",
 | 
						|
            'customer': instance.representative or request.user,
 | 
						|
            'valid_until': timezone.now().date(),
 | 
						|
            'created_by': request.user,
 | 
						|
        }
 | 
						|
    )
 | 
						|
 | 
						|
    # Track whether this step was already completed before this edit
 | 
						|
    step_instance_existing = instance.step_instances.filter(step=step).first()
 | 
						|
    was_already_completed = bool(step_instance_existing and step_instance_existing.status == 'completed')
 | 
						|
 | 
						|
    # Snapshot previous items before overwrite for change detection
 | 
						|
    previous_items_map = {}
 | 
						|
    if not created_q:
 | 
						|
        previous_items_map = {qi.item_id: int(qi.quantity) for qi in quote.items.filter(is_deleted=False).all()}
 | 
						|
 | 
						|
    # Replace quote items with submitted ones
 | 
						|
    quote.items.all().delete()
 | 
						|
    for entry in items_payload:
 | 
						|
        try:
 | 
						|
            item_id = int(entry.get('id'))
 | 
						|
            qty = int(entry.get('qty') or 1)
 | 
						|
        except (TypeError, ValueError):
 | 
						|
            continue
 | 
						|
        if qty <= 0:
 | 
						|
            continue
 | 
						|
        item = Item.objects.filter(id=item_id).first()
 | 
						|
        if not item:
 | 
						|
            continue
 | 
						|
        QuoteItem.objects.create(
 | 
						|
            quote=quote,
 | 
						|
            item=item,
 | 
						|
            quantity=qty,
 | 
						|
            unit_price=item.unit_price,
 | 
						|
            total_price=item.unit_price * qty,
 | 
						|
        )
 | 
						|
 | 
						|
    quote.calculate_totals()
 | 
						|
 | 
						|
    # Detect changes versus previous state and mark audit fields if editing after completion
 | 
						|
    try:
 | 
						|
        new_items_map = {int(entry.get('id')): int(entry.get('qty') or 1) for entry in items_payload}
 | 
						|
    except Exception:
 | 
						|
        new_items_map = {}
 | 
						|
 | 
						|
    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
						|
 | 
						|
    if was_already_completed and new_items_map != previous_items_map:
 | 
						|
        # StepInstance-level generic audit (for reuse across steps)
 | 
						|
        if step_instance_existing:
 | 
						|
            step_instance_existing.edited_after_completion = True
 | 
						|
            step_instance_existing.last_edited_at = timezone.now()
 | 
						|
            step_instance_existing.last_edited_by = request.user
 | 
						|
            step_instance_existing.edit_count = (step_instance_existing.edit_count or 0) + 1
 | 
						|
            step_instance_existing.completed_at = timezone.now()
 | 
						|
            step_instance_existing.save(update_fields=['edited_after_completion', 'last_edited_at', 'last_edited_by', 'edit_count', 'completed_at'])
 | 
						|
 | 
						|
        
 | 
						|
        if quote.status != 'draft':
 | 
						|
            quote.status = 'draft'
 | 
						|
            quote.save(update_fields=['status'])
 | 
						|
 | 
						|
        # Reset ALL subsequent completed steps to in_progress
 | 
						|
        subsequent_steps = instance.process.steps.filter(order__gt=step.order)
 | 
						|
        for subsequent_step in subsequent_steps:
 | 
						|
            subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
 | 
						|
            if subsequent_step_instance and subsequent_step_instance.status == 'completed':
 | 
						|
                # Bypass validation by using update() instead of save()
 | 
						|
                instance.step_instances.filter(step=subsequent_step).update(
 | 
						|
                    status='in_progress',
 | 
						|
                    completed_at=None
 | 
						|
                )
 | 
						|
                # Clear previous approvals if the step requires re-approval
 | 
						|
                try:
 | 
						|
                    subsequent_step_instance.approvals.all().delete()
 | 
						|
                except Exception:
 | 
						|
                    pass
 | 
						|
 | 
						|
        # Set current step to the next step
 | 
						|
        if next_step:
 | 
						|
            instance.current_step = next_step
 | 
						|
            instance.save(update_fields=['current_step'])
 | 
						|
    
 | 
						|
    # تکمیل مرحله
 | 
						|
    step_instance, created = StepInstance.objects.get_or_create(
 | 
						|
        process_instance=instance,
 | 
						|
        step=step
 | 
						|
    )
 | 
						|
    if not was_already_completed:
 | 
						|
        step_instance.status = 'completed'
 | 
						|
        step_instance.completed_at = timezone.now()
 | 
						|
        step_instance.save(update_fields=['status', 'completed_at'])
 | 
						|
    
 | 
						|
    # انتقال به مرحله بعدی
 | 
						|
    redirect_url = None
 | 
						|
    if next_step:
 | 
						|
        # Only advance current step if we are currently on this step to avoid regressions
 | 
						|
        if instance.current_step_id == step.id:
 | 
						|
            instance.current_step = next_step
 | 
						|
            instance.save(update_fields=['current_step'])
 | 
						|
        # هدایت مستقیم به مرحله پیشنمایش پیشفاکتور
 | 
						|
        redirect_url = reverse('invoices:quote_preview_step', args=[instance.id, next_step.id])
 | 
						|
 | 
						|
    return JsonResponse({'success': True, 'quote_id': quote.id, 'redirect': redirect_url})
 | 
						|
 | 
						|
 | 
						|
@login_required
 | 
						|
def quote_preview_step(request, instance_id, step_id):
 | 
						|
    """مرحله صدور پیشفاکتور - نمایش و تایید فاکتور"""
 | 
						|
    # Enforce scoped access to prevent URL tampering
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
 | 
						|
    instance = get_object_or_404(
 | 
						|
        ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile', 'broker', 'broker__company', 'broker__affairs', 'broker__affairs__county', 'broker__affairs__county__city'),
 | 
						|
        id=instance_id
 | 
						|
    )
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
    
 | 
						|
    # بررسی دسترسی به مرحله
 | 
						|
    if not instance.can_access_step(step):
 | 
						|
        messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
 | 
						|
        return redirect('processes:request_list')
 | 
						|
    
 | 
						|
    # دریافت پیشفاکتور
 | 
						|
    quote = get_object_or_404(Quote, process_instance=instance)
 | 
						|
    
 | 
						|
    step_instance = instance.step_instances.filter(step=step).first()
 | 
						|
    
 | 
						|
    # Navigation logic
 | 
						|
    previous_step = instance.process.steps.filter(order__lt=step.order).last()
 | 
						|
    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
						|
    
 | 
						|
    # determine if current user is broker for UI controls
 | 
						|
    profile = getattr(request.user, 'profile', None)
 | 
						|
    is_broker = False
 | 
						|
    try:
 | 
						|
        is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
 | 
						|
    except Exception:
 | 
						|
        is_broker = False
 | 
						|
 | 
						|
    return render(request, 'invoices/quote_preview_step.html', {
 | 
						|
        'instance': instance,
 | 
						|
        'step': step,
 | 
						|
        'step_instance': step_instance,
 | 
						|
        'quote': quote,
 | 
						|
        'previous_step': previous_step,
 | 
						|
        'next_step': next_step,
 | 
						|
        'is_broker': is_broker,
 | 
						|
    })
 | 
						|
 | 
						|
 | 
						|
@login_required
 | 
						|
def quote_print(request, instance_id):
 | 
						|
    """صفحه پرینت پیشفاکتور"""
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
    quote = get_object_or_404(Quote, process_instance=instance)
 | 
						|
    
 | 
						|
    return render(request, 'invoices/quote_print.html', {
 | 
						|
        'instance': instance,
 | 
						|
        'quote': quote,
 | 
						|
    })
 | 
						|
 | 
						|
 | 
						|
@require_POST
 | 
						|
@login_required  
 | 
						|
def approve_quote(request, instance_id, step_id):
 | 
						|
    """تایید پیشفاکتور و انتقال به مرحله بعدی"""
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
    quote = get_object_or_404(Quote, process_instance=instance)
 | 
						|
    # enforce permission: only BROKER can approve
 | 
						|
    profile = getattr(request.user, 'profile', None)
 | 
						|
    try:
 | 
						|
        if not (profile and profile.has_role(UserRoles.BROKER)):
 | 
						|
            return JsonResponse({'success': False, 'message': 'شما مجوز تایید پیشفاکتور را ندارید'})
 | 
						|
    except Exception:
 | 
						|
        return JsonResponse({'success': False, 'message': 'شما مجوز تایید پیشفاکتور را ندارید'})
 | 
						|
    
 | 
						|
    # تایید پیشفاکتور
 | 
						|
    quote.status = 'sent'
 | 
						|
    quote.save()
 | 
						|
    
 | 
						|
    # تکمیل مرحله
 | 
						|
    step_instance, created = StepInstance.objects.get_or_create(
 | 
						|
        process_instance=instance,
 | 
						|
        step=step
 | 
						|
    )
 | 
						|
    step_instance.status = 'completed'
 | 
						|
    step_instance.completed_at = timezone.now()
 | 
						|
    step_instance.save()
 | 
						|
    
 | 
						|
    # انتقال به مرحله بعدی
 | 
						|
    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
						|
    redirect_url = None
 | 
						|
    if next_step:
 | 
						|
        instance.current_step = next_step
 | 
						|
        instance.save()
 | 
						|
        redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
 | 
						|
    else:
 | 
						|
        # در صورت نبود مرحله بعدی، بازگشت به لیست درخواستها
 | 
						|
        redirect_url = reverse('processes:request_list')
 | 
						|
 | 
						|
    messages.success(request, 'پیشفاکتور با موفقیت تایید شد.')
 | 
						|
    return JsonResponse({'success': True, 'message': 'پیشفاکتور با موفقیت تایید شد.', 'redirect': redirect_url})
 | 
						|
 | 
						|
 | 
						|
@login_required
 | 
						|
def quote_payment_step(request, instance_id, step_id):
 | 
						|
    """مرحله سوم: ثبت فیشهای واریزی پیشفاکتور"""
 | 
						|
    # Enforce scoped access to prevent URL tampering
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
 | 
						|
    instance = get_object_or_404(
 | 
						|
        ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
 | 
						|
        id=instance_id
 | 
						|
    )
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
 | 
						|
    # بررسی دسترسی
 | 
						|
    if not instance.can_access_step(step):
 | 
						|
        messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
 | 
						|
        return redirect('processes:request_list')
 | 
						|
 | 
						|
    quote = get_object_or_404(Quote, process_instance=instance)
 | 
						|
    invoice = Invoice.objects.filter(quote=quote).first()
 | 
						|
    payments = invoice.payments.select_related('created_by').filter(is_deleted=False).all() if invoice else []
 | 
						|
 | 
						|
    previous_step = instance.process.steps.filter(order__lt=step.order).last()
 | 
						|
    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
						|
 | 
						|
    totals = {
 | 
						|
        'final_amount': quote.final_amount,
 | 
						|
        'paid_amount': quote.get_paid_amount(),
 | 
						|
        'remaining_amount': quote.get_remaining_amount(),
 | 
						|
        'is_fully_paid': quote.get_remaining_amount() <= 0,
 | 
						|
    }
 | 
						|
 | 
						|
    step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
 | 
						|
 | 
						|
    reqs = list(step.approver_requirements.select_related('role').all())
 | 
						|
    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,
 | 
						|
        })
 | 
						|
 | 
						|
    # dynamic permission: who can approve/reject this step (based on requirements)
 | 
						|
    try:
 | 
						|
        req_role_ids = {r.role_id for r in reqs}
 | 
						|
        user_role_ids = {ur.id for ur in user_roles}
 | 
						|
        can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0
 | 
						|
    except Exception:
 | 
						|
        can_approve_reject = False
 | 
						|
 | 
						|
    # 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()
 | 
						|
        current_user_has_decided = bool(user_has_approval or user_has_rejection)
 | 
						|
    except Exception:
 | 
						|
        current_user_has_decided = False
 | 
						|
        
 | 
						|
 | 
						|
    # Accountant/Admin approval and rejection via POST (multi-role)
 | 
						|
    if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
 | 
						|
        # match user's role against step required approver roles
 | 
						|
        req_roles = [req.role for req in step.approver_requirements.select_related('role').all()]
 | 
						|
        user_roles = list(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).all())
 | 
						|
        matching_role = next((r for r in user_roles if r in req_roles), None)
 | 
						|
        if matching_role is None:
 | 
						|
            messages.error(request, 'شما دسترسی لازم برای تایید/رد این مرحله را ندارید.')
 | 
						|
            return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
 | 
						|
 | 
						|
        action = request.POST.get('action')
 | 
						|
        if action == 'approve':
 | 
						|
            StepApproval.objects.create(
 | 
						|
                step_instance=step_instance,
 | 
						|
                role=matching_role,
 | 
						|
                approved_by=request.user,
 | 
						|
                reason=''
 | 
						|
            )
 | 
						|
            if step_instance.is_fully_approved():
 | 
						|
                step_instance.status = 'completed'
 | 
						|
                step_instance.completed_at = timezone.now()
 | 
						|
                step_instance.save()
 | 
						|
                
 | 
						|
                # Auto-complete next step if it exists
 | 
						|
                if next_step:
 | 
						|
                    next_step_instance, _ = StepInstance.objects.get_or_create(
 | 
						|
                        process_instance=instance,
 | 
						|
                        step=next_step,
 | 
						|
                        defaults={'status': 'in_progress'}
 | 
						|
                    )
 | 
						|
                    next_step_instance.status = 'completed'
 | 
						|
                    next_step_instance.completed_at = timezone.now()
 | 
						|
                    next_step_instance.save()
 | 
						|
                    
 | 
						|
                    # Move to the step after next
 | 
						|
                    step_after_next = instance.process.steps.filter(order__gt=next_step.order).first()
 | 
						|
                    if step_after_next:
 | 
						|
                        instance.current_step = step_after_next
 | 
						|
                        instance.save()
 | 
						|
                        return redirect('processes:step_detail', instance_id=instance.id, step_id=step_after_next.id)
 | 
						|
                    else:
 | 
						|
                        # No more steps, go to request list
 | 
						|
                        return redirect('processes:request_list')
 | 
						|
                
 | 
						|
                return redirect('processes:request_list')
 | 
						|
            messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقشها.')
 | 
						|
            return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
 | 
						|
 | 
						|
        if action == 'reject':
 | 
						|
            reason = (request.POST.get('reject_reason') or '').strip()
 | 
						|
            if not reason:
 | 
						|
                messages.error(request, 'علت رد شدن را وارد کنید')
 | 
						|
                return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
 | 
						|
            StepRejection.objects.create(
 | 
						|
                step_instance=step_instance,
 | 
						|
                role=matching_role,
 | 
						|
                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:
 | 
						|
                    instance.current_step = step
 | 
						|
                    instance.save(update_fields=['current_step'])
 | 
						|
            except Exception:
 | 
						|
                pass
 | 
						|
            messages.success(request, 'مرحله پرداختها رد شد و برای اصلاح بازگشت.')
 | 
						|
            return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
 | 
						|
 | 
						|
    # role flags for permissions (legacy flags kept for compatibility)
 | 
						|
    profile = getattr(request.user, 'profile', None)
 | 
						|
    is_broker = False
 | 
						|
    is_accountant = False
 | 
						|
    try:
 | 
						|
        is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
 | 
						|
        is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT))
 | 
						|
    except Exception:
 | 
						|
        is_broker = False
 | 
						|
        is_accountant = False
 | 
						|
 | 
						|
    return render(request, 'invoices/quote_payment_step.html', {
 | 
						|
        'instance': instance,
 | 
						|
        'step': step,
 | 
						|
        'step_instance': step_instance,
 | 
						|
        'quote': quote,
 | 
						|
        'payments': payments,
 | 
						|
        'totals': totals,
 | 
						|
        'previous_step': previous_step,
 | 
						|
        'next_step': next_step,
 | 
						|
        'approver_statuses': approver_statuses,
 | 
						|
        'is_broker': is_broker,
 | 
						|
        'is_accountant': is_accountant,
 | 
						|
        'can_approve_reject': can_approve_reject,
 | 
						|
        'current_user_has_decided': current_user_has_decided,
 | 
						|
    })
 | 
						|
 | 
						|
 | 
						|
@require_POST
 | 
						|
@login_required
 | 
						|
def add_quote_payment(request, instance_id, step_id):
 | 
						|
    """افزودن فیش واریزی جدید برای پیشفاکتور"""
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
    quote = get_object_or_404(Quote, process_instance=instance)
 | 
						|
    invoice, _ = Invoice.objects.get_or_create(
 | 
						|
        process_instance=instance,
 | 
						|
        quote=quote,
 | 
						|
        defaults={
 | 
						|
            'name': f"Invoice {quote.name}",
 | 
						|
            'customer': quote.customer,
 | 
						|
            'due_date': timezone.now().date(),
 | 
						|
            'created_by': request.user,
 | 
						|
        }
 | 
						|
    )
 | 
						|
 | 
						|
    # who can add payments
 | 
						|
    profile = getattr(request.user, 'profile', None)
 | 
						|
    is_broker = False
 | 
						|
    is_accountant = False
 | 
						|
    try:
 | 
						|
        is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
 | 
						|
        is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT))
 | 
						|
    except Exception:
 | 
						|
        is_broker = False
 | 
						|
        is_accountant = False
 | 
						|
        return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
 | 
						|
 | 
						|
    logger = logging.getLogger(__name__)
 | 
						|
    try:
 | 
						|
        amount = (request.POST.get('amount') or '').strip()
 | 
						|
        payment_date = (request.POST.get('payment_date') or '').strip()
 | 
						|
        payment_method = (request.POST.get('payment_method') or '').strip()
 | 
						|
        reference_number = (request.POST.get('reference_number') or '').strip()
 | 
						|
        notes = (request.POST.get('notes') or '').strip()
 | 
						|
        receipt_image = request.FILES.get('receipt_image')
 | 
						|
        # Server-side validation for required fields
 | 
						|
        if not amount:
 | 
						|
            return JsonResponse({'success': False, 'message': 'مبلغ را وارد کنید'})
 | 
						|
        if not payment_date:
 | 
						|
            return JsonResponse({'success': False, 'message': 'تاریخ پرداخت را وارد کنید'})
 | 
						|
        if not payment_method:
 | 
						|
            return JsonResponse({'success': False, 'message': 'روش پرداخت را انتخاب کنید'})
 | 
						|
        if not reference_number:
 | 
						|
            return JsonResponse({'success': False, 'message': 'شماره مرجع را وارد کنید'})
 | 
						|
        if not receipt_image:
 | 
						|
            return JsonResponse({'success': False, 'message': 'تصویر فیش را بارگذاری کنید'})
 | 
						|
        # Normalize date to YYYY-MM-DD (accept YYYY/MM/DD from Persian datepicker)
 | 
						|
        if '/' in payment_date:
 | 
						|
            payment_date = payment_date.replace('/', '-')
 | 
						|
 | 
						|
        # Prevent overpayment
 | 
						|
        try:
 | 
						|
            amount_dec = Decimal(amount)
 | 
						|
        except InvalidOperation:
 | 
						|
            return JsonResponse({'success': False, 'message': 'مبلغ نامعتبر است'})
 | 
						|
        remaining = quote.get_remaining_amount()
 | 
						|
        if amount_dec > remaining:
 | 
						|
            return JsonResponse({'success': False, 'message': 'مبلغ فیش بیشتر از مانده پیشفاکتور است'})
 | 
						|
 | 
						|
        Payment.objects.create(
 | 
						|
            invoice=invoice,
 | 
						|
            amount=amount_dec,
 | 
						|
            payment_date=payment_date,
 | 
						|
            payment_method=payment_method,
 | 
						|
            reference_number=reference_number,
 | 
						|
            receipt_image=receipt_image,
 | 
						|
            notes=notes,
 | 
						|
            created_by=request.user,
 | 
						|
        )
 | 
						|
    except Exception as e:
 | 
						|
        logger.exception('Error adding quote payment (instance=%s, step=%s)', instance_id, step_id)
 | 
						|
        return JsonResponse({'success': False, 'message': 'خطا در ثبت فیش', 'error': str(e)})
 | 
						|
 | 
						|
    # After modifying payments, set step back to in_progress (awaiting approval)
 | 
						|
    try:
 | 
						|
        si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
 | 
						|
        si.status = 'in_progress'
 | 
						|
        si.completed_at = None
 | 
						|
        si.save()
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    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
 | 
						|
    
 | 
						|
    # Reset ALL subsequent completed steps to in_progress
 | 
						|
    try:
 | 
						|
        subsequent_steps = instance.process.steps.filter(order__gt=step.order)
 | 
						|
        for subsequent_step in subsequent_steps:
 | 
						|
            subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
 | 
						|
            if subsequent_step_instance and subsequent_step_instance.status == 'completed':
 | 
						|
                # Bypass validation by using update() instead of save()
 | 
						|
                instance.step_instances.filter(step=subsequent_step).update(
 | 
						|
                    status='in_progress',
 | 
						|
                    completed_at=None
 | 
						|
                )
 | 
						|
                # Clear previous approvals if the step requires re-approval
 | 
						|
                try:
 | 
						|
                    for appr in list(subsequent_step_instance.approvals.all()):
 | 
						|
                        appr.delete()
 | 
						|
                except Exception:
 | 
						|
                    pass
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
    
 | 
						|
    # 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:
 | 
						|
            instance.current_step = step
 | 
						|
            instance.save(update_fields=['current_step'])
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
    redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
 | 
						|
    return JsonResponse({'success': True, 'redirect': redirect_url})
 | 
						|
 | 
						|
 | 
						|
@require_POST
 | 
						|
@login_required
 | 
						|
def delete_quote_payment(request, instance_id, step_id, payment_id):
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
    quote = get_object_or_404(Quote, process_instance=instance)
 | 
						|
    invoice = Invoice.objects.filter(quote=quote).first()
 | 
						|
    if not invoice:
 | 
						|
        return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
 | 
						|
    payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
 | 
						|
    
 | 
						|
    # who can delete payments
 | 
						|
    profile = getattr(request.user, 'profile', None)
 | 
						|
    is_broker = False
 | 
						|
    is_accountant = False
 | 
						|
    try:
 | 
						|
        is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
 | 
						|
        is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT))
 | 
						|
    except Exception:
 | 
						|
        is_broker = False
 | 
						|
        is_accountant = False
 | 
						|
        return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
 | 
						|
 | 
						|
    try:
 | 
						|
        # soft delete using project's BaseModel delete override
 | 
						|
        payment.hard_delete()
 | 
						|
    except Exception:
 | 
						|
        return JsonResponse({'success': False, 'message': 'خطا در حذف فیش'})
 | 
						|
    # On delete, return to awaiting approval
 | 
						|
    try:
 | 
						|
        si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
 | 
						|
        si.status = 'in_progress'
 | 
						|
        si.completed_at = None
 | 
						|
        si.save()
 | 
						|
        for appr in list(si.approvals.all()):
 | 
						|
            appr.delete()
 | 
						|
        for rej in list(si.rejections.all()):
 | 
						|
            rej.delete()
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    # Reset ALL subsequent completed steps to in_progress
 | 
						|
    try:
 | 
						|
        subsequent_steps = instance.process.steps.filter(order__gt=step.order)
 | 
						|
        for subsequent_step in subsequent_steps:
 | 
						|
            subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
 | 
						|
            if subsequent_step_instance and subsequent_step_instance.status == 'completed':
 | 
						|
                # Bypass validation by using update() instead of save()
 | 
						|
                instance.step_instances.filter(step=subsequent_step).update(
 | 
						|
                    status='in_progress',
 | 
						|
                    completed_at=None
 | 
						|
                )
 | 
						|
                # Clear previous approvals if the step requires re-approval
 | 
						|
                try:
 | 
						|
                    for appr in list(subsequent_step_instance.approvals.all()):
 | 
						|
                        appr.delete()
 | 
						|
                except Exception:
 | 
						|
                    pass
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    # 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:
 | 
						|
            instance.current_step = step
 | 
						|
            instance.save(update_fields=['current_step'])
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
    redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
 | 
						|
    return JsonResponse({'success': True, 'redirect': redirect_url})
 | 
						|
 | 
						|
 | 
						|
@login_required
 | 
						|
def final_invoice_step(request, instance_id, step_id):
 | 
						|
    """تجمیع اقلام پیشفاکتور با تغییرات نصب و صدور فاکتور نهایی"""
 | 
						|
    # Enforce scoped access to prevent URL tampering
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
 | 
						|
    instance = get_object_or_404(
 | 
						|
        ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
 | 
						|
        id=instance_id
 | 
						|
    )
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
 | 
						|
    if not instance.can_access_step(step):
 | 
						|
        messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
 | 
						|
        return redirect('processes:request_list')
 | 
						|
 | 
						|
    quote = get_object_or_404(Quote, process_instance=instance)
 | 
						|
 | 
						|
    # Helper to make safe Decimal from various inputs (handles commas/persian digits)
 | 
						|
    def _to_decimal(value):
 | 
						|
        if isinstance(value, Decimal):
 | 
						|
            return value
 | 
						|
        try:
 | 
						|
            if isinstance(value, (int, float)):
 | 
						|
                return Decimal(str(value))
 | 
						|
            s = str(value or '').strip()
 | 
						|
            if not s:
 | 
						|
                return Decimal('0')
 | 
						|
            # normalize commas and Persian digits
 | 
						|
            persian = '۰۱۲۳۴۵۶۷۸۹'
 | 
						|
            latin = '0123456789'
 | 
						|
            tbl = str.maketrans({persian[i]: latin[i] for i in range(10)})
 | 
						|
            s = s.translate(tbl).replace(',', '')
 | 
						|
            return Decimal(s)
 | 
						|
        except Exception:
 | 
						|
            return Decimal('0')
 | 
						|
 | 
						|
    # Build initial map from quote
 | 
						|
    item_id_to_row = {}
 | 
						|
    for qi in quote.items.all():
 | 
						|
        item_id_to_row[qi.item_id] = {
 | 
						|
            'item': qi.item,
 | 
						|
            'base_qty': qi.quantity,
 | 
						|
            'base_price': _to_decimal(qi.unit_price),
 | 
						|
            'added_qty': 0,
 | 
						|
            'removed_qty': 0,
 | 
						|
        }
 | 
						|
 | 
						|
    # Read installation changes from latest report (if any)
 | 
						|
    latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
 | 
						|
    if latest_report:
 | 
						|
        for ch in latest_report.item_changes.all():
 | 
						|
            row = item_id_to_row.setdefault(ch.item_id, {
 | 
						|
                'item': ch.item,
 | 
						|
                'base_qty': 0,
 | 
						|
                'base_price': _to_decimal(ch.unit_price or ch.item.unit_price),
 | 
						|
                'added_qty': 0,
 | 
						|
                'removed_qty': 0,
 | 
						|
            })
 | 
						|
            if ch.change_type == 'add':
 | 
						|
                row['added_qty'] += ch.quantity
 | 
						|
                if ch.unit_price:
 | 
						|
                    row['base_price'] = _to_decimal(ch.unit_price)
 | 
						|
            else:
 | 
						|
                row['removed_qty'] += ch.quantity
 | 
						|
                if ch.unit_price:
 | 
						|
                    row['base_price'] = _to_decimal(ch.unit_price)
 | 
						|
 | 
						|
    # Compute final invoice lines (include fully removed items for display)
 | 
						|
    rows = []
 | 
						|
    total_amount = Decimal('0')
 | 
						|
    for _, r in item_id_to_row.items():
 | 
						|
        final_qty = max(0, (r['base_qty'] + r['added_qty'] - r['removed_qty']))
 | 
						|
        unit_price_dec = _to_decimal(r['base_price'])
 | 
						|
        line_total = Decimal(final_qty) * unit_price_dec if final_qty > 0 else Decimal('0')
 | 
						|
        if final_qty > 0:
 | 
						|
            total_amount += line_total
 | 
						|
        rows.append({
 | 
						|
            'item': r['item'],
 | 
						|
            'quantity': final_qty,
 | 
						|
            'unit_price': unit_price_dec,
 | 
						|
            'total_price': line_total,
 | 
						|
            'base_qty': r['base_qty'],
 | 
						|
            'added_qty': r['added_qty'],
 | 
						|
            'removed_qty': r['removed_qty'],
 | 
						|
            'is_removed': True if final_qty == 0 else False,
 | 
						|
        })
 | 
						|
 | 
						|
    # Create or reuse final invoice
 | 
						|
    invoice, _ = Invoice.objects.get_or_create(
 | 
						|
        process_instance=instance,
 | 
						|
        customer=quote.customer,
 | 
						|
        quote=quote,
 | 
						|
        defaults={
 | 
						|
            'name': f"فاکتور نهایی {instance.code}",
 | 
						|
            'due_date': timezone.now().date(),
 | 
						|
            'created_by': request.user,
 | 
						|
        }
 | 
						|
    )
 | 
						|
    # Replace only non-special items (preserve special charges added by user)
 | 
						|
    qs = invoice.items.select_related('item').filter(item__is_special=False)
 | 
						|
    try:
 | 
						|
        qs._raw_delete(qs.db)
 | 
						|
    except Exception:
 | 
						|
        qs.delete()
 | 
						|
    for r in rows:
 | 
						|
        if r['quantity'] <= 0:
 | 
						|
            continue
 | 
						|
        from .models import InvoiceItem
 | 
						|
        InvoiceItem.objects.create(
 | 
						|
            invoice=invoice,
 | 
						|
            item=r['item'],
 | 
						|
            quantity=r['quantity'],
 | 
						|
            unit_price=r['unit_price'],
 | 
						|
        )
 | 
						|
    invoice.calculate_totals()
 | 
						|
 | 
						|
    previous_step = instance.process.steps.filter(order__lt=step.order).last()
 | 
						|
    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
						|
 | 
						|
    # Choices for special items from DB
 | 
						|
    special_choices = list(Item.objects.filter(is_special=True).values('id', 'name'))
 | 
						|
 | 
						|
    # role flag for manager-only actions
 | 
						|
    profile = getattr(request.user, 'profile', None)
 | 
						|
    is_manager = False
 | 
						|
    try:
 | 
						|
        is_manager = bool(profile and profile.has_role(UserRoles.MANAGER))
 | 
						|
    except Exception:
 | 
						|
        is_manager = False
 | 
						|
 | 
						|
    return render(request, 'invoices/final_invoice_step.html', {
 | 
						|
        'instance': instance,
 | 
						|
        'step': step,
 | 
						|
        'invoice': invoice,
 | 
						|
        'rows': rows,
 | 
						|
        'special_choices': special_choices,
 | 
						|
        'invoice_specials': invoice.items.select_related('item').filter(item__is_special=True, is_deleted=False).all(),
 | 
						|
        'previous_step': previous_step,
 | 
						|
        'next_step': next_step,
 | 
						|
        'is_manager': is_manager,
 | 
						|
    })
 | 
						|
 | 
						|
 | 
						|
@login_required
 | 
						|
def final_invoice_print(request, instance_id):
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
						|
    items = invoice.items.select_related('item').filter(is_deleted=False).all()
 | 
						|
    return render(request, 'invoices/final_invoice_print.html', {
 | 
						|
        'instance': instance,
 | 
						|
        'invoice': invoice,
 | 
						|
        'items': items,
 | 
						|
    })
 | 
						|
 | 
						|
 | 
						|
@require_POST
 | 
						|
@login_required
 | 
						|
def approve_final_invoice(request, instance_id, step_id):
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
						|
    # only MANAGER can approve
 | 
						|
    try:
 | 
						|
        if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
 | 
						|
            return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
 | 
						|
    except Exception:
 | 
						|
        return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
 | 
						|
 | 
						|
    step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
 | 
						|
    step_instance.status = 'completed'
 | 
						|
    step_instance.completed_at = timezone.now()
 | 
						|
    step_instance.save()
 | 
						|
    # move to next
 | 
						|
    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
						|
    redirect_url = reverse('processes:request_list')
 | 
						|
    if next_step:
 | 
						|
        instance.current_step = next_step
 | 
						|
        instance.save()
 | 
						|
        redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
 | 
						|
    return JsonResponse({'success': True, 'message': 'فاکتور نهایی تایید شد', 'redirect': redirect_url})
 | 
						|
 | 
						|
 | 
						|
@require_POST
 | 
						|
@login_required
 | 
						|
def add_special_charge(request, instance_id, step_id):
 | 
						|
    """افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی بهصورت آیتم جداگانه"""
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
 | 
						|
    # only MANAGER can add special charges
 | 
						|
    try:
 | 
						|
        if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
 | 
						|
            return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403)
 | 
						|
    except Exception:
 | 
						|
        return JsonResponse({'success': False, 'message': 'شما مجوز افزودن هزینه ویژه را ندارید'}, status=403)
 | 
						|
 | 
						|
    item_id = request.POST.get('item_id')
 | 
						|
    amount = (request.POST.get('amount') or '').strip()
 | 
						|
    if not item_id:
 | 
						|
        return JsonResponse({'success': False, 'message': 'آیتم را انتخاب کنید'})
 | 
						|
    try:
 | 
						|
        amount_dec = Decimal(amount)
 | 
						|
    except (InvalidOperation, TypeError):
 | 
						|
        return JsonResponse({'success': False, 'message': 'مبلغ نامعتبر است'})
 | 
						|
    if amount_dec <= 0:
 | 
						|
        return JsonResponse({'success': False, 'message': 'مبلغ باید مثبت باشد'})
 | 
						|
 | 
						|
    # Fetch existing special item from DB
 | 
						|
    special_item = get_object_or_404(Item, id=item_id, is_special=True)
 | 
						|
 | 
						|
    
 | 
						|
    InvoiceItem.objects.create(
 | 
						|
        invoice=invoice,
 | 
						|
        item=special_item,
 | 
						|
        quantity=1,
 | 
						|
        unit_price=amount_dec,
 | 
						|
    )
 | 
						|
    invoice.calculate_totals()
 | 
						|
 | 
						|
 | 
						|
    # After modifying payments, set step back to in_progress
 | 
						|
    try:
 | 
						|
        si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
 | 
						|
        si.status = 'in_progress'
 | 
						|
        si.completed_at = None
 | 
						|
        si.save()
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    # Reset ALL subsequent completed steps to in_progress
 | 
						|
    try:
 | 
						|
        subsequent_steps = instance.process.steps.filter(order__gt=step.order)
 | 
						|
        for subsequent_step in subsequent_steps:
 | 
						|
            subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
 | 
						|
            if subsequent_step_instance:
 | 
						|
                # Bypass validation by using update() instead of save()
 | 
						|
                instance.step_instances.filter(step=subsequent_step).update(
 | 
						|
                    status='in_progress',
 | 
						|
                    completed_at=None
 | 
						|
                )
 | 
						|
                 # Clear prior approvals/rejections as the underlying totals changed
 | 
						|
                try:
 | 
						|
                    for appr in list(subsequent_step_instance.approvals.all()):
 | 
						|
                        appr.delete()
 | 
						|
                except Exception:
 | 
						|
                    pass
 | 
						|
                try:
 | 
						|
                    for rej in list(subsequent_step_instance.rejections.all()):
 | 
						|
                        rej.delete()
 | 
						|
                except Exception:
 | 
						|
                    pass
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    # 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:
 | 
						|
            instance.current_step = step
 | 
						|
            instance.save(update_fields=['current_step'])
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
 | 
						|
 | 
						|
 | 
						|
@require_POST
 | 
						|
@login_required
 | 
						|
def delete_special_charge(request, instance_id, step_id, item_id):
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
 | 
						|
    # only MANAGER can delete special charges
 | 
						|
    try:
 | 
						|
        if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
 | 
						|
            return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403)
 | 
						|
    except Exception:
 | 
						|
        return JsonResponse({'success': False, 'message': 'شما مجوز حذف هزینه ویژه را ندارید'}, status=403)
 | 
						|
    inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice)
 | 
						|
    # allow deletion only for special items
 | 
						|
    try:
 | 
						|
        if not getattr(inv_item.item, 'is_special', False):
 | 
						|
            return JsonResponse({'success': False, 'message': 'امکان حذف این مورد وجود ندارد'})
 | 
						|
    except Exception:
 | 
						|
        return JsonResponse({'success': False, 'message': 'امکان حذف این مورد وجود ندارد'})
 | 
						|
    inv_item.hard_delete()
 | 
						|
    invoice.calculate_totals()
 | 
						|
    
 | 
						|
 | 
						|
    # After modifying payments, set step back to in_progress
 | 
						|
    try:
 | 
						|
        si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
 | 
						|
        si.status = 'in_progress'
 | 
						|
        si.completed_at = None
 | 
						|
        si.save()
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    # Reset ALL subsequent completed steps to in_progress
 | 
						|
    try:
 | 
						|
        subsequent_steps = instance.process.steps.filter(order__gt=step.order)
 | 
						|
        for subsequent_step in subsequent_steps:
 | 
						|
            subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
 | 
						|
            if subsequent_step_instance:
 | 
						|
                # Bypass validation by using update() instead of save()
 | 
						|
                instance.step_instances.filter(step=subsequent_step).update(
 | 
						|
                    status='in_progress',
 | 
						|
                    completed_at=None
 | 
						|
                )
 | 
						|
                 # Clear prior approvals/rejections as the underlying totals changed
 | 
						|
                try:
 | 
						|
                    for appr in list(subsequent_step_instance.approvals.all()):
 | 
						|
                        appr.delete()
 | 
						|
                except Exception:
 | 
						|
                    pass
 | 
						|
                try:
 | 
						|
                    for rej in list(subsequent_step_instance.rejections.all()):
 | 
						|
                        rej.delete()
 | 
						|
                except Exception:
 | 
						|
                    pass
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    # 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:
 | 
						|
            instance.current_step = step
 | 
						|
            instance.save(update_fields=['current_step'])
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
 | 
						|
    return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
 | 
						|
 | 
						|
 | 
						|
@login_required
 | 
						|
def final_settlement_step(request, instance_id, step_id):
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
 | 
						|
    if not instance.can_access_step(step):
 | 
						|
        messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
 | 
						|
        return redirect('processes:request_list')
 | 
						|
    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
						|
 | 
						|
    previous_step = instance.process.steps.filter(order__lt=step.order).last()
 | 
						|
    next_step = instance.process.steps.filter(order__gt=step.order).first()
 | 
						|
 | 
						|
    # Ensure step instance exists
 | 
						|
    step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
 | 
						|
 | 
						|
    # Auto-complete step when invoice is fully settled (no approvals needed)
 | 
						|
    try:
 | 
						|
        invoice.calculate_totals()
 | 
						|
        if invoice.get_remaining_amount() == 0:
 | 
						|
            if step_instance.status != 'completed':
 | 
						|
                step_instance.status = 'completed'
 | 
						|
                step_instance.completed_at = timezone.now()
 | 
						|
                step_instance.save()
 | 
						|
            # if next_step:
 | 
						|
            #     instance.current_step = next_step
 | 
						|
            #     instance.save(update_fields=['current_step'])
 | 
						|
            #     return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
 | 
						|
            # return redirect('processes:request_list')
 | 
						|
    except Exception:
 | 
						|
        # If totals calculation fails, continue with normal flow
 | 
						|
        pass
 | 
						|
    
 | 
						|
    # 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_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({
 | 
						|
            'role': r.role,
 | 
						|
            'status': status,
 | 
						|
            'reason': reason,
 | 
						|
        })
 | 
						|
    # dynamic permission to control approve/reject UI
 | 
						|
    try:
 | 
						|
        user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none())
 | 
						|
        user_role_ids = set(user_roles_qs.values_list('id', flat=True))
 | 
						|
        req_role_ids = {r.role_id for r in reqs}
 | 
						|
        can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0
 | 
						|
    except Exception:
 | 
						|
        can_approve_reject = False
 | 
						|
 | 
						|
    # 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()
 | 
						|
        current_user_has_decided = bool(user_has_approval or user_has_rejection)
 | 
						|
    except Exception:
 | 
						|
        current_user_has_decided = False
 | 
						|
 | 
						|
    # Accountant/Admin approval and rejection (multi-role)
 | 
						|
    if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject', 'force_approve']:
 | 
						|
        req_roles = [req.role for req in step.approver_requirements.select_related('role').all()]
 | 
						|
        user_roles = list(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).all())
 | 
						|
        matching_role = next((r for r in user_roles if r in req_roles), None)
 | 
						|
        if matching_role is None and request.POST.get('action') != 'force_approve':
 | 
						|
            messages.error(request, 'شما دسترسی لازم برای تایید/رد این مرحله را ندارید.')
 | 
						|
            return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
 | 
						|
 | 
						|
        action = request.POST.get('action')
 | 
						|
        if action == 'approve':
 | 
						|
            # enforce zero remaining
 | 
						|
            invoice.calculate_totals()
 | 
						|
            if invoice.get_remaining_amount() != 0:
 | 
						|
                messages.error(request, f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.get_remaining_amount()})")
 | 
						|
                return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
 | 
						|
            StepApproval.objects.create(
 | 
						|
                step_instance=step_instance,
 | 
						|
                role=matching_role,
 | 
						|
                approved_by=request.user,
 | 
						|
                reason=''
 | 
						|
            )
 | 
						|
            if step_instance.is_fully_approved():
 | 
						|
                step_instance.status = 'completed'
 | 
						|
                step_instance.completed_at = timezone.now()
 | 
						|
                step_instance.save()
 | 
						|
                if next_step:
 | 
						|
                    instance.current_step = next_step
 | 
						|
                    instance.save()
 | 
						|
                    return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
 | 
						|
                return redirect('processes:request_list')
 | 
						|
            messages.success(request, 'تایید شما ثبت شد. منتظر تایید سایر نقشها.')
 | 
						|
            return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
 | 
						|
 | 
						|
        if action == 'reject':
 | 
						|
            reason = (request.POST.get('reject_reason') or '').strip()
 | 
						|
            if not reason:
 | 
						|
                messages.error(request, 'علت رد شدن را وارد کنید')
 | 
						|
                return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
 | 
						|
            StepRejection.objects.create(
 | 
						|
                step_instance=step_instance,
 | 
						|
                role=matching_role,
 | 
						|
                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:
 | 
						|
                    instance.current_step = step
 | 
						|
                    instance.save(update_fields=['current_step'])
 | 
						|
            except Exception:
 | 
						|
                pass
 | 
						|
            messages.success(request, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.')
 | 
						|
            return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
 | 
						|
 | 
						|
        if action == 'force_approve':
 | 
						|
            # Only MANAGER can force approve
 | 
						|
            try:
 | 
						|
                if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
 | 
						|
                    messages.error(request, 'فقط مدیر مجاز به تایید اضطراری است.')
 | 
						|
                    return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
 | 
						|
            except Exception:
 | 
						|
                messages.error(request, 'فقط مدیر مجاز به تایید اضطراری است.')
 | 
						|
                return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
 | 
						|
            # Allow emergency approval only when invoice has a remaining (non-zero)
 | 
						|
            try:
 | 
						|
                invoice.calculate_totals()
 | 
						|
                if invoice.get_remaining_amount() == 0:
 | 
						|
                    messages.error(request, 'فاکتور تسویه شده است؛ تایید اضطراری لازم نیست.')
 | 
						|
                    return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
 | 
						|
            except Exception:
 | 
						|
                pass
 | 
						|
            # Mark step completed regardless of remaining amount/approvals
 | 
						|
            step_instance.status = 'approved'
 | 
						|
            step_instance.save()
 | 
						|
            if next_step:
 | 
						|
                instance.current_step = next_step
 | 
						|
                instance.save()
 | 
						|
                return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
 | 
						|
            return redirect('processes:request_list')
 | 
						|
 | 
						|
    # broker flag for payment management permission
 | 
						|
    profile = getattr(request.user, 'profile', None)
 | 
						|
    is_broker = False
 | 
						|
    try:
 | 
						|
        is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
 | 
						|
    except Exception:
 | 
						|
        is_broker = False
 | 
						|
 | 
						|
    return render(request, 'invoices/final_settlement_step.html', {
 | 
						|
        'instance': instance,
 | 
						|
        'step': step,
 | 
						|
        'invoice': invoice,
 | 
						|
        'payments': invoice.payments.filter(is_deleted=False).all(),
 | 
						|
        'step_instance': step_instance,
 | 
						|
        'previous_step': previous_step,
 | 
						|
        'next_step': next_step,
 | 
						|
        'approver_statuses': approver_statuses,
 | 
						|
        'can_approve_reject': can_approve_reject,
 | 
						|
        'is_broker': is_broker,
 | 
						|
        'current_user_has_decided': current_user_has_decided,
 | 
						|
        'is_manager': bool(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).filter(slug=UserRoles.MANAGER.value).exists()) if getattr(request.user, 'profile', None) else False,
 | 
						|
    })
 | 
						|
 | 
						|
 | 
						|
@require_POST
 | 
						|
@login_required
 | 
						|
def add_final_payment(request, instance_id, step_id):
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_id)
 | 
						|
    step = get_object_or_404(instance.process.steps, id=step_id)
 | 
						|
    invoice = get_object_or_404(Invoice, process_instance=instance)
 | 
						|
    # Only BROKER can add 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)
 | 
						|
 | 
						|
    # Prevent adding payments if invoice already settled
 | 
						|
    try:
 | 
						|
        invoice.calculate_totals()
 | 
						|
        if invoice.get_remaining_amount() == 0:
 | 
						|
            return JsonResponse({'success': False, 'message': 'فاکتور تسویه شده است؛ افزودن تراکنش مجاز نیست'})
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    amount = (request.POST.get('amount') or '').strip()
 | 
						|
    payment_date = (request.POST.get('payment_date') or '').strip()
 | 
						|
    payment_method = (request.POST.get('payment_method') or '').strip()
 | 
						|
    reference_number = (request.POST.get('reference_number') or '').strip()
 | 
						|
    direction = (request.POST.get('direction') or 'in').strip()
 | 
						|
    receipt_image = request.FILES.get('receipt_image')
 | 
						|
    if not amount:
 | 
						|
        return JsonResponse({'success': False, 'message': 'مبلغ را وارد کنید'})
 | 
						|
    if not payment_date:
 | 
						|
        return JsonResponse({'success': False, 'message': 'تاریخ پرداخت را وارد کنید'})
 | 
						|
    if not payment_method:
 | 
						|
        return JsonResponse({'success': False, 'message': 'روش پرداخت را انتخاب کنید'})
 | 
						|
    if not reference_number:
 | 
						|
        return JsonResponse({'success': False, 'message': 'شماره مرجع را وارد کنید'})
 | 
						|
    if not receipt_image:
 | 
						|
        return JsonResponse({'success': False, 'message': 'تصویر فیش الزامی است'})
 | 
						|
    if '/' in payment_date:
 | 
						|
        payment_date = payment_date.replace('/', '-')
 | 
						|
    try:
 | 
						|
        amount_dec = Decimal(amount)
 | 
						|
    except InvalidOperation:
 | 
						|
        return JsonResponse({'success': False, 'message': 'مبلغ نامعتبر است'})
 | 
						|
    # Only allow outgoing (پرداخت به مشتری) when current net due is negative
 | 
						|
    # Compute net due explicitly from current items/payments
 | 
						|
    try:
 | 
						|
        current_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in invoice.payments.filter(is_deleted=False).all())
 | 
						|
    except Exception:
 | 
						|
        current_paid = Decimal('0')
 | 
						|
    # Ensure invoice totals are up-to-date for final_amount
 | 
						|
    invoice.calculate_totals()
 | 
						|
    net_due = invoice.final_amount - current_paid
 | 
						|
    if direction == 'out' and net_due >= 0:
 | 
						|
        return JsonResponse({'success': False, 'message': 'در حال حاضر مانده به نفع مشتری نیست'})
 | 
						|
 | 
						|
    # Amount constraints by sign of net due
 | 
						|
    if net_due > 0 and direction == 'in' and amount_dec > net_due:
 | 
						|
        return JsonResponse({'success': False, 'message': 'مبلغ فیش بیشتر از مانده فاکتور است'})
 | 
						|
    if net_due < 0 and direction == 'out' and amount_dec > abs(net_due):
 | 
						|
        return JsonResponse({'success': False, 'message': 'مبلغ فیش بیشتر از مانده بدهی شرکت به مشتری است'})
 | 
						|
    if net_due < 0 and direction == 'in':
 | 
						|
        return JsonResponse({'success': False, 'message': 'در حال حاضر مانده به نفع مشتری است؛ دریافت از مشتری مجاز نیست'})
 | 
						|
 | 
						|
    Payment.objects.create(
 | 
						|
        invoice=invoice,
 | 
						|
        amount=amount_dec,
 | 
						|
        payment_date=payment_date,
 | 
						|
        payment_method=payment_method,
 | 
						|
        reference_number=reference_number,
 | 
						|
        direction='in' if direction != 'out' else 'out',
 | 
						|
        receipt_image=receipt_image,
 | 
						|
        created_by=request.user,
 | 
						|
    )
 | 
						|
    # After creation, totals auto-updated by model save. Respond with redirect and new totals for UX.
 | 
						|
    invoice.refresh_from_db()
 | 
						|
 | 
						|
    # On delete, return to awaiting approval
 | 
						|
    try:
 | 
						|
        si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
 | 
						|
        if si.status != 'approved':
 | 
						|
            si.status = 'in_progress'
 | 
						|
        si.completed_at = None
 | 
						|
        si.save()
 | 
						|
        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
 | 
						|
    
 | 
						|
    # Reset ALL subsequent completed steps to in_progress
 | 
						|
    try:
 | 
						|
        subsequent_steps = instance.process.steps.filter(order__gt=step.order)
 | 
						|
        for subsequent_step in subsequent_steps:
 | 
						|
            subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
 | 
						|
            if subsequent_step_instance and subsequent_step_instance.status == 'completed':
 | 
						|
                # Bypass validation by using update() instead of save()
 | 
						|
                instance.step_instances.filter(step=subsequent_step).update(
 | 
						|
                    status='in_progress',
 | 
						|
                    completed_at=None
 | 
						|
                )
 | 
						|
                # Clear previous approvals if the step requires re-approval
 | 
						|
                try:
 | 
						|
                    for appr in list(subsequent_step_instance.approvals.all()):
 | 
						|
                        appr.delete()
 | 
						|
                except Exception:
 | 
						|
                    pass
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    # 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:
 | 
						|
            instance.current_step = step
 | 
						|
            instance.save(update_fields=['current_step'])
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
 | 
						|
    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()),
 | 
						|
        }
 | 
						|
    })
 | 
						|
 | 
						|
 | 
						|
@require_POST
 | 
						|
@login_required
 | 
						|
def delete_final_payment(request, instance_id, step_id, payment_id):
 | 
						|
    instance = get_scoped_instance_or_404(request, instance_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!
 | 
						|
 | 
						|
    # On delete, return to awaiting approval
 | 
						|
    try:
 | 
						|
        si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
 | 
						|
        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()
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    # Reset ALL subsequent completed steps to in_progress
 | 
						|
    try:
 | 
						|
        subsequent_steps = instance.process.steps.filter(order__gt=step.order)
 | 
						|
        for subsequent_step in subsequent_steps:
 | 
						|
            subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
 | 
						|
            if subsequent_step_instance and subsequent_step_instance.status == 'completed':
 | 
						|
                # Bypass validation by using update() instead of save()
 | 
						|
                instance.step_instances.filter(step=subsequent_step).update(
 | 
						|
                    status='in_progress',
 | 
						|
                    completed_at=None
 | 
						|
                )
 | 
						|
                # Clear previous approvals if the step requires re-approval
 | 
						|
                try:
 | 
						|
                    for appr in list(subsequent_step_instance.approvals.all()):
 | 
						|
                        appr.delete()
 | 
						|
                except Exception:
 | 
						|
                    pass
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    # 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:
 | 
						|
            instance.current_step = step
 | 
						|
            instance.save(update_fields=['current_step'])
 | 
						|
    except Exception:
 | 
						|
        pass
 | 
						|
 | 
						|
    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()),
 | 
						|
    }})
 | 
						|
 |