shafafiyat/invoices/views.py
2025-08-27 10:27:30 +03:30

771 lines
32 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
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.all().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()
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,
})
@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)
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).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, _ = 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,
}
)
# 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()
# تکمیل مرحله
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('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'),
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()
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,
})
@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)
# تایید پیش‌فاکتور
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 = instance.step_instances.filter(step=step).first()
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,
})
@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,
}
)
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)})
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@require_POST
@login_required
def update_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)
try:
amount = request.POST.get('amount')
payment_date = request.POST.get('payment_date') or payment.payment_date
payment_method = request.POST.get('payment_method') or payment.payment_method
reference_number = request.POST.get('reference_number') or ''
notes = request.POST.get('notes') or ''
receipt_image = request.FILES.get('receipt_image')
if amount:
payment.amount = amount
payment.payment_date = payment_date
payment.payment_method = payment_method
payment.reference_number = reference_number
payment.notes = notes
# اگر نیاز به ذخیره عکس در Payment دارید، فیلد آن اضافه شده است
if receipt_image:
payment.receipt_image = receipt_image
payment.save()
except Exception:
return JsonResponse({'success': False, 'message': 'خطا در ویرایش فیش'})
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)
try:
# soft delete using project's BaseModel delete override
payment.delete()
except Exception:
return JsonResponse({'success': False, 'message': 'خطا در حذف فیش'})
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@require_POST
@login_required
def approve_payments(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)
is_fully_paid = quote.get_remaining_amount() <= 0
# تکمیل مرحله
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()
# حرکت به مرحله بعد
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])
msg = 'پرداخت‌ها تایید شد'
if is_fully_paid:
msg += ' - مبلغ پیش‌فاکتور به طور کامل پرداخت شده است.'
else:
msg += ' - توجه: مبلغ پیش‌فاکتور به طور کامل پرداخت نشده است.'
return JsonResponse({'success': True, 'message': msg, 'redirect': redirect_url, 'is_fully_paid': is_fully_paid})
@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'))
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,
})
@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)
# 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)
# 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)
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()
return render(request, 'invoices/final_settlement_step.html', {
'instance': instance,
'step': step,
'invoice': invoice,
'payments': invoice.payments.filter(is_deleted=False).all(),
'previous_step': previous_step,
'next_step': next_step,
})
@require_POST
@login_required
def add_final_payment(request, instance_id, step_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
invoice = get_object_or_404(Invoice, process_instance=instance)
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()
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)
invoice = get_object_or_404(Invoice, process_instance=instance)
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
payment.delete()
invoice.refresh_from_db()
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})