diff --git a/db.sqlite3 b/db.sqlite3 index 94e4e20..700863f 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/invoices/models.py b/invoices/models.py index 10d8f83..53059d8 100644 --- a/invoices/models.py +++ b/invoices/models.py @@ -91,6 +91,7 @@ class Quote(NameSlugModel): verbose_name="ایجاد کننده", related_name='created_quotes' ) + history = HistoricalRecords() class Meta: diff --git a/invoices/templates/invoices/quote_payment_step.html b/invoices/templates/invoices/quote_payment_step.html index 1b963ee..e73e0d3 100644 --- a/invoices/templates/invoices/quote_payment_step.html +++ b/invoices/templates/invoices/quote_payment_step.html @@ -18,15 +18,16 @@ - + {% endblock %} {% block content %} {% include '_toasts.html' %} + + +{% instance_info_modal instance %} + + {% csrf_token %}
@@ -35,19 +36,21 @@

{{ step.name }}: {{ instance.process.name }}

- اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} - | نماینده: {{ instance.representative.profile.national_code|default:"-" }} + {% instance_info instance %}
-
+
{% stepper_header instance step %}
@@ -60,7 +63,7 @@
- {% if can_manage_payments %} + {% if is_broker %}
@@ -104,7 +107,7 @@
{% endif %} -
+
وضعیت پیش‌فاکتور
@@ -171,7 +174,7 @@ {% endif %} - {% if can_manage_payments %} + {% if is_broker %} @@ -195,7 +198,7 @@
وضعیت تاییدها
{% if can_approve_reject %}
- +
{% endif %} diff --git a/invoices/templates/invoices/quote_preview_step.html b/invoices/templates/invoices/quote_preview_step.html index 509c138..5a2fbbd 100644 --- a/invoices/templates/invoices/quote_preview_step.html +++ b/invoices/templates/invoices/quote_preview_step.html @@ -41,7 +41,7 @@
- پرینت + پرینت diff --git a/invoices/urls.py b/invoices/urls.py index c338c4c..f959799 100644 --- a/invoices/urls.py +++ b/invoices/urls.py @@ -15,9 +15,7 @@ urlpatterns = [ # Quote payments step (step 3) path('instance//step//payments/', views.quote_payment_step, name='quote_payment_step'), path('instance//step//payments/add/', views.add_quote_payment, name='add_quote_payment'), - path('instance//step//payments//update/', views.update_quote_payment, name='update_quote_payment'), path('instance//step//payments//delete/', views.delete_quote_payment, name='delete_quote_payment'), - path('instance//step//payments/approve/', views.approve_payments, name='approve_payments'), # Quote print path('instance//quote/print/', views.quote_print, name='quote_print'), diff --git a/invoices/views.py b/invoices/views.py index e017518..cc1f925 100644 --- a/invoices/views.py +++ b/invoices/views.py @@ -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): """تجمیع اقلام پیش‌فاکتور با تغییرات نصب و صدور فاکتور نهایی""" diff --git a/processes/admin.py b/processes/admin.py index 3eb169e..a23a341 100644 --- a/processes/admin.py +++ b/processes/admin.py @@ -50,6 +50,7 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin): verbose_name_plural = "درخواست‌ها" list_display = [ 'code', + 'current_step', 'slug', 'well_display', 'representative', @@ -142,7 +143,7 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin): @admin.register(StepInstance) class StepInstanceAdmin(SimpleHistoryAdmin): - list_display = ['process_instance', 'step', 'assigned_to', 'status_display', 'rejection_count', 'started_at', 'completed_at'] + list_display = ['process_instance', 'step', 'assigned_to', 'status_display', 'rejection_count', 'edit_count', 'started_at', 'completed_at'] list_filter = ['status', 'step__process', 'started_at'] search_fields = ['process_instance__name', 'step__name', 'assigned_to__username'] readonly_fields = ['started_at', 'completed_at'] diff --git a/processes/migrations/0003_historicalstepinstance_edit_count_and_more.py b/processes/migrations/0003_historicalstepinstance_edit_count_and_more.py new file mode 100644 index 0000000..6e983f6 --- /dev/null +++ b/processes/migrations/0003_historicalstepinstance_edit_count_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.4 on 2025-09-08 08:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('processes', '0002_processinstance_broker'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='historicalstepinstance', + name='edit_count', + field=models.PositiveIntegerField(default=0, verbose_name='تعداد ویرایش پس از تکمیل'), + ), + migrations.AddField( + model_name='historicalstepinstance', + name='edited_after_completion', + field=models.BooleanField(default=False, verbose_name='ویرایش پس از تکمیل'), + ), + migrations.AddField( + model_name='historicalstepinstance', + name='last_edited_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='آخرین زمان ویرایش پس از تکمیل'), + ), + migrations.AddField( + model_name='historicalstepinstance', + name='last_edited_by', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='ویرایش توسط'), + ), + migrations.AddField( + model_name='stepinstance', + name='edit_count', + field=models.PositiveIntegerField(default=0, verbose_name='تعداد ویرایش پس از تکمیل'), + ), + migrations.AddField( + model_name='stepinstance', + name='edited_after_completion', + field=models.BooleanField(default=False, verbose_name='ویرایش پس از تکمیل'), + ), + migrations.AddField( + model_name='stepinstance', + name='last_edited_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='آخرین زمان ویرایش پس از تکمیل'), + ), + migrations.AddField( + model_name='stepinstance', + name='last_edited_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='step_instances_edited', to=settings.AUTH_USER_MODEL, verbose_name='ویرایش توسط'), + ), + ] diff --git a/processes/models.py b/processes/models.py index 0ff6484..414d475 100644 --- a/processes/models.py +++ b/processes/models.py @@ -327,6 +327,12 @@ class StepInstance(models.Model): notes = models.TextField(verbose_name="یادداشت‌ها", blank=True) started_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ شروع") completed_at = models.DateTimeField(null=True, blank=True, verbose_name="تاریخ تکمیل") + # Generic edit-tracking for post-completion modifications + edited_after_completion = models.BooleanField(default=False, verbose_name="ویرایش پس از تکمیل") + last_edited_at = models.DateTimeField(null=True, blank=True, verbose_name="آخرین زمان ویرایش پس از تکمیل") + last_edited_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='step_instances_edited', verbose_name="ویرایش توسط") + edit_count = models.PositiveIntegerField(default=0, verbose_name="تعداد ویرایش پس از تکمیل") + history = HistoricalRecords() class Meta: