Add confirmation and summary

This commit is contained in:
aminhashemi92 2025-09-05 13:35:33 +03:30
parent 9b3973805e
commit 35799b7754
25 changed files with 1419 additions and 265 deletions

View file

@ -6,8 +6,8 @@ from .models import Item, Quote, QuoteItem, Invoice, InvoiceItem, Payment
@admin.register(Item)
class ItemAdmin(SimpleHistoryAdmin):
list_display = ['name', 'unit_price', 'default_quantity', 'is_default_in_quotes', 'is_active', 'created_by']
list_filter = ['is_default_in_quotes', 'is_active', 'created_by']
list_display = ['name', 'unit_price', 'default_quantity', 'is_default_in_quotes', 'is_special', 'is_active', 'created_by']
list_filter = ['is_default_in_quotes', 'is_special', 'is_active', 'created_by']
search_fields = ['name', 'description']
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ['deleted_at', 'created', 'updated']

View file

@ -50,7 +50,9 @@
<div class="card border">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">فاکتور نهایی</h5>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSpecialChargeModal()"><i class="bx bx-plus"></i> افزودن هزینه تعمیر/تعویض</button>
{% if is_manager %}
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSpecialChargeModal()"><i class="bx bx-plus"></i> افزودن هزینه تعمیر/تعویض</button>
{% endif %}
</div>
<div class="card-body">
@ -127,7 +129,9 @@
<td class="text-end">{{ si.unit_price|floatformat:0|intcomma:False }}</td>
<td class="text-end">
{{ si.total_price|floatformat:0|intcomma:False }}
<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="deleteSpecial('{{ si.id }}')" title="حذف"><i class="bx bx-trash"></i></button>
{% if is_manager %}
<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="deleteSpecial('{{ si.id }}')" title="حذف"><i class="bx bx-trash"></i></button>
{% endif %}
</td>
</tr>
{% endfor %}
@ -164,7 +168,11 @@
<span></span>
{% endif %}
{% if next_step %}
<button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button>
{% if is_manager %}
<button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button>
{% else %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
{% endif %}
{% endif %}
</div>
</div>

View file

@ -2,6 +2,7 @@
{% load static %}
{% load processes_tags %}
{% load common_tags %}
{% load accounts_tags %}
{% load humanize %}
{% block sidebar %}
@ -46,6 +47,7 @@
<div class="bs-stepper-content">
<div class="row g-3">
{% if is_broker %}
<div class="col-12 col-lg-5">
<div class="card border h-100">
<div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div>
@ -78,11 +80,11 @@
</select>
</div>
<div class="mb-3">
<label class="form-label">شماره مرجع</label>
<label class="form-label">شماره مرجع/چک</label>
<input type="text" class="form-control" name="reference_number" id="id_reference_number" required>
</div>
<div class="mb-3">
<label class="form-label">تصویر فیش</label>
<label class="form-label">تصویر فیش/چک</label>
<input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
</div>
<div class="d-flex justify-content-end">
@ -92,23 +94,39 @@
</div>
</div>
</div>
<div class="col-12 col-lg-7">
{% 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"><h5 class="mb-0">وضعیت فاکتور</h5></div>
<div class="card-header d-flex justify-content-between">
<h5 class="mb-0">وضعیت فاکتور</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6">
<div class="border rounded p-3">
<div class="col-6 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-muted">مبلغ نهایی</div>
<div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
<div class="col-6">
<div class="border rounded p-3">
<div class="col-6 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-muted">پرداختی‌ها</div>
<div class="h5 mt-1 text-success">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-muted">مانده</div>
<div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
<div class="col-6 d-flex align-items-center">
{% if invoice.remaining_amount <= 0 %}
<span class="badge bg-success">تسویه کامل</span>
{% else %}
<span class="badge bg-warning text-dark">باقی‌مانده دارد</span>
{% endif %}
</div>
</div>
</div>
</div>
@ -123,8 +141,8 @@
<th>مبلغ</th>
<th>تاریخ</th>
<th>روش</th>
<th>شماره مرجع</th>
<th style="width:150px">عملیات</th>
<th class="text-nowrap">شماره مرجع/چک</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
@ -132,7 +150,7 @@
<tr>
<td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td>
<td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
<td>{{ p.payment_date|to_jalali }}</td>
<td>{{ p.payment_date|date:'Y/m/d' }}</td>
<td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td>
<td>
@ -142,7 +160,9 @@
<i class="bx bx-show"></i>
</a>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteFinalPayment({{ p.id }})" title="حذف" aria-label="حذف"><i class="bx bx-trash"></i></button>
{% 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>
{% endif %}
</div>
</td>
</tr>
@ -152,20 +172,141 @@
</tbody>
</table>
</div>
<div class="card-footer d-flex justify-content-between">
{% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %}
<span></span>
{% endif %}
<button type="button" id="btnApproveFinalSettlement" class="btn btn-primary">تایید و ادامه</button>
</div>
</div>
</div>
</div>
{% if approver_statuses %}
<div class="card border mt-2">
<div class="card-header d-flex justify-content-between align-items-center">
<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="#approveFinalSettleModal" {% if step_instance.status == 'completed' %}disabled{% endif %}>تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
</div>
{% endif %}
</div>
<div class="card-body py-3">
<div class="row g-2">
{% for st in approver_statuses %}
<div class="col-12 col-md-6 col-lg-4">
<div class="d-flex flex-column border rounded px-2 py-1">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark">{{ st.role.name }}</span>
{% if st.status == 'approved' %}
<span class="badge bg-success">تایید شد</span>
{% elif st.status == 'rejected' %}
<span class="badge bg-danger">رد شد</span>
{% else %}
<span class="badge bg-warning text-dark">در انتظار</span>
{% endif %}
</div>
{% if st.status == 'rejected' and st.reason %}
<div class="mt-1 small text-danger">علت: {{ st.reason }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="col-12 d-flex justify-content-between mt-3">
{% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
{% else %}
<span></span>
{% endif %}
{% if step_instance.status == 'completed' %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
{% else %}
<a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal (final settlement payments) -->
<div class="modal fade" id="deletePaymentModal" tabindex="-1" aria-labelledby="deletePaymentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deletePaymentModalLabel">تایید حذف تراکنش</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
آیا از حذف این تراکنش مطمئن هستید؟ این عمل قابل بازگشت نیست.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-danger" onclick="confirmDeletePayment()" data-bs-dismiss="modal">حذف</button>
</div>
</div>
</div>
</div>
<!-- Approve Final Settlement Modal -->
<div class="modal fade" id="approveFinalSettleModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<div class="modal-header">
<h5 class="modal-title">تایید تسویه نهایی</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if invoice.remaining_amount != 0 %}
<div class="alert alert-warning" role="alert">
مانده فاکتور صفر نیست: <strong>{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br>
تا صفر نشود امکان تایید نیست.
</div>
{% else %}
آیا از تایید این مرحله اطمینان دارید؟
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-success" {% if invoice.remaining_amount != 0 %}disabled{% endif %}>تایید</button>
</div>
</form>
</div>
</div>
</div>
<!-- Reject Final Settlement Modal -->
<div class="modal fade" id="rejectFinalSettleModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="reject">
<div class="modal-header">
<h5 class="modal-title">رد تسویه نهایی</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label">علت رد</label>
<textarea class="form-control" name="reject_reason" rows="3" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-danger">ثبت رد</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block script %}
@ -191,8 +332,11 @@
if (g) { fd.set('payment_date', g); }
return fd;
}
document.getElementById('btnAddFinalPayment').addEventListener('click', function(){
const fd = buildForm();
(function(){
const btn = document.getElementById('btnAddFinalPayment');
if (!btn) return;
btn.addEventListener('click', function(){
const fd = buildForm();
// Frontend validation
const amount = document.getElementById('id_amount').value.trim();
const payDate = document.getElementById('id_payment_date').value.trim();
@ -204,7 +348,7 @@
showToast('همه فیلدها الزامی است', 'danger');
return;
}
fetch('{% url "invoices:add_final_payment" instance.id step.id %}', { method:'POST', body: fd })
fetch('{% url "invoices:add_final_payment" instance.id step.id %}', { method:'POST', body: fd })
.then(r=>r.json()).then(resp=>{
if (resp.success) {
showToast('تراکنش ثبت شد', 'success');
@ -213,12 +357,20 @@
showToast(resp.message || 'خطا در ثبت تراکنش', 'danger');
}
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
});
});
})();
function deleteFinalPayment(id){
let deleteTargetId = null;
function openDeleteModal(id){
deleteTargetId = id;
const modal = new bootstrap.Modal(document.getElementById('deletePaymentModal'));
modal.show();
}
function confirmDeletePayment(){
if (!deleteTargetId) return;
const fd = new FormData();
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
fetch(`{% url "invoices:delete_final_payment" instance.id step.id 0 %}`.replace('/0/', `/${id}/`), { method:'POST', body: fd })
fetch(`{% url "invoices:delete_final_payment" instance.id step.id 0 %}`.replace('/0/', `/${deleteTargetId}/`), { method:'POST', body: fd })
.then(r=>r.json()).then(resp=>{
if (resp.success) {
showToast('حذف شد', 'success');
@ -229,20 +381,7 @@
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
}
document.getElementById('btnApproveFinalSettlement').addEventListener('click', function(){
const fd = new FormData();
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
fetch('{% url "invoices:approve_final_settlement" instance.id step.id %}', { method:'POST', body: fd })
.then(r=>r.json()).then(resp=>{
if (resp.success) {
showToast(resp.message || 'تایید شد', 'success');
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 600);
} else {
showToast(resp.message || 'خطا در تایید', 'danger');
}
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
});
// Legacy approve button removed; using modal forms below
</script>
{% endblock %}

View file

@ -1,6 +1,7 @@
{% extends '_base.html' %}
{% load static %}
{% load processes_tags %}
{% load accounts_tags %}
{% load humanize %}
{% block sidebar %}
@ -55,14 +56,15 @@
<div class="content active dstepper-block">
<div class="content-header mb-3">
<h6 class="mb-0">{{ step.name }}</h6>
<small>ثبت فیش‌های واریزی برای پیش‌فاکتور</small>
<small>ثبت فیش‌ها/چک‌های واریزی برای پیش‌فاکتور</small>
</div>
<div class="row g-3">
{% if can_manage_payments %}
<div class="col-12 col-lg-5">
<div class="card h-100 border">
<div class="card-header">
<h5 class="card-title mb-0">ثبت فیش جدید</h5>
<h5 class="card-title mb-0">ثبت فیش/چک جدید</h5>
</div>
<div class="card-body">
<div class="mb-3">
@ -84,11 +86,11 @@
</select>
</div>
<div class="mb-3">
<label class="form-label">شماره مرجع</label>
<label class="form-label">شماره مرجع/چک</label>
<input type="text" class="form-control" name="reference_number" id="id_reference_number" placeholder="..." required>
</div>
<div class="mb-3">
<label class="form-label">تصویر فیش</label>
<label class="form-label">تصویر فیش/چک</label>
<input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
</div>
<div class="mb-3">
@ -96,16 +98,16 @@
<textarea class="form-control" rows="2" name="notes" id="id_notes"></textarea>
</div>
<div class="d-flex justify-content-end">
<button type="button" id="btnAddPayment" class="btn btn-primary">افزودن فیش</button>
<button type="button" id="btnAddPayment" class="btn btn-primary">افزودن فیش/چک</button>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-7">
{% endif %}
<div class="col-12 {% if can_manage_payments %}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>
<a href="{% url 'invoices:quote_preview_step' instance.id step.id|add:'-1' %}" class="btn btn-sm btn-label-secondary">مشاهده پیش‌فاکتور</a>
<h5 class="card-title mb-0">وضعیت پیش‌فاکتور</h5>
</div>
<div class="card-body">
<div class="row g-3">
@ -139,8 +141,10 @@
</div>
<div class="card border">
<div class="card-header">
<h5 class="card-title mb-0">فیش‌های ثبت شده</h5>
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title mb-0">فیش‌ها/چک‌های ثبت شده</h5>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped mb-0">
@ -149,9 +153,8 @@
<th>مبلغ</th>
<th>تاریخ</th>
<th>روش</th>
<th>شماره مرجع</th>
<th>تصویر</th>
<th style="width:120px">عملیات</th>
<th>شماره مرجع/چک</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
@ -162,28 +165,23 @@
<td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td>
<td>
{% if p.receipt_image %}
<div class="btn-group">
{% if p.receipt_image %}
<a href="{{ p.receipt_image.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="مشاهده" aria-label="مشاهده">
<i class="bx bx-show"></i>
</a>
{% else %}
-
{% endif %}
</td>
<td>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="editPayment({{ p.id }})" title="ویرایش" aria-label="ویرایش">
<i class="bx bx-edit"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal({{ p.id }})" title="حذف" aria-label="حذف">
{% endif %}
{% if can_manage_payments %}
<button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف">
<i class="bx bx-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center text-muted">تا کنون فیشی ثبت نشده است</td>
<td colspan="6" class="text-center text-muted">تا کنون فیش/چکی ثبت نشده است</td>
</tr>
{% endfor %}
</tbody>
@ -191,6 +189,42 @@
</div>
</div>
</div>
{% if approver_statuses %}
<div class="card border mt-2">
<div class="card-header d-flex justify-content-between align-items-center">
<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-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
</div>
{% endif %}
</div>
<div class="card-body py-3">
<div class="row g-2">
{% for st in approver_statuses %}
<div class="col-12 col-md-6 col-lg-4">
<div class="d-flex flex-column border rounded px-2 py-1">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-light text-dark">{{ st.role.name }}</span>
{% if st.status == 'approved' %}
<span class="badge bg-success">تایید شد</span>
{% elif st.status == 'rejected' %}
<span class="badge bg-danger">رد شد</span>
{% else %}
<span class="badge bg-warning text-dark">در انتظار</span>
{% endif %}
</div>
{% if st.status == 'rejected' and st.reason %}
<div class="mt-1 small text-danger">علت: {{ st.reason }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="col-12 d-flex justify-content-between mt-3">
{% if previous_step %}
@ -201,30 +235,114 @@
{% else %}
<span></span>
{% endif %}
<button type="button" id="btnApprovePayments" class="btn btn-primary">
تایید پرداخت‌ها
<i class="bx bx-chevron-left bx-sm ms-sm-2"></i>
</button>
{% if step_instance.status == 'completed' %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
<span class="align-middle d-sm-inline-block d-none me-sm-1">بعدی</span>
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% else %}
<a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
{% endif %}
{% endif %}
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deletePaymentModal" tabindex="-1" aria-labelledby="deletePaymentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deletePaymentModalLabel">تایید حذف فیش</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
آیا از حذف این فیش مطمئن هستید؟ این عمل قابل بازگشت نیست.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-danger" onclick="confirmDeletePayment()" data-bs-dismiss="modal">حذف</button>
</div>
</div>
</div>
</div>
<!-- Removed legacy approvePaymentsModal; using approvePaymentsModal2 with form POST -->
<!-- Approve Modal 2 (direct approve button in header) -->
<div class="modal fade" id="approvePaymentsModal2" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="approve">
<div class="modal-header">
<h5 class="modal-title">تایید پرداخت‌ها</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if not totals.is_fully_paid %}
<div class="alert alert-warning" role="alert">
مبلغی از پیش‌فاکتور هنوز پرداخت نشده است.
<div class="mt-1">مانده: <strong>{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان</strong></div>
</div>
آیا مطمئن هستید که می‌خواهید مرحله را تایید کنید؟
{% else %}
آیا از تایید این مرحله اطمینان دارید؟
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-success">تایید</button>
</div>
</form>
</div>
</div>
</div>
<!-- Reject Modal for payments step -->
<div class="modal fade" id="rejectPaymentsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="reject">
<div class="modal-header">
<h5 class="modal-title">رد مرحله پرداخت‌ها</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label">علت رد</label>
<textarea class="form-control" name="reject_reason" rows="3" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="submit" class="btn btn-danger">ثبت رد</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
const isFullyPaid = {{ totals.is_fully_paid|yesno:'true,false' }};
// Removed legacy isFullyPaid-driven approve flow; approval now via modal submit
function buildFormData(form) {
const fd = new FormData(form);
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
return fd;
}
document.getElementById('btnAddPayment').addEventListener('click', function() {
const btnAddPayment = document.getElementById('btnAddPayment');
if (btnAddPayment) btnAddPayment.addEventListener('click', function() {
// Front-end validation
const amount = document.getElementById('id_amount').value.trim();
const payDate = document.getElementById('id_payment_date').value.trim();
@ -283,51 +401,7 @@
alert('ویرایش فیش را بعدا با مدال تکمیل می‌کنیم. فعلا حذف و افزودن مجدد انجام دهید.');
}
function performApprovePayments() {
const fd = new FormData();
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
fetch('{% url "invoices:approve_payments" instance.id step.id %}', {
method: 'POST',
body: fd
}).then(r => r.json()).then(resp => {
if (resp.success) {
showToast(resp.message || 'پرداخت‌ها تایید شد', 'success');
if (resp.redirect) {
setTimeout(() => { window.location.href = resp.redirect; }, 600);
}
} else {
showToast(resp.message || resp.error || 'خطا در تایید پرداخت‌ها', 'danger');
}
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
}
function openApproveModal() {
const el = document.getElementById('approvePaymentsModal');
const remEl = document.getElementById('remainingAmountText');
if (remEl) {
remEl.textContent = '{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان';
}
// Prefer jQuery plugin if available to avoid namespace issues
if (window.$ && typeof $(el).modal === 'function') {
$(el).modal('show');
} else if (window.bootstrap && window.bootstrap.Modal) {
const modal = new window.bootstrap.Modal(el);
modal.show();
} else {
// fallback: force display
el.classList.add('show');
el.style.display = 'block';
el.removeAttribute('aria-hidden');
}
}
document.getElementById('btnApprovePayments').addEventListener('click', function() {
if (isFullyPaid) {
performApprovePayments();
} else {
openApproveModal();
}
});
// Legacy approve JS removed; approval handled by modal forms in header
</script>
<!-- Persian Date Picker JS -->
@ -365,42 +439,4 @@
})();
</script>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deletePaymentModal" tabindex="-1" aria-labelledby="deletePaymentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deletePaymentModalLabel">تایید حذف فیش</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
آیا از حذف این فیش مطمئن هستید؟ این عمل قابل بازگشت نیست.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-danger" onclick="confirmDeletePayment()" data-bs-dismiss="modal">حذف</button>
</div>
</div>
</div>
</div>
<!-- Approve Confirmation Modal (shown when remaining amount > 0) -->
<div class="modal fade" id="approvePaymentsModal" tabindex="-1" aria-labelledby="approvePaymentsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="approvePaymentsModalLabel">تایید نهایی پرداخت‌ها</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
مبلغی از پیش‌فاکتور هنوز پرداخت نشده است.
<div class="mt-2">مانده: <strong id="remainingAmountText"></strong></div>
آیا مطمئن هستید که می‌خواهید مرحله را تایید و به مرحله بعد بروید؟
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="performApprovePayments()">بله، تایید</button>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -221,20 +221,32 @@
<span></span>
{% endif %}
{% if step_instance.status == 'completed' %}
{% if is_broker %}
{% if step_instance.status == 'completed' %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}"
class="btn btn-primary">
<span class="align-middle d-sm-inline-block d-none me-sm-1">بعدی</span>
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% else %}
<button class="btn btn-success" type="button">اتمام</button>
{% endif %}
{% else %}
<button type="button" class="btn btn-primary" id="btnApproveQuote">
تایید پیش‌فاکتور
</button>
{% endif %}
{% else %}
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}"
class="btn btn-primary">
<span class="align-middle d-sm-inline-block d-none me-sm-1">بعدی</span>
class="btn btn-label-primary">
مرحله بعد
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% else %}
<button class="btn btn-success" type="button">اتمام</button>
<a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
{% endif %}
{% else %}
<button type="button" class="btn btn-primary" id="btnApproveQuote">
تایید پیش‌فاکتور
</button>
{% endif %}
</div>
</div>

View file

@ -58,6 +58,7 @@
{% endif %}
<div class="col-12">
{% if is_broker or existing_quote %}
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
@ -77,7 +78,8 @@
data-item-id="{{ item.id }}"
data-is-default="{% if item.is_default_in_quotes %}1{% else %}0{% endif %}"
{% if selected_qty %}checked{% elif item.is_default_in_quotes %}checked{% endif %}
{% if item.is_default_in_quotes %}disabled title="آیتم پیش‌فرض است و قابل حذف نیست"{% endif %}>
{% if item.is_default_in_quotes or not is_broker %}disabled{% endif %}
{% if item.is_default_in_quotes %}title="آیتم پیش‌فرض است و قابل حذف نیست"{% elif not is_broker %}title="فقط کارگزار مجاز به تغییر اقلام است"{% endif %}>
</td>
<td>
<div class="d-flex flex-column">
@ -86,15 +88,15 @@
<span class="badge bg-label-primary me-2">پیش‌فرض</span>
{% endif %}
</span>
{% if item.description %}<small class="text-muted">{{ item.description }}</small>{% endif %}
</div>
</td>
<td>{{ item.unit_price|floatformat:0|intcomma:False }} تومان</td>
<td>
<input type="number" class="form-control form-control-sm quote-item-qty" min="1"
data-item-id="{{ item.id }}"
value="{% if selected_qty %}{{ selected_qty }}{% else %}{{ item.default_quantity }}{% endif %}">
<input type="number" class="form-control form-control-sm quote-item-qty" min="1"
data-item-id="{{ item.id }}"
value="{% if selected_qty %}{{ selected_qty }}{% else %}{{ item.default_quantity }}{% endif %}"
{% if not is_broker %}disabled title="فقط کارگزار مجاز به تغییر تعداد است"{% endif %}>
</td>
</tr>
{% endwith %}
@ -102,8 +104,9 @@
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-warning mb-0">شما دسترسی به ثبت اقلام ندارید.</div>
{% endif %}
</div>
<div class="col-12 d-flex justify-content-between">
@ -118,27 +121,35 @@
{% endif %}
{% if step_instance.status == 'completed' %}
{% if next_step %}
<div class="d-flex justify-content-end mt-3">
<button type="button" class="btn btn-primary" id="btnCreateQuote">
{% if existing_quote %}بروزرسانی پیش‌فاکتور{% else %}ثبت پیش‌فاکتور{% endif %}
و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button>
</div>
{% if is_broker %}
{% if step_instance.status == 'completed' %}
{% if next_step %}
<div class="d-flex justify-content-end mt-3">
<button type="button" class="btn btn-primary" id="btnCreateQuote">
{% if existing_quote %}بروزرسانی پیش‌فاکتور{% else %}ثبت پیش‌فاکتور{% endif %}
و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button>
</div>
{% else %}
<button class="btn btn-success" type="button">اتمام</button>
{% endif %}
{% else %}
<button class="btn btn-success" type="button">اتمام</button>
<button type="button" class="btn btn-primary" id="btnCreateQuote">
{% if existing_quote %}بروزرسانی پیش‌فاکتور{% else %}ثبت پیش‌فاکتور{% endif %}
و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button>
{% endif %}
{% else %}
<button type="button" class="btn btn-primary" id="btnCreateQuote">
{% if existing_quote %}بروزرسانی پیش‌فاکتور{% else %}ثبت پیش‌فاکتور{% endif %}
و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button>
{% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-label-primary">
مرحله بعد
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% else %}
<a href="{% url 'processes:request_list' %}" class="btn btn-success">اتمام</a>
{% endif %}
{% endif %}
</div>
</div>

View file

@ -9,7 +9,9 @@ from django.urls import reverse
from decimal import Decimal, InvalidOperation
import json
from processes.models import ProcessInstance, ProcessStep, StepInstance
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
@ -28,7 +30,7 @@ def quote_step(request, instance_id, step_id):
return redirect('processes:request_list')
# دریافت آیتم‌ها
items = Item.objects.all().order_by('name')
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:
@ -40,6 +42,14 @@ def quote_step(request, instance_id, step_id):
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,
@ -49,6 +59,7 @@ def quote_step(request, instance_id, step_id):
'existing_quote': existing_quote,
'previous_step': previous_step,
'next_step': next_step,
'is_broker': is_broker,
})
@require_POST
@ -57,6 +68,13 @@ 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 '[]')
@ -72,7 +90,7 @@ def create_quote(request, instance_id, step_id):
except Exception:
continue
default_item_ids = set(Item.objects.filter(is_default_in_quotes=True).values_list('id', flat=True))
default_item_ids = set(Item.objects.filter(is_default_in_quotes=True, is_deleted=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:
@ -163,6 +181,14 @@ def quote_preview_step(request, instance_id, step_id):
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,
@ -170,6 +196,7 @@ def quote_preview_step(request, instance_id, step_id):
'quote': quote,
'previous_step': previous_step,
'next_step': next_step,
'is_broker': is_broker,
})
@login_required
@ -190,6 +217,13 @@ 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'
@ -247,7 +281,97 @@ def quote_payment_step(request, instance_id, step_id):
'is_fully_paid': quote.get_remaining_amount() <= 0,
}
step_instance = instance.step_instances.filter(step=step).first()
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
# 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']:
# 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)
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,
@ -258,6 +382,12 @@ def quote_payment_step(request, instance_id, step_id):
'totals': totals,
'previous_step': previous_step,
'next_step': next_step,
'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,
})
@ -279,6 +409,16 @@ def add_quote_payment(request, instance_id, step_id):
}
)
# dynamic permission: users whose roles are among required approvers can add payments
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': 'شما مجوز افزودن فیش را ندارید'})
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
logger = logging.getLogger(__name__)
try:
amount = (request.POST.get('amount') or '').strip()
@ -325,6 +465,15 @@ def add_quote_payment(request, instance_id, step_id):
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
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@ -360,6 +509,15 @@ def update_quote_payment(request, instance_id, step_id, payment_id):
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()
except Exception:
pass
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@ -374,11 +532,30 @@ 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
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': 'شما مجوز حذف فیش را ندارید'})
except Exception:
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
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@ -534,6 +711,14 @@ def final_invoice_step(request, instance_id, step_id):
# 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,
@ -543,6 +728,7 @@ def final_invoice_step(request, instance_id, step_id):
'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,
})
@ -564,6 +750,12 @@ 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:
@ -592,6 +784,12 @@ 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()
@ -623,6 +821,12 @@ def add_special_charge(request, instance_id, step_id):
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
@ -648,13 +852,87 @@ def final_settlement_step(request, instance_id, step_id):
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,
})
@ -662,7 +940,14 @@ def final_settlement_step(request, instance_id, step_id):
@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()
@ -717,6 +1002,14 @@ def add_final_payment(request, instance_id, step_id):
)
# 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]),
@ -732,10 +1025,25 @@ def add_final_payment(request, instance_id, step_id):
@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),