complete first version of main proccess

This commit is contained in:
aminhashemi92 2025-08-27 07:11:26 +03:30
parent 6ff4740d04
commit f2fc2362a7
61 changed files with 3280 additions and 28 deletions

View file

@ -54,6 +54,9 @@ INSTALLED_APPS = [
'common.apps.CommonConfig', 'common.apps.CommonConfig',
'processes.apps.ProcessesConfig', 'processes.apps.ProcessesConfig',
'invoices.apps.InvoicesConfig', 'invoices.apps.InvoicesConfig',
'contracts.apps.ContractsConfig',
'certificates.apps.CertificatesConfig',
'installations.apps.InstallationsConfig',
# ----------------------- # # ----------------------- #
] ]

View file

@ -25,6 +25,9 @@ urlpatterns = [
path('wells/', include('wells.urls')), path('wells/', include('wells.urls')),
path('processes/', include('processes.urls')), path('processes/', include('processes.urls')),
path('invoices/', include('invoices.urls')), path('invoices/', include('invoices.urls')),
path('contracts/', include('contracts.urls')),
path('certificates/', include('certificates.urls')),
path('installations/', include('installations.urls')),
] ]
if settings.DEBUG: if settings.DEBUG:

View file

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from accounts.models import Role, Profile from accounts.models import Role, Profile, Company
# Register your models here. # Register your models here.
@ -30,3 +30,12 @@ class ProfileAdmin(admin.ModelAdmin):
date_hierarchy = 'created' date_hierarchy = 'created'
ordering = ['-created'] ordering = ['-created']
readonly_fields = ['created', 'updated'] 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']

View file

@ -29,7 +29,7 @@ class CustomerForm(forms.ModelForm):
model = Profile model = Profile
fields = [ fields = [
'phone_number_1', 'phone_number_2', 'national_code', 'phone_number_1', 'phone_number_2', 'national_code',
'address', 'card_number', 'account_number' 'address', 'card_number', 'account_number', 'bank_name'
] ]
widgets = { widgets = {
'phone_number_1': forms.TextInput(attrs={ 'phone_number_1': forms.TextInput(attrs={
@ -61,6 +61,10 @@ class CustomerForm(forms.ModelForm):
'placeholder': 'شماره حساب بانکی', 'placeholder': 'شماره حساب بانکی',
'maxlength': '20' 'maxlength': '20'
}), }),
'bank_name': forms.Select(attrs={
'class': 'form-control',
'placeholder': 'نام بانک',
}),
} }
labels = { labels = {
'phone_number_1': 'تلفن ۱', 'phone_number_1': 'تلفن ۱',
@ -69,6 +73,7 @@ class CustomerForm(forms.ModelForm):
'address': 'آدرس', 'address': 'آدرس',
'card_number': 'شماره کارت', 'card_number': 'شماره کارت',
'account_number': 'شماره حساب', 'account_number': 'شماره حساب',
'bank_name': 'نام بانک',
} }
def clean_national_code(self): def clean_national_code(self):

View 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ها',
},
),
]

View file

@ -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='نام بانک'),
),
]

View file

@ -3,14 +3,15 @@ from django.db import models
from django.utils.html import format_html from django.utils.html import format_html
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from common.models import TagModel, BaseModel from common.models import TagModel, BaseModel, NameSlugModel
from common.consts import UserRoles from common.consts import UserRoles, BANK_CHOICES
from locations.models import Affairs, Broker, County from locations.models import Affairs, Broker, County
# Create your models here. # Create your models here.
class Role(TagModel): class Role(TagModel):
class Meta: class Meta:
verbose_name = "نقش" verbose_name = "نقش"
verbose_name_plural = "نقش‌ها" 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( phone_number_1 = models.CharField(
max_length=11, max_length=11,
null=True, null=True,
@ -170,3 +178,17 @@ class Profile(BaseModel):
return format_html(f"<img style='width:30px;' src='{self.pic.url}'>") return format_html(f"<img style='width:30px;' src='{self.pic.url}'>")
pic_tag.short_description = "تصویر" 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

View file

@ -64,6 +64,7 @@
<th>کد ملی</th> <th>کد ملی</th>
<th>تلفن</th> <th>تلفن</th>
<th>آدرس</th> <th>آدرس</th>
<th>بانک</th>
<th>وضعیت</th> <th>وضعیت</th>
<th>عملیات</th> <th>عملیات</th>
</tr> </tr>
@ -122,6 +123,17 @@
<span class="text-muted">آدرس ثبت نشده</span> <span class="text-muted">آدرس ثبت نشده</span>
{% endif %} {% endif %}
</td> </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> <td>
{% if customer.is_completed %} {% if customer.is_completed %}
<span class="badge bg-label-success">تکمیل شده</span> <span class="badge bg-label-success">تکمیل شده</span>
@ -242,6 +254,17 @@
{% endif %} {% endif %}
</div> </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"> <div class="col-sm-12">
<label class="form-label fw-bold" for="{{ form.card_number.id_for_label }}">{{ form.card_number.label }}</label> <label class="form-label fw-bold" for="{{ form.card_number.id_for_label }}">{{ form.card_number.label }}</label>
<div class="input-group input-group-merge"> <div class="input-group input-group-merge">

View file

@ -144,6 +144,7 @@ def get_customer_data(request, customer_id):
'card_number': str(form['card_number']), 'card_number': str(form['card_number']),
'account_number': str(form['account_number']), 'account_number': str(form['account_number']),
'address': str(form['address']), 'address': str(form['address']),
'bank_name': str(form['bank_name']),
} }
return JsonResponse({ return JsonResponse({
@ -157,7 +158,8 @@ def get_customer_data(request, customer_id):
'national_code': customer.national_code or '', 'national_code': customer.national_code or '',
'card_number': customer.card_number or '', 'card_number': customer.card_number or '',
'account_number': customer.account_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 'form_html': form_html
}) })

0
certificates/__init__.py Normal file
View file

20
certificates/admin.py Normal file
View 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
View file

@ -0,0 +1,10 @@
from django.apps import AppConfig
class CertificatesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'certificates'
verbose_name = 'گواهی‌ها'

View 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ها',
},
),
]

View file

@ -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='شرکت صادر کننده'),
),
]

View file

38
certificates/models.py Normal file
View 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}"

View 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 %}

View 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
View 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
View 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,
})

View file

@ -11,3 +11,21 @@ class UserRoles(Enum):
REGIONAL_WATER_AUTHORITY = "rwa" # کارشناس امور REGIONAL_WATER_AUTHORITY = "rwa" # کارشناس امور
WATER_RESOURCE_MANAGER = "wrm" # مدیر منابع آب WATER_RESOURCE_MANAGER = "wrm" # مدیر منابع آب
HEADQUARTER = "hdq" # ستاد آب منطقه‌ای HEADQUARTER = "hdq" # ستاد آب منطقه‌ای
BANK_CHOICES = [
('mellat', 'بانک ملت'),
('saman', 'بانک سامان'),
('parsian', 'بانک پارسیان'),
('sina', 'بانک سینا'),
('tejarat', 'بانک تجارت'),
('tosee', 'بانک توسعه'),
('iran_zamin', 'بانک ایران زمین'),
('meli', 'بانک ملی'),
('saderat', 'بانک توسعه صادرات'),
('iran_zamin', 'بانک ایران زمین'),
('refah', 'بانک رفاه'),
('eghtesad_novin', 'بانک اقتصاد نوین'),
('pasargad', 'بانک پاسارگاد'),
('other', 'سایر'),
]

View file

@ -0,0 +1,3 @@
# Intentionally empty to mark templatetags package

View 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
View file

15
contracts/admin.py Normal file
View 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
View file

@ -0,0 +1,8 @@
from django.apps import AppConfig
class ContractsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'contracts'
verbose_name = 'قراردادها'

View 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),
),
]

View file

@ -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',
),
]

View file

36
contracts/models.py Normal file
View 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}"

View 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 %}

View 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>

View 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
View 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
View 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,
})

View file

39
installations/admin.py Normal file
View 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
View file

@ -0,0 +1,8 @@
from django.apps import AppConfig
class InstallationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'installations'
verbose_name = 'نصب'

View 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'],
},
),
]

View file

@ -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='تاریخ تایید'),
),
]

View file

111
installations/models.py Normal file
View 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']

View file

@ -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 %}

View file

@ -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
View 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
View 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,
})

View file

@ -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='شماره مرجع'),
),
]

View file

@ -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='نوع تراکنش'),
),
]

View file

@ -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='نوع ویژه'),
),
]

View file

@ -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',
),
]

View file

@ -18,6 +18,7 @@ class Item(NameSlugModel):
decimal_places=2, decimal_places=2,
verbose_name="قیمت واحد" verbose_name="قیمت واحد"
) )
is_special = models.BooleanField(default=False, verbose_name='ویژه برای فاکتور نهایی')
default_quantity = models.PositiveIntegerField( default_quantity = models.PositiveIntegerField(
default=1, default=1,
verbose_name="تعداد پیش‌فرض" verbose_name="تعداد پیش‌فرض"
@ -102,7 +103,8 @@ class Quote(NameSlugModel):
def calculate_totals(self): 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 self.total_amount = total
# محاسبه تخفیف # محاسبه تخفیف
@ -260,15 +262,19 @@ class Invoice(NameSlugModel):
self.discount_amount = 0 self.discount_amount = 0
self.final_amount = self.total_amount - self.discount_amount self.final_amount = self.total_amount - self.discount_amount
self.remaining_amount = self.final_amount - self.paid_amount # خالص مانده به نفع شرکت (مثبت) یا به نفع مشتری (منفی)
net_due = self.final_amount - self.paid_amount
self.remaining_amount = net_due
# بروزرسانی وضعیت # وضعیت بر اساس مانده خالص
if self.remaining_amount <= 0: if net_due == 0:
self.status = 'paid' self.status = 'paid'
elif self.paid_amount > 0: elif net_due > 0:
self.status = 'partially_paid' # مشتری هنوز باید پرداخت کند
self.status = 'partially_paid' if self.paid_amount > 0 else 'sent'
else: else:
self.status = 'sent' # شرکت باید به مشتری پرداخت کند
self.status = 'partially_paid'
self.save() self.save()
@ -314,6 +320,12 @@ class Payment(BaseModel):
"""مدل پرداخت‌ها""" """مدل پرداخت‌ها"""
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments', verbose_name="فاکتور") invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments', verbose_name="فاکتور")
amount = models.DecimalField(max_digits=15, decimal_places=2, 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( payment_method = models.CharField(
max_length=20, max_length=20,
choices=[ choices=[
@ -326,7 +338,7 @@ class Payment(BaseModel):
default='cash', default='cash',
verbose_name="روش پرداخت" 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="تاریخ پرداخت") payment_date = models.DateField(verbose_name="تاریخ پرداخت")
notes = models.TextField(verbose_name="یادداشت‌ها", blank=True) notes = models.TextField(verbose_name="یادداشت‌ها", blank=True)
receipt_image = models.ImageField(upload_to='payments/%Y/%m/%d/', null=True, blank=True, verbose_name="تصویر فیش") 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) 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.paid_amount = total_paid
self.invoice.calculate_totals() 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

View 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 %}

View 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 %}

View 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 %}

View file

@ -243,12 +243,12 @@
body: fd body: fd
}).then(r => r.json()).then(resp => { }).then(r => r.json()).then(resp => {
if (resp.success) { if (resp.success) {
showToast('فیش با موفقیت ثبت شد', 'success'); showToast(resp.message || 'فیش با موفقیت ثبت شد', 'success');
if (resp.redirect) { if (resp.redirect) {
setTimeout(() => { window.location.href = resp.redirect; }, 700); setTimeout(() => { window.location.href = resp.redirect; }, 700);
} }
} else { } else {
showToast(resp.message || 'خطا در ثبت فیش', 'danger'); showToast(resp.message + ':' + resp.error || 'خطا در ثبت فیش', 'danger');
} }
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger')); }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
}); });
@ -267,11 +267,13 @@
method: 'POST', method: 'POST',
body: fd body: fd
}).then(r => r.json()).then(resp => { }).then(r => r.json()).then(resp => {
if (resp.success && resp.redirect) { if (resp.success) {
showToast('فیش با موفقیت حذف شد', 'success'); showToast(resp.message || 'فیش با موفقیت حذف شد', 'success');
setTimeout(() => { window.location.href = resp.redirect; }, 700); if (resp.redirect) {
setTimeout(() => { window.location.href = resp.redirect; }, 700);
}
} else { } else {
showToast(resp.message || 'خطا در حذف فیش', 'danger'); showToast(resp.message || resp.error || 'خطا در حذف فیش', 'danger');
} }
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger')); }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
} }
@ -288,11 +290,13 @@
method: 'POST', method: 'POST',
body: fd body: fd
}).then(r => r.json()).then(resp => { }).then(r => r.json()).then(resp => {
if (resp.success && resp.redirect) { if (resp.success) {
showToast(resp.message, 'success'); showToast(resp.message || 'پرداخت‌ها تایید شد', 'success');
setTimeout(() => { window.location.href = resp.redirect; }, 600); if (resp.redirect) {
setTimeout(() => { window.location.href = resp.redirect; }, 600);
}
} else { } else {
showToast(resp.message || 'خطا در تایید پرداخت‌ها', 'danger'); showToast(resp.message || resp.error || 'خطا در تایید پرداخت‌ها', 'danger');
} }
}).catch(() => showToast('خطا در ارتباط با سرور', 'danger')); }).catch(() => showToast('خطا در ارتباط با سرور', 'danger'));
} }

View file

@ -48,7 +48,7 @@
{% stepper_header instance step %} {% stepper_header instance step %}
<div class="bs-stepper-content"> <div class="bs-stepper-content">
<!-- Invoice Preview Card --> <!-- 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="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="d-flex justify-content-between flex-xl-row flex-md-column flex-sm-row flex-column p-sm-3 p-0">
<div class="mb-xl-0 mb-4"> <div class="mb-xl-0 mb-4">

View file

@ -21,4 +21,17 @@ urlpatterns = [
# Quote print # Quote print
path('instance/<int:instance_id>/quote/print/', views.quote_print, name='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'),
] ]

View file

@ -11,6 +11,7 @@ import json
from processes.models import ProcessInstance, ProcessStep, StepInstance from processes.models import ProcessInstance, ProcessStep, StepInstance
from .models import Item, Quote, QuoteItem, Payment, Invoice from .models import Item, Quote, QuoteItem, Payment, Invoice
from installations.models import InstallationReport, InstallationItemChange
@login_required @login_required
def quote_step(request, instance_id, step_id): def quote_step(request, instance_id, step_id):
@ -413,3 +414,358 @@ def approve_payments(request, instance_id, step_id):
msg += ' - توجه: مبلغ پیش‌فاکتور به طور کامل پرداخت نشده است.' msg += ' - توجه: مبلغ پیش‌فاکتور به طور کامل پرداخت نشده است.'
return JsonResponse({'success': True, 'message': msg, 'redirect': redirect_url, 'is_fully_paid': is_fully_paid}) 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})

View file

@ -43,6 +43,7 @@
<tr> <tr>
<th>شناسه</th> <th>شناسه</th>
<th>فرآیند</th> <th>فرآیند</th>
<th>مرحله فعلی</th>
<th>شماره اشتراک آب</th> <th>شماره اشتراک آب</th>
<th>نماینده</th> <th>نماینده</th>
<th>درخواست‌کننده</th> <th>درخواست‌کننده</th>
@ -57,6 +58,7 @@
<tr> <tr>
<td>{{ inst.code }}</td> <td>{{ inst.code }}</td>
<td>{{ inst.process.name }}</td> <td>{{ inst.process.name }}</td>
<td class="text-primary">{{ inst.current_step.name|default:"--" }}</td>
<td>{{ inst.well.water_subscription_number }}</td> <td>{{ inst.well.water_subscription_number }}</td>
<td>{% if inst.representative %}{{ inst.representative.get_full_name }}{% else %}-{% endif %}</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> <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> <label class="form-label" for="id_account_number">{{ customer_form.account_number.label }}</label>
{{ customer_form.account_number }} {{ customer_form.account_number }}
</div> </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"> <div class="col-sm-12">
<label class="form-label" for="id_address">{{ customer_form.address.label }}</label> <label class="form-label" for="id_address">{{ customer_form.address.label }}</label>
{{ customer_form.address }} {{ customer_form.address }}
@ -426,6 +432,7 @@
case 'phone_number_2': return '#id_phone_number_2'; case 'phone_number_2': return '#id_phone_number_2';
case 'card_number': return '#id_card_number'; case 'card_number': return '#id_card_number';
case 'account_number': return '#id_account_number'; case 'account_number': return '#id_account_number';
case 'bank_name': return '#id_bank_name';
case 'address': return '#id_address'; case 'address': return '#id_address';
default: return '#id_' + field; default: return '#id_' + field;
} }
@ -549,6 +556,7 @@
$('#id_phone_number_2').val(resp.user.profile.phone_number_2 || ''); $('#id_phone_number_2').val(resp.user.profile.phone_number_2 || '');
$('#id_card_number').val(resp.user.profile.card_number || ''); $('#id_card_number').val(resp.user.profile.card_number || '');
$('#id_account_number').val(resp.user.profile.account_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 || ''); $('#id_address').val(resp.user.profile.address || '');
} else { } else {
$('#id_national_code').val(nc); $('#id_national_code').val(nc);
@ -556,6 +564,7 @@
$('#id_phone_number_2').val(''); $('#id_phone_number_2').val('');
$('#id_card_number').val(''); $('#id_card_number').val('');
$('#id_account_number').val(''); $('#id_account_number').val('');
$('#id_bank_name').val('');
$('#id_address').val(''); $('#id_address').val('');
} }
setStatus('#repStatus', 'نماینده یافت شد.', 'success'); setStatus('#repStatus', 'نماینده یافت شد.', 'success');
@ -570,6 +579,7 @@
$('#id_phone_number_2').val(''); $('#id_phone_number_2').val('');
$('#id_card_number').val(''); $('#id_card_number').val('');
$('#id_account_number').val(''); $('#id_account_number').val('');
$('#id_bank_name').val('');
$('#id_address').val(''); $('#id_address').val('');
setStatus('#repStatus', 'نماینده یافت نشد. لطفا اطلاعات را تکمیل کنید.', 'danger'); setStatus('#repStatus', 'نماینده یافت نشد. لطفا اطلاعات را تکمیل کنید.', 'danger');
} }
@ -595,7 +605,7 @@
formData.append('card_number', $('#id_card_number').val() || ''); formData.append('card_number', $('#id_card_number').val() || '');
formData.append('account_number', $('#id_account_number').val() || ''); formData.append('account_number', $('#id_account_number').val() || '');
formData.append('address', $('#id_address').val() || ''); formData.append('address', $('#id_address').val() || '');
formData.append('bank_name', $('#id_bank_name').val() || '');
// Include WellForm fields so edits are saved // Include WellForm fields so edits are saved
if ($('#wellFormBlock').is(':visible')) { if ($('#wellFormBlock').is(':visible')) {
formData.append('electricity_subscription_number', $('#id_electricity_subscription_number').val() || ''); formData.append('electricity_subscription_number', $('#id_electricity_subscription_number').val() || '');

View file

@ -46,3 +46,5 @@ def stepper_header(instance, current_step=None):
'instance': instance, 'instance': instance,
'steps_context': steps_context, 'steps_context': steps_context,
} }
# moved to _base/common/templatetags/common_tags.py

View file

@ -106,6 +106,7 @@ def lookup_representative_by_national_code(request):
'phone_number_2': profile.phone_number_2, 'phone_number_2': profile.phone_number_2,
'card_number': profile.card_number, 'card_number': profile.card_number,
'account_number': profile.account_number, 'account_number': profile.account_number,
'bank_name': profile.bank_name,
'address': profile.address, '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_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_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_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') representative_address = request.POST.get('address') or request.POST.get('representative_address')
if not process_id: if not process_id:
@ -174,6 +176,8 @@ def create_request_with_entities(request):
representative_profile.card_number = representative_card_number representative_profile.card_number = representative_card_number
if representative_account_number is not None: if representative_account_number is not None:
representative_profile.account_number = representative_account_number 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: if representative_address is not None:
representative_profile.address = representative_address representative_profile.address = representative_address
representative_profile.save() representative_profile.save()
@ -191,6 +195,7 @@ def create_request_with_entities(request):
'address': representative_address or '', 'address': representative_address or '',
'card_number': representative_card_number or '', 'card_number': representative_card_number or '',
'account_number': representative_account_number or '', 'account_number': representative_account_number or '',
'bank_name': representative_bank_name or '',
} }
customer_form = CustomerForm(customer_data, instance=profile_instance) customer_form = CustomerForm(customer_data, instance=profile_instance)
customer_form.request = request 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) return redirect('invoices:quote_preview_step', instance_id=instance.id, step_id=step.id)
elif step.order == 3: # مرحله سوم - ثبت فیش‌های واریزی elif step.order == 3: # مرحله سوم - ثبت فیش‌های واریزی
return redirect('invoices:quote_payment_step', instance_id=instance.id, step_id=step.id) 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 عمومی نمایش داده می‌شود # برای سایر مراحل، template عمومی نمایش داده می‌شود
step_instance = instance.step_instances.filter(step=step).first() step_instance = instance.step_instances.filter(step=step).first()