complete first version of main proccess
This commit is contained in:
		
							parent
							
								
									6ff4740d04
								
							
						
					
					
						commit
						f2fc2362a7
					
				
					 61 changed files with 3280 additions and 28 deletions
				
			
		| 
						 | 
				
			
			@ -11,6 +11,7 @@ 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):
 | 
			
		||||
| 
						 | 
				
			
			@ -413,3 +414,358 @@ def approve_payments(request, instance_id, step_id):
 | 
			
		|||
        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})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue