fix until contracts step

This commit is contained in:
aminhashemi92 2025-09-08 13:24:05 +03:30
parent 246a2c0759
commit af40e169ae
9 changed files with 180 additions and 128 deletions

View file

@ -91,6 +91,7 @@ class Quote(NameSlugModel):
verbose_name="ایجاد کننده",
related_name='created_quotes'
)
history = HistoricalRecords()
class Meta:

View file

@ -18,15 +18,16 @@
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
<!-- Persian Date Picker CSS -->
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
<style>
@media print {
.no-print { display: none !important; }
}
</style>
{% endblock %}
{% block content %}
{% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
{% csrf_token %}
<div class="container-xxl flex-grow-1 container-p-y">
<div class="row">
@ -35,19 +36,21 @@
<div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
{% instance_info instance %}
</small>
</div>
<div class="d-flex gap-2">
<a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer"></i> پرینت
<i class="bx bx-printer me-2"></i> پرینت
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</div>
</div>
<div class="bs-stepper wizard-vertical vertical mt-2 no-print">
<div class="bs-stepper wizard-vertical vertical mt-2">
{% stepper_header instance step %}
<div class="bs-stepper-content">
@ -60,7 +63,7 @@
</div>
<div class="row g-3">
{% if can_manage_payments %}
{% if is_broker %}
<div class="col-12 col-lg-5">
<div class="card h-100 border">
<div class="card-header">
@ -104,7 +107,7 @@
</div>
</div>
{% endif %}
<div class="col-12 {% if can_manage_payments %}col-lg-7{% else %}col-lg-12{% endif %}">
<div class="col-12 {% if is_broker %}col-lg-7{% else %}col-lg-12{% endif %}">
<div class="card mb-3 border">
<div class="card-header d-flex justify-content-between">
<h5 class="card-title mb-0">وضعیت پیش‌فاکتور</h5>
@ -171,7 +174,7 @@
<i class="bx bx-show"></i>
</a>
{% endif %}
{% if can_manage_payments %}
{% if is_broker %}
<button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف">
<i class="bx bx-trash"></i>
</button>
@ -195,7 +198,7 @@
<h6 class="mb-0">وضعیت تاییدها</h6>
{% if can_approve_reject %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2" {% if step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
</div>
{% endif %}

View file

@ -41,7 +41,7 @@
</div>
<div class="d-flex gap-2">
<a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer"></i> پرینت
<i class="bx bx-printer me-2"></i> پرینت
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>

View file

@ -15,9 +15,7 @@ urlpatterns = [
# Quote payments step (step 3)
path('instance/<int:instance_id>/step/<int:step_id>/payments/', views.quote_payment_step, name='quote_payment_step'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/add/', views.add_quote_payment, name='add_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/update/', views.update_quote_payment, name='update_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/delete/', views.delete_quote_payment, name='delete_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/approve/', views.approve_payments, name='approve_payments'),
# Quote print
path('instance/<int:instance_id>/quote/print/', views.quote_print, name='quote_print'),

View file

@ -107,7 +107,7 @@ def create_quote(request, instance_id, step_id):
return JsonResponse({'success': False, 'message': 'هیچ آیتمی انتخاب نشده است'})
# Create or reuse quote
quote, _ = Quote.objects.get_or_create(
quote, created_q = Quote.objects.get_or_create(
process_instance=instance,
defaults={
'name': f"پیش‌فاکتور {instance.code}",
@ -117,6 +117,15 @@ def create_quote(request, instance_id, step_id):
}
)
# 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:
@ -139,22 +148,62 @@ def create_quote(request, instance_id, step_id):
)
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
)
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
if not was_already_completed:
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save(update_fields=['status', 'completed_at'])
# انتقال به مرحله بعدی
next_step = instance.process.steps.filter(order__gt=step.order).first()
redirect_url = None
if next_step:
instance.current_step = next_step
instance.save()
# 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])
@ -202,6 +251,7 @@ def quote_preview_step(request, instance_id, step_id):
'is_broker': is_broker,
})
@login_required
def quote_print(request, instance_id):
"""صفحه پرینت پیش‌فاکتور"""
@ -213,6 +263,7 @@ def quote_print(request, instance_id):
'quote': quote,
})
@require_POST
@login_required
def approve_quote(request, instance_id, step_id):
@ -285,6 +336,7 @@ def quote_payment_step(request, instance_id, step_id):
}
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 []
@ -298,6 +350,7 @@ def quote_payment_step(request, instance_id, step_id):
}
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}
@ -305,20 +358,7 @@ def quote_payment_step(request, instance_id, step_id):
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']:
@ -362,6 +402,13 @@ def quote_payment_step(request, instance_id, step_id):
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)
@ -388,8 +435,6 @@ def quote_payment_step(request, instance_id, step_id):
'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,
})
@ -412,14 +457,16 @@ def add_quote_payment(request, instance_id, step_id):
}
)
# dynamic permission: users whose roles are among required approvers can add payments
# who can add payments
profile = getattr(request.user, 'profile', None)
is_broker = False
is_accountant = False
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': 'شما مجوز افزودن فیش را ندارید'})
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__)
@ -477,48 +524,11 @@ def add_quote_payment(request, instance_id, step_id):
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})
@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)
# If current step is ahead of this step, reset it back to this step
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': 'خطا در ویرایش فیش'})
# 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()
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])
@ -535,15 +545,18 @@ 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
# who can delete payments
profile = getattr(request.user, 'profile', None)
is_broker = False
is_accountant = False
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': 'شما مجوز حذف فیش را ندارید'})
is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT))
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'})
is_broker = False
is_accountant = False
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
try:
# soft delete using project's BaseModel delete override
@ -559,43 +572,17 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
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 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):
"""تجمیع اقلام پیش‌فاکتور با تغییرات نصب و صدور فاکتور نهایی"""