complete first version of main proccess
This commit is contained in:
parent
6ff4740d04
commit
f2fc2362a7
61 changed files with 3280 additions and 28 deletions
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-21 18:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0002_historicalpayment_receipt_image_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='historicalpayment',
|
||||
name='reference_number',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=100, verbose_name='شماره مرجع'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='reference_number',
|
||||
field=models.CharField(blank=True, max_length=100, unique=True, verbose_name='شماره مرجع'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-22 08:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0003_alter_historicalpayment_reference_number_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='historicalpayment',
|
||||
name='direction',
|
||||
field=models.CharField(choices=[('in', 'دریافتی'), ('out', 'پرداختی')], default='in', max_length=3, verbose_name='نوع تراکنش'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='direction',
|
||||
field=models.CharField(choices=[('in', 'دریافتی'), ('out', 'پرداختی')], default='in', max_length=3, verbose_name='نوع تراکنش'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-22 08:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0004_historicalpayment_direction_payment_direction'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='historicalitem',
|
||||
name='is_special',
|
||||
field=models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalitem',
|
||||
name='special_kind',
|
||||
field=models.CharField(blank=True, choices=[('repair', 'تعمیر'), ('replace', 'تعویض')], max_length=10, verbose_name='نوع ویژه'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='is_special',
|
||||
field=models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='special_kind',
|
||||
field=models.CharField(blank=True, choices=[('repair', 'تعمیر'), ('replace', 'تعویض')], max_length=10, verbose_name='نوع ویژه'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,21 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-22 08:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0005_historicalitem_is_special_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='historicalitem',
|
||||
name='special_kind',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='item',
|
||||
name='special_kind',
|
||||
),
|
||||
]
|
|
@ -18,6 +18,7 @@ class Item(NameSlugModel):
|
|||
decimal_places=2,
|
||||
verbose_name="قیمت واحد"
|
||||
)
|
||||
is_special = models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی')
|
||||
default_quantity = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name="تعداد پیشفرض"
|
||||
|
@ -102,7 +103,8 @@ class Quote(NameSlugModel):
|
|||
|
||||
def calculate_totals(self):
|
||||
"""محاسبه مبالغ کل"""
|
||||
total = sum(item.total_price for item in self.items.all())
|
||||
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
|
||||
|
||||
# محاسبه تخفیف
|
||||
|
@ -260,15 +262,19 @@ class Invoice(NameSlugModel):
|
|||
self.discount_amount = 0
|
||||
|
||||
self.final_amount = self.total_amount - self.discount_amount
|
||||
self.remaining_amount = self.final_amount - self.paid_amount
|
||||
|
||||
# بروزرسانی وضعیت
|
||||
if self.remaining_amount <= 0:
|
||||
# خالص مانده به نفع شرکت (مثبت) یا به نفع مشتری (منفی)
|
||||
net_due = self.final_amount - self.paid_amount
|
||||
self.remaining_amount = net_due
|
||||
|
||||
# وضعیت بر اساس مانده خالص
|
||||
if net_due == 0:
|
||||
self.status = 'paid'
|
||||
elif self.paid_amount > 0:
|
||||
self.status = 'partially_paid'
|
||||
elif net_due > 0:
|
||||
# مشتری هنوز باید پرداخت کند
|
||||
self.status = 'partially_paid' if self.paid_amount > 0 else 'sent'
|
||||
else:
|
||||
self.status = 'sent'
|
||||
# شرکت باید به مشتری پرداخت کند
|
||||
self.status = 'partially_paid'
|
||||
|
||||
self.save()
|
||||
|
||||
|
@ -314,6 +320,12 @@ class Payment(BaseModel):
|
|||
"""مدل پرداختها"""
|
||||
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments', verbose_name="فاکتور")
|
||||
amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="مبلغ پرداخت")
|
||||
direction = models.CharField(
|
||||
max_length=3,
|
||||
choices=[('in', 'دریافتی'), ('out', 'پرداختی')],
|
||||
default='in',
|
||||
verbose_name='نوع تراکنش'
|
||||
)
|
||||
payment_method = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
|
@ -326,7 +338,7 @@ class Payment(BaseModel):
|
|||
default='cash',
|
||||
verbose_name="روش پرداخت"
|
||||
)
|
||||
reference_number = models.CharField(max_length=100, verbose_name="شماره مرجع", blank=True)
|
||||
reference_number = models.CharField(max_length=100, verbose_name="شماره مرجع", blank=True, unique=True)
|
||||
payment_date = models.DateField(verbose_name="تاریخ پرداخت")
|
||||
notes = models.TextField(verbose_name="یادداشتها", blank=True)
|
||||
receipt_image = models.ImageField(upload_to='payments/%Y/%m/%d/', null=True, blank=True, verbose_name="تصویر فیش")
|
||||
|
@ -345,6 +357,17 @@ class Payment(BaseModel):
|
|||
"""بروزرسانی مبالغ فاکتور"""
|
||||
super().save(*args, **kwargs)
|
||||
# بروزرسانی مبلغ پرداخت شده فاکتور
|
||||
total_paid = sum(payment.amount for payment in self.invoice.payments.all())
|
||||
total_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in self.invoice.payments.filter(is_deleted=False).all())
|
||||
self.invoice.paid_amount = total_paid
|
||||
self.invoice.calculate_totals()
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
"""حذف نرم و بروزرسانی مبالغ فاکتور پس از حذف"""
|
||||
result = super().delete(using=using, keep_parents=keep_parents)
|
||||
try:
|
||||
total_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in self.invoice.payments.filter(is_deleted=False).all())
|
||||
self.invoice.paid_amount = total_paid
|
||||
self.invoice.calculate_totals()
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
|
55
invoices/templates/invoices/final_invoice_print.html
Normal file
55
invoices/templates/invoices/final_invoice_print.html
Normal file
|
@ -0,0 +1,55 @@
|
|||
{% extends '_base.html' %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="mb-4 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h4 class="mb-1">فاکتور نهایی</h4>
|
||||
<small class="text-muted">کد درخواست: {{ instance.code }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Placeholders for logo/signature -->
|
||||
<div class="text-end">لوگو</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>آیتم</th>
|
||||
<th>تعداد</th>
|
||||
<th>قیمت واحد</th>
|
||||
<th>قیمت کل</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr>
|
||||
<td>{{ it.item.name }}</td>
|
||||
<td>{{ it.quantity }}</td>
|
||||
<td>{{ it.unit_price|floatformat:0|intcomma:False }}</td>
|
||||
<td>{{ it.total_price|floatformat:0|intcomma:False }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="text-center text-muted">آیتمی ندارد</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><th colspan="3" class="text-end">مبلغ کل</th><th>{{ invoice.total_amount|floatformat:0|intcomma:False }}</th></tr>
|
||||
<tr><th colspan="3" class="text-end">تخفیف</th><th>{{ invoice.discount_amount|floatformat:0|intcomma:False }}</th></tr>
|
||||
<tr><th colspan="3" class="text-end">مبلغ نهایی</th><th>{{ invoice.final_amount|floatformat:0|intcomma:False }}</th></tr>
|
||||
<tr><th colspan="3" class="text-end">پرداختیها</th><th>{{ invoice.paid_amount|floatformat:0|intcomma:False }}</th></tr>
|
||||
<tr><th colspan="3" class="text-end">مانده</th><th>{{ invoice.remaining_amount|floatformat:0|intcomma:False }}</th></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-5 d-flex justify-content-between">
|
||||
<div>امضا مشتری</div>
|
||||
<div>امضا شرکت</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.print()</script>
|
||||
{% endblock %}
|
||||
|
||||
|
260
invoices/templates/invoices/final_invoice_step.html
Normal file
260
invoices/templates/invoices/final_invoice_step.html
Normal file
|
@ -0,0 +1,260 @@
|
|||
{% extends '_base.html' %}
|
||||
{% load static %}
|
||||
{% load processes_tags %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'sidebars/admin.html' %}
|
||||
{% endblock sidebar %}
|
||||
|
||||
{% block navbar %}
|
||||
{% include 'navbars/admin.html' %}
|
||||
{% endblock navbar %}
|
||||
|
||||
{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
|
||||
|
||||
{% block style %}
|
||||
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_toasts.html' %}
|
||||
{% csrf_token %}
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 no-print">
|
||||
<div>
|
||||
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
|
||||
<small class="text-muted d-block">
|
||||
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
|
||||
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت</a>
|
||||
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bs-stepper wizard-vertical vertical mt-2 no-print">
|
||||
{% stepper_header instance step %}
|
||||
<div class="bs-stepper-content">
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<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="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<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-3">
|
||||
<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 col-md-3 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 class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>آیتم</th>
|
||||
<th class="text-center">تعداد پایه</th>
|
||||
<th class="text-center">افزوده</th>
|
||||
<th class="text-center">حذف</th>
|
||||
<th class="text-center">تعداد نهایی</th>
|
||||
<th class="text-end">قیمت واحد (تومان)</th>
|
||||
<th class="text-end">قیمت کل (تومان)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-semibold">{{ r.item.name }}</span>
|
||||
{% if r.item.description %}<small class="text-muted">{{ r.item.description }}</small>{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">{{ r.base_qty }}</td>
|
||||
<td class="text-center text-success">{{ r.added_qty }}</td>
|
||||
<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>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="7" class="text-center text-muted">آیتمی یافت نشد</td></tr>
|
||||
{% endfor %}
|
||||
{% for si in invoice_specials %}
|
||||
<tr class="table-warning">
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-semibold">{{ si.item.name }}<span class="badge bg-info mx-2">ویژه</span></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">-</td>
|
||||
<td class="text-center">-</td>
|
||||
<td class="text-center">-</td>
|
||||
<td class="text-center">{{ si.quantity }}</td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="6" class="text-end">مبلغ کل</th>
|
||||
<th class="text-end">{{ invoice.total_amount|floatformat:0|intcomma:False }} تومان</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="6" class="text-end">تخفیف</th>
|
||||
<th class="text-end">{{ invoice.discount_amount|floatformat:0|intcomma:False }} تومان</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="6" class="text-end">مبلغ نهایی</th>
|
||||
<th class="text-end">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="6" class="text-end">پرداختیها</th>
|
||||
<th class="text-end">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="6" class="text-end">مانده</th>
|
||||
<th class="text-end {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</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 %}
|
||||
{% if next_step %}
|
||||
<button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Special Charge Modal -->
|
||||
<div class="modal fade" id="specialChargeModal" tabindex="-1" aria-labelledby="specialChargeModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="specialChargeModalLabel">افزودن هزینه تعمیر/تعویض</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="specialChargeForm" onsubmit="return false;">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">انتخاب آیتم ویژه</label>
|
||||
<select class="form-select" name="item_id" id="id_special_item" required>
|
||||
<option value="">انتخاب کنید...</option>
|
||||
{% for s in special_choices %}
|
||||
<option value="{{ s.id }}">{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">مبلغ (تومان)</label>
|
||||
<input type="number" class="form-control" name="amount" id="id_charge_amount" min="1" required>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitSpecialCharge()">افزودن</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
function openSpecialChargeModal(){
|
||||
const el = document.getElementById('specialChargeModal');
|
||||
if (window.$ && typeof $(el).modal === 'function') { $(el).modal('show'); }
|
||||
else if (window.bootstrap && window.bootstrap.Modal) { new window.bootstrap.Modal(el).show(); }
|
||||
else { el.classList.add('show'); el.style.display = 'block'; }
|
||||
}
|
||||
function submitSpecialCharge(){
|
||||
const fd = new FormData(document.getElementById('specialChargeForm'));
|
||||
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
|
||||
fetch('{% url "invoices:add_special_charge" instance.id step.id %}', { method: 'POST', body: fd })
|
||||
.then(r=>r.json()).then(resp=>{
|
||||
if (resp.success){
|
||||
showToast('هزینه ویژه اضافه شد', 'success');
|
||||
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 600);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در افزودن هزینه', 'danger');
|
||||
}
|
||||
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
}
|
||||
// No filtering needed; show all special items
|
||||
function deleteSpecial(id){
|
||||
const fd = new FormData();
|
||||
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
|
||||
fetch(`{% url "invoices:delete_special_charge" instance.id step.id 0 %}`.replace('/0/', `/${id}/`), { method: 'POST', body: fd })
|
||||
.then(r=>r.json()).then(resp=>{
|
||||
if (resp.success){
|
||||
showToast('حذف شد', 'success');
|
||||
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 500);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در حذف', 'danger');
|
||||
}
|
||||
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
}
|
||||
document.getElementById('btnApproveFinalInvoice')?.addEventListener('click', function(){
|
||||
const fd = new FormData();
|
||||
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
|
||||
fetch('{% url "invoices:approve_final_invoice" 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'));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
248
invoices/templates/invoices/final_settlement_step.html
Normal file
248
invoices/templates/invoices/final_settlement_step.html
Normal file
|
@ -0,0 +1,248 @@
|
|||
{% extends '_base.html' %}
|
||||
{% load static %}
|
||||
{% load processes_tags %}
|
||||
{% load common_tags %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'sidebars/admin.html' %}
|
||||
{% endblock sidebar %}
|
||||
|
||||
{% block navbar %}
|
||||
{% include 'navbars/admin.html' %}
|
||||
{% endblock navbar %}
|
||||
|
||||
{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
|
||||
|
||||
{% block style %}
|
||||
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
|
||||
<!-- Persian Date Picker CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_toasts.html' %}
|
||||
{% csrf_token %}
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 no-print">
|
||||
<div>
|
||||
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
|
||||
<small class="text-muted d-block">
|
||||
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
|
||||
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت</a>
|
||||
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bs-stepper wizard-vertical vertical mt-2 no-print">
|
||||
{% stepper_header instance step %}
|
||||
<div class="bs-stepper-content">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card border h-100">
|
||||
<div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div>
|
||||
<div class="card-body">
|
||||
<form id="formFinalPayment" enctype="multipart/form-data" onsubmit="return false;">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">نوع تراکنش</label>
|
||||
<select class="form-select" name="direction" id="id_direction" required>
|
||||
<option value="in">دریافتی از مشتری</option>
|
||||
<option value="out">پرداخت به مشتری</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">مبلغ (تومان)</label>
|
||||
<input type="number" min="1" class="form-control" name="amount" id="id_amount" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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">
|
||||
<label class="form-label">روش پرداخت</label>
|
||||
<select class="form-select" name="payment_method" id="id_payment_method" required>
|
||||
<option value="bank_transfer">انتقال بانکی</option>
|
||||
<option value="card">کارت بانکی</option>
|
||||
<option value="cash">نقدی</option>
|
||||
<option value="check">چک</option>
|
||||
<option value="other">سایر</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
<input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="button" id="btnAddFinalPayment" class="btn btn-primary">افزودن</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card mb-3 border">
|
||||
<div class="card-header"><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="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="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border">
|
||||
<div class="card-header"><h5 class="mb-0">تراکنشها</h5></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نوع</th>
|
||||
<th>مبلغ</th>
|
||||
<th>تاریخ</th>
|
||||
<th>روش</th>
|
||||
<th>شماره مرجع</th>
|
||||
<th style="width:150px">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in payments %}
|
||||
<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.get_payment_method_display }}</td>
|
||||
<td>{{ p.reference_number|default:'-' }}</td>
|
||||
<td>
|
||||
<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>
|
||||
{% 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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-center text-muted">تراکنشی ندارد</td></tr>
|
||||
{% endfor %}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script src="https://unpkg.com/persian-date@latest/dist/persian-date.min.js"></script>
|
||||
<script src="https://unpkg.com/persian-datepicker@latest/dist/js/persian-datepicker.min.js"></script>
|
||||
<script>
|
||||
(function initPersianDatePicker(){
|
||||
if (window.$ && $.fn.persianDatepicker && $('#id_payment_date').length) {
|
||||
$('#id_payment_date').persianDatepicker({
|
||||
format: 'YYYY/MM/DD', initialValue: false, autoClose: true, persianDigit: false, observer: true,
|
||||
calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } },
|
||||
onSelect: function(unix){
|
||||
const g = new window.persianDate(unix).toCalendar('gregorian').format('YYYY-MM-DD');
|
||||
$('#id_payment_date').attr('data-gregorian', g);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function buildForm(){
|
||||
const fd = new FormData(document.getElementById('formFinalPayment'));
|
||||
const g = document.getElementById('id_payment_date').getAttribute('data-gregorian');
|
||||
if (g) { fd.set('payment_date', g); }
|
||||
return fd;
|
||||
}
|
||||
document.getElementById('btnAddFinalPayment').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();
|
||||
const method = document.getElementById('id_payment_method').value.trim();
|
||||
const ref = document.getElementById('id_reference_number').value.trim();
|
||||
const img = document.getElementById('id_receipt_image').files[0];
|
||||
const dir = document.getElementById('id_direction').value;
|
||||
if (!amount || !payDate || !method || !ref || !img) {
|
||||
showToast('همه فیلدها الزامی است', 'danger');
|
||||
return;
|
||||
}
|
||||
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');
|
||||
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 700);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در ثبت تراکنش', 'danger');
|
||||
}
|
||||
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
});
|
||||
|
||||
function deleteFinalPayment(id){
|
||||
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 })
|
||||
.then(r=>r.json()).then(resp=>{
|
||||
if (resp.success) {
|
||||
showToast('حذف شد', 'success');
|
||||
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 500);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در حذف', 'danger');
|
||||
}
|
||||
}).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'));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -243,12 +243,12 @@
|
|||
body: fd
|
||||
}).then(r => r.json()).then(resp => {
|
||||
if (resp.success) {
|
||||
showToast('فیش با موفقیت ثبت شد', 'success');
|
||||
showToast(resp.message || 'فیش با موفقیت ثبت شد', 'success');
|
||||
if (resp.redirect) {
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 700);
|
||||
}
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در ثبت فیش', 'danger');
|
||||
showToast(resp.message + ':' + resp.error || 'خطا در ثبت فیش', 'danger');
|
||||
}
|
||||
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
});
|
||||
|
@ -267,11 +267,13 @@
|
|||
method: 'POST',
|
||||
body: fd
|
||||
}).then(r => r.json()).then(resp => {
|
||||
if (resp.success && resp.redirect) {
|
||||
showToast('فیش با موفقیت حذف شد', 'success');
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 700);
|
||||
if (resp.success) {
|
||||
showToast(resp.message || 'فیش با موفقیت حذف شد', 'success');
|
||||
if (resp.redirect) {
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 700);
|
||||
}
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در حذف فیش', 'danger');
|
||||
showToast(resp.message || resp.error || 'خطا در حذف فیش', 'danger');
|
||||
}
|
||||
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
}
|
||||
|
@ -288,11 +290,13 @@
|
|||
method: 'POST',
|
||||
body: fd
|
||||
}).then(r => r.json()).then(resp => {
|
||||
if (resp.success && resp.redirect) {
|
||||
showToast(resp.message, 'success');
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 600);
|
||||
if (resp.success) {
|
||||
showToast(resp.message || 'پرداختها تایید شد', 'success');
|
||||
if (resp.redirect) {
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 600);
|
||||
}
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در تایید پرداختها', 'danger');
|
||||
showToast(resp.message || resp.error || 'خطا در تایید پرداختها', 'danger');
|
||||
}
|
||||
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
{% stepper_header instance step %}
|
||||
<div class="bs-stepper-content">
|
||||
<!-- Invoice Preview Card -->
|
||||
<div class="card invoice-preview-card mt-4">
|
||||
<div class="card invoice-preview-card mt-4 border">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between flex-xl-row flex-md-column flex-sm-row flex-column p-sm-3 p-0">
|
||||
<div class="mb-xl-0 mb-4">
|
||||
|
|
|
@ -21,4 +21,17 @@ urlpatterns = [
|
|||
|
||||
# Quote print
|
||||
path('instance/<int:instance_id>/quote/print/', views.quote_print, name='quote_print'),
|
||||
|
||||
# Final invoice (step 7?) and print
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/', views.final_invoice_step, name='final_invoice_step'),
|
||||
path('instance/<int:instance_id>/final-invoice/print/', views.final_invoice_print, name='final_invoice_print'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/special/add/', views.add_special_charge, name='add_special_charge'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/special/<int:item_id>/delete/', views.delete_special_charge, name='delete_special_charge'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/approve/', views.approve_final_invoice, name='approve_final_invoice'),
|
||||
|
||||
# Final settlement payments (step 8?)
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/', views.final_settlement_step, name='final_settlement_step'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/add/', views.add_final_payment, name='add_final_payment'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/<int:payment_id>/delete/', views.delete_final_payment, name='delete_final_payment'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/approve/', views.approve_final_settlement, name='approve_final_settlement'),
|
||||
]
|
||||
|
|
|
@ -11,6 +11,7 @@ import json
|
|||
|
||||
from processes.models import ProcessInstance, ProcessStep, StepInstance
|
||||
from .models import Item, Quote, QuoteItem, Payment, Invoice
|
||||
from installations.models import InstallationReport, InstallationItemChange
|
||||
|
||||
@login_required
|
||||
def quote_step(request, instance_id, step_id):
|
||||
|
@ -413,3 +414,358 @@ def approve_payments(request, instance_id, step_id):
|
|||
msg += ' - توجه: مبلغ پیشفاکتور به طور کامل پرداخت نشده است.'
|
||||
|
||||
return JsonResponse({'success': True, 'message': msg, 'redirect': redirect_url, 'is_fully_paid': is_fully_paid})
|
||||
|
||||
|
||||
@login_required
|
||||
def final_invoice_step(request, instance_id, step_id):
|
||||
"""تجمیع اقلام پیشفاکتور با تغییرات نصب و صدور فاکتور نهایی"""
|
||||
instance = get_object_or_404(
|
||||
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
|
||||
id=instance_id
|
||||
)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
|
||||
if not instance.can_access_step(step):
|
||||
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
|
||||
return redirect('processes:request_list')
|
||||
|
||||
quote = get_object_or_404(Quote, process_instance=instance)
|
||||
|
||||
# Helper to make safe Decimal from various inputs (handles commas/persian digits)
|
||||
def _to_decimal(value):
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
try:
|
||||
if isinstance(value, (int, float)):
|
||||
return Decimal(str(value))
|
||||
s = str(value or '').strip()
|
||||
if not s:
|
||||
return Decimal('0')
|
||||
# normalize commas and Persian digits
|
||||
persian = '۰۱۲۳۴۵۶۷۸۹'
|
||||
latin = '0123456789'
|
||||
tbl = str.maketrans({persian[i]: latin[i] for i in range(10)})
|
||||
s = s.translate(tbl).replace(',', '')
|
||||
return Decimal(s)
|
||||
except Exception:
|
||||
return Decimal('0')
|
||||
|
||||
# Build initial map from quote
|
||||
item_id_to_row = {}
|
||||
for qi in quote.items.all():
|
||||
item_id_to_row[qi.item_id] = {
|
||||
'item': qi.item,
|
||||
'base_qty': qi.quantity,
|
||||
'base_price': _to_decimal(qi.unit_price),
|
||||
'added_qty': 0,
|
||||
'removed_qty': 0,
|
||||
}
|
||||
|
||||
# Read installation changes from latest report (if any)
|
||||
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
|
||||
if latest_report:
|
||||
for ch in latest_report.item_changes.all():
|
||||
row = item_id_to_row.setdefault(ch.item_id, {
|
||||
'item': ch.item,
|
||||
'base_qty': 0,
|
||||
'base_price': _to_decimal(ch.unit_price or ch.item.unit_price),
|
||||
'added_qty': 0,
|
||||
'removed_qty': 0,
|
||||
})
|
||||
if ch.change_type == 'add':
|
||||
row['added_qty'] += ch.quantity
|
||||
if ch.unit_price:
|
||||
row['base_price'] = _to_decimal(ch.unit_price)
|
||||
else:
|
||||
row['removed_qty'] += ch.quantity
|
||||
if ch.unit_price:
|
||||
row['base_price'] = _to_decimal(ch.unit_price)
|
||||
|
||||
# Compute final invoice lines
|
||||
rows = []
|
||||
total_amount = Decimal('0')
|
||||
for _, r in item_id_to_row.items():
|
||||
final_qty = max(0, (r['base_qty'] + r['added_qty'] - r['removed_qty']))
|
||||
if final_qty == 0:
|
||||
continue
|
||||
unit_price_dec = _to_decimal(r['base_price'])
|
||||
line_total = Decimal(final_qty) * unit_price_dec
|
||||
total_amount += line_total
|
||||
rows.append({
|
||||
'item': r['item'],
|
||||
'quantity': final_qty,
|
||||
'unit_price': unit_price_dec,
|
||||
'total_price': line_total,
|
||||
'base_qty': r['base_qty'],
|
||||
'added_qty': r['added_qty'],
|
||||
'removed_qty': r['removed_qty'],
|
||||
})
|
||||
|
||||
# Create or reuse final invoice
|
||||
invoice, _ = Invoice.objects.get_or_create(
|
||||
process_instance=instance,
|
||||
customer=quote.customer,
|
||||
quote=quote,
|
||||
defaults={
|
||||
'name': f"فاکتور نهایی {instance.code}",
|
||||
'due_date': timezone.now().date(),
|
||||
'created_by': request.user,
|
||||
}
|
||||
)
|
||||
# Replace only non-special items (preserve special charges added by user)
|
||||
qs = invoice.items.select_related('item').filter(item__is_special=False)
|
||||
try:
|
||||
qs._raw_delete(qs.db)
|
||||
except Exception:
|
||||
qs.delete()
|
||||
for r in rows:
|
||||
from .models import InvoiceItem
|
||||
InvoiceItem.objects.create(
|
||||
invoice=invoice,
|
||||
item=r['item'],
|
||||
quantity=r['quantity'],
|
||||
unit_price=r['unit_price'],
|
||||
)
|
||||
invoice.calculate_totals()
|
||||
|
||||
previous_step = instance.process.steps.filter(order__lt=step.order).last()
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
|
||||
# Choices for special items from DB
|
||||
special_choices = list(Item.objects.filter(is_special=True).values('id', 'name'))
|
||||
|
||||
return render(request, 'invoices/final_invoice_step.html', {
|
||||
'instance': instance,
|
||||
'step': step,
|
||||
'invoice': invoice,
|
||||
'rows': rows,
|
||||
'special_choices': special_choices,
|
||||
'invoice_specials': invoice.items.select_related('item').filter(item__is_special=True, is_deleted=False).all(),
|
||||
'previous_step': previous_step,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def final_invoice_print(request, instance_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
items = invoice.items.select_related('item').filter(is_deleted=False).all()
|
||||
return render(request, 'invoices/final_invoice_print.html', {
|
||||
'instance': instance,
|
||||
'invoice': invoice,
|
||||
'items': items,
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def approve_final_invoice(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
# Block approval when there is any remaining (positive or negative)
|
||||
invoice.calculate_totals()
|
||||
if invoice.remaining_amount != 0:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})"
|
||||
})
|
||||
# mark step completed
|
||||
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
|
||||
step_instance.status = 'completed'
|
||||
step_instance.completed_at = timezone.now()
|
||||
step_instance.save()
|
||||
# move to next
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
redirect_url = reverse('processes:request_list')
|
||||
if next_step:
|
||||
instance.current_step = next_step
|
||||
instance.save()
|
||||
redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
|
||||
return JsonResponse({'success': True, 'message': 'فاکتور نهایی تایید شد', 'redirect': redirect_url})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def add_special_charge(request, instance_id, step_id):
|
||||
"""افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی بهصورت آیتم جداگانه"""
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
# charge_type was removed from UI; we no longer require it
|
||||
item_id = request.POST.get('item_id')
|
||||
amount = (request.POST.get('amount') or '').strip()
|
||||
if not item_id:
|
||||
return JsonResponse({'success': False, 'message': 'آیتم را انتخاب کنید'})
|
||||
try:
|
||||
amount_dec = Decimal(amount)
|
||||
except (InvalidOperation, TypeError):
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ نامعتبر است'})
|
||||
if amount_dec <= 0:
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ باید مثبت باشد'})
|
||||
|
||||
# Fetch existing special item from DB
|
||||
special_item = get_object_or_404(Item, id=item_id, is_special=True)
|
||||
|
||||
from .models import InvoiceItem
|
||||
InvoiceItem.objects.create(
|
||||
invoice=invoice,
|
||||
item=special_item,
|
||||
quantity=1,
|
||||
unit_price=amount_dec,
|
||||
)
|
||||
invoice.calculate_totals()
|
||||
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def delete_special_charge(request, instance_id, step_id, item_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
from .models import InvoiceItem
|
||||
inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice)
|
||||
# allow deletion only for special items
|
||||
try:
|
||||
if not getattr(inv_item.item, 'is_special', False):
|
||||
return JsonResponse({'success': False, 'message': 'امکان حذف این مورد وجود ندارد'})
|
||||
except Exception:
|
||||
return JsonResponse({'success': False, 'message': 'امکان حذف این مورد وجود ندارد'})
|
||||
inv_item.hard_delete()
|
||||
invoice.calculate_totals()
|
||||
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
|
||||
|
||||
|
||||
@login_required
|
||||
def final_settlement_step(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
if not instance.can_access_step(step):
|
||||
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
|
||||
return redirect('processes:request_list')
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
|
||||
previous_step = instance.process.steps.filter(order__lt=step.order).last()
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
|
||||
return render(request, 'invoices/final_settlement_step.html', {
|
||||
'instance': instance,
|
||||
'step': step,
|
||||
'invoice': invoice,
|
||||
'payments': invoice.payments.filter(is_deleted=False).all(),
|
||||
'previous_step': previous_step,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def add_final_payment(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
amount = (request.POST.get('amount') or '').strip()
|
||||
payment_date = (request.POST.get('payment_date') or '').strip()
|
||||
payment_method = (request.POST.get('payment_method') or '').strip()
|
||||
reference_number = (request.POST.get('reference_number') or '').strip()
|
||||
direction = (request.POST.get('direction') or 'in').strip()
|
||||
receipt_image = request.FILES.get('receipt_image')
|
||||
if not amount:
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ را وارد کنید'})
|
||||
if not payment_date:
|
||||
return JsonResponse({'success': False, 'message': 'تاریخ پرداخت را وارد کنید'})
|
||||
if not payment_method:
|
||||
return JsonResponse({'success': False, 'message': 'روش پرداخت را انتخاب کنید'})
|
||||
if not reference_number:
|
||||
return JsonResponse({'success': False, 'message': 'شماره مرجع را وارد کنید'})
|
||||
if not receipt_image:
|
||||
return JsonResponse({'success': False, 'message': 'تصویر فیش الزامی است'})
|
||||
if '/' in payment_date:
|
||||
payment_date = payment_date.replace('/', '-')
|
||||
try:
|
||||
amount_dec = Decimal(amount)
|
||||
except InvalidOperation:
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ نامعتبر است'})
|
||||
# Only allow outgoing (پرداخت به مشتری) when current net due is negative
|
||||
# Compute net due explicitly from current items/payments
|
||||
try:
|
||||
current_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in invoice.payments.filter(is_deleted=False).all())
|
||||
except Exception:
|
||||
current_paid = Decimal('0')
|
||||
# Ensure invoice totals are up-to-date for final_amount
|
||||
invoice.calculate_totals()
|
||||
net_due = invoice.final_amount - current_paid
|
||||
if direction == 'out' and net_due >= 0:
|
||||
return JsonResponse({'success': False, 'message': 'در حال حاضر مانده به نفع مشتری نیست'})
|
||||
|
||||
# Amount constraints by sign of net due
|
||||
if net_due > 0 and direction == 'in' and amount_dec > net_due:
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ فیش بیشتر از مانده فاکتور است'})
|
||||
if net_due < 0 and direction == 'out' and amount_dec > abs(net_due):
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ فیش بیشتر از مانده بدهی شرکت به مشتری است'})
|
||||
if net_due < 0 and direction == 'in':
|
||||
return JsonResponse({'success': False, 'message': 'در حال حاضر مانده به نفع مشتری است؛ دریافت از مشتری مجاز نیست'})
|
||||
|
||||
Payment.objects.create(
|
||||
invoice=invoice,
|
||||
amount=amount_dec,
|
||||
payment_date=payment_date,
|
||||
payment_method=payment_method,
|
||||
reference_number=reference_number,
|
||||
direction='in' if direction != 'out' else 'out',
|
||||
receipt_image=receipt_image,
|
||||
created_by=request.user,
|
||||
)
|
||||
# After creation, totals auto-updated by model save. Respond with redirect and new totals for UX.
|
||||
invoice.refresh_from_db()
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]),
|
||||
'totals': {
|
||||
'final_amount': str(invoice.final_amount),
|
||||
'paid_amount': str(invoice.paid_amount),
|
||||
'remaining_amount': str(invoice.remaining_amount),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def delete_final_payment(request, instance_id, step_id, payment_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
|
||||
payment.delete()
|
||||
invoice.refresh_from_db()
|
||||
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'totals': {
|
||||
'final_amount': str(invoice.final_amount),
|
||||
'paid_amount': str(invoice.paid_amount),
|
||||
'remaining_amount': str(invoice.remaining_amount),
|
||||
}})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def approve_final_settlement(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
# Block approval if any remaining exists (positive or negative)
|
||||
invoice.calculate_totals()
|
||||
if invoice.remaining_amount != 0:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})"
|
||||
})
|
||||
# complete step
|
||||
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
|
||||
step_instance.status = 'completed'
|
||||
step_instance.completed_at = timezone.now()
|
||||
step_instance.save()
|
||||
# move next
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
redirect_url = reverse('processes:request_list')
|
||||
if next_step:
|
||||
instance.current_step = next_step
|
||||
instance.save()
|
||||
redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
|
||||
return JsonResponse({'success': True, 'message': 'تسویه حساب نهایی ثبت شد', 'redirect': redirect_url})
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue