Add confirmation and summary

This commit is contained in:
aminhashemi92 2025-09-05 13:35:33 +03:30
parent 9b3973805e
commit 35799b7754
25 changed files with 1419 additions and 265 deletions

View file

@ -9,7 +9,9 @@ from django.urls import reverse
from decimal import Decimal, InvalidOperation
import json
from processes.models import ProcessInstance, ProcessStep, StepInstance
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
@ -28,7 +30,7 @@ def quote_step(request, instance_id, step_id):
return redirect('processes:request_list')
# دریافت آیتم‌ها
items = Item.objects.all().order_by('name')
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:
@ -40,6 +42,14 @@ def quote_step(request, instance_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()
# 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,
@ -49,6 +59,7 @@ def quote_step(request, instance_id, step_id):
'existing_quote': existing_quote,
'previous_step': previous_step,
'next_step': next_step,
'is_broker': is_broker,
})
@require_POST
@ -57,6 +68,13 @@ 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 '[]')
@ -72,7 +90,7 @@ def create_quote(request, instance_id, step_id):
except Exception:
continue
default_item_ids = set(Item.objects.filter(is_default_in_quotes=True).values_list('id', flat=True))
default_item_ids = set(Item.objects.filter(is_default_in_quotes=True, is_deleted=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:
@ -163,6 +181,14 @@ def quote_preview_step(request, instance_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()
# 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,
@ -170,6 +196,7 @@ def quote_preview_step(request, instance_id, step_id):
'quote': quote,
'previous_step': previous_step,
'next_step': next_step,
'is_broker': is_broker,
})
@login_required
@ -190,6 +217,13 @@ 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'
@ -247,7 +281,97 @@ def quote_payment_step(request, instance_id, step_id):
'is_fully_paid': quote.get_remaining_amount() <= 0,
}
step_instance = instance.step_instances.filter(step=step).first()
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
# approver status map for template
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
]
# 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)
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,
@ -258,6 +382,12 @@ def quote_payment_step(request, instance_id, step_id):
'totals': totals,
'previous_step': previous_step,
'next_step': next_step,
'approver_statuses': approver_statuses,
'is_broker': is_broker,
'is_accountant': is_accountant,
# dynamic permissions: any role required to approve can also manage payments
'can_manage_payments': can_approve_reject,
'can_approve_reject': can_approve_reject,
})
@ -279,6 +409,16 @@ def add_quote_payment(request, instance_id, step_id):
}
)
# dynamic permission: users whose roles are among required approvers can add payments
try:
req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True))
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))
if len(req_role_ids.intersection(user_role_ids)) == 0:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
logger = logging.getLogger(__name__)
try:
amount = (request.POST.get('amount') or '').strip()
@ -325,6 +465,15 @@ def add_quote_payment(request, instance_id, step_id):
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
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@ -360,6 +509,15 @@ def update_quote_payment(request, instance_id, step_id, payment_id):
except Exception:
return JsonResponse({'success': False, 'message': 'خطا در ویرایش فیش'})
# On update, 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
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@ -374,11 +532,30 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
if not invoice:
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
# dynamic permission: users whose roles are among required approvers can delete payments
try:
req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True))
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))
if len(req_role_ids.intersection(user_role_ids)) == 0:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'})
except Exception:
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
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@ -534,6 +711,14 @@ def final_invoice_step(request, instance_id, step_id):
# 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,
@ -543,6 +728,7 @@ def final_invoice_step(request, instance_id, step_id):
'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,
})
@ -564,6 +750,12 @@ 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:
@ -592,6 +784,12 @@ 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()
@ -623,6 +821,12 @@ def add_special_charge(request, instance_id, step_id):
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
@ -648,13 +852,87 @@ def final_settlement_step(request, instance_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()
# 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,
})
@ -662,7 +940,14 @@ def final_settlement_step(request, instance_id, step_id):
@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()
@ -717,6 +1002,14 @@ def add_final_payment(request, instance_id, step_id):
)
# 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]),
@ -732,10 +1025,25 @@ def add_final_payment(request, instance_id, step_id):
@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),