Add qoute step.
This commit is contained in:
parent
b71ea45681
commit
6ff4740d04
30 changed files with 3362 additions and 376 deletions
|
@ -1,4 +1,4 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-07 09:08
|
||||
# Generated by Django 5.2.4 on 2025-08-14 09:02
|
||||
|
||||
import django.db.models.deletion
|
||||
import simple_history.models
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-16 04:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='historicalpayment',
|
||||
name='receipt_image',
|
||||
field=models.TextField(blank=True, max_length=100, null=True, verbose_name='تصویر فیش'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='receipt_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='payments/%Y/%m/%d/', verbose_name='تصویر فیش'),
|
||||
),
|
||||
]
|
|
@ -4,6 +4,9 @@ from common.models import NameSlugModel, BaseModel
|
|||
from simple_history.models import HistoricalRecords
|
||||
from django.core.exceptions import ValidationError
|
||||
from decimal import Decimal
|
||||
from django.utils import timezone
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.conf import settings
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
@ -123,6 +126,21 @@ class Quote(NameSlugModel):
|
|||
color = status_colors.get(self.status, 'secondary')
|
||||
return '<span class="badge bg-{}">{}</span>'.format(color, self.get_status_display())
|
||||
|
||||
def get_paid_amount(self):
|
||||
"""مبلغ پرداخت شده برای این پیشفاکتور بر اساس پرداختهای فاکتور مرتبط"""
|
||||
invoice = Invoice.objects.filter(quote=self).first()
|
||||
if not invoice:
|
||||
return Decimal('0')
|
||||
return sum(p.amount for p in invoice.payments.filter(is_deleted=False).all())
|
||||
|
||||
def get_remaining_amount(self):
|
||||
"""مبلغ باقیمانده بر اساس پرداختها"""
|
||||
paid = self.get_paid_amount()
|
||||
remaining = self.final_amount - paid
|
||||
if remaining < 0:
|
||||
remaining = Decimal('0')
|
||||
return remaining
|
||||
|
||||
class QuoteItem(BaseModel):
|
||||
"""مدل آیتمهای پیشفاکتور"""
|
||||
quote = models.ForeignKey(Quote, on_delete=models.CASCADE, related_name='items', verbose_name="پیشفاکتور")
|
||||
|
@ -311,6 +329,7 @@ class Payment(BaseModel):
|
|||
reference_number = models.CharField(max_length=100, verbose_name="شماره مرجع", blank=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="تصویر فیش")
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="ثبت کننده")
|
||||
history = HistoricalRecords()
|
||||
|
||||
|
|
402
invoices/templates/invoices/quote_payment_step.html
Normal file
402
invoices/templates/invoices/quote_payment_step.html
Normal file
|
@ -0,0 +1,402 @@
|
|||
{% 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' %}">
|
||||
<!-- Persian Date Picker CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_toasts.html' %}
|
||||
{% 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:quote_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">
|
||||
|
||||
<form id="formAddPayment" enctype="multipart/form-data" onsubmit="return false;">
|
||||
{% csrf_token %}
|
||||
<div class="content active dstepper-block">
|
||||
<div class="content-header mb-3">
|
||||
<h6 class="mb-0">{{ step.name }}</h6>
|
||||
<small>ثبت فیشهای واریزی برای پیشفاکتور</small>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card h-100 border">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">ثبت فیش جدید</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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" placeholder="..." 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="mb-3">
|
||||
<label class="form-label">توضیحات</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-7">
|
||||
<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>
|
||||
</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">{{ totals.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 text-success">{{ totals.paid_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 totals.is_fully_paid %}text-success{% else %}text-danger{% endif %}">{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-center">
|
||||
{% if totals.is_fully_paid %}
|
||||
<span class="badge bg-success">تسویه کامل</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">باقیمانده دارد</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title 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:120px">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in payments %}
|
||||
<tr>
|
||||
<td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
|
||||
<td>{{ p.payment_date|date:'Y/m/d' }}</td>
|
||||
<td>{{ p.get_payment_method_display }}</td>
|
||||
<td>{{ p.reference_number|default:'-' }}</td>
|
||||
<td>
|
||||
{% 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="حذف">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<i class="bx bx-chevron-right bx-sm me-sm-2"></i>
|
||||
قبلی
|
||||
</a>
|
||||
{% 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const isFullyPaid = {{ totals.is_fully_paid|yesno:'true,false' }};
|
||||
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() {
|
||||
// Front-end 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 notes = document.getElementById('id_notes').value.trim();
|
||||
if (!amount || !payDate || !method || !ref || !img) {
|
||||
showToast('همه فیلدها الزامی است', 'danger');
|
||||
return;
|
||||
}
|
||||
const form = document.getElementById('formAddPayment');
|
||||
const fd = buildFormData(form);
|
||||
fetch('{% url "invoices:add_quote_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'));
|
||||
});
|
||||
|
||||
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_quote_payment" instance.id step.id 0 %}`.replace('/0/', `/${deleteTargetId}/`), {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
}).then(r => r.json()).then(resp => {
|
||||
if (resp.success && resp.redirect) {
|
||||
showToast('فیش با موفقیت حذف شد', 'success');
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 700);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در حذف فیش', 'danger');
|
||||
}
|
||||
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
}
|
||||
|
||||
function editPayment(id) {
|
||||
// برای سادگی، همین فرم را استفاده نمیکنیم؛ میتوانید مدال ویرایش اضافه کنید
|
||||
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 && resp.redirect) {
|
||||
showToast(resp.message, 'success');
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 600);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در تایید پرداختها', '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();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Persian Date Picker JS -->
|
||||
<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) {
|
||||
try {
|
||||
$('#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 gregorianDate = new Date(unix);
|
||||
const year = gregorianDate.getFullYear();
|
||||
const month = String(gregorianDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(gregorianDate.getDate()).padStart(2, '0');
|
||||
const gregorianDateString = `${year}-${month}-${day}`;
|
||||
if (window.persianDate) {
|
||||
const persianDate = new window.persianDate(unix);
|
||||
const persianDateString = persianDate.format('YYYY/MM/DD');
|
||||
$('#id_payment_date').val(persianDateString);
|
||||
} else {
|
||||
$('#id_payment_date').val(gregorianDateString);
|
||||
}
|
||||
$('#id_payment_date').attr('data-gregorian', gregorianDateString);
|
||||
}
|
||||
});
|
||||
} catch (e) { console.error('Error initializing Persian Date Picker:', e); }
|
||||
}
|
||||
})();
|
||||
</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 %}
|
287
invoices/templates/invoices/quote_preview_step.html
Normal file
287
invoices/templates/invoices/quote_preview_step.html
Normal file
|
@ -0,0 +1,287 @@
|
|||
{% 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:quote_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">
|
||||
<!-- Invoice Preview Card -->
|
||||
<div class="card invoice-preview-card mt-4">
|
||||
<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">
|
||||
<div class="d-flex svg-illustration mb-3 gap-2">
|
||||
<span class="app-brand-logo demo">
|
||||
<svg width="25" viewBox="0 0 25 42" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<path d="M13.7918663,0.358365126 L3.39788168,7.44174259 C0.566865006,9.69408886 -0.379795268,12.4788597 0.557900856,15.7960551 C0.68998853,16.2305145 1.09562888,17.7872135 3.12357076,19.2293357 C3.8146334,19.7207684 5.32369333,20.3834223 7.65075054,21.2172976 L7.59773219,21.2525164 L2.63468769,24.5493413 C0.445452254,26.3002124 0.0884951797,28.5083815 1.56381646,31.1738486 C2.83770406,32.8170431 5.20850219,33.2640127 7.09180128,32.5391577 C8.347334,32.0559211 11.4559176,30.0011079 16.4175519,26.3747182 C18.0338572,24.4997857 18.6973423,22.4544883 18.4080071,20.2388261 C17.963753,17.5346866 16.1776345,15.5799961 13.0496516,14.3747546 L10.9194936,13.4715819 L18.6192054,7.984237 L13.7918663,0.358365126 Z" id="path-1"></path>
|
||||
<path d="M5.47320593,6.00457225 C4.05321814,8.216144 4.36334763,10.0722806 6.40359441,11.5729822 C8.61520715,12.571656 10.0999176,13.2171421 10.8577257,13.5094407 L15.5088241,14.433041 L18.6192054,7.984237 C15.5364148,3.11535317 13.9273018,0.573395879 13.7918663,0.358365126 C13.5790555,0.511491653 10.8061687,2.3935607 5.47320593,6.00457225 Z" id="path-3"></path>
|
||||
<path d="M7.50063644,21.2294429 L12.3234468,23.3159332 C14.1688022,24.7579751 14.397098,26.4880487 13.008334,28.506154 C11.6195701,30.5242593 10.3099883,31.790241 9.07958868,32.3040991 C5.78142938,33.4346997 4.13234973,34 4.13234973,34 C4.13234973,34 2.75489982,33.0538207 2.37032616e-14,31.1614621 C-0.55822714,27.8186216 -0.55822714,26.0572515 -4.05231404e-15,25.8773518 C0.83734071,25.6075023 2.77988457,22.8248993 3.3049379,22.52991 C3.65497346,22.3332504 5.05353963,21.8997614 7.50063644,21.2294429 Z" id="path-4"></path>
|
||||
<path d="M20.6,7.13333333 L25.6,13.8 C26.2627417,14.6836556 26.0836556,15.9372583 25.2,16.6 C24.8538077,16.8596443 24.4327404,17 24,17 L14,17 C12.8954305,17 12,16.1045695 12,15 C12,14.5672596 12.1403557,14.1461923 12.4,13.8 L17.4,7.13333333 C18.0627417,6.24967773 19.3163444,6.07059163 20.2,6.73333333 C20.3516113,6.84704183 20.4862915,6.981722 20.6,7.13333333 Z" id="path-5"></path>
|
||||
</defs>
|
||||
<g id="g-app-brand" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Brand-Logo" transform="translate(-27.000000, -15.000000)">
|
||||
<g id="Icon" transform="translate(27.000000, 15.000000)">
|
||||
<g id="Mask" transform="translate(0.000000, 8.000000)">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<use fill="#696cff" xlink:href="#path-1"></use>
|
||||
<g id="Path-3" mask="url(#mask-2)">
|
||||
<use fill="#696cff" xlink:href="#path-3"></use>
|
||||
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-3"></use>
|
||||
</g>
|
||||
<g id="Path-4" mask="url(#mask-2)">
|
||||
<use fill="#696cff" xlink:href="#path-4"></use>
|
||||
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-4"></use>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Triangle" transform="translate(19.000000, 11.000000) rotate(-300.000000) translate(-19.000000, -11.000000) ">
|
||||
<use fill="#696cff" xlink:href="#path-5"></use>
|
||||
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-5"></use>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-brand-text demo text-body fw-bold">شرکت آب منطقهای</span>
|
||||
</div>
|
||||
<p class="mb-1">دفتر مرکزی، خیابان اصلی</p>
|
||||
<p class="mb-1">تهران، ایران</p>
|
||||
<p class="mb-0">۰۲۱-۱۲۳۴۵۶۷۸</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>پیشفاکتور #{{ quote.name }}</h4>
|
||||
<div class="mb-2">
|
||||
<span class="me-1">تاریخ صدور:</span>
|
||||
<span class="fw-medium">{{ quote.jcreated }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="me-1">معتبر تا:</span>
|
||||
<span class="fw-medium">{{ quote.valid_until|date:"Y/m/d" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-0">
|
||||
<div class="card-body">
|
||||
<div class="row p-sm-3 p-0">
|
||||
<div class="col-xl-6 col-md-12 col-sm-5 col-12 mb-xl-0 mb-md-4 mb-sm-0 mb-4">
|
||||
<h6 class="pb-2">صادر شده برای:</h6>
|
||||
<p class="mb-1">{{ quote.customer.get_full_name }}</p>
|
||||
{% if instance.representative.profile %}
|
||||
<p class="mb-1">کد ملی: {{ instance.representative.profile.national_code }}</p>
|
||||
<p class="mb-1">{{ instance.representative.profile.address|default:"آدرس نامشخص" }}</p>
|
||||
<p class="mb-1">{{ instance.representative.profile.phone_number_1|default:"" }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-xl-6 col-md-12 col-sm-7 col-12">
|
||||
<h6 class="pb-2">اطلاعات چاه:</h6>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pe-3">شماره اشتراک آب:</td>
|
||||
<td>{{ instance.well.water_subscription_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pe-3">شماره اشتراک برق:</td>
|
||||
<td>{{ instance.well.electricity_subscription_number|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pe-3">سریال کنتور:</td>
|
||||
<td>{{ instance.well.water_meter_serial_number|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pe-3">قدرت چاه:</td>
|
||||
<td>{{ instance.well.well_power|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pe-3">کد درخواست:</td>
|
||||
<td>{{ instance.code }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table border-top m-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>آیتم</th>
|
||||
<th>توضیحات</th>
|
||||
<th>قیمت واحد</th>
|
||||
<th>تعداد</th>
|
||||
<th>قیمت کل</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quote_item in quote.items.all %}
|
||||
<tr>
|
||||
<td class="text-nowrap">{{ quote_item.item.name }}</td>
|
||||
<td class="text-nowrap">{{ quote_item.item.description|default:"-" }}</td>
|
||||
<td>{{ quote_item.unit_price|floatformat:0|intcomma:False }} تومان</td>
|
||||
<td>{{ quote_item.quantity }}</td>
|
||||
<td>{{ quote_item.total_price|floatformat:0|intcomma:False }} تومان</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td colspan="3" class="align-top px-4 py-5">
|
||||
<p class="mb-2">
|
||||
<span class="me-1 fw-medium">صادر کننده:</span>
|
||||
<span>{{ quote.created_by.get_full_name }}</span>
|
||||
</p>
|
||||
<span>با تشکر از انتخاب شما</span>
|
||||
</td>
|
||||
<td class="text-end px-4 py-5">
|
||||
<p class="mb-2">جمع کل:</p>
|
||||
{% if quote.discount_amount > 0 %}
|
||||
<p class="mb-2">تخفیف:</p>
|
||||
{% endif %}
|
||||
<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>
|
||||
{% if quote.discount_amount > 0 %}
|
||||
<p class="fw-medium mb-2">{{ quote.discount_amount|floatformat:0|intcomma:False }} تومان</p>
|
||||
{% endif %}
|
||||
<p class="fw-bold mb-0">{{ quote.final_amount|floatformat:0|intcomma:False }} تومان</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if quote.notes %}
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<span class="fw-medium">یادداشت:</span>
|
||||
<span>{{ quote.notes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="row mt-4 no-print">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}"
|
||||
class="btn btn-label-secondary">
|
||||
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
|
||||
<span class="align-middle d-sm-inline-block d-none">ویرایش اقلام</span>
|
||||
</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">
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script src="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.js' %}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Quote approval
|
||||
const btnApproveQuote = document.getElementById('btnApproveQuote');
|
||||
if (btnApproveQuote) {
|
||||
btnApproveQuote.addEventListener('click', function() {
|
||||
btnApproveQuote.disabled = true;
|
||||
fetch('{% url "invoices:approve_quote" instance.id step.id %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
}).then(r => r.json()).then(resp => {
|
||||
if (resp.success) {
|
||||
showToast('پیشفاکتور با موفقیت تایید شد', 'success');
|
||||
if (resp.redirect) {
|
||||
window.location.href = resp.redirect;
|
||||
return;
|
||||
}
|
||||
setTimeout(() => { window.location.reload(); }, 800);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در تایید پیشفاکتور', 'danger');
|
||||
btnApproveQuote.disabled = false;
|
||||
}
|
||||
}).catch(() => {
|
||||
showToast('خطا در ارتباط با سرور', 'danger');
|
||||
btnApproveQuote.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
283
invoices/templates/invoices/quote_print.html
Normal file
283
invoices/templates/invoices/quote_print.html
Normal file
|
@ -0,0 +1,283 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>پیشفاکتور {{ quote.name }} - {{ instance.code }}</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 1cm;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Vazirmatn', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { print-color-adjust: exact; }
|
||||
.page-break { page-break-before: always; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
|
||||
.invoice-header {
|
||||
border-bottom: 2px solid #696cff;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.company-logo {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #696cff;
|
||||
}
|
||||
|
||||
.invoice-title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.items-table {
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.items-table th {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.items-table td {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.total-section {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.signature-section {
|
||||
margin-top: 50px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding-top: 30px;
|
||||
}
|
||||
|
||||
.signature-box {
|
||||
border: 1px dashed #ccc;
|
||||
height: 80px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<!-- Print Button (hidden in print) -->
|
||||
<div class="no-print mb-3">
|
||||
<button onclick="window.print()" class="btn btn-primary">
|
||||
<i class="bi bi-printer"></i> پرینت
|
||||
</button>
|
||||
<button onclick="window.close()" class="btn btn-secondary">
|
||||
بستن
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Header -->
|
||||
<div class="invoice-header">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="company-logo mb-3">
|
||||
شرکت آب منطقهای
|
||||
</div>
|
||||
<div class="company-info">
|
||||
<p class="mb-1">دفتر مرکزی، خیابان اصلی</p>
|
||||
<p class="mb-1">تهران، ایران</p>
|
||||
<p class="mb-1">تلفن: ۰۲۱-۱۲۳۴۵۶۷۸</p>
|
||||
<p class="mb-0">ایمیل: info@watercompany.ir</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 text-end">
|
||||
<div class="invoice-title">پیشفاکتور</div>
|
||||
<div class="mt-3">
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td><strong>شماره پیشفاکتور:</strong></td>
|
||||
<td>{{ quote.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>کد درخواست:</strong></td>
|
||||
<td>{{ instance.code }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>تاریخ صدور:</strong></td>
|
||||
<td>{{ quote.created|date:"Y/m/d" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>معتبر تا:</strong></td>
|
||||
<td>{{ quote.valid_until|date:"Y/m/d" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer & Well Info -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-6">
|
||||
<h6 class="fw-bold mb-3">مشخصات مشترک:</h6>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td><strong>نام و نام خانوادگی:</strong></td>
|
||||
<td>{{ quote.customer.get_full_name }}</td>
|
||||
</tr>
|
||||
{% if instance.representative.profile %}
|
||||
<tr>
|
||||
<td><strong>کد ملی:</strong></td>
|
||||
<td>{{ instance.representative.profile.national_code }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>تلفن:</strong></td>
|
||||
<td>{{ instance.representative.profile.phone_number_1|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>آدرس:</strong></td>
|
||||
<td>{{ instance.representative.profile.address|default:"آدرس نامشخص" }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h6 class="fw-bold mb-3">مشخصات چاه:</h6>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td><strong>شماره اشتراک آب:</strong></td>
|
||||
<td>{{ instance.well.water_subscription_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>شماره اشتراک برق:</strong></td>
|
||||
<td>{{ instance.well.electricity_subscription_number|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>سریال کنتور:</strong></td>
|
||||
<td>{{ instance.well.water_meter_serial_number|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>قدرت چاه:</strong></td>
|
||||
<td>{{ instance.well.well_power|default:"-" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Table -->
|
||||
<div class="mb-4">
|
||||
<table class="table items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5%">ردیف</th>
|
||||
<th style="width: 30%">شرح کالا/خدمات</th>
|
||||
<th style="width: 30%">توضیحات</th>
|
||||
<th style="width: 10%">تعداد</th>
|
||||
<th style="width: 12.5%">قیمت واحد (تومان)</th>
|
||||
<th style="width: 12.5%">قیمت کل (تومان)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for quote_item in quote.items.all %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td class="text-start">{{ quote_item.item.name }}</td>
|
||||
<td class="text-start">{{ quote_item.item.description|default:"-" }}</td>
|
||||
<td>{{ quote_item.quantity }}</td>
|
||||
<td>{{ quote_item.unit_price|floatformat:0 }}</td>
|
||||
<td>{{ quote_item.total_price|floatformat:0 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-section">
|
||||
<td colspan="5" class="text-end"><strong>جمع کل:</strong></td>
|
||||
<td><strong>{{ quote.total_amount|floatformat:0 }} تومان</strong></td>
|
||||
</tr>
|
||||
{% if quote.discount_amount > 0 %}
|
||||
<tr class="total-section">
|
||||
<td colspan="5" class="text-end"><strong>تخفیف:</strong></td>
|
||||
<td><strong>{{ quote.discount_amount|floatformat:0 }} تومان</strong></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr class="total-section border-top border-2">
|
||||
<td colspan="5" class="text-end"><strong>مبلغ نهایی:</strong></td>
|
||||
<td><strong>{{ quote.final_amount|floatformat:0 }} تومان</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
{% if quote.notes %}
|
||||
<div class="mb-4">
|
||||
<h6 class="fw-bold">یادداشت:</h6>
|
||||
<p>{{ quote.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Additional Info -->
|
||||
<div class="mb-4">
|
||||
<p><strong>صادر کننده:</strong> {{ quote.created_by.get_full_name }}</p>
|
||||
<p class="text-muted">این پیشفاکتور تا تاریخ {{ quote.valid_until|date:"Y/m/d" }} معتبر است.</p>
|
||||
</div>
|
||||
|
||||
<!-- Signature Section -->
|
||||
<div class="signature-section">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="text-center">
|
||||
<p class="mb-2"><strong>امضای مشترک</strong></p>
|
||||
<div class="signature-box">
|
||||
امضا و مهر
|
||||
</div>
|
||||
<p class="mt-2 small">تاریخ: ____/____/____</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-center">
|
||||
<p class="mb-2"><strong>امضای شرکت</strong></p>
|
||||
<div class="signature-box">
|
||||
امضا و مهر
|
||||
</div>
|
||||
<p class="mt-2 small">تاریخ: ____/____/____</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center mt-4 small text-muted">
|
||||
این پیشفاکتور توسط سیستم مدیریت فرآیندها تولید شده است.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto print on load (optional)
|
||||
// window.onload = function() { window.print(); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
204
invoices/templates/invoices/quote_step.html
Normal file
204
invoices/templates/invoices/quote_step.html
Normal file
|
@ -0,0 +1,204 @@
|
|||
{% 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' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_toasts.html' %}
|
||||
<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">
|
||||
<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>
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
|
||||
</div>
|
||||
|
||||
<div class="bs-stepper wizard-vertical vertical mt-2">
|
||||
{% stepper_header instance step %}
|
||||
|
||||
<div class="bs-stepper-content">
|
||||
<form>
|
||||
{% csrf_token %}
|
||||
<div class="content active dstepper-block">
|
||||
<div class="content-header mb-3">
|
||||
<h6 class="mb-0">{{ step.name }}</h6>
|
||||
<small>{{ step.description|default:' ' }}</small>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% if existing_quote %}
|
||||
<div class="col-12 mb-3">
|
||||
<div class="alert alert-info">
|
||||
<h6>پیشفاکتور موجود</h6>
|
||||
<span class="mb-1">نام: {{ existing_quote.name }} | </span>
|
||||
<span class="mb-1">مبلغ کل: {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان | </span>
|
||||
<span class="mb-0">وضعیت: {{ existing_quote.get_status_display_with_color|safe }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px"></th>
|
||||
<th>آیتم</th>
|
||||
<th>قیمت واحد</th>
|
||||
<th style="width:140px">تعداد</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
{% with selected_qty=existing_quote_items|get_item:item.id %}
|
||||
<tr>
|
||||
<td>
|
||||
<input class="form-check-input quote-item-check" type="checkbox"
|
||||
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 %}>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-semibold">{{ item.name }}
|
||||
{% if item.is_default_in_quotes %}
|
||||
<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 %}">
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex justify-content-between">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}"
|
||||
class="btn btn-label-secondary">
|
||||
<i class="bx bx-chevron-left bx-sm ms-sm-n2"></i>
|
||||
<span class="align-middle d-sm-inline-block d-none">قبلی</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% 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>
|
||||
|
||||
{% else %}
|
||||
<button class="btn btn-success" type="button">اتمام</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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script src="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.js' %}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Quote creation
|
||||
const btnCreateQuote = document.getElementById('btnCreateQuote');
|
||||
if (btnCreateQuote) {
|
||||
btnCreateQuote.addEventListener('click', function() {
|
||||
const selections = [];
|
||||
document.querySelectorAll('.quote-item-check').forEach(chk => {
|
||||
if (chk.checked) {
|
||||
const id = chk.getAttribute('data-item-id');
|
||||
const isDefault = chk.getAttribute('data-is-default') === '1';
|
||||
const qtyInput = document.querySelector(`.quote-item-qty[data-item-id="${id}"]`);
|
||||
const qty = qtyInput ? parseInt(qtyInput.value || '1', 10) : 1;
|
||||
selections.push({ id, qty, isDefault });
|
||||
}
|
||||
});
|
||||
if (selections.length === 0) {
|
||||
showToast('حداقل یک آیتم را انتخاب کنید', 'danger');
|
||||
return;
|
||||
}
|
||||
const payload = new FormData();
|
||||
payload.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
|
||||
payload.append('items', JSON.stringify(selections));
|
||||
|
||||
btnCreateQuote.disabled = true;
|
||||
fetch('{% url "invoices:create_quote" instance.id step.id %}', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
}).then(r => r.json()).then(resp => {
|
||||
if (resp.success) {
|
||||
showToast('پیشفاکتور با موفقیت ثبت شد', 'success');
|
||||
if (resp.redirect) {
|
||||
window.location.href = resp.redirect;
|
||||
} else {
|
||||
setTimeout(() => { window.location.reload(); }, 800);
|
||||
}
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در ثبت پیشفاکتور', 'danger');
|
||||
btnCreateQuote.disabled = false;
|
||||
}
|
||||
}).catch(() => {
|
||||
showToast('خطا در ارتباط با سرور', 'danger');
|
||||
btnCreateQuote.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
24
invoices/urls.py
Normal file
24
invoices/urls.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'invoices'
|
||||
|
||||
urlpatterns = [
|
||||
# Quote step for process instances
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/quote/', views.quote_step, name='quote_step'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/quote/create/', views.create_quote, name='create_quote'),
|
||||
|
||||
# Quote preview step (step 2)
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/quote-preview/', views.quote_preview_step, name='quote_preview_step'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/approve/', views.approve_quote, name='approve_quote'),
|
||||
|
||||
# Quote payments step (step 3)
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/payments/', views.quote_payment_step, name='quote_payment_step'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/payments/add/', views.add_quote_payment, name='add_quote_payment'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/update/', views.update_quote_payment, name='update_quote_payment'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/delete/', views.delete_quote_payment, name='delete_quote_payment'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/payments/approve/', views.approve_payments, name='approve_payments'),
|
||||
|
||||
# Quote print
|
||||
path('instance/<int:instance_id>/quote/print/', views.quote_print, name='quote_print'),
|
||||
]
|
|
@ -1,3 +1,415 @@
|
|||
from django.shortcuts import render
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
import logging
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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.urls import reverse
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import json
|
||||
|
||||
# Create your views here.
|
||||
from processes.models import ProcessInstance, ProcessStep, StepInstance
|
||||
from .models import Item, Quote, QuoteItem, Payment, Invoice
|
||||
|
||||
@login_required
|
||||
def quote_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')
|
||||
|
||||
# دریافت آیتمها
|
||||
items = Item.objects.all().order_by('name')
|
||||
existing_quote = Quote.objects.filter(process_instance=instance).first()
|
||||
existing_quote_items = {}
|
||||
if existing_quote:
|
||||
existing_quote_items = {qi.item_id: qi.quantity for qi in existing_quote.items.all()}
|
||||
|
||||
step_instance = instance.step_instances.filter(step=step).first()
|
||||
|
||||
# Navigation logic
|
||||
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/quote_step.html', {
|
||||
'instance': instance,
|
||||
'step': step,
|
||||
'step_instance': step_instance,
|
||||
'items': items,
|
||||
'existing_quote_items': existing_quote_items,
|
||||
'existing_quote': existing_quote,
|
||||
'previous_step': previous_step,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
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)
|
||||
|
||||
try:
|
||||
items_payload = json.loads(request.POST.get('items') or '[]')
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'success': False, 'message': 'دادههای اقلام نامعتبر است'})
|
||||
|
||||
# اطمینان از حضور اقلام پیشفرض حتی اگر کلاینت ارسال نکرده باشد
|
||||
payload_by_id = {}
|
||||
for entry in items_payload:
|
||||
try:
|
||||
iid = int(entry.get('id'))
|
||||
payload_by_id[iid] = int(entry.get('qty') or 1)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
default_item_ids = set(Item.objects.filter(is_default_in_quotes=True).values_list('id', flat=True))
|
||||
if default_item_ids:
|
||||
for default_id in default_item_ids:
|
||||
if default_id not in payload_by_id:
|
||||
# مقدار پیش فرض را قرار بده
|
||||
default_qty = Item.objects.filter(id=default_id).values_list('default_quantity', flat=True).first() or 1
|
||||
payload_by_id[default_id] = int(default_qty)
|
||||
|
||||
# بازسازی payload نهایی معتبر
|
||||
items_payload = [{'id': iid, 'qty': qty} for iid, qty in payload_by_id.items() if qty and qty > 0]
|
||||
|
||||
if not items_payload:
|
||||
return JsonResponse({'success': False, 'message': 'هیچ آیتمی انتخاب نشده است'})
|
||||
|
||||
# Create or reuse quote
|
||||
quote, _ = Quote.objects.get_or_create(
|
||||
process_instance=instance,
|
||||
defaults={
|
||||
'name': f"پیشفاکتور {instance.code}",
|
||||
'customer': instance.representative or request.user,
|
||||
'valid_until': timezone.now().date(),
|
||||
'created_by': request.user,
|
||||
}
|
||||
)
|
||||
|
||||
# Replace quote items with submitted ones
|
||||
quote.items.all().delete()
|
||||
for entry in items_payload:
|
||||
try:
|
||||
item_id = int(entry.get('id'))
|
||||
qty = int(entry.get('qty') or 1)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if qty <= 0:
|
||||
continue
|
||||
item = Item.objects.filter(id=item_id).first()
|
||||
if not item:
|
||||
continue
|
||||
QuoteItem.objects.create(
|
||||
quote=quote,
|
||||
item=item,
|
||||
quantity=qty,
|
||||
unit_price=item.unit_price,
|
||||
total_price=item.unit_price * qty,
|
||||
)
|
||||
|
||||
quote.calculate_totals()
|
||||
|
||||
# تکمیل مرحله
|
||||
step_instance, created = StepInstance.objects.get_or_create(
|
||||
process_instance=instance,
|
||||
step=step
|
||||
)
|
||||
step_instance.status = 'completed'
|
||||
step_instance.completed_at = timezone.now()
|
||||
step_instance.save()
|
||||
|
||||
# انتقال به مرحله بعدی
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
redirect_url = None
|
||||
if next_step:
|
||||
instance.current_step = next_step
|
||||
instance.save()
|
||||
# هدایت مستقیم به مرحله پیشنمایش پیشفاکتور
|
||||
redirect_url = reverse('invoices:quote_preview_step', args=[instance.id, next_step.id])
|
||||
|
||||
return JsonResponse({'success': True, 'quote_id': quote.id, 'redirect': redirect_url})
|
||||
|
||||
@login_required
|
||||
def quote_preview_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)
|
||||
|
||||
step_instance = instance.step_instances.filter(step=step).first()
|
||||
|
||||
# Navigation logic
|
||||
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/quote_preview_step.html', {
|
||||
'instance': instance,
|
||||
'step': step,
|
||||
'step_instance': step_instance,
|
||||
'quote': quote,
|
||||
'previous_step': previous_step,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def quote_print(request, instance_id):
|
||||
"""صفحه پرینت پیشفاکتور"""
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
quote = get_object_or_404(Quote, process_instance=instance)
|
||||
|
||||
return render(request, 'invoices/quote_print.html', {
|
||||
'instance': instance,
|
||||
'quote': quote,
|
||||
})
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
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)
|
||||
|
||||
# تایید پیشفاکتور
|
||||
quote.status = 'sent'
|
||||
quote.save()
|
||||
|
||||
# تکمیل مرحله
|
||||
step_instance, created = StepInstance.objects.get_or_create(
|
||||
process_instance=instance,
|
||||
step=step
|
||||
)
|
||||
step_instance.status = 'completed'
|
||||
step_instance.completed_at = timezone.now()
|
||||
step_instance.save()
|
||||
|
||||
# انتقال به مرحله بعدی
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
redirect_url = None
|
||||
if next_step:
|
||||
instance.current_step = next_step
|
||||
instance.save()
|
||||
redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
|
||||
else:
|
||||
# در صورت نبود مرحله بعدی، بازگشت به لیست درخواستها
|
||||
redirect_url = reverse('processes:request_list')
|
||||
|
||||
messages.success(request, 'پیشفاکتور با موفقیت تایید شد.')
|
||||
return JsonResponse({'success': True, 'message': 'پیشفاکتور با موفقیت تایید شد.', 'redirect': redirect_url})
|
||||
|
||||
|
||||
@login_required
|
||||
def quote_payment_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)
|
||||
invoice = Invoice.objects.filter(quote=quote).first()
|
||||
payments = invoice.payments.select_related('created_by').filter(is_deleted=False).all() if invoice else []
|
||||
|
||||
previous_step = instance.process.steps.filter(order__lt=step.order).last()
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
|
||||
totals = {
|
||||
'final_amount': quote.final_amount,
|
||||
'paid_amount': quote.get_paid_amount(),
|
||||
'remaining_amount': quote.get_remaining_amount(),
|
||||
'is_fully_paid': quote.get_remaining_amount() <= 0,
|
||||
}
|
||||
|
||||
step_instance = instance.step_instances.filter(step=step).first()
|
||||
|
||||
return render(request, 'invoices/quote_payment_step.html', {
|
||||
'instance': instance,
|
||||
'step': step,
|
||||
'step_instance': step_instance,
|
||||
'quote': quote,
|
||||
'payments': payments,
|
||||
'totals': totals,
|
||||
'previous_step': previous_step,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def add_quote_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)
|
||||
quote = get_object_or_404(Quote, process_instance=instance)
|
||||
invoice, _ = Invoice.objects.get_or_create(
|
||||
process_instance=instance,
|
||||
quote=quote,
|
||||
defaults={
|
||||
'name': f"Invoice {quote.name}",
|
||||
'customer': quote.customer,
|
||||
'due_date': timezone.now().date(),
|
||||
'created_by': request.user,
|
||||
}
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
try:
|
||||
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()
|
||||
notes = (request.POST.get('notes') or '').strip()
|
||||
receipt_image = request.FILES.get('receipt_image')
|
||||
# Server-side validation for required fields
|
||||
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': 'تصویر فیش را بارگذاری کنید'})
|
||||
# Normalize date to YYYY-MM-DD (accept YYYY/MM/DD from Persian datepicker)
|
||||
if '/' in payment_date:
|
||||
payment_date = payment_date.replace('/', '-')
|
||||
|
||||
# Prevent overpayment
|
||||
try:
|
||||
amount_dec = Decimal(amount)
|
||||
except InvalidOperation:
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ نامعتبر است'})
|
||||
remaining = quote.get_remaining_amount()
|
||||
if amount_dec > remaining:
|
||||
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,
|
||||
receipt_image=receipt_image,
|
||||
notes=notes,
|
||||
created_by=request.user,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Error adding quote payment (instance=%s, step=%s)', instance_id, step_id)
|
||||
return JsonResponse({'success': False, 'message': 'خطا در ثبت فیش', 'error': str(e)})
|
||||
|
||||
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
|
||||
return JsonResponse({'success': True, 'redirect': redirect_url})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def update_quote_payment(request, instance_id, step_id, payment_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
quote = get_object_or_404(Quote, process_instance=instance)
|
||||
invoice = Invoice.objects.filter(quote=quote).first()
|
||||
if not invoice:
|
||||
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
|
||||
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
|
||||
|
||||
try:
|
||||
amount = request.POST.get('amount')
|
||||
payment_date = request.POST.get('payment_date') or payment.payment_date
|
||||
payment_method = request.POST.get('payment_method') or payment.payment_method
|
||||
reference_number = request.POST.get('reference_number') or ''
|
||||
notes = request.POST.get('notes') or ''
|
||||
receipt_image = request.FILES.get('receipt_image')
|
||||
if amount:
|
||||
payment.amount = amount
|
||||
payment.payment_date = payment_date
|
||||
payment.payment_method = payment_method
|
||||
payment.reference_number = reference_number
|
||||
payment.notes = notes
|
||||
# اگر نیاز به ذخیره عکس در Payment دارید، فیلد آن اضافه شده است
|
||||
if receipt_image:
|
||||
payment.receipt_image = receipt_image
|
||||
payment.save()
|
||||
except Exception:
|
||||
return JsonResponse({'success': False, 'message': 'خطا در ویرایش فیش'})
|
||||
|
||||
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
|
||||
return JsonResponse({'success': True, 'redirect': redirect_url})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def delete_quote_payment(request, instance_id, step_id, payment_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
quote = get_object_or_404(Quote, process_instance=instance)
|
||||
invoice = Invoice.objects.filter(quote=quote).first()
|
||||
if not invoice:
|
||||
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
|
||||
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
|
||||
try:
|
||||
# soft delete using project's BaseModel delete override
|
||||
payment.delete()
|
||||
except Exception:
|
||||
return JsonResponse({'success': False, 'message': 'خطا در حذف فیش'})
|
||||
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
|
||||
return JsonResponse({'success': True, 'redirect': redirect_url})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def approve_payments(request, instance_id, step_id):
|
||||
"""تایید مرحله پرداخت فیشها و انتقال به مرحله بعد"""
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
quote = get_object_or_404(Quote, process_instance=instance)
|
||||
|
||||
is_fully_paid = quote.get_remaining_amount() <= 0
|
||||
|
||||
# تکمیل مرحله
|
||||
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
|
||||
step_instance.status = 'completed'
|
||||
step_instance.completed_at = timezone.now()
|
||||
step_instance.save()
|
||||
|
||||
# حرکت به مرحله بعد
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
redirect_url = reverse('processes:request_list')
|
||||
if next_step:
|
||||
instance.current_step = next_step
|
||||
instance.save()
|
||||
redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
|
||||
|
||||
msg = 'پرداختها تایید شد'
|
||||
if is_fully_paid:
|
||||
msg += ' - مبلغ پیشفاکتور به طور کامل پرداخت شده است.'
|
||||
else:
|
||||
msg += ' - توجه: مبلغ پیشفاکتور به طور کامل پرداخت نشده است.'
|
||||
|
||||
return JsonResponse({'success': True, 'message': msg, 'redirect': redirect_url, 'is_fully_paid': is_fully_paid})
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue