This commit is contained in:
aminhashemi92 2025-09-29 17:38:11 +03:30
parent 810c87e2e0
commit b5bf3a5dbe
51 changed files with 2397 additions and 326 deletions

View file

@ -106,7 +106,6 @@ class Quote(NameSlugModel):
def calculate_totals(self):
"""محاسبه مبالغ کل"""
total = sum(item.total_price for item in self.items.filter(is_deleted=False).all())
total = sum(item.total_price for item in self.items.filter(is_deleted=False).all())
self.total_amount = total
# محاسبه تخفیف
@ -115,7 +114,14 @@ class Quote(NameSlugModel):
else:
self.discount_amount = 0
self.final_amount = self.total_amount - self.discount_amount
# محاسبه مبلغ نهایی با احتساب مالیات
base_amount = self.total_amount - self.discount_amount
try:
vat_rate = Decimal(str(getattr(settings, 'VAT_RATE', 0)))
except Exception:
vat_rate = Decimal('0')
vat_amount = base_amount * vat_rate
self.final_amount = base_amount + vat_amount
self.save()
def get_status_display_with_color(self):
@ -263,7 +269,15 @@ class Invoice(NameSlugModel):
else:
self.discount_amount = 0
self.final_amount = self.total_amount - self.discount_amount
# محاسبه مبلغ نهایی با احتساب مالیات
base_amount = self.total_amount - self.discount_amount
try:
vat_rate = Decimal(str(getattr(settings, 'VAT_RATE', 0)))
except Exception:
vat_rate = Decimal('0')
vat_amount = base_amount * vat_rate
self.final_amount = base_amount + vat_amount
# خالص مانده به نفع شرکت (مثبت) یا به نفع مشتری (منفی)
net_due = self.final_amount - self.paid_amount
self.remaining_amount = net_due
@ -280,6 +294,7 @@ class Invoice(NameSlugModel):
self.save()
def get_status_display_with_color(self):
"""نمایش وضعیت با رنگ"""
status_colors = {

View file

@ -90,7 +90,11 @@
<!-- Customer & Well Info -->
<div class="row mb-3">
<div class="col-6">
<h6 class="fw-bold mb-2">اطلاعات مشترک</h6>
<h6 class="fw-bold mb-2">اطلاعات مشترک {% if instance.representative.profile and instance.representative.profile.user_type == 'legal' %}(حقوقی){% else %}(حقیقی){% endif %}</h6>
{% if instance.representative.profile and instance.representative.profile.user_type == 'legal' %}
<div class="small mb-1"><span class="text-muted">نام شرکت:</span> {{ instance.representative.profile.company_name|default:"-" }}</div>
<div class="small mb-1"><span class="text-muted">شناسه ملی:</span> {{ instance.representative.profile.company_national_id|default:"-" }}</div>
{% endif %}
<div class="small mb-1"><span class="text-muted">نام:</span> {{ invoice.customer.get_full_name|default:instance.representative.get_full_name }}</div>
{% if instance.representative.profile and instance.representative.profile.national_code %}
<div class="small mb-1"><span class="text-muted">کد ملی:</span> {{ instance.representative.profile.national_code }}</div>
@ -150,7 +154,7 @@
</tr>
{% endif %}
<tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>مبلغ نهایی (شامل مالیات)(تومان):</strong></td>
<td><strong>{{ invoice.final_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section">

View file

@ -67,7 +67,7 @@
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="border rounded p-3 h-100">
<div class="small text-muted">مبلغ نهایی</div>
<div class="small text-muted">مبلغ نهایی (با مالیات)</div>
<div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
@ -106,7 +106,7 @@
</thead>
<tbody>
{% for r in rows %}
<tr>
<tr class="{% if r.is_removed %}table-light text-muted{% endif %}">
<td>
<div class="d-flex flex-column">
<span class="fw-semibold">{{ r.item.name }}</span>
@ -118,7 +118,13 @@
<td class="text-center text-danger">{{ r.removed_qty }}</td>
<td class="text-center">{{ r.quantity }}</td>
<td class="text-end">{{ r.unit_price|floatformat:0|intcomma:False }}</td>
<td class="text-end">{{ r.total_price|floatformat:0|intcomma:False }}</td>
<td class="text-end">
{% if r.is_removed %}
<span class="text-muted">-</span>
{% else %}
{{ r.total_price|floatformat:0|intcomma:False }}
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="7" class="text-center text-muted">آیتمی یافت نشد</td></tr>
@ -154,7 +160,7 @@
<th class="text-end">{{ invoice.discount_amount|floatformat:0|intcomma:False }} تومان</th>
</tr>
<tr>
<th colspan="6" class="text-end">مبلغ نهایی</th>
<th colspan="6" class="text-end">مبلغ نهایی (با مالیات)</th>
<th class="text-end">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</th>
</tr>
<tr>

View file

@ -42,6 +42,11 @@
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer me-2"></i> پرینت
</a>
{% if request.user|is_manager and step_instance.status != 'approved' and step_instance.status != 'completed' and invoice.remaining_amount != 0 %}
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#forceApproveModal">
<i class="bx bx-bolt-circle me-1"></i> تایید اضطراری
</button>
{% endif %}
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
@ -106,13 +111,17 @@
<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="mb-0">وضعیت فاکتور</h5>
<h5 class="mb-0">وضعیت فاکتور
{% if step_instance.status == 'approved' %}
<span class="badge bg-warning">تایید اضطراری</span>
{% endif %}
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-muted">مبلغ نهایی</div>
<div class="small text-muted">مبلغ نهایی (با مالیات)</div>
<div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
@ -189,10 +198,17 @@
<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">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
</div>
{% if current_user_has_decided %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" disabled>تایید</button>
<button type="button" class="btn btn-danger btn-sm" disabled>رد</button>
</div>
{% else %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approveFinalSettleModal">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectFinalSettleModal">رد</button>
</div>
{% endif %}
{% endif %}
</div>
<div class="card-body py-3">
@ -243,6 +259,32 @@
</div>
</div>
</div>
<!-- Force Approve Modal -->
<div class="modal fade" id="forceApproveModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="force_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">
<div class="alert alert-warning" role="alert">
با تایید اضطراری ممکن است هنوز پرداخت کامل نشده باشد و این مرحله به صورت استثنا تایید می‌شود.
</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-warning">تایید اضطراری</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal (final settlement payments) -->

View file

@ -75,7 +75,7 @@
<input type="number" min="1" class="form-control" name="amount" id="id_amount" required>
</div>
<div class="mb-3">
<label class="form-label">تاریخ پرداخت</label>
<label class="form-label">تاریخ پرداخت/سررسید چک</label>
<input type="text" class="form-control" id="id_payment_date" name="payment_date" placeholder="انتخاب تاریخ" readonly required>
</div>
<div class="mb-3">
@ -89,7 +89,7 @@
</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">
@ -116,7 +116,7 @@
<div class="row g-3">
<div class="col-6">
<div class="border rounded p-3">
<div class="small text-muted">مبلغ نهایی پیش‌فاکتور</div>
<div class="small text-muted">مبلغ نهایی پیش‌فاکتور (با مالیات)</div>
<div class="h5 mt-1">{{ totals.final_amount|floatformat:0|intcomma:False }} تومان</div>
</div>
</div>
@ -154,9 +154,9 @@
<thead>
<tr>
<th>مبلغ</th>
<th>تاریخ</th>
<th>تاریخ پرداخت/سررسید چک</th>
<th>روش</th>
<th>شماره مرجع/چک</th>
<th>شماره پیگیری/شماره صیادی چک</th>
<th>عملیات</th>
</tr>
</thead>
@ -197,10 +197,17 @@
<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">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
</div>
{% if current_user_has_decided %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" disabled>تایید</button>
<button type="button" class="btn btn-danger btn-sm" disabled>رد</button>
</div>
{% else %}
<div class="d-flex gap-2">
<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 %}
{% endif %}
</div>
<div class="card-body py-3">

View file

@ -114,8 +114,23 @@
<div class="">
<div class="card-body p-3">
<h6 class="card-title text-primary mb-2">
<i class="bx bx-user me-1"></i>اطلاعات مشترک
<i class="bx bx-user me-1"></i>
{% if instance.representative.profile.user_type == 'legal' %}
اطلاعات مشترک (حقوقی)
{% else %}
اطلاعات مشترک (حقیقی)
{% endif %}
</h6>
{% if instance.representative.profile.user_type == 'legal' %}
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">نام شرکت:</span>
<span class="fw-medium small">{{ instance.representative.profile.company_name|default:"-" }}</span>
</div>
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">شناسه ملی:</span>
<span class="fw-medium small">{{ instance.representative.profile.company_national_id|default:"-" }}</span>
</div>
{% endif %}
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">نام:</span>
<span class="fw-medium small">{{ quote.customer.get_full_name }}</span>
@ -198,7 +213,7 @@
{% if quote.discount_amount > 0 %}
<p class="mb-2">تخفیف:</p>
{% endif %}
<p class="mb-0 fw-bold">مبلغ نهایی:</p>
<p class="mb-0 fw-bold">مبلغ نهایی (شامل مالیات):</p>
</td>
<td class="px-4 py-5">
<p class="fw-medium mb-2">{{ quote.total_amount|floatformat:0|intcomma:False }} تومان</p>

View file

@ -203,7 +203,7 @@
</tr>
{% endif %}
<tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>مبلغ نهایی (با مالیات)(تومان):</strong></td>
<td><strong>{{ quote.final_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
</tfoot>

View file

@ -5,6 +5,7 @@ from django.contrib import messages
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.utils import timezone
from django.conf import settings
from django.urls import reverse
from decimal import Decimal, InvalidOperation
import json
@ -356,16 +357,16 @@ def quote_payment_step(request, instance_id, step_id):
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_list = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False))
approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [
{
approver_statuses = []
for r in reqs:
appr = approvals_by_role.get(r.role_id)
approver_statuses.append({
'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
]
'status': (appr.decision if appr else None),
'reason': (appr.reason if appr else ''),
})
# dynamic permission: who can approve/reject this step (based on requirements)
try:
@ -374,6 +375,15 @@ 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
# Compute whether current user has already decided (approved/rejected)
current_user_has_decided = False
try:
user_has_approval = step_instance.approvals.filter(approved_by=request.user, is_deleted=False).exists()
user_has_rejection = step_instance.rejections.filter(rejected_by=request.user, is_deleted=False).exists()
current_user_has_decided = bool(user_has_approval or user_has_rejection)
except Exception:
current_user_has_decided = False
# Accountant/Admin approval and rejection via POST (multi-role)
@ -452,6 +462,7 @@ def quote_payment_step(request, instance_id, step_id):
'is_broker': is_broker,
'is_accountant': is_accountant,
'can_approve_reject': can_approve_reject,
'current_user_has_decided': current_user_has_decided,
})
@ -537,7 +548,17 @@ def add_quote_payment(request, instance_id, step_id):
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
except Exception:
pass
try:
for appr in list(si.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
@ -554,7 +575,8 @@ def add_quote_payment(request, instance_id, step_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
@ -596,7 +618,7 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
try:
# soft delete using project's BaseModel delete override
payment.delete()
payment.hard_delete()
except Exception:
return JsonResponse({'success': False, 'message': 'خطا در حذف فیش'})
# On delete, return to awaiting approval
@ -605,7 +627,10 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
for appr in list(si.approvals.all()):
appr.delete()
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
@ -622,7 +647,8 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
@ -707,16 +733,15 @@ def final_invoice_step(request, instance_id, step_id):
if ch.unit_price:
row['base_price'] = _to_decimal(ch.unit_price)
# Compute final invoice lines
# Compute final invoice lines (include fully removed items for display)
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
line_total = Decimal(final_qty) * unit_price_dec if final_qty > 0 else Decimal('0')
if final_qty > 0:
total_amount += line_total
rows.append({
'item': r['item'],
'quantity': final_qty,
@ -725,6 +750,7 @@ def final_invoice_step(request, instance_id, step_id):
'base_qty': r['base_qty'],
'added_qty': r['added_qty'],
'removed_qty': r['removed_qty'],
'is_removed': True if final_qty == 0 else False,
})
# Create or reuse final invoice
@ -745,6 +771,8 @@ def final_invoice_step(request, instance_id, step_id):
except Exception:
qs.delete()
for r in rows:
if r['quantity'] <= 0:
continue
from .models import InvoiceItem
InvoiceItem.objects.create(
invoice=invoice,
@ -918,12 +946,21 @@ def final_settlement_step(request, instance_id, step_id):
except Exception:
can_approve_reject = False
# Compute whether current user has already decided (approved/rejected)
current_user_has_decided = False
try:
user_has_approval = step_instance.approvals.filter(approved_by=request.user).exists()
user_has_rejection = step_instance.rejections.filter(rejected_by=request.user).exists()
current_user_has_decided = bool(user_has_approval or user_has_rejection)
except Exception:
current_user_has_decided = False
# Accountant/Admin approval and rejection (multi-role)
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject', 'force_approve']:
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:
if matching_role is None and request.POST.get('action') != 'force_approve':
messages.error(request, 'شما دسترسی لازم برای تایید/رد این مرحله را ندارید.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
@ -972,6 +1009,24 @@ def final_settlement_step(request, instance_id, step_id):
messages.success(request, 'مرحله تسویه نهایی رد شد و برای اصلاح بازگشت.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
if action == 'force_approve':
# Only MANAGER can force approve
try:
if not (hasattr(request.user, 'profile') and request.user.profile.has_role(UserRoles.MANAGER)):
messages.error(request, 'فقط مدیر مجاز به تایید اضطراری است.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
except Exception:
messages.error(request, 'فقط مدیر مجاز به تایید اضطراری است.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
# Mark step completed regardless of remaining amount/approvals
step_instance.status = 'approved'
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')
# broker flag for payment management permission
profile = getattr(request.user, 'profile', None)
is_broker = False
@ -991,6 +1046,8 @@ def final_settlement_step(request, instance_id, step_id):
'approver_statuses': approver_statuses,
'can_approve_reject': can_approve_reject,
'is_broker': is_broker,
'current_user_has_decided': current_user_has_decided,
'is_manager': bool(getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()).filter(slug=UserRoles.MANAGER.value).exists()) if getattr(request.user, 'profile', None) else False,
})
@ -1065,10 +1122,20 @@ def add_final_payment(request, instance_id, step_id):
# On delete, return to awaiting approval
try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
si.status = 'in_progress'
if si.status != 'approved':
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
try:
for appr in list(si.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
except Exception:
pass
@ -1085,7 +1152,8 @@ def add_final_payment(request, instance_id, step_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception:
@ -1124,7 +1192,7 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف تراکنش تسویه را ندارید'}, status=403)
payment.delete()
payment.hard_delete()
invoice.refresh_from_db()
# On delete, return to awaiting approval
@ -1133,7 +1201,16 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
si.status = 'in_progress'
si.completed_at = None
si.save()
si.approvals.all().delete()
try:
for appr in list(si.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
except Exception:
pass
@ -1150,7 +1227,8 @@ def delete_final_payment(request, instance_id, step_id, payment_id):
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
for appr in list(subsequent_step_instance.approvals.all()):
appr.delete()
except Exception:
pass
except Exception: