shafafiyat/installations/views.py

407 lines
18 KiB
Python

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
@login_required
def installation_assign_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)
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_object_or_404(ProcessInstance, id=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,
})