complete first version of main proccess
This commit is contained in:
parent
6ff4740d04
commit
f2fc2362a7
61 changed files with 3280 additions and 28 deletions
|
@ -54,6 +54,9 @@ INSTALLED_APPS = [
|
|||
'common.apps.CommonConfig',
|
||||
'processes.apps.ProcessesConfig',
|
||||
'invoices.apps.InvoicesConfig',
|
||||
'contracts.apps.ContractsConfig',
|
||||
'certificates.apps.CertificatesConfig',
|
||||
'installations.apps.InstallationsConfig',
|
||||
# ----------------------- #
|
||||
]
|
||||
|
||||
|
|
|
@ -25,6 +25,9 @@ urlpatterns = [
|
|||
path('wells/', include('wells.urls')),
|
||||
path('processes/', include('processes.urls')),
|
||||
path('invoices/', include('invoices.urls')),
|
||||
path('contracts/', include('contracts.urls')),
|
||||
path('certificates/', include('certificates.urls')),
|
||||
path('installations/', include('installations.urls')),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from accounts.models import Role, Profile
|
||||
from accounts.models import Role, Profile, Company
|
||||
|
||||
|
||||
# Register your models here.
|
||||
|
@ -30,3 +30,12 @@ class ProfileAdmin(admin.ModelAdmin):
|
|||
date_hierarchy = 'created'
|
||||
ordering = ['-created']
|
||||
readonly_fields = ['created', 'updated']
|
||||
|
||||
@admin.register(Company)
|
||||
class CompanyAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'logo', 'signature', 'address', 'phone']
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
search_fields = ['name', 'address', 'phone']
|
||||
list_filter = ['is_active']
|
||||
date_hierarchy = 'created'
|
||||
ordering = ['-created']
|
|
@ -29,7 +29,7 @@ class CustomerForm(forms.ModelForm):
|
|||
model = Profile
|
||||
fields = [
|
||||
'phone_number_1', 'phone_number_2', 'national_code',
|
||||
'address', 'card_number', 'account_number'
|
||||
'address', 'card_number', 'account_number', 'bank_name'
|
||||
]
|
||||
widgets = {
|
||||
'phone_number_1': forms.TextInput(attrs={
|
||||
|
@ -61,6 +61,10 @@ class CustomerForm(forms.ModelForm):
|
|||
'placeholder': 'شماره حساب بانکی',
|
||||
'maxlength': '20'
|
||||
}),
|
||||
'bank_name': forms.Select(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'نام بانک',
|
||||
}),
|
||||
}
|
||||
labels = {
|
||||
'phone_number_1': 'تلفن ۱',
|
||||
|
@ -69,6 +73,7 @@ class CustomerForm(forms.ModelForm):
|
|||
'address': 'آدرس',
|
||||
'card_number': 'شماره کارت',
|
||||
'account_number': 'شماره حساب',
|
||||
'bank_name': 'نام بانک',
|
||||
}
|
||||
|
||||
def clean_national_code(self):
|
||||
|
|
34
accounts/migrations/0002_company.py
Normal file
34
accounts/migrations/0002_company.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-21 06:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Company',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
|
||||
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
|
||||
('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')),
|
||||
('name', models.CharField(max_length=100, verbose_name='نام')),
|
||||
('logo', models.ImageField(blank=True, null=True, upload_to='companies/logos', verbose_name='لوگوی شرکت')),
|
||||
('signature', models.ImageField(blank=True, null=True, upload_to='companies/signatures', verbose_name='امضای شرکت')),
|
||||
('address', models.TextField(blank=True, null=True, verbose_name='آدرس')),
|
||||
('phone', models.CharField(blank=True, max_length=11, null=True, verbose_name='شماره تماس')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'شرکت',
|
||||
'verbose_name_plural': 'شرکت\u200cها',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-21 07:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0002_company'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='historicalprofile',
|
||||
name='bank_name',
|
||||
field=models.CharField(blank=True, choices=[('mellat', 'بانک ملت'), ('saman', 'بانک سامان'), ('parsian', 'بانک پارسیان'), ('sina', 'بانک سینا'), ('tejarat', 'بانک تجارت'), ('tosee', 'بانک توسعه'), ('iran_zamin', 'بانک ایران زمین'), ('meli', 'بانک ملی'), ('saderat', 'بانک توسعه صادرات'), ('iran_zamin', 'بانک ایران زمین'), ('refah', 'بانک رفاه'), ('eghtesad_novin', 'بانک اقتصاد نوین'), ('pasargad', 'بانک پاسارگاد'), ('other', 'سایر')], max_length=255, null=True, verbose_name='نام بانک'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='bank_name',
|
||||
field=models.CharField(blank=True, choices=[('mellat', 'بانک ملت'), ('saman', 'بانک سامان'), ('parsian', 'بانک پارسیان'), ('sina', 'بانک سینا'), ('tejarat', 'بانک تجارت'), ('tosee', 'بانک توسعه'), ('iran_zamin', 'بانک ایران زمین'), ('meli', 'بانک ملی'), ('saderat', 'بانک توسعه صادرات'), ('iran_zamin', 'بانک ایران زمین'), ('refah', 'بانک رفاه'), ('eghtesad_novin', 'بانک اقتصاد نوین'), ('pasargad', 'بانک پاسارگاد'), ('other', 'سایر')], max_length=255, null=True, verbose_name='نام بانک'),
|
||||
),
|
||||
]
|
|
@ -3,14 +3,15 @@ from django.db import models
|
|||
from django.utils.html import format_html
|
||||
from django.core.validators import RegexValidator
|
||||
from simple_history.models import HistoricalRecords
|
||||
from common.models import TagModel, BaseModel
|
||||
from common.consts import UserRoles
|
||||
from common.models import TagModel, BaseModel, NameSlugModel
|
||||
from common.consts import UserRoles, BANK_CHOICES
|
||||
from locations.models import Affairs, Broker, County
|
||||
|
||||
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class Role(TagModel):
|
||||
|
||||
class Meta:
|
||||
verbose_name = "نقش"
|
||||
verbose_name_plural = "نقشها"
|
||||
|
@ -68,6 +69,13 @@ class Profile(BaseModel):
|
|||
)
|
||||
]
|
||||
)
|
||||
bank_name = models.CharField(
|
||||
max_length=255,
|
||||
choices=BANK_CHOICES,
|
||||
null=True,
|
||||
verbose_name="نام بانک",
|
||||
blank=True
|
||||
)
|
||||
phone_number_1 = models.CharField(
|
||||
max_length=11,
|
||||
null=True,
|
||||
|
@ -170,3 +178,17 @@ class Profile(BaseModel):
|
|||
return format_html(f"<img style='width:30px;' src='{self.pic.url}'>")
|
||||
|
||||
pic_tag.short_description = "تصویر"
|
||||
|
||||
|
||||
class Company(NameSlugModel):
|
||||
logo = models.ImageField(upload_to='companies/logos', null=True, blank=True, verbose_name='لوگوی شرکت')
|
||||
signature = models.ImageField(upload_to='companies/signatures', null=True, blank=True, verbose_name='امضای شرکت')
|
||||
address = models.TextField(null=True, blank=True, verbose_name='آدرس')
|
||||
phone = models.CharField(max_length=11, null=True, blank=True, verbose_name='شماره تماس')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'شرکت'
|
||||
verbose_name_plural = 'شرکتها'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
|
@ -64,6 +64,7 @@
|
|||
<th>کد ملی</th>
|
||||
<th>تلفن</th>
|
||||
<th>آدرس</th>
|
||||
<th>بانک</th>
|
||||
<th>وضعیت</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
|
@ -122,6 +123,17 @@
|
|||
<span class="text-muted">آدرس ثبت نشده</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
{% if customer.bank_name %}
|
||||
<span class="fw-medium">{{ customer.get_bank_name_display }}</span>
|
||||
<span class="text-muted">{{ customer.card_number }}</span>
|
||||
<span class="text-muted">{{ customer.account_number }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">بانک ثبت نشده</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if customer.is_completed %}
|
||||
<span class="badge bg-label-success">تکمیل شده</span>
|
||||
|
@ -241,6 +253,17 @@
|
|||
<div class="invalid-feedback d-block">{{ form.national_code.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12">
|
||||
<label class="form-label fw-bold" for="{{ form.bank_name.id_for_label }}">{{ form.bank_name.label }}</label>
|
||||
<div class="input-group input-group-merge">
|
||||
<span class="input-group-text"><i class="bx bx-credit-card"></i></span>
|
||||
{{ form.bank_name }}
|
||||
</div>
|
||||
{% if form.bank_name.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.bank_name.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12">
|
||||
<label class="form-label fw-bold" for="{{ form.card_number.id_for_label }}">{{ form.card_number.label }}</label>
|
||||
|
|
|
@ -144,6 +144,7 @@ def get_customer_data(request, customer_id):
|
|||
'card_number': str(form['card_number']),
|
||||
'account_number': str(form['account_number']),
|
||||
'address': str(form['address']),
|
||||
'bank_name': str(form['bank_name']),
|
||||
}
|
||||
|
||||
return JsonResponse({
|
||||
|
@ -157,7 +158,8 @@ def get_customer_data(request, customer_id):
|
|||
'national_code': customer.national_code or '',
|
||||
'card_number': customer.card_number or '',
|
||||
'account_number': customer.account_number or '',
|
||||
'address': customer.address or ''
|
||||
'address': customer.address or '',
|
||||
'bank_name': customer.bank_name or '',
|
||||
},
|
||||
'form_html': form_html
|
||||
})
|
||||
|
|
0
certificates/__init__.py
Normal file
0
certificates/__init__.py
Normal file
20
certificates/admin.py
Normal file
20
certificates/admin.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from django.contrib import admin
|
||||
from .models import CertificateTemplate, CertificateInstance
|
||||
|
||||
|
||||
@admin.register(CertificateTemplate)
|
||||
class CertificateTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ('title', 'company', 'is_active', 'created')
|
||||
list_filter = ('is_active', 'company')
|
||||
search_fields = ('title', 'company__name')
|
||||
autocomplete_fields = ('company',)
|
||||
|
||||
|
||||
@admin.register(CertificateInstance)
|
||||
class CertificateInstanceAdmin(admin.ModelAdmin):
|
||||
list_display = ('process_instance', 'rendered_title', 'issued_at', 'approved')
|
||||
list_filter = ('approved', 'issued_at')
|
||||
search_fields = ('process_instance__code', 'rendered_title')
|
||||
autocomplete_fields = ('process_instance', 'template')
|
||||
|
||||
|
10
certificates/apps.py
Normal file
10
certificates/apps.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CertificatesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'certificates'
|
||||
verbose_name = 'گواهیها'
|
||||
|
||||
|
||||
|
58
certificates/migrations/0001_initial.py
Normal file
58
certificates/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,58 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-22 09:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('processes', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CertificateTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
|
||||
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
|
||||
('title', models.CharField(max_length=200, verbose_name='عنوان')),
|
||||
('body', models.TextField(verbose_name='متن قالب (با جایگزین\u200cها)')),
|
||||
('company_logo', models.ImageField(blank=True, null=True, upload_to='certificates/logos/%Y/%m/%d/', verbose_name='لوگو')),
|
||||
('company_name', models.CharField(blank=True, max_length=200, verbose_name='نام شرکت')),
|
||||
('company_seal_signature', models.ImageField(blank=True, null=True, upload_to='certificates/seals/%Y/%m/%d/', verbose_name='مهر و امضا')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'قالب گواهی',
|
||||
'verbose_name_plural': 'قالب\u200cهای گواهی',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CertificateInstance',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
|
||||
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
|
||||
('rendered_title', models.CharField(max_length=250, verbose_name='عنوان رندر شده')),
|
||||
('rendered_body', models.TextField(verbose_name='متن رندر شده')),
|
||||
('issued_at', models.DateField(auto_now_add=True, verbose_name='تاریخ صدور')),
|
||||
('approved', models.BooleanField(default=False, verbose_name='تایید شده')),
|
||||
('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید')),
|
||||
('process_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to='processes.processinstance', verbose_name='نمونه فرآیند')),
|
||||
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='certificates.certificatetemplate', verbose_name='قالب')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'گواهی',
|
||||
'verbose_name_plural': 'گواهی\u200cها',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-22 10:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0003_historicalprofile_bank_name_profile_bank_name'),
|
||||
('certificates', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='certificatetemplate',
|
||||
name='company_logo',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='certificatetemplate',
|
||||
name='company_name',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='certificatetemplate',
|
||||
name='company_seal_signature',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='certificatetemplate',
|
||||
name='company',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.company', verbose_name='شرکت صادر کننده'),
|
||||
),
|
||||
]
|
0
certificates/migrations/__init__.py
Normal file
0
certificates/migrations/__init__.py
Normal file
38
certificates/models.py
Normal file
38
certificates/models.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from common.models import BaseModel
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CertificateTemplate(BaseModel):
|
||||
title = models.CharField(max_length=200, verbose_name='عنوان')
|
||||
body = models.TextField(verbose_name='متن قالب (با جایگزینها)')
|
||||
company = models.ForeignKey('accounts.Company', on_delete=models.SET_NULL, null=True, blank=True, verbose_name='شرکت صادر کننده')
|
||||
is_active = models.BooleanField(default=True, verbose_name='فعال')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'قالب گواهی'
|
||||
verbose_name_plural = 'قالبهای گواهی'
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class CertificateInstance(BaseModel):
|
||||
process_instance = models.ForeignKey('processes.ProcessInstance', on_delete=models.CASCADE, related_name='certificates', verbose_name='نمونه فرآیند')
|
||||
template = models.ForeignKey(CertificateTemplate, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='قالب')
|
||||
rendered_title = models.CharField(max_length=250, verbose_name='عنوان رندر شده')
|
||||
rendered_body = models.TextField(verbose_name='متن رندر شده')
|
||||
issued_at = models.DateField(auto_now_add=True, verbose_name='تاریخ صدور')
|
||||
approved = models.BooleanField(default=False, verbose_name='تایید شده')
|
||||
approved_at = models.DateTimeField(null=True, blank=True, verbose_name='تاریخ تایید')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'گواهی'
|
||||
verbose_name_plural = 'گواهیها'
|
||||
|
||||
def __str__(self):
|
||||
return f"گواهی {self.process_instance.code}"
|
||||
|
||||
|
28
certificates/templates/certificates/print.html
Normal file
28
certificates/templates/certificates/print.html
Normal file
|
@ -0,0 +1,28 @@
|
|||
{% extends '_base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="text-center mb-4">
|
||||
{% if template.company and template.company.logo %}
|
||||
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:90px">
|
||||
{% endif %}
|
||||
<h4 class="mt-2">{{ cert.rendered_title }}</h4>
|
||||
{% if template.company %}<div class="text-muted">{{ template.company.name }}</div>{% endif %}
|
||||
</div>
|
||||
<div style="white-space:pre-line; line-height:1.9;">
|
||||
{{ cert.rendered_body|safe }}
|
||||
</div>
|
||||
<div class="mt-5 d-flex justify-content-between">
|
||||
<div>تاریخ: {{ cert.issued_at }}</div>
|
||||
<div class="text-center">
|
||||
{% if template.company and template.company.signature %}
|
||||
<img src="{{ template.company.signature.url }}" alt="seal" style="max-height:120px">
|
||||
{% endif %}
|
||||
<div>مهر و امضای شرکت</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.print()</script>
|
||||
{% endblock %}
|
||||
|
||||
|
53
certificates/templates/certificates/step.html
Normal file
53
certificates/templates/certificates/step.html
Normal file
|
@ -0,0 +1,53 @@
|
|||
{% extends '_base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h4 class="mb-1">گواهی نهایی</h4>
|
||||
<small class="text-muted d-block">کد درخواست: {{ instance.code }}</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary" target="_blank" href="{% url 'certificates:certificate_print' instance.id %}"><i class="bx bx-printer"></i> پرینت</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-3">
|
||||
{% if template.company and template.company.logo %}
|
||||
<img src="{{ template.company.logo.url }}" alt="logo" style="max-height:80px">
|
||||
{% endif %}
|
||||
<h5 class="mt-2">{{ cert.rendered_title }}</h5>
|
||||
{% if template.company %}<div class="text-muted">{{ template.company.name }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="mt-3" style="white-space:pre-line; line-height:1.9;">
|
||||
{{ cert.rendered_body|safe }}
|
||||
</div>
|
||||
<div class="mt-4 d-flex justify-content-between align-items-end">
|
||||
<div>
|
||||
<div>تاریخ صدور: {{ cert.issued_at }}</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{% if template.company and template.company.signature %}
|
||||
<img src="{{ template.company.signature.url }}" alt="seal" style="max-height:100px">
|
||||
{% endif %}
|
||||
<div>مهر و امضای شرکت</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
{% else %}<span></span>{% endif %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-primary" type="submit">تایید و پایان</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
11
certificates/urls.py
Normal file
11
certificates/urls.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'certificates'
|
||||
|
||||
urlpatterns = [
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/', views.certificate_step, name='certificate_step'),
|
||||
path('instance/<int:instance_id>/print/', views.certificate_print, name='certificate_print'),
|
||||
]
|
||||
|
||||
|
114
certificates/views.py
Normal file
114
certificates/views.py
Normal file
|
@ -0,0 +1,114 @@
|
|||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib import messages
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from processes.models import ProcessInstance, StepInstance
|
||||
from invoices.models import Invoice
|
||||
from installations.models import InstallationReport
|
||||
from .models import CertificateTemplate, CertificateInstance
|
||||
|
||||
from _helpers.jalali import Gregorian
|
||||
|
||||
|
||||
def _to_jalali(date_obj):
|
||||
try:
|
||||
g = Gregorian(date_obj)
|
||||
y, m, d = g.persian_tuple()
|
||||
return f"{y}/{m:02d}/{d:02d}"
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _render_template(template: CertificateTemplate, instance: ProcessInstance):
|
||||
well = instance.well
|
||||
rep = instance.representative
|
||||
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
|
||||
ctx = {
|
||||
'today_jalali': _to_jalali(timezone.now().date()),
|
||||
'request_code': instance.code,
|
||||
'company_name': (template.company.name if template.company else '') or '',
|
||||
'customer_full_name': rep.get_full_name() if rep else '',
|
||||
'water_subscription_number': getattr(well, 'water_subscription_number', '') or '',
|
||||
'address': getattr(well, 'address', '') or '',
|
||||
'visit_date_jalali': _to_jalali(getattr(latest_report, 'visited_date', None)) if latest_report else '',
|
||||
}
|
||||
title = (template.title or '').format(**ctx)
|
||||
body = (template.body or '')
|
||||
for k, v in ctx.items():
|
||||
body = body.replace(f"{{{{ {k} }}}}", str(v))
|
||||
return title, body
|
||||
|
||||
|
||||
@login_required
|
||||
def certificate_step(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
|
||||
# Ensure all previous steps are completed and invoice settled
|
||||
prior_steps = instance.process.steps.filter(order__lt=instance.current_step.order if instance.current_step else 9999)
|
||||
incomplete = StepInstance.objects.filter(process_instance=instance, step__in=prior_steps).exclude(status='completed').exists()
|
||||
if incomplete:
|
||||
messages.error(request, 'ابتدا همه مراحل قبلی را تکمیل کنید')
|
||||
return redirect('processes:request_list')
|
||||
inv = Invoice.objects.filter(process_instance=instance).first()
|
||||
if inv:
|
||||
inv.calculate_totals()
|
||||
if inv.remaining_amount != 0:
|
||||
messages.error(request, 'مانده فاکتور باید صفر باشد')
|
||||
return redirect('processes:request_list')
|
||||
|
||||
template = CertificateTemplate.objects.filter(is_active=True).order_by('-created').first()
|
||||
if not template:
|
||||
return render(request, 'certificates/missing.html', {})
|
||||
|
||||
title, body = _render_template(template, instance)
|
||||
cert, _ = CertificateInstance.objects.get_or_create(
|
||||
process_instance=instance,
|
||||
defaults={'template': template, 'rendered_title': title, 'rendered_body': body}
|
||||
)
|
||||
# keep rendered up-to-date
|
||||
cert.template = template
|
||||
cert.rendered_title = title
|
||||
cert.rendered_body = body
|
||||
cert.save()
|
||||
|
||||
previous_step = instance.process.steps.filter(order__lt=instance.current_step.order).last() if instance.current_step else None
|
||||
next_step = instance.process.steps.filter(order__gt=instance.current_step.order).first() if instance.current_step else None
|
||||
|
||||
if request.method == 'POST':
|
||||
cert.approved = True
|
||||
cert.approved_at = timezone.now()
|
||||
cert.save()
|
||||
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step_id=step_id)
|
||||
step_instance.status = 'completed'
|
||||
step_instance.completed_at = timezone.now()
|
||||
step_instance.save()
|
||||
if next_step:
|
||||
instance.current_step = next_step
|
||||
instance.save()
|
||||
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
|
||||
return redirect('processes:request_list')
|
||||
|
||||
return render(request, 'certificates/step.html', {
|
||||
'instance': instance,
|
||||
'template': template,
|
||||
'cert': cert,
|
||||
'previous_step': previous_step,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def certificate_print(request, instance_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
cert = CertificateInstance.objects.filter(process_instance=instance).order_by('-created').first()
|
||||
template = cert.template if cert else None
|
||||
return render(request, 'certificates/print.html', {
|
||||
'instance': instance,
|
||||
'cert': cert,
|
||||
'template': template,
|
||||
})
|
||||
|
||||
|
|
@ -11,3 +11,21 @@ class UserRoles(Enum):
|
|||
REGIONAL_WATER_AUTHORITY = "rwa" # کارشناس امور
|
||||
WATER_RESOURCE_MANAGER = "wrm" # مدیر منابع آب
|
||||
HEADQUARTER = "hdq" # ستاد آب منطقهای
|
||||
|
||||
|
||||
BANK_CHOICES = [
|
||||
('mellat', 'بانک ملت'),
|
||||
('saman', 'بانک سامان'),
|
||||
('parsian', 'بانک پارسیان'),
|
||||
('sina', 'بانک سینا'),
|
||||
('tejarat', 'بانک تجارت'),
|
||||
('tosee', 'بانک توسعه'),
|
||||
('iran_zamin', 'بانک ایران زمین'),
|
||||
('meli', 'بانک ملی'),
|
||||
('saderat', 'بانک توسعه صادرات'),
|
||||
('iran_zamin', 'بانک ایران زمین'),
|
||||
('refah', 'بانک رفاه'),
|
||||
('eghtesad_novin', 'بانک اقتصاد نوین'),
|
||||
('pasargad', 'بانک پاسارگاد'),
|
||||
('other', 'سایر'),
|
||||
]
|
3
common/templatetags/__init__.py
Normal file
3
common/templatetags/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Intentionally empty to mark templatetags package
|
||||
|
||||
|
7
common/templatetags/common_tags.py
Normal file
7
common/templatetags/common_tags.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from django import template
|
||||
from _helpers.utils import jalali_converter2
|
||||
register = template.Library()
|
||||
|
||||
@register.filter(name='to_jalali')
|
||||
def to_jalali(value):
|
||||
return jalali_converter2(value)
|
0
contracts/__init__.py
Normal file
0
contracts/__init__.py
Normal file
15
contracts/admin.py
Normal file
15
contracts/admin.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from django.contrib import admin
|
||||
from .models import ContractTemplate, ContractInstance
|
||||
|
||||
@admin.register(ContractTemplate)
|
||||
class ContractTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ['name',]
|
||||
search_fields = ['name']
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
readonly_fields = ['created', 'updated']
|
||||
|
||||
@admin.register(ContractInstance)
|
||||
class ContractInstanceAdmin(admin.ModelAdmin):
|
||||
list_display = ['process_instance', 'template']
|
||||
search_fields = ['process_instance__code', 'template__name']
|
||||
readonly_fields = ['created', 'updated']
|
8
contracts/apps.py
Normal file
8
contracts/apps.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ContractsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'contracts'
|
||||
verbose_name = 'قراردادها'
|
||||
|
118
contracts/migrations/0001_initial.py
Normal file
118
contracts/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-21 06:00
|
||||
|
||||
import django.db.models.deletion
|
||||
import simple_history.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('processes', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ContractTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
|
||||
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
|
||||
('slug', models.SlugField(max_length=100, unique=True, verbose_name='اسلاگ')),
|
||||
('name', models.CharField(max_length=100, verbose_name='نام')),
|
||||
('body', models.TextField(verbose_name='متن قرارداد')),
|
||||
('company_logo', models.ImageField(blank=True, null=True, upload_to='contracts/logos/%Y/%m/%d/', verbose_name='لوگوی شرکت')),
|
||||
('company_signature', models.ImageField(blank=True, null=True, upload_to='contracts/signatures/%Y/%m/%d/', verbose_name='امضای شرکت')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'قالب قرارداد',
|
||||
'verbose_name_plural': 'قالب\u200cهای قرارداد',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContractInstance',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
|
||||
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
|
||||
('rendered_body', models.TextField(verbose_name='متن نهایی قرارداد')),
|
||||
('approved', models.BooleanField(default=False, verbose_name='تایید شده')),
|
||||
('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='ایجاد کننده')),
|
||||
('process_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contracts', to='processes.processinstance', verbose_name='نمونه فرآیند')),
|
||||
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contracts.contracttemplate', verbose_name='قالب مورد استفاده')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'قرارداد',
|
||||
'verbose_name_plural': 'قراردادها',
|
||||
'ordering': ['-created'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HistoricalContractInstance',
|
||||
fields=[
|
||||
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
|
||||
('created', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ ایجاد')),
|
||||
('updated', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ بروزرسانی')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
|
||||
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
|
||||
('rendered_body', models.TextField(verbose_name='متن نهایی قرارداد')),
|
||||
('approved', models.BooleanField(default=False, verbose_name='تایید شده')),
|
||||
('approved_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید')),
|
||||
('history_id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('history_date', models.DateTimeField(db_index=True)),
|
||||
('history_change_reason', models.CharField(max_length=100, null=True)),
|
||||
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
|
||||
('created_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='ایجاد کننده')),
|
||||
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
('process_instance', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='processes.processinstance', verbose_name='نمونه فرآیند')),
|
||||
('template', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='contracts.contracttemplate', verbose_name='قالب مورد استفاده')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'historical قرارداد',
|
||||
'verbose_name_plural': 'historical قراردادها',
|
||||
'ordering': ('-history_date', '-history_id'),
|
||||
'get_latest_by': ('history_date', 'history_id'),
|
||||
},
|
||||
bases=(simple_history.models.HistoricalChanges, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HistoricalContractTemplate',
|
||||
fields=[
|
||||
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
|
||||
('created', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ ایجاد')),
|
||||
('updated', models.DateTimeField(blank=True, editable=False, verbose_name='تاریخ بروزرسانی')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
|
||||
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
|
||||
('slug', models.SlugField(max_length=100, verbose_name='اسلاگ')),
|
||||
('name', models.CharField(max_length=100, verbose_name='نام')),
|
||||
('body', models.TextField(verbose_name='متن قرارداد')),
|
||||
('company_logo', models.TextField(blank=True, max_length=100, null=True, verbose_name='لوگوی شرکت')),
|
||||
('company_signature', models.TextField(blank=True, max_length=100, null=True, verbose_name='امضای شرکت')),
|
||||
('history_id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('history_date', models.DateTimeField(db_index=True)),
|
||||
('history_change_reason', models.CharField(max_length=100, null=True)),
|
||||
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
|
||||
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'historical قالب قرارداد',
|
||||
'verbose_name_plural': 'historical قالب\u200cهای قرارداد',
|
||||
'ordering': ('-history_date', '-history_id'),
|
||||
'get_latest_by': ('history_date', 'history_id'),
|
||||
},
|
||||
bases=(simple_history.models.HistoricalChanges, models.Model),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,38 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-21 06:33
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0002_company'),
|
||||
('contracts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='historicalcontracttemplate',
|
||||
name='history_user',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contracttemplate',
|
||||
name='company_logo',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contracttemplate',
|
||||
name='company_signature',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contracttemplate',
|
||||
name='company',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.company', verbose_name='شرکت'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='HistoricalContractInstance',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='HistoricalContractTemplate',
|
||||
),
|
||||
]
|
0
contracts/migrations/__init__.py
Normal file
0
contracts/migrations/__init__.py
Normal file
36
contracts/models.py
Normal file
36
contracts/models.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from common.models import NameSlugModel, BaseModel
|
||||
from accounts.models import Company
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ContractTemplate(NameSlugModel):
|
||||
body = models.TextField(verbose_name='متن قرارداد')
|
||||
company = models.ForeignKey(Company, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='شرکت')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'قالب قرارداد'
|
||||
verbose_name_plural = 'قالبهای قرارداد'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ContractInstance(BaseModel):
|
||||
process_instance = models.ForeignKey('processes.ProcessInstance', on_delete=models.CASCADE, related_name='contracts', verbose_name='نمونه فرآیند')
|
||||
template = models.ForeignKey(ContractTemplate, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='قالب مورد استفاده')
|
||||
rendered_body = models.TextField(verbose_name='متن نهایی قرارداد')
|
||||
approved = models.BooleanField(default=False, verbose_name='تایید شده')
|
||||
approved_at = models.DateTimeField(null=True, blank=True, verbose_name='تاریخ تایید')
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='ایجاد کننده')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'قرارداد'
|
||||
verbose_name_plural = 'قراردادها'
|
||||
ordering = ['-created']
|
||||
|
||||
def __str__(self):
|
||||
return f"Contract for {self.process_instance}"
|
||||
|
29
contracts/templates/contracts/contract_missing.html
Normal file
29
contracts/templates/contracts/contract_missing.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
{% extends '_base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'sidebars/admin.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block navbar %}
|
||||
{% include 'navbars/admin.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}قالب قرارداد یافت نشد{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="alert alert-warning">
|
||||
<h5 class="alert-heading mb-2">قالب قرارداد تعریف نشده است</h5>
|
||||
<p class="mb-2">برای نمایش این مرحله، ابتدا یک «قالب قرارداد» ایجاد کنید.</p>
|
||||
<ul class="mb-2">
|
||||
<li>از منوی ادمین یک قالب با متن قرارداد ایجاد کنید.</li>
|
||||
<li>در متن میتوانید از جاینگهدارهایی مثل {{customer_full_name}} ، {{national_code}} ، {{water_subscription_number}} استفاده کنید.</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary" href="/admin/contracts/contracttemplate/add/">ایجاد قالب قرارداد</a>
|
||||
<a class="btn btn-outline-secondary" href="{% url 'processes:request_list' %}">بازگشت</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
52
contracts/templates/contracts/contract_print.html
Normal file
52
contracts/templates/contracts/contract_print.html
Normal file
|
@ -0,0 +1,52 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>چاپ قرارداد {{ instance.code }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
@page { size: A4; margin: 1.2cm; }
|
||||
body { font-family: 'Vazirmatn', sans-serif; }
|
||||
.logo { max-height: 80px; }
|
||||
.signature { height: 90px; border: 1px dashed #ccc; }
|
||||
</style>
|
||||
<script>
|
||||
window.addEventListener('load', function(){ setTimeout(function(){ window.print(); }, 300); });
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5>{{ contract.template.company.name }}</h5>
|
||||
<h5 class="mb-1">{{ contract.template.name }}</h5>
|
||||
<div class="text-muted small">کد درخواست: {{ instance.code }} | تاریخ: {{ contract.jcreated }}</div>
|
||||
</div>
|
||||
{% if contract.template.company.logo %}
|
||||
<img class="logo" src="{{ contract.template.company.logo.url }}" alt="لوگو" />
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<hr>
|
||||
<div style="white-space: pre-line; line-height: 1.9;">{{ contract.rendered_body|safe }}</div>
|
||||
<hr>
|
||||
<div class="row mt-4">
|
||||
<div class="col-6 text-center">
|
||||
<div>امضای مشترک</div>
|
||||
<div class="signature mt-2"></div>
|
||||
</div>
|
||||
<div class="col-6 text-center">
|
||||
<div>امضای شرکت</div>
|
||||
<div class="signature mt-2">
|
||||
{% if contract.template.company.signature %}
|
||||
<img src="{{ contract.template.company.signature.url }}" alt="امضای شرکت" style="max-height: 80px;" />
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
92
contracts/templates/contracts/contract_step.html
Normal file
92
contracts/templates/contracts/contract_step.html
Normal file
|
@ -0,0 +1,92 @@
|
|||
{% 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>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
|
||||
<a href="{% url 'contracts:contract_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">پرینت</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bs-stepper wizard-vertical vertical mt-2">
|
||||
{% stepper_header instance step %}
|
||||
<div class="bs-stepper-content">
|
||||
<div class="card border">
|
||||
<div class="card-body">
|
||||
{% if template.company.logo %}
|
||||
<div class="text-center mb-3">
|
||||
<img src="{{ template.company.logo.url }}" alt="لوگوی شرکت" style="max-height:80px;">
|
||||
<h4 class="text-muted">{{ contract.template.company.name }}</h4>
|
||||
<h5 class="text-muted">{{ contract.template.name }}</h5>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="small text-muted mb-2">تاریخ: {{ contract.jcreated }}</div>
|
||||
<hr>
|
||||
<div class="contract-body" style="white-space: pre-line; line-height:1.9;">{{ contract.rendered_body|safe }}</div>
|
||||
<hr>
|
||||
<div class="row mt-4">
|
||||
<div class="col-6 text-center">
|
||||
<div>امضای مشترک</div>
|
||||
<div style="height:90px;border:1px dashed #ccc; margin-top:10px;"></div>
|
||||
</div>
|
||||
<div class="col-6 text-center">
|
||||
<div>امضای شرکت</div>
|
||||
<div style="height:90px;border:1px dashed #ccc; margin-top:10px;">
|
||||
{% if template.company.signature %}
|
||||
<img src="{{ template.company.signature.url }}" alt="امضای شرکت" style="max-height:80px;">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="d-flex justify-content-between mt-3">
|
||||
{% csrf_token %}
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
{% if next_step %}
|
||||
<button type="submit" class="btn btn-primary">بعدی</button>
|
||||
{% else %}
|
||||
<button class="btn btn-success" type="button">اتمام</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
11
contracts/urls.py
Normal file
11
contracts/urls.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'contracts'
|
||||
|
||||
urlpatterns = [
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/', views.contract_step, name='contract_step'),
|
||||
path('instance/<int:instance_id>/print/', views.contract_print, name='contract_print'),
|
||||
]
|
||||
|
||||
|
89
contracts/views.py
Normal file
89
contracts/views.py
Normal file
|
@ -0,0 +1,89 @@
|
|||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.template import Template, Context
|
||||
from processes.models import ProcessInstance, StepInstance
|
||||
from .models import ContractTemplate, ContractInstance
|
||||
from _helpers.utils import jalali_converter2
|
||||
|
||||
|
||||
def build_contract_context(instance: ProcessInstance) -> dict:
|
||||
representative = instance.representative
|
||||
profile = getattr(representative, 'profile', None)
|
||||
well = instance.well
|
||||
return {
|
||||
'customer_full_name': representative.get_full_name() if representative else '',
|
||||
'national_code': profile.national_code if profile else '',
|
||||
'address': profile.address if profile else '',
|
||||
'phone': profile.phone_number_1 if profile else '',
|
||||
'phone2': profile.phone_number_2 if profile else '',
|
||||
'water_subscription_number': well.water_subscription_number if well else '',
|
||||
'electricity_subscription_number': well.electricity_subscription_number if well else '',
|
||||
'water_meter_serial_number': well.water_meter_serial_number if well else '',
|
||||
'well_power': well.well_power if well else '',
|
||||
'request_code': instance.code,
|
||||
'today': jalali_converter2(timezone.now()),
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def contract_step(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
# Resolve step navigation
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
previous_step = instance.process.steps.filter(order__lt=step.order).last()
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
template_obj = ContractTemplate.objects.first()
|
||||
if not template_obj:
|
||||
return render(request, 'contracts/contract_missing.html', {'instance': instance})
|
||||
|
||||
ctx = build_contract_context(instance)
|
||||
rendered = Template(template_obj.body).render(Context(ctx))
|
||||
|
||||
contract, _ = ContractInstance.objects.get_or_create(
|
||||
process_instance=instance,
|
||||
defaults={
|
||||
'template': template_obj,
|
||||
'rendered_body': rendered,
|
||||
'created_by': request.user,
|
||||
}
|
||||
)
|
||||
# keep latest rendering if template changed (optional)
|
||||
contract.template = template_obj
|
||||
contract.rendered_body = rendered
|
||||
contract.save()
|
||||
|
||||
# If user submits to go next, mark this step completed and go to next
|
||||
if request.method == 'POST':
|
||||
StepInstance.objects.update_or_create(
|
||||
process_instance=instance,
|
||||
step=step,
|
||||
defaults={'status': 'completed', 'completed_at': timezone.now()}
|
||||
)
|
||||
if next_step:
|
||||
instance.current_step = next_step
|
||||
instance.save()
|
||||
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
|
||||
return redirect('processes:request_list')
|
||||
|
||||
return render(request, 'contracts/contract_step.html', {
|
||||
'instance': instance,
|
||||
'step': step,
|
||||
'contract': contract,
|
||||
'template': template_obj,
|
||||
'previous_step': previous_step,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def contract_print(request, instance_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
contract = get_object_or_404(ContractInstance, process_instance=instance)
|
||||
return render(request, 'contracts/contract_print.html', {
|
||||
'instance': instance,
|
||||
'contract': contract,
|
||||
})
|
||||
|
||||
|
0
installations/__init__.py
Normal file
0
installations/__init__.py
Normal file
39
installations/admin.py
Normal file
39
installations/admin.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
from django.contrib import admin
|
||||
from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange
|
||||
|
||||
|
||||
@admin.register(InstallationAssignment)
|
||||
class InstallationAssignmentAdmin(admin.ModelAdmin):
|
||||
list_display = ('process_instance', 'installer', 'scheduled_date', 'created')
|
||||
search_fields = ('process_instance__code', 'installer__username', 'installer__first_name', 'installer__last_name')
|
||||
list_filter = ('scheduled_date',)
|
||||
|
||||
|
||||
class InstallationPhotoInline(admin.TabularInline):
|
||||
model = InstallationPhoto
|
||||
extra = 0
|
||||
|
||||
|
||||
class InstallationItemChangeInline(admin.TabularInline):
|
||||
model = InstallationItemChange
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(InstallationReport)
|
||||
class InstallationReportAdmin(admin.ModelAdmin):
|
||||
list_display = ('assignment', 'visited_date', 'new_water_meter_serial', 'seal_number', 'is_meter_suspicious', 'approved', 'created')
|
||||
list_filter = ('is_meter_suspicious', 'approved', 'visited_date')
|
||||
search_fields = ('assignment__process_instance__code', 'new_water_meter_serial', 'seal_number')
|
||||
inlines = [InstallationPhotoInline, InstallationItemChangeInline]
|
||||
|
||||
|
||||
@admin.register(InstallationPhoto)
|
||||
class InstallationPhotoAdmin(admin.ModelAdmin):
|
||||
list_display = ('report', 'created')
|
||||
|
||||
|
||||
@admin.register(InstallationItemChange)
|
||||
class InstallationItemChangeAdmin(admin.ModelAdmin):
|
||||
list_display = ('report', 'item', 'change_type', 'quantity', 'unit_price', 'total_price', 'created')
|
||||
list_filter = ('change_type',)
|
||||
|
8
installations/apps.py
Normal file
8
installations/apps.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InstallationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'installations'
|
||||
verbose_name = 'نصب'
|
||||
|
106
installations/migrations/0001_initial.py
Normal file
106
installations/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,106 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-21 08:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0002_historicalpayment_receipt_image_and_more'),
|
||||
('processes', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='InstallationAssignment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
|
||||
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
|
||||
('scheduled_date', models.DateField(blank=True, null=True, verbose_name='تاریخ مراجعه')),
|
||||
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
|
||||
('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigner_installations', to=settings.AUTH_USER_MODEL, verbose_name='اختصاص\u200cدهنده')),
|
||||
('installer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_installations', to=settings.AUTH_USER_MODEL, verbose_name='نصاب')),
|
||||
('process_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='installation_assignments', to='processes.processinstance', verbose_name='نمونه فرآیند')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'اختصاص نصاب',
|
||||
'verbose_name_plural': 'اختصاص\u200cهای نصاب',
|
||||
'ordering': ['-created'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InstallationReport',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
|
||||
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
|
||||
('visited_date', models.DateField(blank=True, null=True, verbose_name='تاریخ مراجعه')),
|
||||
('new_water_meter_serial', models.CharField(blank=True, max_length=50, null=True, verbose_name='سریال کنتور جدید')),
|
||||
('seal_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='شماره پلمپ')),
|
||||
('is_meter_suspicious', models.BooleanField(default=False, verbose_name='کنتور مشکوک است؟')),
|
||||
('utm_x', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='UTM X')),
|
||||
('utm_y', models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True, verbose_name='UTM Y')),
|
||||
('description', models.TextField(blank=True, verbose_name='توضیحات')),
|
||||
('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='installations.installationassignment', verbose_name='اختصاص')),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='ایجادکننده')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'گزارش نصب',
|
||||
'verbose_name_plural': 'گزارش\u200cهای نصب',
|
||||
'ordering': ['-created'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InstallationPhoto',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
|
||||
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
|
||||
('image', models.ImageField(upload_to='installations/photos/%Y/%m/%d/', verbose_name='عکس')),
|
||||
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='installations.installationreport', verbose_name='گزارش')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'عکس نصب',
|
||||
'verbose_name_plural': 'عکس\u200cهای نصب',
|
||||
'ordering': ['created'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InstallationItemChange',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
|
||||
('updated', models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
|
||||
('is_deleted', models.BooleanField(default=False, verbose_name='حذف شده')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='تاریخ حذف')),
|
||||
('change_type', models.CharField(choices=[('add', 'افزودن'), ('remove', 'حذف')], max_length=6, verbose_name='نوع تغییر')),
|
||||
('quantity', models.PositiveIntegerField(verbose_name='تعداد')),
|
||||
('unit_price', models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True, verbose_name='قیمت واحد')),
|
||||
('total_price', models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True, verbose_name='قیمت کل')),
|
||||
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='invoices.item', verbose_name='آیتم')),
|
||||
('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_changes', to='installations.installationreport', verbose_name='گزارش')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'تغییر آیتم نصب',
|
||||
'verbose_name_plural': 'تغییرات آیتم\u200cهای نصب',
|
||||
'ordering': ['created'],
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-21 09:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('installations', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='installationreport',
|
||||
name='approved',
|
||||
field=models.BooleanField(default=False, verbose_name='تایید شده'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='installationreport',
|
||||
name='approved_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='تاریخ تایید'),
|
||||
),
|
||||
]
|
0
installations/migrations/__init__.py
Normal file
0
installations/migrations/__init__.py
Normal file
111
installations/models.py
Normal file
111
installations/models.py
Normal file
|
@ -0,0 +1,111 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from common.models import BaseModel
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class InstallationAssignment(BaseModel):
|
||||
"""انتخاب نصاب و زمان مراجعه برای یک درخواست"""
|
||||
process_instance = models.ForeignKey(
|
||||
'processes.ProcessInstance', on_delete=models.CASCADE,
|
||||
related_name='installation_assignments', verbose_name='نمونه فرآیند'
|
||||
)
|
||||
installer = models.ForeignKey(
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='assigned_installations', verbose_name='نصاب'
|
||||
)
|
||||
scheduled_date = models.DateField(null=True, blank=True, verbose_name='تاریخ مراجعه')
|
||||
notes = models.TextField(blank=True, verbose_name='یادداشت')
|
||||
assigned_by = models.ForeignKey(
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='assigner_installations', verbose_name='اختصاصدهنده'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'اختصاص نصاب'
|
||||
verbose_name_plural = 'اختصاصهای نصاب'
|
||||
ordering = ['-created']
|
||||
|
||||
def __str__(self):
|
||||
return f"Assignment for {self.process_instance.code} to {getattr(self.installer, 'username', '-') }"
|
||||
|
||||
|
||||
class InstallationReport(BaseModel):
|
||||
"""گزارش نصب توسط نصاب"""
|
||||
assignment = models.ForeignKey(
|
||||
InstallationAssignment, on_delete=models.CASCADE,
|
||||
related_name='reports', verbose_name='اختصاص'
|
||||
)
|
||||
visited_date = models.DateField(null=True, blank=True, verbose_name='تاریخ مراجعه')
|
||||
new_water_meter_serial = models.CharField(max_length=50, null=True, blank=True, verbose_name='سریال کنتور جدید')
|
||||
seal_number = models.CharField(max_length=50, null=True, blank=True, verbose_name='شماره پلمپ')
|
||||
is_meter_suspicious = models.BooleanField(default=False, verbose_name='کنتور مشکوک است؟')
|
||||
utm_x = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True, verbose_name='UTM X')
|
||||
utm_y = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True, verbose_name='UTM Y')
|
||||
description = models.TextField(blank=True, verbose_name='توضیحات')
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='ایجادکننده')
|
||||
approved = models.BooleanField(default=False, verbose_name='تایید شده')
|
||||
approved_at = models.DateTimeField(null=True, blank=True, verbose_name='تاریخ تایید')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'گزارش نصب'
|
||||
verbose_name_plural = 'گزارشهای نصب'
|
||||
ordering = ['-created']
|
||||
|
||||
def __str__(self):
|
||||
return f"Report for {self.assignment.process_instance.code}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# set approved time
|
||||
if self.approved and self.approved_at is None:
|
||||
self.approved_at = timezone.now()
|
||||
super().save(*args, **kwargs)
|
||||
# if approved, propagate UTM to well
|
||||
try:
|
||||
if self.approved and self.assignment and self.assignment.process_instance and self.assignment.process_instance.well:
|
||||
well = self.assignment.process_instance.well
|
||||
changed = False
|
||||
if self.utm_x is not None:
|
||||
well.utm_x = self.utm_x
|
||||
changed = True
|
||||
if self.utm_y is not None:
|
||||
well.utm_y = self.utm_y
|
||||
changed = True
|
||||
if changed:
|
||||
well.save()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class InstallationPhoto(BaseModel):
|
||||
report = models.ForeignKey(InstallationReport, on_delete=models.CASCADE, related_name='photos', verbose_name='گزارش')
|
||||
image = models.ImageField(upload_to='installations/photos/%Y/%m/%d/', verbose_name='عکس')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'عکس نصب'
|
||||
verbose_name_plural = 'عکسهای نصب'
|
||||
ordering = ['created']
|
||||
|
||||
|
||||
class InstallationItemChange(BaseModel):
|
||||
"""تغییرات اقلام در مرحله نصب (افزودن/حذف نسبت به اقلام مرحله ۱)"""
|
||||
CHANGE_CHOICES = [
|
||||
('add', 'افزودن'),
|
||||
('remove', 'حذف'),
|
||||
]
|
||||
report = models.ForeignKey(InstallationReport, on_delete=models.CASCADE, related_name='item_changes', verbose_name='گزارش')
|
||||
item = models.ForeignKey('invoices.Item', on_delete=models.CASCADE, verbose_name='آیتم')
|
||||
change_type = models.CharField(max_length=6, choices=CHANGE_CHOICES, verbose_name='نوع تغییر')
|
||||
quantity = models.PositiveIntegerField(verbose_name='تعداد')
|
||||
unit_price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='قیمت واحد', null=True, blank=True)
|
||||
total_price = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='قیمت کل', null=True, blank=True)
|
||||
notes = models.TextField(blank=True, verbose_name='یادداشت')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'تغییر آیتم نصب'
|
||||
verbose_name_plural = 'تغییرات آیتمهای نصب'
|
||||
ordering = ['created']
|
||||
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
{% 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">
|
||||
{% 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 method="post">
|
||||
{% csrf_token %}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">نصاب</label>
|
||||
<select name="installer_id" class="form-select" required>
|
||||
<option value="">انتخاب کنید...</option>
|
||||
{% for p in installers %}
|
||||
<option value="{{ p.user.id }}" {% if assignment.installer and p.user.id == assignment.installer.id %}selected{% endif %}>{{ p.user.get_full_name }} ({{ p.user.username }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">تاریخ مراجعه نصاب</label>
|
||||
<input type="text" id="id_scheduled_date_display" class="form-control" placeholder="انتخاب تاریخ" readonly required value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y/m/d' }}{% endif %}">
|
||||
<input type="hidden" id="id_scheduled_date" name="scheduled_date" value="{% if assignment.scheduled_date %}{{ assignment.scheduled_date|date:'Y-m-d' }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
<button class="btn btn-primary" type="submit">ثبت و ادامه</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block 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(){
|
||||
function convertPersianToEnglishNumbers(str) {
|
||||
const persianNumbers = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const englishNumbers = '0123456789';
|
||||
return String(str || '').split('').map(function(char){
|
||||
const index = persianNumbers.indexOf(char);
|
||||
return index !== -1 ? englishNumbers[index] : char;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function initPersianDatePicker() {
|
||||
if ($.fn.persianDatepicker && $('#id_scheduled_date_display').length) {
|
||||
try {
|
||||
var $display = $('#id_scheduled_date_display');
|
||||
var $hidden = $('#id_scheduled_date');
|
||||
|
||||
// Prefill from hidden Gregorian to visible Jalali
|
||||
var initialGregorian = $hidden.val();
|
||||
if (initialGregorian) {
|
||||
try {
|
||||
var initialJalali = new window.persianDate(new Date(initialGregorian)).format('YYYY/MM/DD');
|
||||
$display.val(initialJalali);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
$display.persianDatepicker({
|
||||
calendarType: 'persian',
|
||||
altField: '#id_scheduled_date',
|
||||
format: 'YYYY/MM/DD',
|
||||
altFormat: 'YYYY-MM-DD',
|
||||
observer: true,
|
||||
autoClose: true,
|
||||
initialValue: false,
|
||||
calendar:{ persian: { leapYearMode: 'astronomical' } },
|
||||
onSelect: function (unixDate) {
|
||||
var g = new window.persianDate(unixDate).toCalendar('gregorian').format('YYYY-MM-DD');
|
||||
g = convertPersianToEnglishNumbers(g);
|
||||
$hidden.val(g);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error initializing Persian Date Picker:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initPersianDatePicker);
|
||||
})();
|
||||
|
||||
// Require date and show success toast on submit
|
||||
(function(){
|
||||
const form = document.querySelector('form');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function(ev){
|
||||
const display = document.getElementById('id_scheduled_date_display');
|
||||
const hidden = document.getElementById('id_scheduled_date');
|
||||
if (!display.value || !hidden.value) {
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
if (typeof showToast === 'function') showToast('تاریخ مراجعه نصاب را انتخاب کنید', 'danger');
|
||||
display.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
return false;
|
||||
}
|
||||
try { sessionStorage.setItem('assign_saved', '1'); } catch(_) {}
|
||||
}, false);
|
||||
|
||||
try {
|
||||
if (sessionStorage.getItem('assign_saved') === '1') {
|
||||
sessionStorage.removeItem('assign_saved');
|
||||
if (typeof showToast === 'function') showToast('با موفقیت ثبت شد', 'success');
|
||||
}
|
||||
} catch(_) {}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -0,0 +1,448 @@
|
|||
{% extends '_base.html' %}
|
||||
{% load static %}
|
||||
{% load processes_tags %}
|
||||
{% load common_tags %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'sidebars/admin.html' %}
|
||||
{% endblock sidebar %}
|
||||
|
||||
{% block navbar %}
|
||||
{% include 'navbars/admin.html' %}
|
||||
{% endblock navbar %}
|
||||
|
||||
{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
|
||||
|
||||
{% block style %}
|
||||
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
|
||||
|
||||
<!-- Persian Date Picker CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_toasts.html' %}
|
||||
<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">
|
||||
|
||||
{% if report and not edit_mode %}
|
||||
<div class="card mb-3 border">
|
||||
<div class="card-header d-flex justify-content-end">
|
||||
<a href="?edit=1" class="btn btn-primary">ویرایش گزارش نصب</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p class="text-nowrap mb-2"><i class="bx bx-calendar-event bx-sm me-2"></i>تاریخ مراجعه: {{ report.visited_date|to_jalali|default:'-' }}</p>
|
||||
<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>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p class="text-nowrap mb-2"><i class="bx bx-help-circle bx-sm me-2"></i>کنتور مشکوک: {{ report.is_meter_suspicious|yesno:'بله,خیر' }}</p>
|
||||
<p class="text-nowrap mb-2"><i class="bx bx-map bx-sm me-2"></i>UTM X: {{ report.utm_x|default:'-' }}</p>
|
||||
<p class="text-nowrap mb-2"><i class="bx bx-map-pin bx-sm me-2"></i>UTM Y: {{ report.utm_y|default:'-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if report.description %}
|
||||
<div class="mt-2">
|
||||
<p class="mb-0"><i class="bx bx-text bx-sm me-2"></i><strong>توضیحات:</strong></p>
|
||||
<div class="text-muted">{{ report.description|default:'-' }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<hr>
|
||||
<h6>عکسها</h6>
|
||||
<div class="row">
|
||||
{% for p in report.photos.all %}
|
||||
<div class="col-6 col-md-3 mb-2"><img class="img-fluid rounded border" src="{{ p.image.url }}" alt="photo"></div>
|
||||
{% empty %}
|
||||
<div class="text-muted">بدون عکس</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<h6 class="mb-2">اقلام</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">نوع</th>
|
||||
<th>آیتم</th>
|
||||
<th>تعداد</th>
|
||||
<th>قیمت واحد</th>
|
||||
<th>قیمت کل</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ch in report.item_changes.all %}
|
||||
<tr>
|
||||
<td>{% if ch.change_type == 'add' %}<span class="text-success"><i class="bx bx-plus"></i></span>{% else %}<span class="text-danger"><i class="bx bx-minus"></i></span>{% endif %}</td>
|
||||
<td>{{ ch.item.name }}</td>
|
||||
<td>{{ ch.quantity }}</td>
|
||||
<td>{% if ch.unit_price %}{{ ch.unit_price|floatformat:0|intcomma:False }}{% else %}-{% endif %}</td>
|
||||
<td>
|
||||
{% if ch.total_price %}
|
||||
{{ ch.total_price|floatformat:0|intcomma:False }}
|
||||
{% elif ch.unit_price %}
|
||||
{{ ch.unit_price|floatformat:0|intcomma:False }}
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="text-center text-muted">تغییری ثبت نشده است</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Persistent nav in edit mode (outside cards) -->
|
||||
<div class="d-flex justify-content-between mt-3">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
{% if next_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<div class="">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">تاریخ مراجعه</label>
|
||||
<input type="text" id="id_visited_date_display" class="form-control" placeholder="انتخاب تاریخ" readonly required value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y/m/d' }}{% endif %}">
|
||||
<input type="hidden" id="id_visited_date" name="visited_date" value="{% if report and edit_mode and report.visited_date %}{{ report.visited_date|date:'Y-m-d' }}{% endif %}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">سریال کنتور جدید</label>
|
||||
<input type="text" class="form-control" name="new_water_meter_serial">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">شماره پلمپ</label>
|
||||
<input type="text" class="form-control" name="seal_number">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="is_meter_suspicious" id="id_is_meter_suspicious">
|
||||
<label class="form-check-label" for="id_is_meter_suspicious">کنتور مشکوک است</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">UTM X</label>
|
||||
<input type="number" step="0.000001" class="form-control" name="utm_x" value="{% if instance.well.utm_x %}{{ instance.well.utm_x }}{% endif %}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">UTM Y</label>
|
||||
<input type="number" step="0.000001" class="form-control" name="utm_y" value="{% if instance.well.utm_y %}{{ instance.well.utm_y }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<label class="form-label">توضیحات (اختیاری)</label>
|
||||
<textarea class="form-control" rows="3" name="description"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<label class="form-label mb-0">عکسها</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="btnAddPhoto"><i class="bx bx-plus"></i> افزودن عکس</button>
|
||||
</div>
|
||||
{% if report %}
|
||||
<div class="row mt-2">
|
||||
{% for p in report.photos.all %}
|
||||
<div class="col-6 col-md-3 mb-2" id="existing-photo-{{ p.id }}">
|
||||
<div class="position-relative border rounded p-1">
|
||||
<img class="img-fluid rounded" src="{{ p.image.url }}" alt="photo">
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute" style="top:6px; left:6px;" onclick="markDeletePhoto({{ p.id }})" title="حذف/برگردان"><i class='bx bx-trash'></i></button>
|
||||
<input type="hidden" name="del_photo_{{ p.id }}" id="del-photo-{{ p.id }}" value="0">
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row mt-2" id="photosPreview"></div>
|
||||
<div id="photoInputs" class="d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">اقلام</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 mb-4">
|
||||
<h6 class="mb-2">اقلام انتخابشده قبلی <small class="text-muted">(برای حذف در نصب تیک بزنید)</small></h6>
|
||||
<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 qi in quote_items %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input" name="rem_{{ qi.item.id }}_type" value="remove" title="حذف در نصب" {% if removed_qty|get_item:qi.item.id %}checked{% endif %}>
|
||||
<input type="hidden" name="rem_{{ qi.item.id }}_qty" value="{% if removed_qty|get_item:qi.item.id %}{{ removed_qty|get_item:qi.item.id }}{% else %}{{ qi.quantity }}{% endif %}">
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-semibold">{{ qi.item.name }}</span>
|
||||
{% 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>
|
||||
<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>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="text-center text-muted">اقلامی ثبت نشده است</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="col-12">
|
||||
<h6 class="mb-2">افزودن اقلام جدید</h6>
|
||||
<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 it in all_items %}
|
||||
<tr>
|
||||
<td>
|
||||
{% with add_entry=added_map|get_item:it.id %}
|
||||
<input type="checkbox" name="add_{{ it.id }}_type" value="add" class="form-check-input" {% if add_entry %}checked{% endif %}>
|
||||
<input type="hidden" name="add_{{ it.id }}_price" value="{{ it.unit_price }}">
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-semibold">{{ it.name }}</span>
|
||||
{% if it.description %}<small class="text-muted">{{ it.description }}</small>{% endif %}
|
||||
</div>
|
||||
</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 %}">
|
||||
{% endwith %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-3 d-flex justify-content-between">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">ثبت گزارش</button>
|
||||
{% if next_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-success">بعدی</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block 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>
|
||||
// Persian datepicker for visited_date (exact pattern like sample: display + altField)
|
||||
(function(){
|
||||
function convertPersianToEnglishNumbers(str) {
|
||||
const persianNumbers = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const englishNumbers = '0123456789';
|
||||
return String(str || '').split('').map(function(char){
|
||||
const index = persianNumbers.indexOf(char);
|
||||
return index !== -1 ? englishNumbers[index] : char;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
if (window.$ && $.fn.persianDatepicker && $('#id_visited_date_display').length) {
|
||||
try {
|
||||
var $display = $('#id_visited_date_display');
|
||||
var $hidden = $('#id_visited_date');
|
||||
|
||||
// Prefill from hidden Gregorian to visible Jalali
|
||||
var initialGregorian = $hidden.val();
|
||||
if (initialGregorian) {
|
||||
try {
|
||||
var initialJalali = new window.persianDate(new Date(initialGregorian)).format('YYYY/MM/DD');
|
||||
$display.val(initialJalali);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Initialize datepicker with altField exactly like the sample
|
||||
var picker = $display.persianDatepicker({
|
||||
calendarType: 'persian',
|
||||
altField: '#id_visited_date',
|
||||
format: 'YYYY/MM/DD',
|
||||
altFormat: 'YYYY-MM-DD',
|
||||
observer: true,
|
||||
autoClose: true,
|
||||
initialValue: false,
|
||||
calendar:{ persian: { leapYearMode: 'astronomical' } },
|
||||
onSelect: function (unixDate) {
|
||||
var g = new window.persianDate(unixDate).toCalendar('gregorian').format('YYYY-MM-DD');
|
||||
g = convertPersianToEnglishNumbers(g);
|
||||
$hidden.val(g);
|
||||
}
|
||||
});
|
||||
} catch (e) { console.error('Error initializing Persian Date Picker:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Require date and show success toast on submit (persist across redirect)
|
||||
(function(){
|
||||
const form = document.querySelector('form[enctype]') || document.querySelector('form');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function(ev){
|
||||
const display = document.getElementById('id_visited_date_display');
|
||||
const hidden = document.getElementById('id_visited_date');
|
||||
if (!display || !hidden) return;
|
||||
if (!display.value || !hidden.value) {
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
showToast('تاریخ مراجعه را انتخاب کنید', 'danger');
|
||||
display.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
return false;
|
||||
}
|
||||
try { sessionStorage.setItem('install_report_saved', '1'); } catch(_) {}
|
||||
}, false);
|
||||
// on load, if saved flag exists, show toast
|
||||
try {
|
||||
if (sessionStorage.getItem('install_report_saved') === '1') {
|
||||
sessionStorage.removeItem('install_report_saved');
|
||||
showToast('گزارش نصب با موفقیت ثبت شد', 'success');
|
||||
}
|
||||
} catch(_) {}
|
||||
})();
|
||||
|
||||
// Dynamic photo add/remove
|
||||
(function(){
|
||||
const photoInputs = document.getElementById('photoInputs');
|
||||
const photosPreview = document.getElementById('photosPreview');
|
||||
const btnAddPhoto = document.getElementById('btnAddPhoto');
|
||||
let photoCounter = 0;
|
||||
function createPhotoInput() {
|
||||
photoCounter += 1;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.name = 'photos';
|
||||
input.accept = 'image/*';
|
||||
input.className = 'd-none';
|
||||
input.dataset.key = String(photoCounter);
|
||||
input.addEventListener('change', function(){
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(){
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-6 col-md-3 mb-2';
|
||||
col.id = 'photo-preview-' + input.dataset.key;
|
||||
col.innerHTML = `
|
||||
<div class="position-relative border rounded p-1">
|
||||
<img src="${reader.result}" class="img-fluid rounded" alt="photo">
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute" style="top:6px; left:6px;" data-key="${input.dataset.key}"><i class=\"bx bx-trash\"></i></button>
|
||||
</div>
|
||||
`;
|
||||
photosPreview.appendChild(col);
|
||||
col.querySelector('button').addEventListener('click', function(ev){
|
||||
const key = ev.currentTarget.getAttribute('data-key');
|
||||
const preview = document.getElementById('photo-preview-' + key);
|
||||
if (preview) preview.remove();
|
||||
const inp = photoInputs.querySelector(`input[data-key="${key}"]`);
|
||||
if (inp) inp.remove();
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
photoInputs.appendChild(input);
|
||||
input.click();
|
||||
}
|
||||
if (btnAddPhoto) btnAddPhoto.addEventListener('click', createPhotoInput);
|
||||
})();
|
||||
|
||||
// Mark delete for existing photos
|
||||
function markDeletePhoto(id){
|
||||
const hidden = document.getElementById('del-photo-' + id);
|
||||
const wrap = document.getElementById('existing-photo-' + id);
|
||||
if (hidden && wrap){
|
||||
// toggle behavior
|
||||
if (hidden.value === '1') {
|
||||
hidden.value = '0';
|
||||
wrap.style.opacity = '1';
|
||||
// update button title back to delete
|
||||
const btn = wrap.querySelector('button');
|
||||
if (btn) btn.title = 'حذف';
|
||||
} else {
|
||||
hidden.value = '1';
|
||||
wrap.style.opacity = '0.5';
|
||||
wrap.style.position = 'relative';
|
||||
// update button title to undo
|
||||
const btn = wrap.querySelector('button');
|
||||
if (btn) btn.title = 'انصراف از حذف';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
11
installations/urls.py
Normal file
11
installations/urls.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'installations'
|
||||
|
||||
urlpatterns = [
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/assign/', views.installation_assign_step, name='installation_assign_step'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/report/', views.installation_report_step, name='installation_report_step'),
|
||||
]
|
||||
|
||||
|
255
installations/views.py
Normal file
255
installations/views.py
Normal file
|
@ -0,0 +1,255 @@
|
|||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib import messages
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from accounts.models import Profile
|
||||
from common.consts import UserRoles
|
||||
from processes.models import ProcessInstance, StepInstance
|
||||
from invoices.models import Item, Quote, QuoteItem
|
||||
from .models import InstallationAssignment, InstallationReport, InstallationPhoto, InstallationItemChange
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
@login_required
|
||||
def installation_assign_step(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
previous_step = instance.process.steps.filter(order__lt=step.order).last()
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
|
||||
# Installers list (profiles that have installer role)
|
||||
installers = Profile.objects.filter(roles__slug=UserRoles.INSTALLER.value).select_related('user').all()
|
||||
assignment, _ = InstallationAssignment.objects.get_or_create(process_instance=instance)
|
||||
|
||||
if request.method == 'POST':
|
||||
installer_id = request.POST.get('installer_id')
|
||||
scheduled_date = (request.POST.get('scheduled_date') or '').strip()
|
||||
assignment.installer_id = installer_id or None
|
||||
if scheduled_date:
|
||||
assignment.scheduled_date = scheduled_date.replace('/', '-')
|
||||
assignment.assigned_by = request.user
|
||||
assignment.save()
|
||||
|
||||
# complete step
|
||||
StepInstance.objects.update_or_create(
|
||||
process_instance=instance,
|
||||
step=step,
|
||||
defaults={'status': 'completed', 'completed_at': timezone.now()}
|
||||
)
|
||||
|
||||
if next_step:
|
||||
instance.current_step = next_step
|
||||
instance.save()
|
||||
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
|
||||
return redirect('processes:request_list')
|
||||
|
||||
return render(request, 'installations/installation_assign_step.html', {
|
||||
'instance': instance,
|
||||
'step': step,
|
||||
'assignment': assignment,
|
||||
'installers': installers,
|
||||
'previous_step': previous_step,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def installation_report_step(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
previous_step = instance.process.steps.filter(order__lt=step.order).last()
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
assignment = InstallationAssignment.objects.filter(process_instance=instance).first()
|
||||
existing_report = InstallationReport.objects.filter(assignment=assignment).order_by('-created').first()
|
||||
edit_mode = True if request.GET.get('edit') == '1' else False
|
||||
|
||||
# current quote items baseline
|
||||
quote = Quote.objects.filter(process_instance=instance).first()
|
||||
quote_items = list(quote.items.select_related('item').all()) if quote else []
|
||||
quote_price_map = {qi.item_id: qi.unit_price for qi in quote_items}
|
||||
items = Item.objects.all().order_by('name')
|
||||
|
||||
if request.method == 'POST':
|
||||
description = (request.POST.get('description') or '').strip()
|
||||
visited_date = (request.POST.get('visited_date') or '').strip()
|
||||
if '/' in visited_date:
|
||||
visited_date = visited_date.replace('/', '-')
|
||||
new_serial = (request.POST.get('new_water_meter_serial') or '').strip()
|
||||
seal_number = (request.POST.get('seal_number') or '').strip()
|
||||
is_suspicious = True if request.POST.get('is_meter_suspicious') == 'on' else False
|
||||
utm_x = request.POST.get('utm_x') or None
|
||||
utm_y = request.POST.get('utm_y') or None
|
||||
|
||||
# Build maps from form fields: remove and add
|
||||
remove_map = {}
|
||||
add_map = {}
|
||||
for key in request.POST.keys():
|
||||
if key.startswith('rem_') and key.endswith('_type'):
|
||||
# rem_{id}_type = 'remove'
|
||||
try:
|
||||
item_id = int(key.split('_')[1])
|
||||
except Exception:
|
||||
continue
|
||||
if request.POST.get(key) != 'remove':
|
||||
continue
|
||||
qty_val = request.POST.get(f'rem_{item_id}_qty') or '1'
|
||||
try:
|
||||
qty = int(qty_val)
|
||||
except Exception:
|
||||
qty = 1
|
||||
remove_map[item_id] = qty
|
||||
if key.startswith('add_') and key.endswith('_type'):
|
||||
try:
|
||||
item_id = int(key.split('_')[1])
|
||||
except Exception:
|
||||
continue
|
||||
if request.POST.get(key) != 'add':
|
||||
continue
|
||||
qty_val = request.POST.get(f'add_{item_id}_qty') or '1'
|
||||
price_val = request.POST.get(f'add_{item_id}_price')
|
||||
try:
|
||||
qty = int(qty_val)
|
||||
except Exception:
|
||||
qty = 1
|
||||
# resolve unit price
|
||||
unit_price = None
|
||||
if price_val:
|
||||
try:
|
||||
unit_price = Decimal(price_val)
|
||||
except InvalidOperation:
|
||||
unit_price = None
|
||||
if unit_price is None:
|
||||
item_obj = Item.objects.filter(id=item_id).first()
|
||||
unit_price = item_obj.unit_price if item_obj else None
|
||||
add_map[item_id] = {'qty': qty, 'price': unit_price}
|
||||
|
||||
# اجازهٔ ثبت همزمان حذف و افزودن برای یک قلم (بدون محدودیت و ادغام)
|
||||
|
||||
if existing_report and edit_mode:
|
||||
report = existing_report
|
||||
report.description = description
|
||||
report.visited_date = visited_date or None
|
||||
report.new_water_meter_serial = new_serial or None
|
||||
report.seal_number = seal_number or None
|
||||
report.is_meter_suspicious = is_suspicious
|
||||
report.utm_x = utm_x
|
||||
report.utm_y = utm_y
|
||||
report.save()
|
||||
# delete selected existing photos
|
||||
for key, val in request.POST.items():
|
||||
if key.startswith('del_photo_') and val == '1':
|
||||
try:
|
||||
pid = int(key.split('_')[-1])
|
||||
InstallationPhoto.objects.filter(id=pid, report=report).delete()
|
||||
except Exception:
|
||||
continue
|
||||
# append new photos
|
||||
for f in request.FILES.getlist('photos'):
|
||||
InstallationPhoto.objects.create(report=report, image=f)
|
||||
# replace item changes with new submission
|
||||
report.item_changes.all().delete()
|
||||
for item_id, qty in remove_map.items():
|
||||
up = quote_price_map.get(item_id)
|
||||
total = (up * qty) if up is not None else None
|
||||
InstallationItemChange.objects.create(
|
||||
report=report,
|
||||
item_id=item_id,
|
||||
change_type='remove',
|
||||
quantity=qty,
|
||||
unit_price=up,
|
||||
total_price=total,
|
||||
)
|
||||
for item_id, data in add_map.items():
|
||||
unit_price = data.get('price')
|
||||
qty = data.get('qty') or 1
|
||||
total = (unit_price * qty) if (unit_price is not None) else None
|
||||
InstallationItemChange.objects.create(
|
||||
report=report,
|
||||
item_id=item_id,
|
||||
change_type='add',
|
||||
quantity=qty,
|
||||
unit_price=unit_price,
|
||||
total_price=total,
|
||||
)
|
||||
else:
|
||||
report = InstallationReport.objects.create(
|
||||
assignment=assignment,
|
||||
description=description,
|
||||
visited_date=visited_date or None,
|
||||
new_water_meter_serial=new_serial or None,
|
||||
seal_number=seal_number or None,
|
||||
is_meter_suspicious=is_suspicious,
|
||||
utm_x=utm_x,
|
||||
utm_y=utm_y,
|
||||
created_by=request.user,
|
||||
)
|
||||
# photos
|
||||
for f in request.FILES.getlist('photos'):
|
||||
InstallationPhoto.objects.create(report=report, image=f)
|
||||
# item changes
|
||||
for item_id, qty in remove_map.items():
|
||||
up = quote_price_map.get(item_id)
|
||||
total = (up * qty) if up is not None else None
|
||||
InstallationItemChange.objects.create(
|
||||
report=report,
|
||||
item_id=item_id,
|
||||
change_type='remove',
|
||||
quantity=qty,
|
||||
unit_price=up,
|
||||
total_price=total,
|
||||
)
|
||||
for item_id, data in add_map.items():
|
||||
unit_price = data.get('price')
|
||||
qty = data.get('qty') or 1
|
||||
total = (unit_price * qty) if (unit_price is not None) else None
|
||||
InstallationItemChange.objects.create(
|
||||
report=report,
|
||||
item_id=item_id,
|
||||
change_type='add',
|
||||
quantity=qty,
|
||||
unit_price=unit_price,
|
||||
total_price=total,
|
||||
)
|
||||
|
||||
# complete step
|
||||
StepInstance.objects.update_or_create(
|
||||
process_instance=instance,
|
||||
step=step,
|
||||
defaults={'status': 'completed', 'completed_at': timezone.now()}
|
||||
)
|
||||
|
||||
if next_step:
|
||||
instance.current_step = next_step
|
||||
instance.save()
|
||||
return redirect('processes:step_detail', instance_id=instance.id, step_id=next_step.id)
|
||||
return redirect('processes:request_list')
|
||||
|
||||
# Build prefill maps from existing report changes
|
||||
removed_ids = set()
|
||||
removed_qty = {}
|
||||
added_map = {}
|
||||
if existing_report:
|
||||
for ch in existing_report.item_changes.all():
|
||||
if ch.change_type == 'remove':
|
||||
removed_ids.add(ch.item_id)
|
||||
removed_qty[ch.item_id] = ch.quantity
|
||||
elif ch.change_type == 'add':
|
||||
added_map[ch.item_id] = {'qty': ch.quantity, 'price': ch.unit_price}
|
||||
|
||||
return render(request, 'installations/installation_report_step.html', {
|
||||
'instance': instance,
|
||||
'step': step,
|
||||
'assignment': assignment,
|
||||
'report': existing_report,
|
||||
'edit_mode': edit_mode,
|
||||
'quote': quote,
|
||||
'quote_items': quote_items,
|
||||
'all_items': items,
|
||||
'removed_ids': removed_ids,
|
||||
'removed_qty': removed_qty,
|
||||
'added_map': added_map,
|
||||
'previous_step': previous_step,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-21 18:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0002_historicalpayment_receipt_image_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='historicalpayment',
|
||||
name='reference_number',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=100, verbose_name='شماره مرجع'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='reference_number',
|
||||
field=models.CharField(blank=True, max_length=100, unique=True, verbose_name='شماره مرجع'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-22 08:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0003_alter_historicalpayment_reference_number_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='historicalpayment',
|
||||
name='direction',
|
||||
field=models.CharField(choices=[('in', 'دریافتی'), ('out', 'پرداختی')], default='in', max_length=3, verbose_name='نوع تراکنش'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='direction',
|
||||
field=models.CharField(choices=[('in', 'دریافتی'), ('out', 'پرداختی')], default='in', max_length=3, verbose_name='نوع تراکنش'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-22 08:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0004_historicalpayment_direction_payment_direction'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='historicalitem',
|
||||
name='is_special',
|
||||
field=models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalitem',
|
||||
name='special_kind',
|
||||
field=models.CharField(blank=True, choices=[('repair', 'تعمیر'), ('replace', 'تعویض')], max_length=10, verbose_name='نوع ویژه'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='is_special',
|
||||
field=models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='special_kind',
|
||||
field=models.CharField(blank=True, choices=[('repair', 'تعمیر'), ('replace', 'تعویض')], max_length=10, verbose_name='نوع ویژه'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,21 @@
|
|||
# Generated by Django 5.2.4 on 2025-08-22 08:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0005_historicalitem_is_special_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='historicalitem',
|
||||
name='special_kind',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='item',
|
||||
name='special_kind',
|
||||
),
|
||||
]
|
|
@ -18,6 +18,7 @@ class Item(NameSlugModel):
|
|||
decimal_places=2,
|
||||
verbose_name="قیمت واحد"
|
||||
)
|
||||
is_special = models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی')
|
||||
default_quantity = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name="تعداد پیشفرض"
|
||||
|
@ -102,7 +103,8 @@ class Quote(NameSlugModel):
|
|||
|
||||
def calculate_totals(self):
|
||||
"""محاسبه مبالغ کل"""
|
||||
total = sum(item.total_price for item in self.items.all())
|
||||
total = sum(item.total_price for item in self.items.filter(is_deleted=False).all())
|
||||
total = sum(item.total_price for item in self.items.filter(is_deleted=False).all())
|
||||
self.total_amount = total
|
||||
|
||||
# محاسبه تخفیف
|
||||
|
@ -260,15 +262,19 @@ class Invoice(NameSlugModel):
|
|||
self.discount_amount = 0
|
||||
|
||||
self.final_amount = self.total_amount - self.discount_amount
|
||||
self.remaining_amount = self.final_amount - self.paid_amount
|
||||
|
||||
# بروزرسانی وضعیت
|
||||
if self.remaining_amount <= 0:
|
||||
# خالص مانده به نفع شرکت (مثبت) یا به نفع مشتری (منفی)
|
||||
net_due = self.final_amount - self.paid_amount
|
||||
self.remaining_amount = net_due
|
||||
|
||||
# وضعیت بر اساس مانده خالص
|
||||
if net_due == 0:
|
||||
self.status = 'paid'
|
||||
elif self.paid_amount > 0:
|
||||
self.status = 'partially_paid'
|
||||
elif net_due > 0:
|
||||
# مشتری هنوز باید پرداخت کند
|
||||
self.status = 'partially_paid' if self.paid_amount > 0 else 'sent'
|
||||
else:
|
||||
self.status = 'sent'
|
||||
# شرکت باید به مشتری پرداخت کند
|
||||
self.status = 'partially_paid'
|
||||
|
||||
self.save()
|
||||
|
||||
|
@ -314,6 +320,12 @@ class Payment(BaseModel):
|
|||
"""مدل پرداختها"""
|
||||
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments', verbose_name="فاکتور")
|
||||
amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="مبلغ پرداخت")
|
||||
direction = models.CharField(
|
||||
max_length=3,
|
||||
choices=[('in', 'دریافتی'), ('out', 'پرداختی')],
|
||||
default='in',
|
||||
verbose_name='نوع تراکنش'
|
||||
)
|
||||
payment_method = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
|
@ -326,7 +338,7 @@ class Payment(BaseModel):
|
|||
default='cash',
|
||||
verbose_name="روش پرداخت"
|
||||
)
|
||||
reference_number = models.CharField(max_length=100, verbose_name="شماره مرجع", blank=True)
|
||||
reference_number = models.CharField(max_length=100, verbose_name="شماره مرجع", blank=True, unique=True)
|
||||
payment_date = models.DateField(verbose_name="تاریخ پرداخت")
|
||||
notes = models.TextField(verbose_name="یادداشتها", blank=True)
|
||||
receipt_image = models.ImageField(upload_to='payments/%Y/%m/%d/', null=True, blank=True, verbose_name="تصویر فیش")
|
||||
|
@ -345,6 +357,17 @@ class Payment(BaseModel):
|
|||
"""بروزرسانی مبالغ فاکتور"""
|
||||
super().save(*args, **kwargs)
|
||||
# بروزرسانی مبلغ پرداخت شده فاکتور
|
||||
total_paid = sum(payment.amount for payment in self.invoice.payments.all())
|
||||
total_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in self.invoice.payments.filter(is_deleted=False).all())
|
||||
self.invoice.paid_amount = total_paid
|
||||
self.invoice.calculate_totals()
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
"""حذف نرم و بروزرسانی مبالغ فاکتور پس از حذف"""
|
||||
result = super().delete(using=using, keep_parents=keep_parents)
|
||||
try:
|
||||
total_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in self.invoice.payments.filter(is_deleted=False).all())
|
||||
self.invoice.paid_amount = total_paid
|
||||
self.invoice.calculate_totals()
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
|
55
invoices/templates/invoices/final_invoice_print.html
Normal file
55
invoices/templates/invoices/final_invoice_print.html
Normal file
|
@ -0,0 +1,55 @@
|
|||
{% extends '_base.html' %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="mb-4 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h4 class="mb-1">فاکتور نهایی</h4>
|
||||
<small class="text-muted">کد درخواست: {{ instance.code }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Placeholders for logo/signature -->
|
||||
<div class="text-end">لوگو</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>آیتم</th>
|
||||
<th>تعداد</th>
|
||||
<th>قیمت واحد</th>
|
||||
<th>قیمت کل</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr>
|
||||
<td>{{ it.item.name }}</td>
|
||||
<td>{{ it.quantity }}</td>
|
||||
<td>{{ it.unit_price|floatformat:0|intcomma:False }}</td>
|
||||
<td>{{ it.total_price|floatformat:0|intcomma:False }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4" class="text-center text-muted">آیتمی ندارد</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><th colspan="3" class="text-end">مبلغ کل</th><th>{{ invoice.total_amount|floatformat:0|intcomma:False }}</th></tr>
|
||||
<tr><th colspan="3" class="text-end">تخفیف</th><th>{{ invoice.discount_amount|floatformat:0|intcomma:False }}</th></tr>
|
||||
<tr><th colspan="3" class="text-end">مبلغ نهایی</th><th>{{ invoice.final_amount|floatformat:0|intcomma:False }}</th></tr>
|
||||
<tr><th colspan="3" class="text-end">پرداختیها</th><th>{{ invoice.paid_amount|floatformat:0|intcomma:False }}</th></tr>
|
||||
<tr><th colspan="3" class="text-end">مانده</th><th>{{ invoice.remaining_amount|floatformat:0|intcomma:False }}</th></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-5 d-flex justify-content-between">
|
||||
<div>امضا مشتری</div>
|
||||
<div>امضا شرکت</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.print()</script>
|
||||
{% endblock %}
|
||||
|
||||
|
260
invoices/templates/invoices/final_invoice_step.html
Normal file
260
invoices/templates/invoices/final_invoice_step.html
Normal file
|
@ -0,0 +1,260 @@
|
|||
{% extends '_base.html' %}
|
||||
{% load static %}
|
||||
{% load processes_tags %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'sidebars/admin.html' %}
|
||||
{% endblock sidebar %}
|
||||
|
||||
{% block navbar %}
|
||||
{% include 'navbars/admin.html' %}
|
||||
{% endblock navbar %}
|
||||
|
||||
{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
|
||||
|
||||
{% block style %}
|
||||
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_toasts.html' %}
|
||||
{% csrf_token %}
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 no-print">
|
||||
<div>
|
||||
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
|
||||
<small class="text-muted d-block">
|
||||
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
|
||||
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت</a>
|
||||
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bs-stepper wizard-vertical vertical mt-2 no-print">
|
||||
{% stepper_header instance step %}
|
||||
<div class="bs-stepper-content">
|
||||
|
||||
<div class="card border">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">فاکتور نهایی</h5>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSpecialChargeModal()"><i class="bx bx-plus"></i> افزودن هزینه تعمیر/تعویض</button>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="small text-muted">مبلغ نهایی</div>
|
||||
<div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="small text-muted">پرداختیها</div>
|
||||
<div class="h5 mt-1 text-success">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="small text-muted">مانده</div>
|
||||
<div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 d-flex align-items-center">
|
||||
{% if invoice.remaining_amount <= 0 %}
|
||||
<span class="badge bg-success">تسویه کامل</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">باقیمانده دارد</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>آیتم</th>
|
||||
<th class="text-center">تعداد پایه</th>
|
||||
<th class="text-center">افزوده</th>
|
||||
<th class="text-center">حذف</th>
|
||||
<th class="text-center">تعداد نهایی</th>
|
||||
<th class="text-end">قیمت واحد (تومان)</th>
|
||||
<th class="text-end">قیمت کل (تومان)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-semibold">{{ r.item.name }}</span>
|
||||
{% if r.item.description %}<small class="text-muted">{{ r.item.description }}</small>{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">{{ r.base_qty }}</td>
|
||||
<td class="text-center text-success">{{ r.added_qty }}</td>
|
||||
<td class="text-center text-danger">{{ r.removed_qty }}</td>
|
||||
<td class="text-center">{{ r.quantity }}</td>
|
||||
<td class="text-end">{{ r.unit_price|floatformat:0|intcomma:False }}</td>
|
||||
<td class="text-end">{{ r.total_price|floatformat:0|intcomma:False }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="7" class="text-center text-muted">آیتمی یافت نشد</td></tr>
|
||||
{% endfor %}
|
||||
{% for si in invoice_specials %}
|
||||
<tr class="table-warning">
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-semibold">{{ si.item.name }}<span class="badge bg-info mx-2">ویژه</span></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">-</td>
|
||||
<td class="text-center">-</td>
|
||||
<td class="text-center">-</td>
|
||||
<td class="text-center">{{ si.quantity }}</td>
|
||||
<td class="text-end">{{ si.unit_price|floatformat:0|intcomma:False }}</td>
|
||||
<td class="text-end">
|
||||
{{ si.total_price|floatformat:0|intcomma:False }}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="deleteSpecial('{{ si.id }}')" title="حذف"><i class="bx bx-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="6" class="text-end">مبلغ کل</th>
|
||||
<th class="text-end">{{ invoice.total_amount|floatformat:0|intcomma:False }} تومان</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="6" class="text-end">تخفیف</th>
|
||||
<th class="text-end">{{ invoice.discount_amount|floatformat:0|intcomma:False }} تومان</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="6" class="text-end">مبلغ نهایی</th>
|
||||
<th class="text-end">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="6" class="text-end">پرداختیها</th>
|
||||
<th class="text-end">{{ invoice.paid_amount|floatformat:0|intcomma:False }} تومان</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="6" class="text-end">مانده</th>
|
||||
<th class="text-end {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
{% if next_step %}
|
||||
<button type="button" class="btn btn-primary" id="btnApproveFinalInvoice">تایید و ادامه</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Special Charge Modal -->
|
||||
<div class="modal fade" id="specialChargeModal" tabindex="-1" aria-labelledby="specialChargeModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="specialChargeModalLabel">افزودن هزینه تعمیر/تعویض</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="specialChargeForm" onsubmit="return false;">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">انتخاب آیتم ویژه</label>
|
||||
<select class="form-select" name="item_id" id="id_special_item" required>
|
||||
<option value="">انتخاب کنید...</option>
|
||||
{% for s in special_choices %}
|
||||
<option value="{{ s.id }}">{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">مبلغ (تومان)</label>
|
||||
<input type="number" class="form-control" name="amount" id="id_charge_amount" min="1" required>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">انصراف</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitSpecialCharge()">افزودن</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
function openSpecialChargeModal(){
|
||||
const el = document.getElementById('specialChargeModal');
|
||||
if (window.$ && typeof $(el).modal === 'function') { $(el).modal('show'); }
|
||||
else if (window.bootstrap && window.bootstrap.Modal) { new window.bootstrap.Modal(el).show(); }
|
||||
else { el.classList.add('show'); el.style.display = 'block'; }
|
||||
}
|
||||
function submitSpecialCharge(){
|
||||
const fd = new FormData(document.getElementById('specialChargeForm'));
|
||||
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
|
||||
fetch('{% url "invoices:add_special_charge" instance.id step.id %}', { method: 'POST', body: fd })
|
||||
.then(r=>r.json()).then(resp=>{
|
||||
if (resp.success){
|
||||
showToast('هزینه ویژه اضافه شد', 'success');
|
||||
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 600);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در افزودن هزینه', 'danger');
|
||||
}
|
||||
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
}
|
||||
// No filtering needed; show all special items
|
||||
function deleteSpecial(id){
|
||||
const fd = new FormData();
|
||||
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
|
||||
fetch(`{% url "invoices:delete_special_charge" instance.id step.id 0 %}`.replace('/0/', `/${id}/`), { method: 'POST', body: fd })
|
||||
.then(r=>r.json()).then(resp=>{
|
||||
if (resp.success){
|
||||
showToast('حذف شد', 'success');
|
||||
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 500);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در حذف', 'danger');
|
||||
}
|
||||
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
}
|
||||
document.getElementById('btnApproveFinalInvoice')?.addEventListener('click', function(){
|
||||
const fd = new FormData();
|
||||
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
|
||||
fetch('{% url "invoices:approve_final_invoice" instance.id step.id %}', { method:'POST', body: fd })
|
||||
.then(r=>r.json()).then(resp=>{
|
||||
if (resp.success){
|
||||
showToast(resp.message || 'تایید شد', 'success');
|
||||
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 600);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در تایید', 'danger');
|
||||
}
|
||||
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
248
invoices/templates/invoices/final_settlement_step.html
Normal file
248
invoices/templates/invoices/final_settlement_step.html
Normal file
|
@ -0,0 +1,248 @@
|
|||
{% extends '_base.html' %}
|
||||
{% load static %}
|
||||
{% load processes_tags %}
|
||||
{% load common_tags %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'sidebars/admin.html' %}
|
||||
{% endblock sidebar %}
|
||||
|
||||
{% block navbar %}
|
||||
{% include 'navbars/admin.html' %}
|
||||
{% endblock navbar %}
|
||||
|
||||
{% block title %}{{ step.name }} - درخواست {{ instance.code }}{% endblock %}
|
||||
|
||||
{% block style %}
|
||||
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
|
||||
<!-- Persian Date Picker CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_toasts.html' %}
|
||||
{% csrf_token %}
|
||||
<div class="container-xxl flex-grow-1 container-p-y">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 no-print">
|
||||
<div>
|
||||
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
|
||||
<small class="text-muted d-block">
|
||||
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }}
|
||||
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'invoices:final_invoice_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"><i class="bx bx-printer"></i> پرینت</a>
|
||||
|
||||
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bs-stepper wizard-vertical vertical mt-2 no-print">
|
||||
{% stepper_header instance step %}
|
||||
<div class="bs-stepper-content">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card border h-100">
|
||||
<div class="card-header"><h5 class="mb-0">ثبت تراکنش تسویه</h5></div>
|
||||
<div class="card-body">
|
||||
<form id="formFinalPayment" enctype="multipart/form-data" onsubmit="return false;">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">نوع تراکنش</label>
|
||||
<select class="form-select" name="direction" id="id_direction" required>
|
||||
<option value="in">دریافتی از مشتری</option>
|
||||
<option value="out">پرداخت به مشتری</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">مبلغ (تومان)</label>
|
||||
<input type="number" min="1" class="form-control" name="amount" id="id_amount" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">تاریخ</label>
|
||||
<input type="text" class="form-control" id="id_payment_date" name="payment_date" placeholder="انتخاب تاریخ" readonly required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">روش پرداخت</label>
|
||||
<select class="form-select" name="payment_method" id="id_payment_method" required>
|
||||
<option value="bank_transfer">انتقال بانکی</option>
|
||||
<option value="card">کارت بانکی</option>
|
||||
<option value="cash">نقدی</option>
|
||||
<option value="check">چک</option>
|
||||
<option value="other">سایر</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">شماره مرجع</label>
|
||||
<input type="text" class="form-control" name="reference_number" id="id_reference_number" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">تصویر فیش</label>
|
||||
<input type="file" class="form-control" name="receipt_image" id="id_receipt_image" accept="image/*" required>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="button" id="btnAddFinalPayment" class="btn btn-primary">افزودن</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card mb-3 border">
|
||||
<div class="card-header"><h5 class="mb-0">وضعیت فاکتور</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<div class="border rounded p-3">
|
||||
<div class="small text-muted">مبلغ نهایی</div>
|
||||
<div class="h5 mt-1">{{ invoice.final_amount|floatformat:0|intcomma:False }} تومان</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="border rounded p-3">
|
||||
<div class="small text-muted">مانده</div>
|
||||
<div class="h5 mt-1 {% if invoice.remaining_amount <= 0 %}text-success{% else %}text-danger{% endif %}">{{ invoice.remaining_amount|floatformat:0|intcomma:False }} تومان</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border">
|
||||
<div class="card-header"><h5 class="mb-0">تراکنشها</h5></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نوع</th>
|
||||
<th>مبلغ</th>
|
||||
<th>تاریخ</th>
|
||||
<th>روش</th>
|
||||
<th>شماره مرجع</th>
|
||||
<th style="width:150px">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in payments %}
|
||||
<tr>
|
||||
<td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td>
|
||||
<td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
|
||||
<td>{{ p.payment_date|to_jalali }}</td>
|
||||
<td>{{ p.get_payment_method_display }}</td>
|
||||
<td>{{ p.reference_number|default:'-' }}</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
{% if p.receipt_image %}
|
||||
<a href="{{ p.receipt_image.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="مشاهده" aria-label="مشاهده">
|
||||
<i class="bx bx-show"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteFinalPayment({{ p.id }})" title="حذف" aria-label="حذف"><i class="bx bx-trash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-center text-muted">تراکنشی ندارد</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
{% if previous_step %}
|
||||
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
<button type="button" id="btnApproveFinalSettlement" class="btn btn-primary">تایید و ادامه</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script src="https://unpkg.com/persian-date@latest/dist/persian-date.min.js"></script>
|
||||
<script src="https://unpkg.com/persian-datepicker@latest/dist/js/persian-datepicker.min.js"></script>
|
||||
<script>
|
||||
(function initPersianDatePicker(){
|
||||
if (window.$ && $.fn.persianDatepicker && $('#id_payment_date').length) {
|
||||
$('#id_payment_date').persianDatepicker({
|
||||
format: 'YYYY/MM/DD', initialValue: false, autoClose: true, persianDigit: false, observer: true,
|
||||
calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } },
|
||||
onSelect: function(unix){
|
||||
const g = new window.persianDate(unix).toCalendar('gregorian').format('YYYY-MM-DD');
|
||||
$('#id_payment_date').attr('data-gregorian', g);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function buildForm(){
|
||||
const fd = new FormData(document.getElementById('formFinalPayment'));
|
||||
const g = document.getElementById('id_payment_date').getAttribute('data-gregorian');
|
||||
if (g) { fd.set('payment_date', g); }
|
||||
return fd;
|
||||
}
|
||||
document.getElementById('btnAddFinalPayment').addEventListener('click', function(){
|
||||
const fd = buildForm();
|
||||
// Frontend validation
|
||||
const amount = document.getElementById('id_amount').value.trim();
|
||||
const payDate = document.getElementById('id_payment_date').value.trim();
|
||||
const method = document.getElementById('id_payment_method').value.trim();
|
||||
const ref = document.getElementById('id_reference_number').value.trim();
|
||||
const img = document.getElementById('id_receipt_image').files[0];
|
||||
const dir = document.getElementById('id_direction').value;
|
||||
if (!amount || !payDate || !method || !ref || !img) {
|
||||
showToast('همه فیلدها الزامی است', 'danger');
|
||||
return;
|
||||
}
|
||||
fetch('{% url "invoices:add_final_payment" instance.id step.id %}', { method:'POST', body: fd })
|
||||
.then(r=>r.json()).then(resp=>{
|
||||
if (resp.success) {
|
||||
showToast('تراکنش ثبت شد', 'success');
|
||||
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 700);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در ثبت تراکنش', 'danger');
|
||||
}
|
||||
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
});
|
||||
|
||||
function deleteFinalPayment(id){
|
||||
const fd = new FormData();
|
||||
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
|
||||
fetch(`{% url "invoices:delete_final_payment" instance.id step.id 0 %}`.replace('/0/', `/${id}/`), { method:'POST', body: fd })
|
||||
.then(r=>r.json()).then(resp=>{
|
||||
if (resp.success) {
|
||||
showToast('حذف شد', 'success');
|
||||
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 500);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در حذف', 'danger');
|
||||
}
|
||||
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
}
|
||||
|
||||
document.getElementById('btnApproveFinalSettlement').addEventListener('click', function(){
|
||||
const fd = new FormData();
|
||||
fd.append('csrfmiddlewaretoken', document.querySelector('input[name=csrfmiddlewaretoken]').value);
|
||||
fetch('{% url "invoices:approve_final_settlement" instance.id step.id %}', { method:'POST', body: fd })
|
||||
.then(r=>r.json()).then(resp=>{
|
||||
if (resp.success) {
|
||||
showToast(resp.message || 'تایید شد', 'success');
|
||||
if (resp.redirect) setTimeout(()=>{ window.location.href = resp.redirect; }, 600);
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در تایید', 'danger');
|
||||
}
|
||||
}).catch(()=> showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -243,12 +243,12 @@
|
|||
body: fd
|
||||
}).then(r => r.json()).then(resp => {
|
||||
if (resp.success) {
|
||||
showToast('فیش با موفقیت ثبت شد', 'success');
|
||||
showToast(resp.message || 'فیش با موفقیت ثبت شد', 'success');
|
||||
if (resp.redirect) {
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 700);
|
||||
}
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در ثبت فیش', 'danger');
|
||||
showToast(resp.message + ':' + resp.error || 'خطا در ثبت فیش', 'danger');
|
||||
}
|
||||
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
});
|
||||
|
@ -267,11 +267,13 @@
|
|||
method: 'POST',
|
||||
body: fd
|
||||
}).then(r => r.json()).then(resp => {
|
||||
if (resp.success && resp.redirect) {
|
||||
showToast('فیش با موفقیت حذف شد', 'success');
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 700);
|
||||
if (resp.success) {
|
||||
showToast(resp.message || 'فیش با موفقیت حذف شد', 'success');
|
||||
if (resp.redirect) {
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 700);
|
||||
}
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در حذف فیش', 'danger');
|
||||
showToast(resp.message || resp.error || 'خطا در حذف فیش', 'danger');
|
||||
}
|
||||
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
}
|
||||
|
@ -288,11 +290,13 @@
|
|||
method: 'POST',
|
||||
body: fd
|
||||
}).then(r => r.json()).then(resp => {
|
||||
if (resp.success && resp.redirect) {
|
||||
showToast(resp.message, 'success');
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 600);
|
||||
if (resp.success) {
|
||||
showToast(resp.message || 'پرداختها تایید شد', 'success');
|
||||
if (resp.redirect) {
|
||||
setTimeout(() => { window.location.href = resp.redirect; }, 600);
|
||||
}
|
||||
} else {
|
||||
showToast(resp.message || 'خطا در تایید پرداختها', 'danger');
|
||||
showToast(resp.message || resp.error || 'خطا در تایید پرداختها', 'danger');
|
||||
}
|
||||
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
{% stepper_header instance step %}
|
||||
<div class="bs-stepper-content">
|
||||
<!-- Invoice Preview Card -->
|
||||
<div class="card invoice-preview-card mt-4">
|
||||
<div class="card invoice-preview-card mt-4 border">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between flex-xl-row flex-md-column flex-sm-row flex-column p-sm-3 p-0">
|
||||
<div class="mb-xl-0 mb-4">
|
||||
|
|
|
@ -21,4 +21,17 @@ urlpatterns = [
|
|||
|
||||
# Quote print
|
||||
path('instance/<int:instance_id>/quote/print/', views.quote_print, name='quote_print'),
|
||||
|
||||
# Final invoice (step 7?) and print
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/', views.final_invoice_step, name='final_invoice_step'),
|
||||
path('instance/<int:instance_id>/final-invoice/print/', views.final_invoice_print, name='final_invoice_print'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/special/add/', views.add_special_charge, name='add_special_charge'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/special/<int:item_id>/delete/', views.delete_special_charge, name='delete_special_charge'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-invoice/approve/', views.approve_final_invoice, name='approve_final_invoice'),
|
||||
|
||||
# Final settlement payments (step 8?)
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/', views.final_settlement_step, name='final_settlement_step'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/add/', views.add_final_payment, name='add_final_payment'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/<int:payment_id>/delete/', views.delete_final_payment, name='delete_final_payment'),
|
||||
path('instance/<int:instance_id>/step/<int:step_id>/final-settlement/approve/', views.approve_final_settlement, name='approve_final_settlement'),
|
||||
]
|
||||
|
|
|
@ -11,6 +11,7 @@ import json
|
|||
|
||||
from processes.models import ProcessInstance, ProcessStep, StepInstance
|
||||
from .models import Item, Quote, QuoteItem, Payment, Invoice
|
||||
from installations.models import InstallationReport, InstallationItemChange
|
||||
|
||||
@login_required
|
||||
def quote_step(request, instance_id, step_id):
|
||||
|
@ -413,3 +414,358 @@ def approve_payments(request, instance_id, step_id):
|
|||
msg += ' - توجه: مبلغ پیشفاکتور به طور کامل پرداخت نشده است.'
|
||||
|
||||
return JsonResponse({'success': True, 'message': msg, 'redirect': redirect_url, 'is_fully_paid': is_fully_paid})
|
||||
|
||||
|
||||
@login_required
|
||||
def final_invoice_step(request, instance_id, step_id):
|
||||
"""تجمیع اقلام پیشفاکتور با تغییرات نصب و صدور فاکتور نهایی"""
|
||||
instance = get_object_or_404(
|
||||
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'),
|
||||
id=instance_id
|
||||
)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
|
||||
if not instance.can_access_step(step):
|
||||
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
|
||||
return redirect('processes:request_list')
|
||||
|
||||
quote = get_object_or_404(Quote, process_instance=instance)
|
||||
|
||||
# Helper to make safe Decimal from various inputs (handles commas/persian digits)
|
||||
def _to_decimal(value):
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
try:
|
||||
if isinstance(value, (int, float)):
|
||||
return Decimal(str(value))
|
||||
s = str(value or '').strip()
|
||||
if not s:
|
||||
return Decimal('0')
|
||||
# normalize commas and Persian digits
|
||||
persian = '۰۱۲۳۴۵۶۷۸۹'
|
||||
latin = '0123456789'
|
||||
tbl = str.maketrans({persian[i]: latin[i] for i in range(10)})
|
||||
s = s.translate(tbl).replace(',', '')
|
||||
return Decimal(s)
|
||||
except Exception:
|
||||
return Decimal('0')
|
||||
|
||||
# Build initial map from quote
|
||||
item_id_to_row = {}
|
||||
for qi in quote.items.all():
|
||||
item_id_to_row[qi.item_id] = {
|
||||
'item': qi.item,
|
||||
'base_qty': qi.quantity,
|
||||
'base_price': _to_decimal(qi.unit_price),
|
||||
'added_qty': 0,
|
||||
'removed_qty': 0,
|
||||
}
|
||||
|
||||
# Read installation changes from latest report (if any)
|
||||
latest_report = InstallationReport.objects.filter(assignment__process_instance=instance).order_by('-created').first()
|
||||
if latest_report:
|
||||
for ch in latest_report.item_changes.all():
|
||||
row = item_id_to_row.setdefault(ch.item_id, {
|
||||
'item': ch.item,
|
||||
'base_qty': 0,
|
||||
'base_price': _to_decimal(ch.unit_price or ch.item.unit_price),
|
||||
'added_qty': 0,
|
||||
'removed_qty': 0,
|
||||
})
|
||||
if ch.change_type == 'add':
|
||||
row['added_qty'] += ch.quantity
|
||||
if ch.unit_price:
|
||||
row['base_price'] = _to_decimal(ch.unit_price)
|
||||
else:
|
||||
row['removed_qty'] += ch.quantity
|
||||
if ch.unit_price:
|
||||
row['base_price'] = _to_decimal(ch.unit_price)
|
||||
|
||||
# Compute final invoice lines
|
||||
rows = []
|
||||
total_amount = Decimal('0')
|
||||
for _, r in item_id_to_row.items():
|
||||
final_qty = max(0, (r['base_qty'] + r['added_qty'] - r['removed_qty']))
|
||||
if final_qty == 0:
|
||||
continue
|
||||
unit_price_dec = _to_decimal(r['base_price'])
|
||||
line_total = Decimal(final_qty) * unit_price_dec
|
||||
total_amount += line_total
|
||||
rows.append({
|
||||
'item': r['item'],
|
||||
'quantity': final_qty,
|
||||
'unit_price': unit_price_dec,
|
||||
'total_price': line_total,
|
||||
'base_qty': r['base_qty'],
|
||||
'added_qty': r['added_qty'],
|
||||
'removed_qty': r['removed_qty'],
|
||||
})
|
||||
|
||||
# Create or reuse final invoice
|
||||
invoice, _ = Invoice.objects.get_or_create(
|
||||
process_instance=instance,
|
||||
customer=quote.customer,
|
||||
quote=quote,
|
||||
defaults={
|
||||
'name': f"فاکتور نهایی {instance.code}",
|
||||
'due_date': timezone.now().date(),
|
||||
'created_by': request.user,
|
||||
}
|
||||
)
|
||||
# Replace only non-special items (preserve special charges added by user)
|
||||
qs = invoice.items.select_related('item').filter(item__is_special=False)
|
||||
try:
|
||||
qs._raw_delete(qs.db)
|
||||
except Exception:
|
||||
qs.delete()
|
||||
for r in rows:
|
||||
from .models import InvoiceItem
|
||||
InvoiceItem.objects.create(
|
||||
invoice=invoice,
|
||||
item=r['item'],
|
||||
quantity=r['quantity'],
|
||||
unit_price=r['unit_price'],
|
||||
)
|
||||
invoice.calculate_totals()
|
||||
|
||||
previous_step = instance.process.steps.filter(order__lt=step.order).last()
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
|
||||
# Choices for special items from DB
|
||||
special_choices = list(Item.objects.filter(is_special=True).values('id', 'name'))
|
||||
|
||||
return render(request, 'invoices/final_invoice_step.html', {
|
||||
'instance': instance,
|
||||
'step': step,
|
||||
'invoice': invoice,
|
||||
'rows': rows,
|
||||
'special_choices': special_choices,
|
||||
'invoice_specials': invoice.items.select_related('item').filter(item__is_special=True, is_deleted=False).all(),
|
||||
'previous_step': previous_step,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def final_invoice_print(request, instance_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
items = invoice.items.select_related('item').filter(is_deleted=False).all()
|
||||
return render(request, 'invoices/final_invoice_print.html', {
|
||||
'instance': instance,
|
||||
'invoice': invoice,
|
||||
'items': items,
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def approve_final_invoice(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
# Block approval when there is any remaining (positive or negative)
|
||||
invoice.calculate_totals()
|
||||
if invoice.remaining_amount != 0:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})"
|
||||
})
|
||||
# mark step completed
|
||||
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
|
||||
step_instance.status = 'completed'
|
||||
step_instance.completed_at = timezone.now()
|
||||
step_instance.save()
|
||||
# move to next
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
redirect_url = reverse('processes:request_list')
|
||||
if next_step:
|
||||
instance.current_step = next_step
|
||||
instance.save()
|
||||
redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
|
||||
return JsonResponse({'success': True, 'message': 'فاکتور نهایی تایید شد', 'redirect': redirect_url})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def add_special_charge(request, instance_id, step_id):
|
||||
"""افزودن هزینه ویژه تعمیر/تعویض به فاکتور نهایی بهصورت آیتم جداگانه"""
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
# charge_type was removed from UI; we no longer require it
|
||||
item_id = request.POST.get('item_id')
|
||||
amount = (request.POST.get('amount') or '').strip()
|
||||
if not item_id:
|
||||
return JsonResponse({'success': False, 'message': 'آیتم را انتخاب کنید'})
|
||||
try:
|
||||
amount_dec = Decimal(amount)
|
||||
except (InvalidOperation, TypeError):
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ نامعتبر است'})
|
||||
if amount_dec <= 0:
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ باید مثبت باشد'})
|
||||
|
||||
# Fetch existing special item from DB
|
||||
special_item = get_object_or_404(Item, id=item_id, is_special=True)
|
||||
|
||||
from .models import InvoiceItem
|
||||
InvoiceItem.objects.create(
|
||||
invoice=invoice,
|
||||
item=special_item,
|
||||
quantity=1,
|
||||
unit_price=amount_dec,
|
||||
)
|
||||
invoice.calculate_totals()
|
||||
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def delete_special_charge(request, instance_id, step_id, item_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
from .models import InvoiceItem
|
||||
inv_item = get_object_or_404(InvoiceItem, id=item_id, invoice=invoice)
|
||||
# allow deletion only for special items
|
||||
try:
|
||||
if not getattr(inv_item.item, 'is_special', False):
|
||||
return JsonResponse({'success': False, 'message': 'امکان حذف این مورد وجود ندارد'})
|
||||
except Exception:
|
||||
return JsonResponse({'success': False, 'message': 'امکان حذف این مورد وجود ندارد'})
|
||||
inv_item.hard_delete()
|
||||
invoice.calculate_totals()
|
||||
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_invoice_step', args=[instance.id, step_id])})
|
||||
|
||||
|
||||
@login_required
|
||||
def final_settlement_step(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
if not instance.can_access_step(step):
|
||||
messages.error(request, 'شما به این مرحله دسترسی ندارید. ابتدا مراحل قبلی را تکمیل کنید.')
|
||||
return redirect('processes:request_list')
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
|
||||
previous_step = instance.process.steps.filter(order__lt=step.order).last()
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
|
||||
return render(request, 'invoices/final_settlement_step.html', {
|
||||
'instance': instance,
|
||||
'step': step,
|
||||
'invoice': invoice,
|
||||
'payments': invoice.payments.filter(is_deleted=False).all(),
|
||||
'previous_step': previous_step,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def add_final_payment(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
amount = (request.POST.get('amount') or '').strip()
|
||||
payment_date = (request.POST.get('payment_date') or '').strip()
|
||||
payment_method = (request.POST.get('payment_method') or '').strip()
|
||||
reference_number = (request.POST.get('reference_number') or '').strip()
|
||||
direction = (request.POST.get('direction') or 'in').strip()
|
||||
receipt_image = request.FILES.get('receipt_image')
|
||||
if not amount:
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ را وارد کنید'})
|
||||
if not payment_date:
|
||||
return JsonResponse({'success': False, 'message': 'تاریخ پرداخت را وارد کنید'})
|
||||
if not payment_method:
|
||||
return JsonResponse({'success': False, 'message': 'روش پرداخت را انتخاب کنید'})
|
||||
if not reference_number:
|
||||
return JsonResponse({'success': False, 'message': 'شماره مرجع را وارد کنید'})
|
||||
if not receipt_image:
|
||||
return JsonResponse({'success': False, 'message': 'تصویر فیش الزامی است'})
|
||||
if '/' in payment_date:
|
||||
payment_date = payment_date.replace('/', '-')
|
||||
try:
|
||||
amount_dec = Decimal(amount)
|
||||
except InvalidOperation:
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ نامعتبر است'})
|
||||
# Only allow outgoing (پرداخت به مشتری) when current net due is negative
|
||||
# Compute net due explicitly from current items/payments
|
||||
try:
|
||||
current_paid = sum((p.amount if p.direction == 'in' else -p.amount) for p in invoice.payments.filter(is_deleted=False).all())
|
||||
except Exception:
|
||||
current_paid = Decimal('0')
|
||||
# Ensure invoice totals are up-to-date for final_amount
|
||||
invoice.calculate_totals()
|
||||
net_due = invoice.final_amount - current_paid
|
||||
if direction == 'out' and net_due >= 0:
|
||||
return JsonResponse({'success': False, 'message': 'در حال حاضر مانده به نفع مشتری نیست'})
|
||||
|
||||
# Amount constraints by sign of net due
|
||||
if net_due > 0 and direction == 'in' and amount_dec > net_due:
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ فیش بیشتر از مانده فاکتور است'})
|
||||
if net_due < 0 and direction == 'out' and amount_dec > abs(net_due):
|
||||
return JsonResponse({'success': False, 'message': 'مبلغ فیش بیشتر از مانده بدهی شرکت به مشتری است'})
|
||||
if net_due < 0 and direction == 'in':
|
||||
return JsonResponse({'success': False, 'message': 'در حال حاضر مانده به نفع مشتری است؛ دریافت از مشتری مجاز نیست'})
|
||||
|
||||
Payment.objects.create(
|
||||
invoice=invoice,
|
||||
amount=amount_dec,
|
||||
payment_date=payment_date,
|
||||
payment_method=payment_method,
|
||||
reference_number=reference_number,
|
||||
direction='in' if direction != 'out' else 'out',
|
||||
receipt_image=receipt_image,
|
||||
created_by=request.user,
|
||||
)
|
||||
# After creation, totals auto-updated by model save. Respond with redirect and new totals for UX.
|
||||
invoice.refresh_from_db()
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]),
|
||||
'totals': {
|
||||
'final_amount': str(invoice.final_amount),
|
||||
'paid_amount': str(invoice.paid_amount),
|
||||
'remaining_amount': str(invoice.remaining_amount),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def delete_final_payment(request, instance_id, step_id, payment_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
|
||||
payment.delete()
|
||||
invoice.refresh_from_db()
|
||||
return JsonResponse({'success': True, 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'totals': {
|
||||
'final_amount': str(invoice.final_amount),
|
||||
'paid_amount': str(invoice.paid_amount),
|
||||
'remaining_amount': str(invoice.remaining_amount),
|
||||
}})
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def approve_final_settlement(request, instance_id, step_id):
|
||||
instance = get_object_or_404(ProcessInstance, id=instance_id)
|
||||
step = get_object_or_404(instance.process.steps, id=step_id)
|
||||
invoice = get_object_or_404(Invoice, process_instance=instance)
|
||||
# Block approval if any remaining exists (positive or negative)
|
||||
invoice.calculate_totals()
|
||||
if invoice.remaining_amount != 0:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': f"تا زمانی که مانده فاکتور صفر نشده امکان تایید نیست (مانده فعلی: {invoice.remaining_amount})"
|
||||
})
|
||||
# complete step
|
||||
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
|
||||
step_instance.status = 'completed'
|
||||
step_instance.completed_at = timezone.now()
|
||||
step_instance.save()
|
||||
# move next
|
||||
next_step = instance.process.steps.filter(order__gt=step.order).first()
|
||||
redirect_url = reverse('processes:request_list')
|
||||
if next_step:
|
||||
instance.current_step = next_step
|
||||
instance.save()
|
||||
redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
|
||||
return JsonResponse({'success': True, 'message': 'تسویه حساب نهایی ثبت شد', 'redirect': redirect_url})
|
||||
|
|
|
@ -43,6 +43,7 @@
|
|||
<tr>
|
||||
<th>شناسه</th>
|
||||
<th>فرآیند</th>
|
||||
<th>مرحله فعلی</th>
|
||||
<th>شماره اشتراک آب</th>
|
||||
<th>نماینده</th>
|
||||
<th>درخواستکننده</th>
|
||||
|
@ -57,6 +58,7 @@
|
|||
<tr>
|
||||
<td>{{ inst.code }}</td>
|
||||
<td>{{ inst.process.name }}</td>
|
||||
<td class="text-primary">{{ inst.current_step.name|default:"--" }}</td>
|
||||
<td>{{ inst.well.water_subscription_number }}</td>
|
||||
<td>{% if inst.representative %}{{ inst.representative.get_full_name }}{% else %}-{% endif %}</td>
|
||||
<td>{% if inst.requester %}{{ inst.requester.get_full_name }}{% else %}-{% endif %}</td>
|
||||
|
@ -247,6 +249,10 @@
|
|||
<label class="form-label" for="id_account_number">{{ customer_form.account_number.label }}</label>
|
||||
{{ customer_form.account_number }}
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" for="id_bank_name">{{ customer_form.bank_name.label }}</label>
|
||||
{{ customer_form.bank_name }}
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<label class="form-label" for="id_address">{{ customer_form.address.label }}</label>
|
||||
{{ customer_form.address }}
|
||||
|
@ -426,6 +432,7 @@
|
|||
case 'phone_number_2': return '#id_phone_number_2';
|
||||
case 'card_number': return '#id_card_number';
|
||||
case 'account_number': return '#id_account_number';
|
||||
case 'bank_name': return '#id_bank_name';
|
||||
case 'address': return '#id_address';
|
||||
default: return '#id_' + field;
|
||||
}
|
||||
|
@ -549,6 +556,7 @@
|
|||
$('#id_phone_number_2').val(resp.user.profile.phone_number_2 || '');
|
||||
$('#id_card_number').val(resp.user.profile.card_number || '');
|
||||
$('#id_account_number').val(resp.user.profile.account_number || '');
|
||||
$('#id_bank_name').val(resp.user.profile.bank_name || '');
|
||||
$('#id_address').val(resp.user.profile.address || '');
|
||||
} else {
|
||||
$('#id_national_code').val(nc);
|
||||
|
@ -556,6 +564,7 @@
|
|||
$('#id_phone_number_2').val('');
|
||||
$('#id_card_number').val('');
|
||||
$('#id_account_number').val('');
|
||||
$('#id_bank_name').val('');
|
||||
$('#id_address').val('');
|
||||
}
|
||||
setStatus('#repStatus', 'نماینده یافت شد.', 'success');
|
||||
|
@ -570,6 +579,7 @@
|
|||
$('#id_phone_number_2').val('');
|
||||
$('#id_card_number').val('');
|
||||
$('#id_account_number').val('');
|
||||
$('#id_bank_name').val('');
|
||||
$('#id_address').val('');
|
||||
setStatus('#repStatus', 'نماینده یافت نشد. لطفا اطلاعات را تکمیل کنید.', 'danger');
|
||||
}
|
||||
|
@ -595,7 +605,7 @@
|
|||
formData.append('card_number', $('#id_card_number').val() || '');
|
||||
formData.append('account_number', $('#id_account_number').val() || '');
|
||||
formData.append('address', $('#id_address').val() || '');
|
||||
|
||||
formData.append('bank_name', $('#id_bank_name').val() || '');
|
||||
// Include WellForm fields so edits are saved
|
||||
if ($('#wellFormBlock').is(':visible')) {
|
||||
formData.append('electricity_subscription_number', $('#id_electricity_subscription_number').val() || '');
|
||||
|
|
|
@ -46,3 +46,5 @@ def stepper_header(instance, current_step=None):
|
|||
'instance': instance,
|
||||
'steps_context': steps_context,
|
||||
}
|
||||
|
||||
# moved to _base/common/templatetags/common_tags.py
|
||||
|
|
|
@ -106,6 +106,7 @@ def lookup_representative_by_national_code(request):
|
|||
'phone_number_2': profile.phone_number_2,
|
||||
'card_number': profile.card_number,
|
||||
'account_number': profile.account_number,
|
||||
'bank_name': profile.bank_name,
|
||||
'address': profile.address,
|
||||
}
|
||||
}
|
||||
|
@ -135,6 +136,7 @@ def create_request_with_entities(request):
|
|||
representative_phone_number_2 = request.POST.get('phone_number_2') or request.POST.get('representative_phone_number_2')
|
||||
representative_card_number = request.POST.get('card_number') or request.POST.get('representative_card_number')
|
||||
representative_account_number = request.POST.get('account_number') or request.POST.get('representative_account_number')
|
||||
representative_bank_name = request.POST.get('bank_name') or request.POST.get('representative_bank_name')
|
||||
representative_address = request.POST.get('address') or request.POST.get('representative_address')
|
||||
|
||||
if not process_id:
|
||||
|
@ -174,6 +176,8 @@ def create_request_with_entities(request):
|
|||
representative_profile.card_number = representative_card_number
|
||||
if representative_account_number is not None:
|
||||
representative_profile.account_number = representative_account_number
|
||||
if representative_bank_name is not None:
|
||||
representative_profile.bank_name = representative_bank_name
|
||||
if representative_address is not None:
|
||||
representative_profile.address = representative_address
|
||||
representative_profile.save()
|
||||
|
@ -191,6 +195,7 @@ def create_request_with_entities(request):
|
|||
'address': representative_address or '',
|
||||
'card_number': representative_card_number or '',
|
||||
'account_number': representative_account_number or '',
|
||||
'bank_name': representative_bank_name or '',
|
||||
}
|
||||
customer_form = CustomerForm(customer_data, instance=profile_instance)
|
||||
customer_form.request = request
|
||||
|
@ -365,6 +370,18 @@ def step_detail(request, instance_id, step_id):
|
|||
return redirect('invoices:quote_preview_step', instance_id=instance.id, step_id=step.id)
|
||||
elif step.order == 3: # مرحله سوم - ثبت فیشهای واریزی
|
||||
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id)
|
||||
elif step.order == 4: # مرحله چهارم - قرارداد
|
||||
return redirect('contracts:contract_step', instance_id=instance.id, step_id=step.id)
|
||||
elif step.order == 5: # مرحله پنجم - انتخاب نصاب
|
||||
return redirect('installations:installation_assign_step', instance_id=instance.id, step_id=step.id)
|
||||
elif step.order == 6: # مرحله ششم - گزارش نصب
|
||||
return redirect('installations:installation_report_step', instance_id=instance.id, step_id=step.id)
|
||||
elif step.order == 7: # مرحله هفتم - فاکتور نهایی
|
||||
return redirect('invoices:final_invoice_step', instance_id=instance.id, step_id=step.id)
|
||||
elif step.order == 8: # مرحله هشتم - تسویه حساب نهایی
|
||||
return redirect('invoices:final_settlement_step', instance_id=instance.id, step_id=step.id)
|
||||
elif step.order == 9: # مرحله نهم - گواهی نهایی
|
||||
return redirect('certificates:certificate_step', instance_id=instance.id, step_id=step.id)
|
||||
|
||||
# برای سایر مراحل، template عمومی نمایش داده میشود
|
||||
step_instance = instance.step_instances.filter(step=step).first()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue