shafafiyat/invoices/views.py

1069 lines
48 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.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})