from django.shortcuts import render, get_object_or_404, redirect from django.contrib.auth.decorators import login_required from django.contrib import messages from django.urls import reverse from django.utils import timezone from accounts.models import Profile from common.consts import UserRoles from processes.models import ProcessInstance, StepInstance, StepRejection, StepApproval from accounts.models import Role from invoices.models import Item, Quote, QuoteItem from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange from decimal import Decimal, InvalidOperation from processes.utils import get_scoped_instance_or_404 @login_required def installation_assign_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) previous_step = instance.process.steps.filter(order__lt=step.order).last() next_step = instance.process.steps.filter(order__gt=step.order).first() # Installers list (profiles that have installer role) installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value, county=instance.well.county).select_related('user').all() assignment, _ = InstallationAssignment.objects.get_or_create(process_instance=instance) # Role flags 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 if request.method == 'POST': if not is_manager: messages.error(request, 'شما اجازه تعیین نصاب را ندارید') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) installer_id = request.POST.get('installer_id') scheduled_date = (request.POST.get('scheduled_date') or '').strip() assignment.installer_id = installer_id or None if scheduled_date: assignment.scheduled_date = scheduled_date.replace('/', '-') assignment.assigned_by = request.user assignment.save() # complete step StepInstance.objects.update_or_create( process_instance=instance, step=step, defaults={'status': 'completed', 'completed_at': timezone.now()} ) 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') # Read-only logic for non-managers read_only = not is_manager show_denied_msg = (not is_manager) and (assignment.installer_id is None) return render(request, 'installations/installation_assign_step.html', { 'instance': instance, 'step': step, 'assignment': assignment, 'installers': installers, 'previous_step': previous_step, 'next_step': next_step, 'is_manager': is_manager, 'read_only': read_only, 'show_denied_msg': show_denied_msg, }) def create_item_changes_for_report(report, remove_map, add_map, quote_price_map): """Helper function to create item changes for a report""" # Create remove changes for item_id, qty in remove_map.items(): up = quote_price_map.get(item_id) total = (up * qty) if up is not None else None InstallationItemChange.objects.create( report=report, item_id=item_id, change_type='remove', quantity=qty, unit_price=up, total_price=total, ) # Create add changes for item_id, data in add_map.items(): unit_price = data.get('price') qty = data.get('qty') or 1 total = (unit_price * qty) if (unit_price is not None) else None InstallationItemChange.objects.create( report=report, item_id=item_id, change_type='add', quantity=qty, unit_price=unit_price, total_price=total, ) @login_required def installation_report_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) previous_step = instance.process.steps.filter(order__lt=step.order).last() next_step = instance.process.steps.filter(order__gt=step.order).first() assignment = InstallationAssignment.objects.filter(process_instance=instance).first() existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first() # Only the assigned installer can create/edit the report try: has_installer_role = bool(getattr(request.user, 'profile', None) and request.user.profile.has_role(UserRoles.INSTALLER)) except Exception: has_installer_role = False is_assigned_installer = bool(assignment and assignment.installer_id == request.user.id) user_is_installer = bool(has_installer_role and is_assigned_installer) edit_mode = True if (request.GET.get('edit') == '1' and user_is_installer) else False # current quote items baseline quote = Quote.objects.filter(process_instance=instance).first() quote_items = list(quote.items.select_related('item').all()) if quote else [] quote_price_map = {qi.item_id: qi.unit_price for qi in quote_items} items = Item.objects.filter(is_active=True, is_special=False, is_deleted=False).order_by('name') # Ensure a StepInstance exists for this step step_instance, _ = StepInstance.objects.get_or_create( process_instance=instance, step=step, defaults={'status': 'in_progress'} ) # Build approver requirements/status for UI 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 [] # Align permission check with invoices flow (role id intersection) 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 user_can_approve = can_approve_reject 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 ] # Manager approval/rejection actions if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']: action = request.POST.get('action') # find a matching approver role based on step requirements 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('processes:step_detail', instance_id=instance.id, step_id=step.id) if not existing_report: messages.error(request, 'گزارش برای تایید/رد وجود ندارد.') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) if action == 'approve': existing_report.approved = True existing_report.save() 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('processes:step_detail', 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('processes:step_detail', 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) existing_report.approved = False existing_report.save() # If current step moved ahead of this step, reset it back for correction (align with invoices) 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('processes:step_detail', instance_id=instance.id, step_id=step.id) if request.method == 'POST': # Only installers can submit or edit reports (non-approval actions) if request.POST.get('action') not in ['approve', 'reject'] and not user_is_installer: messages.error(request, 'شما مجوز ثبت/ویرایش گزارش نصب را ندارید') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) description = (request.POST.get('description') or '').strip() visited_date = (request.POST.get('visited_date') or '').strip() if '/' in visited_date: visited_date = visited_date.replace('/', '-') new_serial = (request.POST.get('new_water_meter_serial') or '').strip() seal_number = (request.POST.get('seal_number') or '').strip() is_suspicious = True if request.POST.get('is_meter_suspicious') == 'on' else False utm_x = request.POST.get('utm_x') or None utm_y = request.POST.get('utm_y') or None # Normalize UTM to integer meters if utm_x is not None and utm_x != '': try: utm_x = int(Decimal(str(utm_x))) except InvalidOperation: utm_x = None else: utm_x = None if utm_y is not None and utm_y != '': try: utm_y = int(Decimal(str(utm_y))) except InvalidOperation: utm_y = None else: utm_y = None # Build maps from form fields: remove and add remove_map = {} add_map = {} for key in request.POST.keys(): if key.startswith('rem_') and key.endswith('_type'): # rem_{id}_type = 'remove' try: item_id = int(key.split('_')[1]) except Exception: continue if request.POST.get(key) != 'remove': continue qty_val = request.POST.get(f'rem_{item_id}_qty') or '1' try: qty = int(qty_val) except Exception: qty = 1 remove_map[item_id] = qty if key.startswith('add_') and key.endswith('_type'): try: item_id = int(key.split('_')[1]) except Exception: continue if request.POST.get(key) != 'add': continue qty_val = request.POST.get(f'add_{item_id}_qty') or '1' price_val = request.POST.get(f'add_{item_id}_price') try: qty = int(qty_val) except Exception: qty = 1 # resolve unit price unit_price = None if price_val: try: unit_price = Decimal(price_val) except InvalidOperation: unit_price = None if unit_price is None: item_obj = Item.objects.filter(id=item_id).first() unit_price = item_obj.unit_price if item_obj else None add_map[item_id] = {'qty': qty, 'price': unit_price} if existing_report and edit_mode: report = existing_report report.description = description report.visited_date = visited_date or None report.new_water_meter_serial = new_serial or None report.seal_number = seal_number or None report.is_meter_suspicious = is_suspicious report.utm_x = utm_x report.utm_y = utm_y report.approved = False # back to awaiting approval after edits report.save() # delete selected existing photos for key, val in request.POST.items(): if key.startswith('del_photo_') and val == '1': try: pid = int(key.split('_')[-1]) InstallationPhoto.objects.filter(id=pid, report=report).delete() except Exception: continue # append new photos for f in request.FILES.getlist('photos'): InstallationPhoto.objects.create(report=report, image=f) # replace item changes with new submission report.item_changes.all().delete() create_item_changes_for_report(report, remove_map, add_map, quote_price_map) else: report = InstallationReport.objects.create( assignment=assignment, description=description, visited_date=visited_date or None, new_water_meter_serial=new_serial or None, seal_number=seal_number or None, is_meter_suspicious=is_suspicious, utm_x=utm_x, utm_y=utm_y, created_by=request.user, ) # photos for f in request.FILES.getlist('photos'): InstallationPhoto.objects.create(report=report, image=f) # item changes create_item_changes_for_report(report, remove_map, add_map, quote_price_map) # After installer submits/edits, set step back to in_progress and clear approvals step_instance.status = 'in_progress' step_instance.completed_at = None step_instance.save() try: step_instance.approvals.all().delete() except Exception: pass # If the report was edited, ensure downstream steps reopen like invoices flow 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': # Reopen the step instance.step_instances.filter(step=subsequent_step).update( status='in_progress', completed_at=None ) # Clear previous approvals if any try: subsequent_step_instance.approvals.all().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 messages.success(request, 'گزارش ثبت شد و در انتظار تایید است.') return redirect('processes:step_detail', instance_id=instance.id, step_id=step.id) # Build prefill maps from existing report changes removed_ids = set() removed_qty = {} added_map = {} if existing_report: for ch in existing_report.item_changes.all(): if ch.change_type == 'remove': removed_ids.add(ch.item_id) removed_qty[ch.item_id] = ch.quantity elif ch.change_type == 'add': added_map[ch.item_id] = {'qty': ch.quantity, 'price': ch.unit_price} return render(request, 'installations/installation_report_step.html', { 'instance': instance, 'step': step, 'assignment': assignment, 'report': existing_report, 'edit_mode': edit_mode, 'user_is_installer': user_is_installer, 'quote': quote, 'quote_items': quote_items, 'all_items': items, 'removed_ids': removed_ids, 'removed_qty': removed_qty, 'added_map': added_map, 'previous_step': previous_step, 'next_step': next_step, 'step_instance': step_instance, 'approver_statuses': approver_statuses, 'user_can_approve': user_can_approve, 'can_approve_reject': can_approve_reject, })