from django.shortcuts import render, get_object_or_404, redirect import logging from django.contrib.auth.decorators import login_required from django.contrib import messages from django.http import JsonResponse from django.views.decorators.http import require_POST from django.utils import timezone from django.urls import reverse from decimal import Decimal, InvalidOperation import json 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 from installations.models import InstallationReport, InstallationItemChange @login_required def quote_step(request, instance_id, step_id): """مرحله انتخاب اقلام و ساخت پیش‌فاکتور""" instance = get_object_or_404( ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'), id=instance_id ) step = get_object_or_404(instance.process.steps, id=step_id) # بررسی دسترسی به مرحله if not instance.can_access_step(step): messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.') return redirect('processes:request_list') # دریافت آیتم‌ها items = Item.objects.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_object_or_404(ProcessInstance, id=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']) if next_step: next_step_instance = instance.step_instances.filter(step=next_step).first() if next_step_instance and next_step_instance.status == 'completed': next_step_instance.status = 'in_progress' next_step_instance.completed_at = None next_step_instance.save(update_fields=['status', 'completed_at']) # Clear previous approvals if the step requires re-approval try: next_step_instance.approvals.all().delete() except Exception: pass 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): """مرحله صدور پیش‌فاکتور - نمایش و تایید فاکتور""" 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_object_or_404(ProcessInstance, id=instance_id) quote = get_object_or_404(Quote, process_instance=instance) return render(request, 'invoices/quote_print.html', { 'instance': instance, 'quote': quote, }) @require_POST @login_required def approve_quote(request, instance_id, step_id): """تایید پیش‌فاکتور و انتقال به مرحله بعدی""" instance = get_object_or_404(ProcessInstance, id=instance_id) step = get_object_or_404(instance.process.steps, id=step_id) quote = get_object_or_404(Quote, process_instance=instance) # 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): """مرحله سوم: ثبت فیش‌های واریزی پیش‌فاکتور""" 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').all()) approvals_by_role = {a.role_id: a for a in approvals_list} approver_statuses = [ { 'role': r.role, '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: 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 # 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.update_or_create( step_instance=step_instance, role=matching_role, defaults={'approved_by': request.user, 'decision': 'approved', 'reason': ''} ) if step_instance.is_fully_approved(): step_instance.status = 'completed' step_instance.completed_at = timezone.now() step_instance.save() # move to next step redirect_url = 'processes:request_list' 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(redirect_url) 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) 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) # 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, }) @require_POST @login_required def add_quote_payment(request, instance_id, step_id): """افزودن فیش واریزی جدید برای پیش‌فاکتور""" instance = get_object_or_404(ProcessInstance, id=instance_id) step = get_object_or_404(instance.process.steps, id=step_id) quote = get_object_or_404(Quote, process_instance=instance) invoice, _ = Invoice.objects.get_or_create( process_instance=instance, quote=quote, defaults={ 'name': f"Invoice {quote.name}", 'customer': quote.customer, 'due_date': timezone.now().date(), 'created_by': request.user, } ) # 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() si.approvals.all().delete() 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_object_or_404(ProcessInstance, id=instance_id) step = get_object_or_404(instance.process.steps, id=step_id) quote = get_object_or_404(Quote, process_instance=instance) invoice = Invoice.objects.filter(quote=quote).first() if not invoice: return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'}) payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) # 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.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() si.approvals.all().delete() 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): """تجمیع اقلام پیش‌فاکتور با تغییرات نصب و صدور فاکتور نهایی""" 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 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'])) if final_qty == 0: continue unit_price_dec = _to_decimal(r['base_price']) line_total = Decimal(final_qty) * unit_price_dec 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'], }) # 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: 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_object_or_404(ProcessInstance, id=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_object_or_404(ProcessInstance, id=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) # Block approval when there is any remaining (positive or negative) invoice.calculate_totals() # if invoice.remaining_amount != 0: # return JsonResponse({ # 'success': False, # 'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})" # }) # mark step completed 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_object_or_404(ProcessInstance, id=instance_id) invoice = get_object_or_404(Invoice, process_instance=instance) # 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) # charge_type was removed from UI; we no longer require it 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) from .models import InvoiceItem InvoiceItem.objects.create( invoice=invoice, item=special_item, quantity=1, unit_price=amount_dec, ) invoice.calculate_totals() 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_object_or_404(ProcessInstance, id=instance_id) invoice = get_object_or_404(Invoice, process_instance=instance) # 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) from .models import InvoiceItem 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() 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_object_or_404(ProcessInstance, 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') 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'}) # Build approver statuses for template reqs = list(step.approver_requirements.select_related('role').all()) approvals_map = {a.role_id: a.decision for a in step_instance.approvals.select_related('role').all()} approver_statuses = [{'role': r.role, 'status': approvals_map.get(r.role_id)} 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()) 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 # Accountant/Admin approval and rejection (multi-role) if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']: 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: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.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.update_or_create( step_instance=step_instance, role=matching_role, defaults={'approved_by': request.user, 'decision': 'approved', '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) 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) messages.success(request, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.') return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id) # 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, }) @require_POST @login_required def add_final_payment(request, instance_id, step_id): instance = get_object_or_404(ProcessInstance, id=instance_id) step = get_object_or_404(instance.process.steps, id=step_id) 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) 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() # After payment change, 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 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.paid_amount), 'remaining_amount': str(invoice.remaining_amount), } }) @require_POST @login_required def delete_final_payment(request, instance_id, step_id, payment_id): instance = get_object_or_404(ProcessInstance, id=instance_id) step = get_object_or_404(instance.process.steps, id=step_id) 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) payment.delete() invoice.refresh_from_db() # After payment change, 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 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.paid_amount), 'remaining_amount': str(invoice.remaining_amount), }}) @require_POST @login_required def approve_final_settlement(request, instance_id, step_id): instance = get_object_or_404(ProcessInstance, id=instance_id) step = get_object_or_404(instance.process.steps, id=step_id) invoice = get_object_or_404(Invoice, process_instance=instance) # Block approval if any remaining exists (positive or negative) invoice.calculate_totals() if invoice.remaining_amount != 0: return JsonResponse({ 'success': False, 'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})" }) # complete step 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 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})