Compare commits

...

8 commits

19 changed files with 453 additions and 71 deletions

4
.gitignore vendored
View file

@ -9,8 +9,8 @@
*.pyc
__pycache__/
local_settings.py
# *.sqlite3
# db.sqlite3
*.sqlite3
db.sqlite3
db.sqlite3-journal
media
#static

View file

@ -20,7 +20,7 @@ class InstallationReportForm(forms.ModelForm):
model = InstallationReport
fields = [
'visited_date', 'new_water_meter_serial', 'seal_number',
'utm_x', 'utm_y', 'meter_type', 'meter_size',
'utm_x', 'utm_y', 'meter_type', 'meter_size', 'meter_model',
'discharge_pipe_diameter', 'usage_type', 'exploitation_license_number',
'motor_power', 'pre_calibration_flow_rate', 'post_calibration_flow_rate',
'water_meter_manufacturer', 'sim_number', 'driving_force',
@ -62,6 +62,13 @@ class InstallationReportForm(forms.ModelForm):
'meter_size': forms.TextInput(attrs={
'class': 'form-control'
}),
'meter_model': forms.Select(attrs={
'class': 'form-select'
}, choices=[
('', 'انتخاب کنید'),
('A', 'A'),
('B', 'B')
]),
'discharge_pipe_diameter': forms.NumberInput(attrs={
'class': 'form-control',
'min': '0',

View file

@ -47,6 +47,11 @@ class InstallationReport(BaseModel):
('volumetric', 'حجمی'),
]
meter_type = models.CharField(max_length=20, choices=METER_TYPE_CHOICES, null=True, blank=True, verbose_name='نوع کنتور')
METER_MODEL_CHOICES = [
('A', 'A'),
('B', 'B'),
]
meter_model = models.CharField(max_length=20, choices=METER_MODEL_CHOICES, null=True, blank=True, verbose_name='مدل کنتور')
meter_size = models.CharField(max_length=50, null=True, blank=True, verbose_name='سایز کنتور')
discharge_pipe_diameter = models.PositiveIntegerField(null=True, blank=True, verbose_name='قطر لوله آبده (اینچ)')
USAGE_TYPE_CHOICES = [

View file

@ -86,7 +86,11 @@
<p class="text-nowrap mb-2"><i class="bx bx-purchase-tag bx-sm me-2"></i>سریال جدید: {{ report.new_water_meter_serial|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-lock-alt bx-sm me-2"></i>شماره پلمپ: {{ report.seal_number|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-chip bx-sm me-2"></i>نوع کنتور: {{ report.get_meter_type_display|default:'-' }}</p>
{% if report.meter_type == 'smart' %}
<p class="text-nowrap mb-2"><i class="bx bx-chip bx-sm me-2"></i>مدل کنتور: {{ report.get_meter_model_display|default:'-' }}</p>
{% else %}
<p class="text-nowrap mb-2"><i class="bx bx-ruler bx-sm me-2"></i>سایز کنتور: {{ report.meter_size|default:'-' }}</p>
{% endif %}
<p class="text-nowrap mb-2"><i class="bx bx-tachometer bx-sm me-2"></i>قطر لوله آبده (اینچ): {{ report.discharge_pipe_diameter|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-building bx-sm me-2"></i>سازنده کنتور: {{ report.water_meter_manufacturer|default:'-' }}</p>
<p class="text-nowrap mb-2"><i class="bx bx-sim-card bx-sm me-2"></i>شماره سیمکارت: {{ report.sim_number|default:'-' }}</p>
@ -279,13 +283,20 @@
<div class="invalid-feedback">{{ form.meter_type.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
<div class="col-md-3" id="meter_size_wrapper">
{{ form.meter_size.label_tag }}
{{ form.meter_size }}
{% if form.meter_size.errors %}
<div class="invalid-feedback">{{ form.meter_size.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3" id="meter_model_wrapper">
{{ form.meter_model.label_tag }}
{{ form.meter_model }}
{% if form.meter_model.errors %}
<div class="invalid-feedback">{{ form.meter_size.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-3">
{{ form.discharge_pipe_diameter.label_tag }}
{{ form.discharge_pipe_diameter }}
@ -329,7 +340,7 @@
{% endif %}
</div>
<div class="col-md-3">
{{ form.water_meter_manufacturer.label_tag }}
{{ form.water_meter_manufacturer.label_tag }}حجمی
<div class="input-group">
{{ form.water_meter_manufacturer }}
{{ form.new_manufacturer }}
@ -435,7 +446,7 @@
{% if qi.item.description %}<small class="text-muted">{{ qi.item.description }}</small>{% endif %}
</div>
</td>
<td>{{ qi.unit_price|floatformat:0|intcomma:False }} تومان</td>
<td>{{ qi.unit_price|floatformat:0|intcomma:False }} ریال</td>
<td>
<span class="text-muted">{% if removed_qty|get_item:qi.item.id %}{{ removed_qty|get_item:qi.item.id }}{% else %}{{ qi.quantity }}{% endif %}</span>
</td>
@ -474,7 +485,7 @@
{% if it.description %}<small class="text-muted">{{ it.description }}</small>{% endif %}
</div>
</td>
<td>{{ it.unit_price|floatformat:0|intcomma:False }} تومان</td>
<td>{{ it.unit_price|floatformat:0|intcomma:False }} ریال</td>
<td>
{% with add_entry=added_map|get_item:it.id %}
<input class="form-control form-control-sm" type="number" min="1" name="add_{{ it.id }}_qty" value="{% if add_entry %}{{ add_entry.qty }}{% endif %}">
@ -505,7 +516,7 @@
{% if user_is_installer %}
<button type="submit" class="btn btn-success" form="installation-report-form">ثبت گزارش</button>
{% endif %}
{% if next_step %}
{% if next_step and not edit_mode %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
@ -759,6 +770,47 @@
}
}
}
// Dynamic meter field visibility based on meter type
(function() {
const meterTypeSelect = document.getElementById('{{ form.meter_type.id_for_label }}');
const meterSizeWrapper = document.getElementById('meter_size_wrapper');
const meterModelWrapper = document.getElementById('meter_model_wrapper');
function updateMeterFields() {
if (!meterTypeSelect) return;
const selectedType = meterTypeSelect.value;
if (selectedType === 'smart') {
// Show meter_model, hide meter_size
meterModelWrapper.style.display = '';
meterSizeWrapper.style.display = 'none';
// Clear meter_size value when hidden
const meterSizeInput = meterSizeWrapper.querySelector('input, select');
if (meterSizeInput) meterSizeInput.value = '';
} else if (selectedType === 'volumetric') {
// Show meter_size, hide meter_model
meterSizeWrapper.style.display = '';
meterModelWrapper.style.display = 'none';
// Clear meter_model value when hidden
const meterModelSelect = meterModelWrapper.querySelector('select');
if (meterModelSelect) meterModelSelect.value = '';
} else {
// No selection: hide both
meterSizeWrapper.style.display = 'none';
meterModelWrapper.style.display = 'none';
}
}
// Initial update on page load
updateMeterFields();
// Update on change
if (meterTypeSelect) {
meterTypeSelect.addEventListener('change', updateMeterFields);
}
})();
</script>
{% endblock %}

View file

@ -57,13 +57,13 @@ class InvoiceAdmin(SimpleHistoryAdmin):
status_display.short_description = "وضعیت"
def paid_amount_display(self, obj):
return f"{obj.get_paid_amount():,.0f} تومان"
return f"{obj.get_paid_amount():,.0f} ریال"
paid_amount_display.short_description = "مبلغ پرداخت شده"
def remaining_amount_display(self, obj):
amount = obj.get_remaining_amount()
color = "green" if amount <= 0 else "red"
return format_html('<span style="color: {};">{:,.0f} تومان</span>', color, amount)
return format_html('<span style="color: {};">{:,.0f} ریال</span>', color, amount)
remaining_amount_display.short_description = "مبلغ باقی‌مانده"
@admin.register(Payment)

View file

@ -38,7 +38,7 @@ class Item(NameSlugModel):
ordering = ['name']
def __str__(self):
return f"{self.name} - {self.unit_price} تومان"
return f"{self.name} - {self.unit_price} ریال"
class Quote(NameSlugModel):
"""مدل پیش‌فاکتور"""
@ -137,11 +137,11 @@ class Quote(NameSlugModel):
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())
return sum((p.amount if p.direction == 'in' else -p.amount) for p in invoice.payments.filter(is_deleted=False).all())
def get_remaining_amount(self):
"""مبلغ باقی‌مانده بر اساس پرداخت‌ها"""
@ -151,6 +151,15 @@ class Quote(NameSlugModel):
remaining = Decimal('0')
return remaining
def get_vat_amount(self) -> Decimal:
"""محاسبه مبلغ مالیات به صورت جداگانه بر اساس VAT_RATE."""
base_amount = (self.total_amount or Decimal('0')) - (self.discount_amount or Decimal('0'))
try:
vat_rate = Decimal(str(getattr(settings, 'VAT_RATE', 0)))
except Exception:
vat_rate = Decimal('0')
return base_amount * vat_rate
class QuoteItem(BaseModel):
"""مدل آیتم‌های پیش‌فاکتور"""
quote = models.ForeignKey(Quote, on_delete=models.CASCADE, related_name='items', verbose_name="پیش‌فاکتور")
@ -291,6 +300,15 @@ class Invoice(NameSlugModel):
remaining = self.final_amount - paid
return remaining
def get_vat_amount(self) -> Decimal:
"""محاسبه مبلغ مالیات به صورت جداگانه بر اساس VAT_RATE."""
base_amount = (self.total_amount or Decimal('0')) - (self.discount_amount or Decimal('0'))
try:
vat_rate = Decimal(str(getattr(settings, 'VAT_RATE', 0)))
except Exception:
vat_rate = Decimal('0')
return base_amount * vat_rate
def get_status_display_with_color(self):
"""نمایش وضعیت با رنگ"""
@ -365,7 +383,7 @@ class Payment(BaseModel):
ordering = ['-payment_date']
def __str__(self):
return f"پرداخت {self.amount} تومان - {self.invoice.name}"
return f"پرداخت {self.amount} ریال - {self.invoice.name}"
def save(self, *args, **kwargs):
"""بروزرسانی مبالغ فاکتور"""

View file

@ -124,8 +124,8 @@
<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>
<th style="width: 12.5%">قیمت واحد(ریال)</th>
<th style="width: 12.5%">قیمت کل(ریال)</th>
</tr>
</thead>
<tbody>
@ -144,25 +144,29 @@
</tbody>
<tfoot>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>جمع کل(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>جمع کل(ریال):</strong></td>
<td><strong>{{ invoice.total_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
{% if invoice.discount_amount > 0 %}
<tr class="total-section">
<td colspan="5" class="text-end"><strong>تخفیف(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>تخفیف(ریال):</strong></td>
<td><strong>{{ invoice.discount_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
{% endif %}
<tr class="total-section">
<td colspan="5" class="text-end"><strong>مالیات بر ارزش افزوده(ریال):</strong></td>
<td><strong>{{ invoice.get_vat_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی (شامل مالیات)(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>مبلغ نهایی (شامل مالیات)(ریال):</strong></td>
<td><strong>{{ invoice.final_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>پرداختی‌ها(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>پرداختی‌ها(ریال):</strong></td>
<td><strong">{{ invoice.get_paid_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>مانده(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>مانده(ریال):</strong></td>
<td><strong>{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
</tfoot>

View file

@ -68,19 +68,19 @@
<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 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.get_paid_amount|floatformat:0|intcomma:False }} تومان</div>
<div class="h5 mt-1 text-success">{{ invoice.get_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.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان</div>
<div class="h5 mt-1 {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} ریال</div>
</div>
</div>
<div class="col-6 col-md-3 d-flex align-items-center">
@ -100,8 +100,8 @@
<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>
<th class="text-end">قیمت واحد (ریال)</th>
<th class="text-end">قیمت کل (ریال)</th>
</tr>
</thead>
<tbody>
@ -153,23 +153,27 @@
<tfoot>
<tr>
<th colspan="6" class="text-end">مبلغ کل</th>
<th class="text-end">{{ invoice.total_amount|floatformat:0|intcomma:False }} تومان</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>
<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.get_vat_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>
<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.get_paid_amount|floatformat:0|intcomma:False }} تومان</th>
<th class="text-end">{{ invoice.get_paid_amount|floatformat:0|intcomma:False }} ریال</th>
</tr>
<tr>
<th colspan="6" class="text-end">مانده</th>
<th class="text-end {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان</th>
<th class="text-end {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} ریال</th>
</tr>
</tfoot>
</table>
@ -223,8 +227,8 @@
</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>
<label class="form-label">مبلغ (ریال)</label>
<input type="text" inputmode="numeric" pattern="\d*" class="form-control" name="amount" id="id_charge_amount" dir="ltr" autocomplete="off" required>
</div>
</form>
</div>
@ -246,8 +250,17 @@
else { el.classList.add('show'); el.style.display = 'block'; }
}
function submitSpecialCharge(){
const fd = new FormData(document.getElementById('specialChargeForm'));
const form = document.getElementById('specialChargeForm');
const fd = new FormData(form);
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
// Ensure raw numeric amount is sent
(function ensureRawAmount(){
const amountInput = document.getElementById('id_charge_amount');
if (amountInput){
const raw = (amountInput.getAttribute('data-raw-value') || amountInput.value.replace(/\D/g, ''));
if (raw) fd.set('amount', raw);
}
})();
fetch('{% url "invoices:add_special_charge" instance.id step.id %}', { method: 'POST', body: fd })
.then(r=>r.json()).then(resp=>{
if (resp.success){
@ -285,6 +298,8 @@
}
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
});
// Number formatting is handled by number-formatter.js
</script>
{% endblock %}

View file

@ -60,7 +60,7 @@
<div class="bs-stepper-content">
<div class="row g-3">
{% if is_broker %}
{% if is_broker and invoice.get_remaining_amount != 0 %}
<div class="col-12 col-lg-5">
<div class="card border h-100">
<div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div>
@ -75,8 +75,8 @@
</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>
<label class="form-label">مبلغ (ریال)</label>
<input type="text" inputmode="numeric" pattern="\d*" class="form-control" name="amount" id="id_amount" dir="ltr" autocomplete="off" required>
</div>
<div class="mb-3">
<label class="form-label">تاریخ</label>
@ -122,19 +122,19 @@
<div class="col-6 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-muted">مبلغ نهایی (با مالیات)</div>
<div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
<div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} ریال</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-muted">پرداختی‌ها</div>
<div class="h5 mt-1 text-success">{{ invoice.get_paid_amount|floatformat:0|intcomma:False }} تومان</div>
<div class="h5 mt-1 text-success">{{ invoice.get_paid_amount|floatformat:0|intcomma:False }} ریال</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-muted">مانده</div>
<div class="h5 mt-1 {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان</div>
<div class="h5 mt-1 {% if invoice.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} ریال</div>
</div>
</div>
<div class="col-6 d-flex align-items-center">
@ -166,7 +166,7 @@
{% 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.amount|floatformat:0|intcomma:False }} ریال</td>
<td>{{ p.jpayment_date }}</td>
<td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td>
@ -193,7 +193,7 @@
</div>
</div>
</div>
{% if approver_statuses %}
{% if approver_statuses and invoice.get_remaining_amount != 0 and step_instance.status != 'completed' %}
<div class="card border mt-2">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">وضعیت تاییدها</h6>
@ -320,7 +320,7 @@
<div class="modal-body">
{% if invoice.get_remaining_amount != 0 %}
<div class="alert alert-warning" role="alert">
مانده فاکتور: <strong>{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} تومان</strong><br>
مانده فاکتور: <strong>{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} ریال</strong><br>
امکان تایید تا تسویه کامل فاکتور وجود ندارد.
</div>
{% else %}
@ -405,6 +405,14 @@
function buildForm(){
const fd = new FormData(document.getElementById('formFinalPayment'));
// Ensure raw numeric amount is sent
(function ensureRawAmount(){
const amountInput = document.getElementById('id_amount');
if (amountInput){
const raw = (amountInput.getAttribute('data-raw-value') || amountInput.value.replace(/\D/g, ''));
if (raw) fd.set('amount', raw);
}
})();
// تبدیل تاریخ شمسی به میلادی برای ارسال
const persianDateValue = $('#id_payment_date').val();
@ -465,6 +473,24 @@
}
// Legacy approve button removed; using modal forms below
// Handle AJAX form submission with number formatting
$(document).ready(function() {
// Override buildForm function for AJAX submission
const originalBuildForm = window.buildForm;
window.buildForm = function() {
// Set raw values before creating FormData
if (window.setRawValuesForSubmission) {
window.setRawValuesForSubmission();
}
const result = originalBuildForm ? originalBuildForm() : new FormData(document.querySelector('form'));
// Restore formatted values for display
if (window.restoreFormattedValues) {
window.restoreFormattedValues();
}
return result;
};
});
</script>
{% endblock %}

View file

@ -71,8 +71,8 @@
</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>
<label class="form-label">مبلغ (ریال)</label>
<input type="text" inputmode="numeric" pattern="\d*" class="form-control" name="amount" id="id_amount" dir="ltr" autocomplete="off" required>
</div>
<div class="mb-3">
<label class="form-label">تاریخ پرداخت/سررسید چک</label>
@ -117,19 +117,19 @@
<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 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 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 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">
@ -153,6 +153,7 @@
<table class="table table-striped mb-0">
<thead>
<tr>
<th>نوع</th>
<th>مبلغ</th>
<th>تاریخ پرداخت/سررسید چک</th>
<th>روش</th>
@ -163,7 +164,8 @@
<tbody>
{% for p in payments %}
<tr>
<td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
<td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td>
<td class="{% if p.direction == 'in' %}text-success{% else %}text-danger{% endif %}">{{ p.amount|floatformat:0|intcomma:False }} ریال</td>
<td>{{ p.jpayment_date }}</td>
<td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td>
@ -175,9 +177,7 @@
</a>
{% endif %}
{% if is_broker %}
<button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف">
<i class="bx bx-trash"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف"><i class="bx bx-trash"></i></button>
{% endif %}
</div>
</td>
@ -301,7 +301,7 @@
{% if not totals.is_fully_paid %}
<div class="alert alert-warning" role="alert">
مبلغی از پیش‌فاکتور هنوز پرداخت نشده است.
<div class="mt-1">مانده: <strong>{{ totals.remaining_amount|floatformat:0|intcomma:False }} تومان</strong></div>
<div class="mt-1">مانده: <strong>{{ totals.remaining_amount|floatformat:0|intcomma:False }} ریال</strong></div>
</div>
آیا مطمئن هستید که می‌خواهید مرحله را تایید کنید؟
{% else %}
@ -366,6 +366,12 @@
}
const form = document.getElementById('formAddPayment');
const fd = buildFormData(form);
// Ensure raw numeric amount is sent
(function ensureRawAmount(){
const amountInput = document.getElementById('id_amount');
const raw = (amountInput.getAttribute('data-raw-value') || amountInput.value.replace(/\D/g, ''));
if (raw) fd.set('amount', raw);
})();
// تبدیل تاریخ شمسی به میلادی برای ارسال
const persianDateValue = $('#id_payment_date').val();
@ -383,7 +389,7 @@
setTimeout(() => { window.location.href = resp.redirect; }, 700);
}
} else {
showToast(resp.message + ':' + resp.error || 'خطا در ثبت فیش', 'danger');
showToast((resp.message || resp.error || 'خطا در ثبت فیش'), 'danger');
}
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
});
@ -460,6 +466,7 @@
} catch (e) { console.error('Error initializing Persian Date Picker:', e); }
}
})();
</script>
{% endblock %}

View file

@ -200,9 +200,9 @@
<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.unit_price|floatformat:0|intcomma:False }} ریال</td>
<td>{{ quote_item.quantity }}</td>
<td>{{ quote_item.total_price|floatformat:0|intcomma:False }} تومان</td>
<td>{{ quote_item.total_price|floatformat:0|intcomma:False }} ریال</td>
</tr>
{% endfor %}
<tr>
@ -213,14 +213,16 @@
{% if quote.discount_amount > 0 %}
<p class="mb-2">تخفیف:</p>
{% endif %}
<p class="mb-2">مالیات بر ارزش افزوده:</p>
<p class="mb-0 fw-bold">مبلغ نهایی (شامل مالیات):</p>
</td>
<td class="px-4 py-5">
<p class="fw-medium mb-2">{{ quote.total_amount|floatformat:0|intcomma:False }} تومان</p>
<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>
<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>
<p class="fw-medium mb-2">{{ quote.get_vat_amount|floatformat:0|intcomma:False }} ریال</p>
<p class="fw-bold mb-0">{{ quote.final_amount|floatformat:0|intcomma:False }} ریال</p>
</td>
</tr>
</tbody>

View file

@ -185,8 +185,8 @@
<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>
<th style="width: 12.5%">قیمت واحد(ریال)</th>
<th style="width: 12.5%">قیمت کل(ریال)</th>
</tr>
</thead>
<tbody>
@ -203,17 +203,21 @@
</tbody>
<tfoot>
<tr class="total-section">
<td colspan="5" class="text-end"><strong>جمع کل(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>جمع کل(ریال):</strong></td>
<td><strong>{{ quote.total_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
{% if quote.discount_amount > 0 %}
<tr class="total-section">
<td colspan="5" class="text-end"><strong>تخفیف(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>تخفیف(ریال):</strong></td>
<td><strong>{{ quote.discount_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
{% endif %}
<tr class="total-section">
<td colspan="5" class="text-end"><strong>مالیات بر ارزش افزوده(ریال):</strong></td>
<td><strong>{{ quote.get_vat_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
<tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی (با مالیات)(تومان):</strong></td>
<td colspan="5" class="text-end"><strong>مبلغ نهایی (با مالیات)(ریال):</strong></td>
<td><strong>{{ quote.final_amount|floatformat:0|intcomma:False }}</strong></td>
</tr>
</tfoot>

View file

@ -57,7 +57,7 @@
<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-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>
@ -97,7 +97,7 @@
{% if item.description %}<small class="text-muted">{{ item.description }}</small>{% endif %}
</div>
</td>
<td>{{ item.unit_price|floatformat:0|intcomma:False }} تومان</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 }}"

View file

@ -898,6 +898,29 @@ def add_special_charge(request, instance_id, step_id):
unit_price=amount_dec,
)
invoice.calculate_totals()
# If the next step was completed, reopen it (set to in_progress) due to invoice change
try:
step = get_object_or_404(instance.process.steps, id=step_id)
next_step = instance.process.steps.filter(order__gt=step.order).first()
if next_step:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=next_step)
if si.status in ['completed', 'approved']:
si.status = 'in_progress'
si.completed_at = None
si.save(update_fields=['status', 'completed_at'])
# Clear prior approvals/rejections as the underlying totals changed
try:
for appr in list(si.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
except Exception:
pass
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
@ -921,6 +944,29 @@ def delete_special_charge(request, instance_id, step_id, item_id):
return JsonResponse({'success': False, 'message': 'امکان حذف این مورد وجود ندارد'})
inv_item.hard_delete()
invoice.calculate_totals()
# If the next step was completed, reopen it (set to in_progress) due to invoice change
try:
step = get_object_or_404(instance.process.steps, id=step_id)
next_step = instance.process.steps.filter(order__gt=step.order).first()
if next_step:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=next_step)
if si.status in ['completed', 'approved']:
si.status = 'in_progress'
si.completed_at = None
si.save(update_fields=['status', 'completed_at'])
# Clear prior approvals/rejections as the underlying totals changed
try:
for appr in list(si.approvals.all()):
appr.delete()
except Exception:
pass
try:
for rej in list(si.rejections.all()):
rej.delete()
except Exception:
pass
except Exception:
pass
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
@ -940,6 +986,23 @@ def final_settlement_step(request, instance_id, step_id):
# Ensure step instance exists
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
# Auto-complete step when invoice is fully settled (no approvals needed)
try:
invoice.calculate_totals()
if invoice.get_remaining_amount() == 0:
if step_instance.status != 'completed':
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
# if next_step:
# instance.current_step = next_step
# instance.save(update_fields=['current_step'])
# return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
# return redirect('processes:request_list')
except Exception:
# If totals calculation fails, continue with normal flow
pass
# Build approver statuses for template (include reason to display in UI)
reqs = list(step.approver_requirements.select_related('role').all())
approvals = list(step_instance.approvals.select_related('role', 'approved_by').filter(is_deleted=False))
@ -1048,6 +1111,14 @@ def final_settlement_step(request, instance_id, step_id):
except Exception:
messages.error(request, 'فقط مدیر مجاز به تایید اضطراری است.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
# Allow emergency approval only when invoice has a remaining (non-zero)
try:
invoice.calculate_totals()
if invoice.get_remaining_amount() == 0:
messages.error(request, 'فاکتور تسویه شده است؛ تایید اضطراری لازم نیست.')
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
except Exception:
pass
# Mark step completed regardless of remaining amount/approvals
step_instance.status = 'approved'
step_instance.save()
@ -1094,6 +1165,14 @@ def add_final_payment(request, instance_id, step_id):
except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن تراکنش تسویه را ندارید'}, status=403)
# Prevent adding payments if invoice already settled
try:
invoice.calculate_totals()
if invoice.get_remaining_amount() == 0:
return JsonResponse({'success': False, 'message': 'فاکتور تسویه شده است؛ افزودن تراکنش مجاز نیست'})
except Exception:
pass
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()

View file

@ -56,9 +56,9 @@
<div class="card-body">
{% if invoice %}
<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.get_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.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_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">{{ 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.get_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.get_remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.get_remaining_amount|floatformat:0|intcomma:False }} ریال</div></div></div>
</div>
<div class="table-responsive">
<table class="table table-striped mb-0">
@ -237,7 +237,7 @@
{% 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.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>

View file

@ -245,7 +245,12 @@
<small class="text-muted">{{ item.progress_percentage }}%</small>
</div>
</td>
<td>{{ item.instance.get_status_display_with_color|safe }}</td>
<td>
{{ item.instance.get_status_display_with_color|safe }}
{% if item.emergency_approved %}
<span class="badge bg-warning text-dark ms-1" title="تایید اضطراری">تایید اضطراری</span>
{% endif %}
</td>
<td>
{% if item.installation_scheduled_date %}
<div>

View file

@ -123,6 +123,17 @@ def request_list(request):
reference_date = None
installation_scheduled_date = reference_date if reference_date and reference_date > sched_date else sched_date
# Emergency approved flag (final settlement step forced approval)
try:
final_settlement_step = instance.process.steps.filter(order=8).first()
emergency_approved = False
if final_settlement_step:
si = instance.step_instances.filter(step=final_settlement_step).first()
emergency_approved = bool(si and si.status == 'approved')
except Exception:
emergency_approved = False
instances_with_progress.append({
'instance': instance,
'progress_percentage': round(progress_percentage),
@ -130,6 +141,7 @@ def request_list(request):
'total_steps': total_steps,
'installation_scheduled_date': installation_scheduled_date,
'installation_overdue_days': overdue_days,
'emergency_approved': emergency_approved,
})
# Summary stats for header cards

View file

@ -0,0 +1,144 @@
/**
* Number Formatter Utility
* Formats numbers with comma separators for better readability
*/
// Format number with comma separators (e.g., 1234567 -> 1,234,567)
function formatNumber(num) {
if (!num) return '';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
// Remove comma separators from formatted number
function unformatNumber(str) {
if (!str) return '';
return str.replace(/,/g, '');
}
// Extract only digits from any string
function extractDigits(str) {
if (!str) return '';
return str.replace(/\D/g, '');
}
// Initialize number formatting for specified input selectors
function initNumberFormatting(selectors) {
if (typeof $ === 'undefined') {
console.warn('jQuery not found. Number formatting requires jQuery.');
return;
}
$(document).ready(function() {
selectors.forEach(function(selector) {
// Store cursor position to maintain it after formatting
function setCursorPosition(input, pos) {
if (input.setSelectionRange) {
input.setSelectionRange(pos, pos);
}
}
$(selector).on('input', function(e) {
let input = $(this);
let inputElement = this;
let value = input.val();
let cursorPos = inputElement.selectionStart;
// Extract only digits
let digitsOnly = extractDigits(value);
// Store raw value
input.attr('data-raw-value', digitsOnly);
// Format and set the value
let formattedValue = formatNumber(digitsOnly);
input.val(formattedValue);
// Adjust cursor position
let oldLength = value.length;
let newLength = formattedValue.length;
let newCursorPos = cursorPos + (newLength - oldLength);
// Make sure cursor position is valid
if (newCursorPos < 0) newCursorPos = 0;
if (newCursorPos > newLength) newCursorPos = newLength;
// Set cursor position after a short delay
setTimeout(function() {
setCursorPosition(inputElement, newCursorPos);
}, 1);
});
// Handle paste events
$(selector).on('paste', function(e) {
let input = $(this);
setTimeout(function() {
let value = input.val();
let digitsOnly = extractDigits(value);
input.attr('data-raw-value', digitsOnly);
input.val(formatNumber(digitsOnly));
}, 1);
});
});
// Before form submission, replace formatted values with raw values
$('form').on('submit', function() {
selectors.forEach(function(selector) {
let input = $(selector);
let rawValue = input.attr('data-raw-value');
if (rawValue) {
input.val(rawValue);
}
});
});
});
}
// Helper function to get raw value from formatted input
function getRawValue(input) {
return $(input).attr('data-raw-value') || unformatNumber($(input).val());
}
// Helper function to set raw value before AJAX submission
function setRawValuesForSubmission(selectors) {
selectors.forEach(function(selector) {
let input = $(selector);
let rawValue = input.attr('data-raw-value');
if (rawValue) {
input.val(rawValue);
}
});
}
// Helper function to restore formatted values after AJAX submission
function restoreFormattedValues(selectors) {
selectors.forEach(function(selector) {
let input = $(selector);
let rawValue = input.attr('data-raw-value');
if (rawValue) {
input.val(formatNumber(rawValue));
}
});
}
// Auto-initialize for common amount input selectors
$(document).ready(function() {
const commonSelectors = [
'#id_amount',
'#id_charge_amount',
'input[name="amount"]',
'input[name="unit_price"]',
'input[name="price"]'
];
initNumberFormatting(commonSelectors);
// Make helper functions globally available for AJAX forms
window.formatNumber = formatNumber;
window.unformatNumber = unformatNumber;
window.getRawValue = getRawValue;
// Avoid name collision causing recursion by aliasing helpers
const __nf_setRawValuesForSubmission = setRawValuesForSubmission;
const __nf_restoreFormattedValues = restoreFormattedValues;
window.setRawValuesForSubmission = function() { __nf_setRawValuesForSubmission(commonSelectors); };
window.restoreFormattedValues = function() { __nf_restoreFormattedValues(commonSelectors); };
});

View file

@ -169,6 +169,8 @@ layout-navbar-fixed layout-menu-fixed layout-compact
<!-- Main JS -->
<script src="{% static 'assets/js/main.js' %}"></script>
<!-- Number Formatter JS -->
<script src="{% static 'assets/js/number-formatter.js' %}"></script>
<!-- Page JS -->
<script src="{% static 'assets/js/dashboards-analytics.js' %}"></script>