Compare commits

..

7 commits

29 changed files with 1337 additions and 452 deletions

View file

@ -33,9 +33,9 @@ class ProfileAdmin(admin.ModelAdmin):
@admin.register(Company) @admin.register(Company)
class CompanyAdmin(admin.ModelAdmin): class CompanyAdmin(admin.ModelAdmin):
list_display = ['name', 'logo', 'signature', 'address', 'phone'] list_display = ['name', 'logo', 'signature', 'address', 'phone', 'broker', 'registration_number']
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
search_fields = ['name', 'address', 'phone'] search_fields = ['name', 'address', 'phone']
list_filter = ['is_active'] list_filter = ['is_active', 'broker']
date_hierarchy = 'created' date_hierarchy = 'created'
ordering = ['-created'] ordering = ['-created']

View file

@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2025-09-07 13:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
('locations', '0003_remove_broker_company'),
]
operations = [
migrations.AddField(
model_name='company',
name='broker',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='company', to='locations.broker', verbose_name='کارگزار'),
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 5.2.4 on 2025-09-07 14:11
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_company_broker'),
]
operations = [
migrations.AddField(
model_name='company',
name='account_number',
field=models.CharField(blank=True, max_length=20, null=True, validators=[django.core.validators.RegexValidator(code='invalid_account_number', message='شماره حساب باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره حساب'),
),
migrations.AddField(
model_name='company',
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='company',
name='card_number',
field=models.CharField(blank=True, max_length=16, null=True, validators=[django.core.validators.RegexValidator(code='invalid_card_number', message='شماره کارت باید فقط شامل اعداد باشد.', regex='^\\d+$')], verbose_name='شماره کارت'),
),
migrations.AddField(
model_name='company',
name='sheba_number',
field=models.CharField(blank=True, max_length=30, null=True, verbose_name='شماره شبا'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-07 14:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_company_account_number_company_bank_name_and_more'),
]
operations = [
migrations.AddField(
model_name='company',
name='branch_name',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='شعبه بانک'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-08 10:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_company_branch_name'),
]
operations = [
migrations.AddField(
model_name='company',
name='registration_number',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='شماره ثبت شرکت'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-08 10:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_company_registration_number'),
]
operations = [
migrations.AddField(
model_name='company',
name='card_holder_name',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='نام دارنده کارت'),
),
]

View file

@ -181,11 +181,94 @@ class Profile(BaseModel):
class Company(NameSlugModel): class Company(NameSlugModel):
logo = models.ImageField(upload_to='companies/logos', null=True, blank=True, verbose_name='لوگوی شرکت') logo = models.ImageField(
signature = models.ImageField(upload_to='companies/signatures', null=True, blank=True, verbose_name='امضای شرکت') upload_to='companies/logos',
address = models.TextField(null=True, blank=True, verbose_name='آدرس') null=True,
phone = models.CharField(max_length=11, null=True, blank=True, verbose_name='شماره تماس') 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='شماره تماس'
)
registration_number = models.CharField(
max_length=255,
null=True,
blank=True,
verbose_name='شماره ثبت شرکت'
)
broker = models.OneToOneField(
Broker,
on_delete=models.SET_NULL,
verbose_name="کارگزار",
null=True,
blank=True,
related_name='company'
)
card_number = models.CharField(
max_length=16,
null=True,
verbose_name="شماره کارت",
blank=True,
validators=[
RegexValidator(
regex=r'^\d+$',
message='شماره کارت باید فقط شامل اعداد باشد.',
code='invalid_card_number'
)
]
)
account_number = models.CharField(
max_length=20,
null=True,
verbose_name="شماره حساب",
blank=True,
validators=[
RegexValidator(
regex=r'^\d+$',
message='شماره حساب باید فقط شامل اعداد باشد.',
code='invalid_account_number'
)
]
)
card_holder_name = models.CharField(
max_length=255,
null=True,
verbose_name="نام دارنده کارت",
blank=True,
)
sheba_number = models.CharField(
max_length=30,
null=True,
verbose_name="شماره شبا",
blank=True,
)
bank_name = models.CharField(
max_length=255,
choices=BANK_CHOICES,
null=True,
verbose_name="نام بانک",
blank=True
)
branch_name = models.CharField(
max_length=255,
null=True,
verbose_name="شعبه بانک",
blank=True
)
class Meta: class Meta:
verbose_name = 'شرکت' verbose_name = 'شرکت'
verbose_name_plural = 'شرکت‌ها' verbose_name_plural = 'شرکت‌ها'

View file

@ -2,45 +2,71 @@
<html lang="fa" dir="rtl"> <html lang="fa" dir="rtl">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>چاپ قرارداد {{ instance.code }}</title> <title>چاپ قرارداد {{ instance.code }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> {% load static %}
<!-- Match app fonts and theme -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500,1,600,1,700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'assets/vendor/fonts/boxicons.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/fonts/fontawesome.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/css/rtl/core.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/css/rtl/theme-default.css' %}">
<link rel="stylesheet" href="{% static 'assets/css/demo.css' %}">
<link rel="stylesheet" href="{% static 'assets/css/persian-fonts.css' %}">
<style> <style>
@page { size: A4; margin: 1.2cm; } @page { size: A4; margin: 1.2cm; }
body { font-family: 'Vazirmatn', sans-serif; } .invoice-header { border-bottom: 1px solid #dee2e6; padding-bottom: 16px; margin-bottom: 24px; }
.logo { max-height: 80px; } .brand-box { width:64px; height:64px; display:flex; align-items:center; justify-content:center; background:#eef2ff; border-radius:8px; }
.signature { height: 90px; border: 1px dashed #ccc; } .logo { max-height: 58px; max-width: 120px; }
.contract-title { font-size: 20px; font-weight: 600; }
.small-muted { font-size: 12px; color: #6c757d; }
.signature-box { border: 1px dashed #ccc; height: 210px; display:flex; align-items:center; justify-content:center; }
</style> </style>
<script> <script>
window.addEventListener('load', function(){ setTimeout(function(){ window.print(); }, 300); }); window.onload = function(){
window.print();
setTimeout(function(){ window.close(); }, 200);
};
</script> </script>
</head> </head>
<body> <body>
<div class="container-fluid"> <div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-3"> <!-- Header: Company and contract info -->
<div> <div class="invoice-header">
<h5>{{ contract.template.company.name }}</h5> <div class="small text-end text-muted mb-2">تاریخ: {{ contract.jcreated_date }} | کد درخواست: {{ instance.code }}</div>
<h5 class="mb-1">{{ contract.template.name }}</h5> <h5 class="text-center mb-3">
<div class="text-muted small">کد درخواست: {{ instance.code }} | تاریخ: {{ contract.jcreated }}</div> {% if instance.broker and instance.broker.company %}
</div> {{ instance.broker.company.name }}
{% if contract.template.company.logo %} {% elif template.company %}
<img class="logo" src="{{ contract.template.company.logo.url }}" alt="لوگو" /> {{ template.company.name }}
{% else %}
شرکت آب منطقه‌ای
{% endif %} {% endif %}
</h5>
<h4 class="text-center mb-3">{{ contract.template.name }}</h4>
</div> </div>
<hr>
<!-- Contract body -->
<div style="white-space: pre-line; line-height: 1.9;">{{ contract.rendered_body|safe }}</div> <div style="white-space: pre-line; line-height: 1.9;">{{ contract.rendered_body|safe }}</div>
<hr> <hr>
<!-- Signatures -->
<div class="row mt-4"> <div class="row mt-4">
<div class="col-6 text-center"> <div class="col-6 text-center">
<div>امضای مشترک</div> <div>امضای مشترک</div>
<div class="signature mt-2"></div> <div class="signature-box mt-2"></div>
</div> </div>
<div class="col-6 text-center"> <div class="col-6 text-center">
<div>امضای شرکت</div> <div>امضای شرکت</div>
<div class="signature mt-2"> <div class="signature-box mt-2">
{% if contract.template.company.signature %} {% if instance.broker and instance.broker.company and instance.broker.company.signature %}
<img src="{{ contract.template.company.signature.url }}" alt="امضای شرکت" style="max-height: 80px;" /> <img src="{{ instance.broker.company.signature.url }}" alt="امضای شرکت" style="max-height: 200px;" />
{% elif contract.template.company and contract.template.company.signature %}
<img src="{{ contract.template.company.signature.url }}" alt="امضای شرکت" style="max-height: 200px;" />
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -19,6 +19,10 @@
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
@ -26,13 +30,18 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} {% instance_info instance %}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <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 href="{% url 'contracts:contract_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">پرینت</a> <i class="bx bx-printer me-2"></i> پرینت
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
</a>
</div> </div>
</div> </div>
@ -41,29 +50,32 @@
<div class="bs-stepper-content"> <div class="bs-stepper-content">
<div class="card border"> <div class="card border">
<div class="card-body"> <div class="card-body">
<div class="small text-end text-muted mb-2">تاریخ: {{ contract.jcreated_date }} | کد درخواست: {{ instance.code }}</div>
<h5 class="text-center mb-3">
{% if instance.broker and instance.broker.company %}
{{ instance.broker.company.name }}
{% elif template.company %}
{{ template.company.name }}
{% else %}
شرکت آب منطقه‌ای
{% endif %}</h5>
<h4 class="text-center mb-3">{{ contract.template.name }}</h4>
{% if can_view_contract_body %} {% if can_view_contract_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> <hr>
<div class="contract-body" style="white-space: pre-line; line-height:1.9;">{{ contract.rendered_body|safe }}</div> <div class="contract-body" style="white-space: pre-line; line-height:1.9;">{{ contract.rendered_body|safe }}</div>
<hr> <hr>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-6 text-center"> <div class="col-6 text-center">
<div>امضای مشترک</div> <div>امضای مشترک</div>
<div style="height:90px;border:1px dashed #ccc; margin-top:10px;"></div> <div style="height:210px;border:1px dashed #ccc; margin-top:10px;"></div>
</div> </div>
<div class="col-6 text-center"> <div class="col-6 text-center">
<div>امضای شرکت</div> <div>امضای شرکت</div>
<div style="height:90px;border:1px dashed #ccc; margin-top:10px;"> <div style="height:210px;border:1px dashed #ccc; margin-top:10px;">
{% if template.company.signature %} {% if instance.broker and instance.broker.company and instance.broker.company.signature %}
<img src="{{ template.company.signature.url }}" alt="امضای شرکت" style="max-height:80px;"> <img src="{{ instance.broker.company.signature.url }}" alt="امضای شرکت" style="max-height:200px;">
{% elif template.company and template.company.signature %}
<img src="{{ template.company.signature.url }}" alt="امضای شرکت" style="max-height:200px;">
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -76,15 +88,23 @@
<form method="post" class="d-flex justify-content-between mt-3"> <form method="post" class="d-flex justify-content-between mt-3">
{% csrf_token %} {% csrf_token %}
{% if previous_step %} {% if previous_step %}
<a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">قبلی</a> <a href="{% url 'processes:step_detail' instance.id previous_step.id %}" class="btn btn-label-secondary">
<i class="bx bx-chevron-right bx-sm me-sm-2"></i>
قبلی
</a>
{% else %} {% else %}
<span></span> <span></span>
{% endif %} {% endif %}
{% if next_step %} {% if next_step %}
{% if is_broker %} {% if is_broker %}
<button type="submit" class="btn btn-primary">تایید و بعدی</button> <button type="submit" class="btn btn-primary">تایید و بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</button>
{% else %} {% else %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">بعدی</a> <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
بعدی
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a>
{% endif %} {% endif %}
{% else %} {% else %}
{% if is_broker %} {% if is_broker %}

View file

@ -2,29 +2,51 @@ from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from decimal import Decimal
from django.template import Template, Context from django.template import Template, Context
from django.utils.safestring import mark_safe
from processes.models import ProcessInstance, StepInstance from processes.models import ProcessInstance, StepInstance
from common.consts import UserRoles from common.consts import UserRoles
from .models import ContractTemplate, ContractInstance from .models import ContractTemplate, ContractInstance
from invoices.models import Invoice, Quote
from _helpers.utils import jalali_converter2 from _helpers.utils import jalali_converter2
from django.http import JsonResponse
def build_contract_context(instance: ProcessInstance) -> dict: def build_contract_context(instance: ProcessInstance) -> dict:
representative = instance.representative representative = instance.representative
profile = getattr(representative, 'profile', None) profile = getattr(representative, 'profile', None)
well = instance.well well = instance.well
# Compute prepayment from Quote-linked invoice payments
quote = Quote.objects.filter(process_instance=instance).first()
invoice = Invoice.objects.filter(quote=quote).first() if quote else None
payments_qs = invoice.payments.filter(is_deleted=False, direction='in').all() if invoice else []
total_paid = sum((p.amount for p in payments_qs), Decimal('0'))
try:
latest_payment_date = max((p.payment_date for p in payments_qs)) if payments_qs else None
except Exception:
latest_payment_date = None
return { return {
'customer_full_name': representative.get_full_name() if representative else '', 'customer_full_name': mark_safe(f"<span class=\"fw-bold\">{representative.get_full_name() if representative else ''}</span>"),
'national_code': profile.national_code if profile else '', 'registration_number': mark_safe(f"<span class=\"fw-bold\">{instance.broker.company.registration_number if instance.broker and instance.broker.company else ''}</span>"),
'address': profile.address if profile else '', 'national_code': mark_safe(f"<span class=\"fw-bold\">{profile.national_code if profile else ''}</span>"),
'phone': profile.phone_number_1 if profile else '', 'address': mark_safe(f"<span class=\"fw-bold\">{profile.address if profile else ''}</span>"),
'phone2': profile.phone_number_2 if profile else '', 'phone': mark_safe(f"<span class=\"fw-bold\">{profile.phone_number_1 if profile else ''}</span>"),
'water_subscription_number': well.water_subscription_number if well else '', 'phone2': mark_safe(f"<span class=\"fw-bold\">{profile.phone_number_2 if profile else ''}</span>"),
'electricity_subscription_number': well.electricity_subscription_number if well else '', 'water_subscription_number': mark_safe(f"<span class=\"fw-bold\">{well.water_subscription_number if well else ''}</span>"),
'water_meter_serial_number': well.water_meter_serial_number if well else '', 'electricity_subscription_number': mark_safe(f"<span class=\"fw-bold\">{well.electricity_subscription_number if well else ''}</span>"),
'well_power': well.well_power if well else '', 'water_meter_serial_number': mark_safe(f"<span class=\"fw-bold\">{well.water_meter_serial_number if well else ''}</span>"),
'request_code': instance.code, 'well_power': mark_safe(f"<span class=\"fw-bold\">{well.well_power if well else ''}</span>"),
'today': jalali_converter2(timezone.now()), 'request_code': mark_safe(f"<span class=\"fw-bold\">{instance.code}</span>"),
'today': mark_safe(f"<span class=\"fw-bold\">{jalali_converter2(timezone.now())}</span>"),
'company_name': mark_safe(f"<span class=\"fw-bold\">{instance.broker.company.name if instance.broker and instance.broker.company else ''}</span>"),
'city_name': mark_safe(f"<span class=\"fw-bold\">{instance.broker.affairs.county.city.name if instance.broker and instance.broker.affairs and instance.broker.affairs.county and instance.broker.affairs.county.city else ''}</span>"),
'card_number': mark_safe(f"<span class=\"fw-bold\">{instance.representative.profile.card_number if instance.representative else ''}</span>"),
'account_number': mark_safe(f"<span class=\"fw-bold\">{instance.representative.profile.account_number if instance.representative else ''}</span>"),
'bank_name': mark_safe(f"<span class=\"fw-bold\">{instance.representative.profile.get_bank_name_display() if instance.representative else ''}</span>"),
'prepayment_amount': mark_safe(f"<span class=\"fw-bold\">{int(total_paid):,}</span>"),
'prepayment_date': mark_safe(f"<span class=\"fw-bold\">{jalali_converter2(latest_payment_date)}</span>") if latest_payment_date else '',
} }
@ -35,10 +57,7 @@ def contract_step(request, instance_id, step_id):
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
previous_step = instance.process.steps.filter(order__lt=step.order).last() previous_step = instance.process.steps.filter(order__lt=step.order).last()
next_step = instance.process.steps.filter(order__gt=step.order).first() next_step = instance.process.steps.filter(order__gt=step.order).first()
# Access control:
# - INSTALLER: can open step but cannot view contract body (show inline message)
# - Others: can view
# - Only BROKER can submit/complete this step
profile = getattr(request.user, 'profile', None) profile = getattr(request.user, 'profile', None)
is_broker = False is_broker = False
can_view_contract_body = True can_view_contract_body = True
@ -72,7 +91,6 @@ def contract_step(request, instance_id, step_id):
# If user submits to go next, only broker can complete and go to next # If user submits to go next, only broker can complete and go to next
if request.method == 'POST': if request.method == 'POST':
if not is_broker: if not is_broker:
from django.http import JsonResponse
return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403) return JsonResponse({'success': False, 'message': 'شما مجوز تایید این مرحله را ندارید'}, status=403)
StepInstance.objects.update_or_create( StepInstance.objects.update_or_create(
process_instance=instance, process_instance=instance,

Binary file not shown.

View file

@ -7,6 +7,7 @@ from decimal import Decimal
from django.utils import timezone from django.utils import timezone
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.conf import settings from django.conf import settings
from _helpers.utils import jalali_converter2
User = get_user_model() User = get_user_model()
@ -91,6 +92,7 @@ class Quote(NameSlugModel):
verbose_name="ایجاد کننده", verbose_name="ایجاد کننده",
related_name='created_quotes' related_name='created_quotes'
) )
history = HistoricalRecords() history = HistoricalRecords()
class Meta: class Meta:
@ -371,3 +373,6 @@ class Payment(BaseModel):
except Exception: except Exception:
pass pass
return result return result
def jpayment_date(self):
return jalali_converter2(self.payment_date)

View file

@ -150,7 +150,7 @@
<tr> <tr>
<td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td> <td>{% if p.direction == 'in' %}<span class="badge bg-success">دریافتی{% else %}<span class="badge bg-warning text-dark">پرداختی{% endif %}</span></td>
<td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td> <td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
<td>{{ p.payment_date|date:'Y/m/d' }}</td> <td>{{ p.jpayment_date }}</td>
<td>{{ p.get_payment_method_display }}</td> <td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td> <td>{{ p.reference_number|default:'-' }}</td>
<td> <td>
@ -316,11 +316,32 @@
(function initPersianDatePicker(){ (function initPersianDatePicker(){
if (window.$ && $.fn.persianDatepicker && $('#id_payment_date').length) { if (window.$ && $.fn.persianDatepicker && $('#id_payment_date').length) {
$('#id_payment_date').persianDatepicker({ $('#id_payment_date').persianDatepicker({
format: 'YYYY/MM/DD', initialValue: false, autoClose: true, persianDigit: false, observer: true, format: 'YYYY/MM/DD',
initialValue: false,
autoClose: true,
persianDigit: false,
observer: true,
calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } }, calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } },
onSelect: function(unix){ onSelect: function(unix){
const g = new window.persianDate(unix).toCalendar('gregorian').format('YYYY-MM-DD'); // تبدیل تاریخ شمسی به میلادی برای ارسال به سرور
$('#id_payment_date').attr('data-gregorian', g); const gregorianDate = new Date(unix);
const year = gregorianDate.getFullYear();
const month = String(gregorianDate.getMonth() + 1).padStart(2, '0');
const day = String(gregorianDate.getDate()).padStart(2, '0');
const gregorianDateString = `${year}-${month}-${day}`;
// نمایش تاریخ شمسی در فیلد
if (window.persianDate) {
const persianDate = new window.persianDate(unix);
const persianDateString = persianDate.format('YYYY/MM/DD');
$('#id_payment_date').val(persianDateString);
} else {
// اگر persianDate در دسترس نبود، تاریخ میلادی را نمایش بده
$('#id_payment_date').val(gregorianDateString);
}
// ذخیره تاریخ میلادی در فیلد مخفی برای ارسال به سرور
$('#id_payment_date').attr('data-gregorian', gregorianDateString);
} }
}); });
} }
@ -328,8 +349,14 @@
function buildForm(){ function buildForm(){
const fd = new FormData(document.getElementById('formFinalPayment')); const fd = new FormData(document.getElementById('formFinalPayment'));
const g = document.getElementById('id_payment_date').getAttribute('data-gregorian');
if (g) { fd.set('payment_date', g); } // تبدیل تاریخ شمسی به میلادی برای ارسال
const persianDateValue = $('#id_payment_date').val();
const gregorianDateValue = $('#id_payment_date').attr('data-gregorian');
if (persianDateValue && gregorianDateValue) {
fd.set('payment_date', gregorianDateValue);
}
return fd; return fd;
} }
(function(){ (function(){

View file

@ -18,15 +18,16 @@
<link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}"> <link rel="stylesheet" href="{% static 'assets/vendor/libs/bs-stepper/bs-stepper.css' %}">
<!-- Persian Date Picker CSS --> <!-- Persian Date Picker CSS -->
<link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css"> <link rel="stylesheet" href="https://unpkg.com/persian-datepicker@latest/dist/css/persian-datepicker.min.css">
<style>
@media print {
.no-print { display: none !important; }
}
</style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
{% csrf_token %} {% csrf_token %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
@ -35,19 +36,21 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} {% instance_info instance %}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"> <a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer"></i> پرینت <i class="bx bx-printer me-2"></i> پرینت
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
</a> </a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</div> </div>
</div> </div>
<div class="bs-stepper wizard-vertical vertical mt-2 no-print"> <div class="bs-stepper wizard-vertical vertical mt-2">
{% stepper_header instance step %} {% stepper_header instance step %}
<div class="bs-stepper-content"> <div class="bs-stepper-content">
@ -60,7 +63,7 @@
</div> </div>
<div class="row g-3"> <div class="row g-3">
{% if can_manage_payments %} {% if is_broker %}
<div class="col-12 col-lg-5"> <div class="col-12 col-lg-5">
<div class="card h-100 border"> <div class="card h-100 border">
<div class="card-header"> <div class="card-header">
@ -104,7 +107,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="col-12 {% if can_manage_payments %}col-lg-7{% else %}col-lg-12{% endif %}"> <div class="col-12 {% if is_broker %}col-lg-7{% else %}col-lg-12{% endif %}">
<div class="card mb-3 border"> <div class="card mb-3 border">
<div class="card-header d-flex justify-content-between"> <div class="card-header d-flex justify-content-between">
<h5 class="card-title mb-0">وضعیت پیش‌فاکتور</h5> <h5 class="card-title mb-0">وضعیت پیش‌فاکتور</h5>
@ -161,7 +164,7 @@
{% for p in payments %} {% for p in payments %}
<tr> <tr>
<td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td> <td>{{ p.amount|floatformat:0|intcomma:False }} تومان</td>
<td>{{ p.payment_date|date:'Y/m/d' }}</td> <td>{{ p.jpayment_date }}</td>
<td>{{ p.get_payment_method_display }}</td> <td>{{ p.get_payment_method_display }}</td>
<td>{{ p.reference_number|default:'-' }}</td> <td>{{ p.reference_number|default:'-' }}</td>
<td> <td>
@ -171,7 +174,7 @@
<i class="bx bx-show"></i> <i class="bx bx-show"></i>
</a> </a>
{% endif %} {% endif %}
{% if can_manage_payments %} {% if is_broker %}
<button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف"> <button type="button" class="btn btn-sm btn-outline-danger" onclick="openDeleteModal('{{ p.id }}')" title="حذف" aria-label="حذف">
<i class="bx bx-trash"></i> <i class="bx bx-trash"></i>
</button> </button>
@ -195,7 +198,7 @@
<h6 class="mb-0">وضعیت تاییدها</h6> <h6 class="mb-0">وضعیت تاییدها</h6>
{% if can_approve_reject %} {% if can_approve_reject %}
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2" {% if step_instance.status == 'completed' %}disabled{% endif %}>تایید</button> <button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#approvePaymentsModal2">تایید</button>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button> <button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#rejectPaymentsModal">رد</button>
</div> </div>
{% endif %} {% endif %}
@ -356,6 +359,13 @@
} }
const form = document.getElementById('formAddPayment'); const form = document.getElementById('formAddPayment');
const fd = buildFormData(form); const fd = buildFormData(form);
// تبدیل تاریخ شمسی به میلادی برای ارسال
const persianDateValue = $('#id_payment_date').val();
const gregorianDateValue = $('#id_payment_date').attr('data-gregorian');
if (persianDateValue && gregorianDateValue) {
fd.set('payment_date', gregorianDateValue);
}
fetch('{% url "invoices:add_quote_payment" instance.id step.id %}', { fetch('{% url "invoices:add_quote_payment" instance.id step.id %}', {
method: 'POST', method: 'POST',
body: fd body: fd
@ -419,18 +429,24 @@
observer: true, observer: true,
calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } }, calendar: { persian: { locale: 'fa', leapYearMode: 'astronomical' } },
onSelect: function(unix) { onSelect: function(unix) {
// تبدیل تاریخ شمسی به میلادی برای ارسال به سرور
const gregorianDate = new Date(unix); const gregorianDate = new Date(unix);
const year = gregorianDate.getFullYear(); const year = gregorianDate.getFullYear();
const month = String(gregorianDate.getMonth() + 1).padStart(2, '0'); const month = String(gregorianDate.getMonth() + 1).padStart(2, '0');
const day = String(gregorianDate.getDate()).padStart(2, '0'); const day = String(gregorianDate.getDate()).padStart(2, '0');
const gregorianDateString = `${year}-${month}-${day}`; const gregorianDateString = `${year}-${month}-${day}`;
// نمایش تاریخ شمسی در فیلد
if (window.persianDate) { if (window.persianDate) {
const persianDate = new window.persianDate(unix); const persianDate = new window.persianDate(unix);
const persianDateString = persianDate.format('YYYY/MM/DD'); const persianDateString = persianDate.format('YYYY/MM/DD');
$('#id_payment_date').val(persianDateString); $('#id_payment_date').val(persianDateString);
} else { } else {
// اگر persianDate در دسترس نبود، تاریخ میلادی را نمایش بده
$('#id_payment_date').val(gregorianDateString); $('#id_payment_date').val(gregorianDateString);
} }
// ذخیره تاریخ میلادی در فیلد مخفی برای ارسال به سرور
$('#id_payment_date').attr('data-gregorian', gregorianDateString); $('#id_payment_date').attr('data-gregorian', gregorianDateString);
} }
}); });

View file

@ -24,6 +24,10 @@
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
{% csrf_token %} {% csrf_token %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
@ -32,15 +36,17 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} {% instance_info instance %}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary"> <a href="{% url 'invoices:quote_print' instance.id %}" target="_blank" class="btn btn-outline-secondary">
<i class="bx bx-printer"></i> پرینت <i class="bx bx-printer me-2"></i> پرینت
</a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
</a> </a>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a>
</div> </div>
</div> </div>
@ -50,100 +56,116 @@
<!-- Invoice Preview Card --> <!-- Invoice Preview Card -->
<div class="card invoice-preview-card mt-4 border"> <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 align-items-center">
<div class="mb-xl-0 mb-4"> <div class="mb-xl-0 mb-4">
<div class="d-flex svg-illustration mb-3 gap-2"> <!-- Company Logo & Info -->
<span class="app-brand-logo demo"> <div class="d-flex align-items-center">
<svg width="25" viewBox="0 0 25 42" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <div class="avatar avatar-lg me-3">
<defs> <span class="avatar-initial rounded bg-label-primary">
<path d="M13.7918663,0.358365126 L3.39788168,7.44174259 C0.566865006,9.69408886 -0.379795268,12.4788597 0.557900856,15.7960551 C0.68998853,16.2305145 1.09562888,17.7872135 3.12357076,19.2293357 C3.8146334,19.7207684 5.32369333,20.3834223 7.65075054,21.2172976 L7.59773219,21.2525164 L2.63468769,24.5493413 C0.445452254,26.3002124 0.0884951797,28.5083815 1.56381646,31.1738486 C2.83770406,32.8170431 5.20850219,33.2640127 7.09180128,32.5391577 C8.347334,32.0559211 11.4559176,30.0011079 16.4175519,26.3747182 C18.0338572,24.4997857 18.6973423,22.4544883 18.4080071,20.2388261 C17.963753,17.5346866 16.1776345,15.5799961 13.0496516,14.3747546 L10.9194936,13.4715819 L18.6192054,7.984237 L13.7918663,0.358365126 Z" id="path-1"></path> {% if instance.broker.company %}
<path d="M5.47320593,6.00457225 C4.05321814,8.216144 4.36334763,10.0722806 6.40359441,11.5729822 C8.61520715,12.571656 10.0999176,13.2171421 10.8577257,13.5094407 L15.5088241,14.433041 L18.6192054,7.984237 C15.5364148,3.11535317 13.9273018,0.573395879 13.7918663,0.358365126 C13.5790555,0.511491653 10.8061687,2.3935607 5.47320593,6.00457225 Z" id="path-3"></path> <img src="{{ instance.broker.company.logo.url }}" alt="لوگوی شرکت" style="max-height:80px;">
<path d="M7.50063644,21.2294429 L12.3234468,23.3159332 C14.1688022,24.7579751 14.397098,26.4880487 13.008334,28.506154 C11.6195701,30.5242593 10.3099883,31.790241 9.07958868,32.3040991 C5.78142938,33.4346997 4.13234973,34 4.13234973,34 C4.13234973,34 2.75489982,33.0538207 2.37032616e-14,31.1614621 C-0.55822714,27.8186216 -0.55822714,26.0572515 -4.05231404e-15,25.8773518 C0.83734071,25.6075023 2.77988457,22.8248993 3.3049379,22.52991 C3.65497346,22.3332504 5.05353963,21.8997614 7.50063644,21.2294429 Z" id="path-4"></path> {% else %}
<path d="M20.6,7.13333333 L25.6,13.8 C26.2627417,14.6836556 26.0836556,15.9372583 25.2,16.6 C24.8538077,16.8596443 24.4327404,17 24,17 L14,17 C12.8954305,17 12,16.1045695 12,15 C12,14.5672596 12.1403557,14.1461923 12.4,13.8 L17.4,7.13333333 C18.0627417,6.24967773 19.3163444,6.07059163 20.2,6.73333333 C20.3516113,6.84704183 20.4862915,6.981722 20.6,7.13333333 Z" id="path-5"></path> <i class="bx bx-buildings bx-md"></i>
</defs> {% endif %}
<g id="g-app-brand" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Brand-Logo" transform="translate(-27.000000, -15.000000)">
<g id="Icon" transform="translate(27.000000, 15.000000)">
<g id="Mask" transform="translate(0.000000, 8.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use fill="#696cff" xlink:href="#path-1"></use>
<g id="Path-3" mask="url(#mask-2)">
<use fill="#696cff" xlink:href="#path-3"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-3"></use>
</g>
<g id="Path-4" mask="url(#mask-2)">
<use fill="#696cff" xlink:href="#path-4"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-4"></use>
</g>
</g>
<g id="Triangle" transform="translate(19.000000, 11.000000) rotate(-300.000000) translate(-19.000000, -11.000000) ">
<use fill="#696cff" xlink:href="#path-5"></use>
<use fill-opacity="0.2" fill="#FFFFFF" xlink:href="#path-5"></use>
</g>
</g>
</g>
</g>
</svg>
</span> </span>
<span class="app-brand-text demo text-body fw-bold">شرکت آب منطقه‌ای</span>
</div>
<p class="mb-1">دفتر مرکزی، خیابان اصلی</p>
<p class="mb-1">تهران، ایران</p>
<p class="mb-0">۰۲۱-۱۲۳۴۵۶۷۸</p>
</div> </div>
<div> <div>
<h4>پیش‌فاکتور #{{ quote.name }}</h4> <h5 class="mb-1">
<div class="mb-2"> {% if instance.broker.company %}
<span class="me-1">تاریخ صدور:</span> {{ instance.broker.company.name }}
<span class="fw-medium">{{ quote.jcreated }}</span> {% else %}
شرکت آب منطقه‌ای
{% endif %}
</h5>
{% if instance.broker.company %}
<div class="text-muted small">
{% if instance.broker.company.address %}
<div><i class="bx bx-map me-1"></i>{{ instance.broker.company.address }}</div>
{% endif %}
{% if instance.broker.affairs.county.city.name %}
<div><i class="bx bx-current-location me-1"></i>{{ instance.broker.affairs.county.city.name }}، ایران</div>
{% endif %}
{% if instance.broker.company.phone %}
<div><i class="bx bx-phone me-1"></i>{{ instance.broker.company.phone }}</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Invoice Details -->
<div class="text-center">
<div class="mb-3">
<h5 class="text-body">#{{ quote.name }}</h5>
</div>
<div class="invoice-details">
<div class="d-flex justify-content-end align-items-center mb-2">
<span class="text-muted me-2">تاریخ صدور:</span>
<span class="fw-medium text-body">{{ quote.jcreated_date }}</span>
</div> </div>
<div>
<span class="me-1">معتبر تا:</span>
<span class="fw-medium">{{ quote.valid_until|date:"Y/m/d" }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<hr class="my-0"> <hr class="my-0">
<div class="card-body"> <div class="card-body py-1">
<div class="row p-sm-3 p-0"> <div class="row">
<div class="col-xl-6 col-md-12 col-sm-5 col-12 mb-xl-0 mb-md-4 mb-sm-0 mb-4"> <div class="col-xl-6 col-md-12 col-sm-6 col-12 mb-3">
<h6 class="pb-2">صادر شده برای:</h6> <div class="">
<p class="mb-1">{{ quote.customer.get_full_name }}</p> <div class="card-body p-3">
{% if instance.representative.profile %} <h6 class="card-title text-primary mb-2">
<p class="mb-1">کد ملی: {{ instance.representative.profile.national_code }}</p> <i class="bx bx-user me-1"></i>اطلاعات مشترک
<p class="mb-1">{{ instance.representative.profile.address|default:"آدرس نامشخص" }}</p> </h6>
<p class="mb-1">{{ instance.representative.profile.phone_number_1|default:"" }}</p> <div class="d-flex gap-2 mb-1">
{% endif %} <span class="text-muted small">نام:</span>
<span class="fw-medium small">{{ quote.customer.get_full_name }}</span>
</div>
{% if instance.representative.profile.national_code %}
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">کد ملی:</span>
<span class="fw-medium small">{{ instance.representative.profile.national_code }}</span>
</div>
{% endif %}
{% if instance.representative.profile.phone_number_1 %}
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">تلفن:</span>
<span class="fw-medium small">{{ instance.representative.profile.phone_number_1 }}</span>
</div>
{% endif %}
{% if instance.representative.profile.address %}
<div class="d-flex gap-2">
<span class="text-muted small">آدرس:</span>
<span class="fw-medium small">{{ instance.representative.profile.address }}</span>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-xl-6 col-md-12 col-sm-6 col-12 mb-3">
<div class="border-0 bg-light">
<div class="card-body p-3">
<h6 class="card-title text-primary mb-2">
<i class="bx bx-droplet me-1"></i>اطلاعات چاه
</h6>
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">شماره اشتراک آب:</span>
<span class="fw-medium small">{{ instance.well.water_subscription_number }}</span>
</div>
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">شماره اشتراک برق:</span>
<span class="fw-medium small">{{ instance.well.electricity_subscription_number|default:"-" }}</span>
</div>
<div class="d-flex gap-2 mb-1">
<span class="text-muted small">سریال کنتور:</span>
<span class="fw-medium small">{{ instance.well.water_meter_serial_number|default:"-" }}</span>
</div>
<div class="d-flex gap-2">
<span class="text-muted small">قدرت چاه:</span>
<span class="fw-medium small">{{ instance.well.well_power|default:"-" }}</span>
</div>
</div>
</div> </div>
<div class="col-xl-6 col-md-12 col-sm-7 col-12">
<h6 class="pb-2">اطلاعات چاه:</h6>
<table>
<tbody>
<tr>
<td class="pe-3">شماره اشتراک آب:</td>
<td>{{ instance.well.water_subscription_number }}</td>
</tr>
<tr>
<td class="pe-3">شماره اشتراک برق:</td>
<td>{{ instance.well.electricity_subscription_number|default:"-" }}</td>
</tr>
<tr>
<td class="pe-3">سریال کنتور:</td>
<td>{{ instance.well.water_meter_serial_number|default:"-" }}</td>
</tr>
<tr>
<td class="pe-3">قدرت چاه:</td>
<td>{{ instance.well.well_power|default:"-" }}</td>
</tr>
<tr>
<td class="pe-3">کد درخواست:</td>
<td>{{ instance.code }}</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
@ -170,11 +192,6 @@
{% endfor %} {% endfor %}
<tr> <tr>
<td colspan="3" class="align-top px-4 py-5"> <td colspan="3" class="align-top px-4 py-5">
<p class="mb-2">
<span class="me-1 fw-medium">صادر کننده:</span>
<span>{{ quote.created_by.get_full_name }}</span>
</p>
<span>با تشکر از انتخاب شما</span>
</td> </td>
<td class="text-end px-4 py-5"> <td class="text-end px-4 py-5">
<p class="mb-2">جمع کل:</p> <p class="mb-2">جمع کل:</p>
@ -193,6 +210,72 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- Footer Information -->
<div class="card-body border-top">
<div class="row">
<div class="col-md-8">
<h6 class="mb-3">شرایط و ضوابط:</h6>
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bx bx-time-five text-muted me-2"></i>
اعتبار پیش‌فاکتور صادر شده ۴۸ ساعت پس از تاریخ صدور می‌باشد
</li>
<li class="mb-2">
<i class="bx bx-money text-muted me-2"></i>
مبلغ فوق به صورت علی‌الحساب دریافت می‌گردد
</li>
<li class="mb-0">
<i class="bx bx-info-circle text-muted me-2"></i>
این برگه صرفاً جهت اعلام قیمت بوده و ارزش قانونی دیگری ندارد
</li>
{% if instance.broker.company.signature %}
<li class="mb-0 text-start mt-4 ms-5">
<img src="{{ instance.broker.company.signature.url }}" alt="امضای شرکت" style="height: 200px;">
</li>
{% endif %}
</ul>
</div>
{% if instance.broker.company %}
<div class="col-md-4">
<h6 class="mb-3">اطلاعات پرداخت:</h6>
<div class="d-flex flex-column gap-2">
{% if instance.broker.company.card_number %}
<div>
<small class="text-muted">شماره کارت:</small>
<div class="fw-medium">{{ instance.broker.company.card_number }}</div>
</div>
{% endif %}
{% if instance.broker.company.account_number %}
<div>
<small class="text-muted">شماره حساب:</small>
<div class="fw-medium">{{ instance.broker.company.account_number }}</div>
</div>
{% endif %}
{% if instance.broker.company.sheba_number %}
<div>
<small class="text-muted">شماره شبا:</small>
<div class="fw-medium">{{ instance.broker.company.sheba_number }}</div>
</div>
{% endif %}
{% if instance.broker.company.bank_name %}
<div>
<small class="text-muted">بانک:</small>
<div class="fw-medium">{{ instance.broker.company.get_bank_name_display }}</div>
</div>
{% endif %}
{% if instance.broker.company.branch_name %}
<div>
<small class="text-muted">شعبه:</small>
<div class="fw-medium">{{ instance.broker.company.branch_name }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div> </div>
{% if quote.notes %} {% if quote.notes %}
@ -240,7 +323,7 @@
{% else %} {% else %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" <a href="{% url 'processes:step_detail' instance.id next_step.id %}"
class="btn btn-label-primary"> class="btn btn-primary">
مرحله بعد مرحله بعد
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i> <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a> </a>

View file

@ -5,8 +5,24 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>پیش‌فاکتور {{ quote.name }} - {{ instance.code }}</title> <title>پیش‌فاکتور {{ quote.name }} - {{ instance.code }}</title>
<!-- Bootstrap CSS --> {% load static %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> {% load humanize %}
<!-- Fonts (match base) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap" rel="stylesheet">
<!-- Icons (optional) -->
<link rel="stylesheet" href="{% static 'assets/vendor/fonts/boxicons.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/fonts/fontawesome.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/fonts/flag-icons.css' %}">
<!-- Core CSS (same as preview) -->
<link rel="stylesheet" href="{% static 'assets/vendor/css/rtl/core.css' %}">
<link rel="stylesheet" href="{% static 'assets/vendor/css/rtl/theme-default.css' %}">
<link rel="stylesheet" href="{% static 'assets/css/demo.css' %}">
<link rel="stylesheet" href="{% static 'assets/css/persian-fonts.css' %}">
<style> <style>
@page { @page {
@ -14,11 +30,7 @@
margin: 1cm; margin: 1cm;
} }
body { /* Inherit project fonts and sizes from core.css/persian-fonts */
font-family: 'Vazirmatn', sans-serif;
font-size: 14px;
line-height: 1.6;
}
@media print { @media print {
body { print-color-adjust: exact; } body { print-color-adjust: exact; }
@ -27,7 +39,7 @@
} }
.invoice-header { .invoice-header {
border-bottom: 2px solid #696cff; border-bottom: 1px solid #dee2e6;
padding-bottom: 20px; padding-bottom: 20px;
margin-bottom: 30px; margin-bottom: 30px;
} }
@ -89,195 +101,159 @@
</head> </head>
<body> <body>
<div class="container-fluid"> <div class="container-fluid">
<!-- Print Button (hidden in print) --> <!-- Auto print: buttons removed -->
<div class="no-print mb-3">
<button onclick="window.print()" class="btn btn-primary">
<i class="bi bi-printer"></i> پرینت
</button>
<button onclick="window.close()" class="btn btn-secondary">
بستن
</button>
</div>
<!-- Invoice Header --> <!-- Invoice Header (compact, matches preview) -->
<div class="invoice-header"> <div class="invoice-header">
<div class="row"> <div class="row align-items-center">
<div class="col-6"> <div class="col-6 d-flex align-items-center">
<div class="company-logo mb-3"> <div class="me-3" style="width:64px;height:64px;display:flex;align-items:center;justify-content:center;background:#eef2ff;border-radius:8px;">
شرکت آب منطقه‌ای {% if instance.broker.company and instance.broker.company.logo %}
<img src="{{ instance.broker.company.logo.url }}" alt="لوگو" style="max-height:58px;max-width:120px;">
{% else %}
<span class="company-logo">شرکت</span>
{% endif %}
</div> </div>
<div class="company-info"> <div>
<p class="mb-1">دفتر مرکزی، خیابان اصلی</p> {% if instance.broker.company %}
<p class="mb-1">تهران، ایران</p> {{ instance.broker.company.name }}
<p class="mb-1">تلفن: ۰۲۱-۱۲۳۴۵۶۷۸</p> {% endif %}
<p class="mb-0">ایمیل: info@watercompany.ir</p> {% if instance.broker.company %}
<div class="text-muted small">
{% if instance.broker.company.address %}
<div>{{ instance.broker.company.address }}</div>
{% endif %}
{% if instance.broker.affairs.county.city.name %}
<div>{{ instance.broker.affairs.county.city.name }}، ایران</div>
{% endif %}
{% if instance.broker.company.phone %}
<div>تلفن: {{ instance.broker.company.phone }}</div>
{% endif %}
</div>
{% endif %}
</div> </div>
</div> </div>
<div class="col-6 text-end"> <div class="col-6 text-end">
<div class="invoice-title">پیش‌فاکتور</div> <div class="mt-2">
<div class="mt-3"> <div><strong>#{{ quote.name }}</strong></div>
<table class="info-table"> <div class="text-muted small">تاریخ صدور: {{ quote.jcreated_date }}</div>
<tr>
<td><strong>شماره پیش‌فاکتور:</strong></td>
<td>{{ quote.name }}</td>
</tr>
<tr>
<td><strong>کد درخواست:</strong></td>
<td>{{ instance.code }}</td>
</tr>
<tr>
<td><strong>تاریخ صدور:</strong></td>
<td>{{ quote.created|date:"Y/m/d" }}</td>
</tr>
<tr>
<td><strong>معتبر تا:</strong></td>
<td>{{ quote.valid_until|date:"Y/m/d" }}</td>
</tr>
</table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Customer & Well Info --> <!-- Customer & Well Info (compact to match preview) -->
<div class="row mb-4"> <div class="row mb-3">
<div class="col-6"> <div class="col-6">
<h6 class="fw-bold mb-3">مشخصات مشترک:</h6> <h6 class="fw-bold mb-2">اطلاعات مشترک</h6>
<table class="info-table"> <div class="small mb-1"><span class="text-muted">نام:</span> {{ quote.customer.get_full_name }}</div>
<tr> {% if instance.representative.profile and instance.representative.profile.national_code %}
<td><strong>نام و نام خانوادگی:</strong></td> <div class="small mb-1"><span class="text-muted">کد ملی:</span> {{ instance.representative.profile.national_code }}</div>
<td>{{ quote.customer.get_full_name }}</td> {% endif %}
</tr> {% if instance.representative.profile and instance.representative.profile.phone_number_1 %}
{% if instance.representative.profile %} <div class="small mb-1"><span class="text-muted">تلفن:</span> {{ instance.representative.profile.phone_number_1 }}</div>
<tr> {% endif %}
<td><strong>کد ملی:</strong></td> {% if instance.representative.profile and instance.representative.profile.address %}
<td>{{ instance.representative.profile.national_code }}</td> <div class="small"><span class="text-muted">آدرس:</span> {{ instance.representative.profile.address }}</div>
</tr>
<tr>
<td><strong>تلفن:</strong></td>
<td>{{ instance.representative.profile.phone_number_1|default:"-" }}</td>
</tr>
<tr>
<td><strong>آدرس:</strong></td>
<td>{{ instance.representative.profile.address|default:"آدرس نامشخص" }}</td>
</tr>
{% endif %} {% endif %}
</table>
</div> </div>
<div class="col-6"> <div class="col-6">
<h6 class="fw-bold mb-3">مشخصات چاه:</h6> <h6 class="fw-bold mb-2">اطلاعات چاه</h6>
<table class="info-table"> <div class="small mb-1"><span class="text-muted">شماره اشتراک آب:</span> {{ instance.well.water_subscription_number }}</div>
<tr> <div class="small mb-1"><span class="text-muted">شماره اشتراک برق:</span> {{ instance.well.electricity_subscription_number|default:"-" }}</div>
<td><strong>شماره اشتراک آب:</strong></td> <div class="small mb-1"><span class="text-muted">سریال کنتور:</span> {{ instance.well.water_meter_serial_number|default:"-" }}</div>
<td>{{ instance.well.water_subscription_number }}</td> <div class="small"><span class="text-muted">قدرت چاه:</span> {{ instance.well.well_power|default:"-" }}</div>
</tr>
<tr>
<td><strong>شماره اشتراک برق:</strong></td>
<td>{{ instance.well.electricity_subscription_number|default:"-" }}</td>
</tr>
<tr>
<td><strong>سریال کنتور:</strong></td>
<td>{{ instance.well.water_meter_serial_number|default:"-" }}</td>
</tr>
<tr>
<td><strong>قدرت چاه:</strong></td>
<td>{{ instance.well.well_power|default:"-" }}</td>
</tr>
</table>
</div> </div>
</div> </div>
<!-- Items Table --> <!-- Items Table -->
<div class="mb-4"> <div class="mb-4">
<table class="table items-table"> <table class="table border-top m-0">
<thead> <thead>
<tr> <tr>
<th style="width: 5%">ردیف</th> <th style="width: 5%">ردیف</th>
<th style="width: 30%">شرح کالا/خدمات</th> <th style="width: 30%">شرح کالا/خدمات</th>
<th style="width: 30%">توضیحات</th> <th style="width: 30%">توضیحات</th>
<th style="width: 10%">تعداد</th> <th style="width: 10%">تعداد</th>
<th style="width: 12.5%">قیمت واحد (تومان)</th> <th style="width: 12.5%">قیمت واحد(تومان)</th>
<th style="width: 12.5%">قیمت کل (تومان)</th> <th style="width: 12.5%">قیمت کل(تومان)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for quote_item in quote.items.all %} {% for quote_item in quote.items.all %}
<tr> <tr>
<td>{{ forloop.counter }}</td> <td>{{ forloop.counter }}</td>
<td class="text-start">{{ quote_item.item.name }}</td> <td class="text-nowrap">{{ quote_item.item.name }}</td>
<td class="text-start">{{ quote_item.item.description|default:"-" }}</td> <td class="text-nowrap">{{ quote_item.item.description|default:"-" }}</td>
<td>{{ quote_item.quantity }}</td> <td>{{ quote_item.quantity }}</td>
<td>{{ quote_item.unit_price|floatformat:0 }}</td> <td>{{ quote_item.unit_price|floatformat:0|intcomma:False }}</td>
<td>{{ quote_item.total_price|floatformat:0 }}</td> <td>{{ quote_item.total_price|floatformat:0|intcomma:False }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="total-section"> <tr class="total-section">
<td colspan="5" class="text-end"><strong>جمع کل:</strong></td> <td colspan="5" class="text-end"><strong>جمع کل(تومان):</strong></td>
<td><strong>{{ quote.total_amount|floatformat:0 }} تومان</strong></td> <td><strong>{{ quote.total_amount|floatformat:0|intcomma:False }}</strong></td>
</tr> </tr>
{% if quote.discount_amount > 0 %} {% if quote.discount_amount > 0 %}
<tr class="total-section"> <tr class="total-section">
<td colspan="5" class="text-end"><strong>تخفیف:</strong></td> <td colspan="5" class="text-end"><strong>تخفیف(تومان):</strong></td>
<td><strong>{{ quote.discount_amount|floatformat:0 }} تومان</strong></td> <td><strong>{{ quote.discount_amount|floatformat:0|intcomma:False }}</strong></td>
</tr> </tr>
{% endif %} {% endif %}
<tr class="total-section border-top border-2"> <tr class="total-section border-top border-2">
<td colspan="5" class="text-end"><strong>مبلغ نهایی:</strong></td> <td colspan="5" class="text-end"><strong>مبلغ نهایی(تومان):</strong></td>
<td><strong>{{ quote.final_amount|floatformat:0 }} تومان</strong></td> <td><strong>{{ quote.final_amount|floatformat:0|intcomma:False }}</strong></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
<!-- Notes --> <!-- Conditions & Payment (matches preview) -->
{% if quote.notes %} <div class="row">
<div class="mb-4"> <div class="col-8">
<h6 class="fw-bold">یادداشت:</h6> <h6 class="fw-bold mb-2">شرایط و ضوابط</h6>
<p>{{ quote.notes }}</p> <ul class="small mb-0">
<li class="mb-1">اعتبار پیش‌فاکتور صادر شده ۴۸ ساعت پس از تاریخ صدور می‌باشد</li>
<li class="mb-1">مبلغ فوق به صورت علی‌الحساب دریافت می‌گردد</li>
<li class="mb-1">این برگه صرفاً جهت اعلام قیمت بوده و ارزش قانونی دیگری ندارد</li>
{% if instance.broker.company and instance.broker.company.signature %}
<li class="mt-3" style="list-style:none;"><img src="{{ instance.broker.company.signature.url }}" alt="امضا" style="height: 200px;"></li>
{% endif %}
</ul>
</div>
{% if instance.broker.company %}
<div class="col-4">
<h6 class="fw-bold mb-2">اطلاعات پرداخت</h6>
{% if instance.broker.company.card_number %}
<div class="small mb-1"><span class="text-muted">شماره کارت:</span> {{ instance.broker.company.card_number }}</div>
{% endif %}
{% if instance.broker.company.account_number %}
<div class="small mb-1"><span class="text-muted">شماره حساب:</span> {{ instance.broker.company.account_number }}</div>
{% endif %}
{% if instance.broker.company.sheba_number %}
<div class="small mb-1"><span class="text-muted">شماره شبا:</span> {{ instance.broker.company.sheba_number }}</div>
{% endif %}
{% if instance.broker.company.bank_name %}
<div class="small"><span class="text-muted">بانک:</span> {{ instance.broker.company.get_bank_name_display }}</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
<!-- Additional Info -->
<div class="mb-4">
<p><strong>صادر کننده:</strong> {{ quote.created_by.get_full_name }}</p>
<p class="text-muted">این پیش‌فاکتور تا تاریخ {{ quote.valid_until|date:"Y/m/d" }} معتبر است.</p>
</div> </div>
<!-- Signature Section --> <!-- Signature Section (optional, compact) -->
<div class="signature-section"> {% if quote.notes %}
<div class="row"> <div class="mt-3 small text-muted">یادداشت: {{ quote.notes }}</div>
<div class="col-6"> {% endif %}
<div class="text-center">
<p class="mb-2"><strong>امضای مشترک</strong></p>
<div class="signature-box">
امضا و مهر
</div>
<p class="mt-2 small">تاریخ: ____/____/____</p>
</div>
</div>
<div class="col-6">
<div class="text-center">
<p class="mb-2"><strong>امضای شرکت</strong></p>
<div class="signature-box">
امضا و مهر
</div>
<p class="mt-2 small">تاریخ: ____/____/____</p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="text-center mt-4 small text-muted">
این پیش‌فاکتور توسط سیستم مدیریت فرآیندها تولید شده است.
</div>
</div> </div>
<script> <script>
// Auto print on load (optional) window.onload = function() {
// window.onload = function() { window.print(); } window.print();
setTimeout(function(){ window.close(); }, 200);
};
</script> </script>
</body> </body>
</html> </html>

View file

@ -19,6 +19,10 @@
{% block content %} {% block content %}
{% include '_toasts.html' %} {% include '_toasts.html' %}
<!-- Instance Info Modal -->
{% instance_info_modal instance %}
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="row"> <div class="row">
<div class="col-12 mb-4"> <div class="col-12 mb-4">
@ -26,11 +30,13 @@
<div> <div>
<h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4> <h4 class="mb-1">{{ step.name }}: {{ instance.process.name }}</h4>
<small class="text-muted d-block"> <small class="text-muted d-block">
اشتراک آب: {{ instance.well.water_subscription_number|default:"-" }} {% instance_info instance %}
| نماینده: {{ instance.representative.profile.national_code|default:"-" }}
</small> </small>
</div> </div>
<a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">بازگشت</a> <a href="{% url 'processes:request_list' %}" class="btn btn-outline-secondary">
<i class="bx bx-chevron-right bx-sm ms-sm-n2"></i>
بازگشت
</a>
</div> </div>
<div class="bs-stepper wizard-vertical vertical mt-2"> <div class="bs-stepper wizard-vertical vertical mt-2">
@ -50,7 +56,7 @@
<div class="col-12 mb-3"> <div class="col-12 mb-3">
<div class="alert alert-info"> <div class="alert alert-info">
<h6>پیش‌فاکتور موجود</h6> <h6>پیش‌فاکتور موجود</h6>
<span class="mb-1">نام: {{ existing_quote.name }} | </span> <span class="mb-1">{{ existing_quote.name }} | </span>
<span class="mb-1">مبلغ کل: {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان | </span> <span class="mb-1">مبلغ کل: {{ existing_quote.final_amount|floatformat:0|intcomma:False }} تومان | </span>
<span class="mb-0">وضعیت: {{ existing_quote.get_status_display_with_color|safe }}</span> <span class="mb-0">وضعیت: {{ existing_quote.get_status_display_with_color|safe }}</span>
</div> </div>
@ -143,7 +149,7 @@
{% endif %} {% endif %}
{% else %} {% else %}
{% if next_step %} {% if next_step %}
<a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-label-primary"> <a href="{% url 'processes:step_detail' instance.id next_step.id %}" class="btn btn-primary">
مرحله بعد مرحله بعد
<i class="bx bx-chevron-left bx-sm me-sm-n2"></i> <i class="bx bx-chevron-left bx-sm me-sm-n2"></i>
</a> </a>
@ -212,4 +218,6 @@
} }
}); });
</script> </script>
{% endblock %} {% endblock %}

View file

@ -15,9 +15,7 @@ urlpatterns = [
# Quote payments step (step 3) # Quote payments step (step 3)
path('instance/<int:instance_id>/step/<int:step_id>/payments/', views.quote_payment_step, name='quote_payment_step'), path('instance/<int:instance_id>/step/<int:step_id>/payments/', views.quote_payment_step, name='quote_payment_step'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/add/', views.add_quote_payment, name='add_quote_payment'), path('instance/<int:instance_id>/step/<int:step_id>/payments/add/', views.add_quote_payment, name='add_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/update/', views.update_quote_payment, name='update_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/delete/', views.delete_quote_payment, name='delete_quote_payment'), path('instance/<int:instance_id>/step/<int:step_id>/payments/<int:payment_id>/delete/', views.delete_quote_payment, name='delete_quote_payment'),
path('instance/<int:instance_id>/step/<int:step_id>/payments/approve/', views.approve_payments, name='approve_payments'),
# Quote print # 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'),

View file

@ -15,6 +15,7 @@ from common.consts import UserRoles
from .models import Item, Quote, QuoteItem, Payment, Invoice from .models import Item, Quote, QuoteItem, Payment, Invoice
from installations.models import InstallationReport, InstallationItemChange 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):
"""مرحله انتخاب اقلام و ساخت پیش‌فاکتور""" """مرحله انتخاب اقلام و ساخت پیش‌فاکتور"""
@ -62,6 +63,7 @@ def quote_step(request, instance_id, step_id):
'is_broker': is_broker, 'is_broker': is_broker,
}) })
@require_POST @require_POST
@login_required @login_required
def create_quote(request, instance_id, step_id): def create_quote(request, instance_id, step_id):
@ -90,7 +92,7 @@ def create_quote(request, instance_id, step_id):
except Exception: except Exception:
continue continue
default_item_ids = set(Item.objects.filter(is_default_in_quotes=True, is_deleted=False).values_list('id', flat=True)) default_item_ids = set(Item.objects.filter(is_default_in_quotes=True, is_deleted=False, is_special=False).values_list('id', flat=True))
if default_item_ids: if default_item_ids:
for default_id in default_item_ids: for default_id in default_item_ids:
if default_id not in payload_by_id: if default_id not in payload_by_id:
@ -105,7 +107,7 @@ def create_quote(request, instance_id, step_id):
return JsonResponse({'success': False, 'message': 'هیچ آیتمی انتخاب نشده است'}) return JsonResponse({'success': False, 'message': 'هیچ آیتمی انتخاب نشده است'})
# Create or reuse quote # Create or reuse quote
quote, _ = Quote.objects.get_or_create( quote, created_q = Quote.objects.get_or_create(
process_instance=instance, process_instance=instance,
defaults={ defaults={
'name': f"پیش‌فاکتور {instance.code}", 'name': f"پیش‌فاکتور {instance.code}",
@ -115,6 +117,15 @@ def create_quote(request, instance_id, step_id):
} }
) )
# Track whether this step was already completed before this edit
step_instance_existing = instance.step_instances.filter(step=step).first()
was_already_completed = bool(step_instance_existing and step_instance_existing.status == 'completed')
# Snapshot previous items before overwrite for change detection
previous_items_map = {}
if not created_q:
previous_items_map = {qi.item_id: int(qi.quantity) for qi in quote.items.filter(is_deleted=False).all()}
# Replace quote items with submitted ones # Replace quote items with submitted ones
quote.items.all().delete() quote.items.all().delete()
for entry in items_payload: for entry in items_payload:
@ -138,31 +149,78 @@ def create_quote(request, instance_id, step_id):
quote.calculate_totals() quote.calculate_totals()
# Detect changes versus previous state and mark audit fields if editing after completion
try:
new_items_map = {int(entry.get('id')): int(entry.get('qty') or 1) for entry in items_payload}
except Exception:
new_items_map = {}
next_step = instance.process.steps.filter(order__gt=step.order).first()
if was_already_completed and new_items_map != previous_items_map:
# StepInstance-level generic audit (for reuse across steps)
if step_instance_existing:
step_instance_existing.edited_after_completion = True
step_instance_existing.last_edited_at = timezone.now()
step_instance_existing.last_edited_by = request.user
step_instance_existing.edit_count = (step_instance_existing.edit_count or 0) + 1
step_instance_existing.completed_at = timezone.now()
step_instance_existing.save(update_fields=['edited_after_completion', 'last_edited_at', 'last_edited_by', 'edit_count', 'completed_at'])
if quote.status != 'draft':
quote.status = 'draft'
quote.save(update_fields=['status'])
# Reset ALL subsequent completed steps to in_progress
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance and subsequent_step_instance.status == 'completed':
# Bypass validation by using update() instead of save()
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
except Exception:
pass
# Set current step to the next step
if next_step:
instance.current_step = next_step
instance.save(update_fields=['current_step'])
# تکمیل مرحله # تکمیل مرحله
step_instance, created = StepInstance.objects.get_or_create( step_instance, created = StepInstance.objects.get_or_create(
process_instance=instance, process_instance=instance,
step=step step=step
) )
if not was_already_completed:
step_instance.status = 'completed' step_instance.status = 'completed'
step_instance.completed_at = timezone.now() step_instance.completed_at = timezone.now()
step_instance.save() step_instance.save(update_fields=['status', 'completed_at'])
# انتقال به مرحله بعدی # انتقال به مرحله بعدی
next_step = instance.process.steps.filter(order__gt=step.order).first()
redirect_url = None redirect_url = None
if next_step: if next_step:
# Only advance current step if we are currently on this step to avoid regressions
if instance.current_step_id == step.id:
instance.current_step = next_step instance.current_step = next_step
instance.save() instance.save(update_fields=['current_step'])
# هدایت مستقیم به مرحله پیش‌نمایش پیش‌فاکتور # هدایت مستقیم به مرحله پیش‌نمایش پیش‌فاکتور
redirect_url = reverse('invoices:quote_preview_step', args=[instance.id, next_step.id]) redirect_url = reverse('invoices:quote_preview_step', args=[instance.id, next_step.id])
return JsonResponse({'success': True, 'quote_id': quote.id, 'redirect': redirect_url}) return JsonResponse({'success': True, 'quote_id': quote.id, 'redirect': redirect_url})
@login_required @login_required
def quote_preview_step(request, instance_id, step_id): def quote_preview_step(request, instance_id, step_id):
"""مرحله صدور پیش‌فاکتور - نمایش و تایید فاکتور""" """مرحله صدور پیش‌فاکتور - نمایش و تایید فاکتور"""
instance = get_object_or_404( instance = get_object_or_404(
ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile'), ProcessInstance.objects.select_related('process', 'well', 'requester', 'representative', 'representative__profile', 'broker', 'broker__company', 'broker__affairs', 'broker__affairs__county', 'broker__affairs__county__city'),
id=instance_id id=instance_id
) )
step = get_object_or_404(instance.process.steps, id=step_id) step = get_object_or_404(instance.process.steps, id=step_id)
@ -199,6 +257,7 @@ def quote_preview_step(request, instance_id, step_id):
'is_broker': is_broker, 'is_broker': is_broker,
}) })
@login_required @login_required
def quote_print(request, instance_id): def quote_print(request, instance_id):
"""صفحه پرینت پیش‌فاکتور""" """صفحه پرینت پیش‌فاکتور"""
@ -210,6 +269,7 @@ def quote_print(request, instance_id):
'quote': quote, 'quote': quote,
}) })
@require_POST @require_POST
@login_required @login_required
def approve_quote(request, instance_id, step_id): def approve_quote(request, instance_id, step_id):
@ -282,6 +342,7 @@ def quote_payment_step(request, instance_id, step_id):
} }
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'}) step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step, defaults={'status': 'in_progress'})
reqs = list(step.approver_requirements.select_related('role').all()) reqs = list(step.approver_requirements.select_related('role').all())
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None) user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else [] user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
@ -295,6 +356,7 @@ def quote_payment_step(request, instance_id, step_id):
} }
for r in reqs for r in reqs
] ]
# dynamic permission: who can approve/reject this step (based on requirements) # dynamic permission: who can approve/reject this step (based on requirements)
try: try:
req_role_ids = {r.role_id for r in reqs} req_role_ids = {r.role_id for r in reqs}
@ -302,20 +364,7 @@ def quote_payment_step(request, instance_id, step_id):
can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0 can_approve_reject = len(req_role_ids.intersection(user_role_ids)) > 0
except Exception: except Exception:
can_approve_reject = False can_approve_reject = False
# approver status map for template
reqs = list(step.approver_requirements.select_related('role').all())
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', None)
user_roles = list(user_roles_qs.all()) if user_roles_qs is not None else []
approvals_list = list(step_instance.approvals.select_related('role').all())
approvals_by_role = {a.role_id: a for a in approvals_list}
approver_statuses = [
{
'role': r.role,
'status': (approvals_by_role.get(r.role_id).decision if approvals_by_role.get(r.role_id) else None),
'reason': (approvals_by_role.get(r.role_id).reason if approvals_by_role.get(r.role_id) else ''),
}
for r in reqs
]
# Accountant/Admin approval and rejection via POST (multi-role) # Accountant/Admin approval and rejection via POST (multi-role)
if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']: if request.method == 'POST' and request.POST.get('action') in ['approve', 'reject']:
@ -359,6 +408,13 @@ def quote_payment_step(request, instance_id, step_id):
defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason} defaults={'approved_by': request.user, 'decision': 'rejected', 'reason': reason}
) )
StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason) StepRejection.objects.create(step_instance=step_instance, rejected_by=request.user, reason=reason)
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
messages.success(request, 'مرحله پرداخت‌ها رد شد و برای اصلاح بازگشت.') messages.success(request, 'مرحله پرداخت‌ها رد شد و برای اصلاح بازگشت.')
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)
@ -385,8 +441,6 @@ def quote_payment_step(request, instance_id, step_id):
'approver_statuses': approver_statuses, 'approver_statuses': approver_statuses,
'is_broker': is_broker, 'is_broker': is_broker,
'is_accountant': is_accountant, 'is_accountant': is_accountant,
# dynamic permissions: any role required to approve can also manage payments
'can_manage_payments': can_approve_reject,
'can_approve_reject': can_approve_reject, 'can_approve_reject': can_approve_reject,
}) })
@ -409,14 +463,16 @@ def add_quote_payment(request, instance_id, step_id):
} }
) )
# dynamic permission: users whose roles are among required approvers can add payments # who can add payments
profile = getattr(request.user, 'profile', None)
is_broker = False
is_accountant = False
try: try:
req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True)) is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()) is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT))
user_role_ids = set(user_roles_qs.values_list('id', flat=True))
if len(req_role_ids.intersection(user_role_ids)) == 0:
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
except Exception: except Exception:
is_broker = False
is_accountant = False
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'}) return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -474,48 +530,31 @@ def add_quote_payment(request, instance_id, step_id):
si.approvals.all().delete() si.approvals.all().delete()
except Exception: except Exception:
pass pass
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url})
@require_POST
@login_required
def update_quote_payment(request, instance_id, step_id, payment_id):
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance)
invoice = Invoice.objects.filter(quote=quote).first()
if not invoice:
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
# Reset ALL subsequent completed steps to in_progress
try: try:
amount = request.POST.get('amount') subsequent_steps = instance.process.steps.filter(order__gt=step.order)
payment_date = request.POST.get('payment_date') or payment.payment_date for subsequent_step in subsequent_steps:
payment_method = request.POST.get('payment_method') or payment.payment_method subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
reference_number = request.POST.get('reference_number') or '' if subsequent_step_instance and subsequent_step_instance.status == 'completed':
notes = request.POST.get('notes') or '' # Bypass validation by using update() instead of save()
receipt_image = request.FILES.get('receipt_image') instance.step_instances.filter(step=subsequent_step).update(
if amount: status='in_progress',
payment.amount = amount completed_at=None
payment.payment_date = payment_date )
payment.payment_method = payment_method # Clear previous approvals if the step requires re-approval
payment.reference_number = reference_number try:
payment.notes = notes subsequent_step_instance.approvals.all().delete()
# اگر نیاز به ذخیره عکس در Payment دارید، فیلد آن اضافه شده است
if receipt_image:
payment.receipt_image = receipt_image
payment.save()
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'خطا در ویرایش فیش'}) pass
except Exception:
pass
# On update, return to awaiting approval # If current step is ahead of this step, reset it back to this step
try: try:
si, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step) if instance.current_step and instance.current_step.order > step.order:
si.status = 'in_progress' instance.current_step = step
si.completed_at = None instance.save(update_fields=['current_step'])
si.save()
si.approvals.all().delete()
except Exception: except Exception:
pass pass
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id]) redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
@ -532,15 +571,18 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
if not invoice: if not invoice:
return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'}) return JsonResponse({'success': False, 'message': 'فاکتور یافت نشد'})
payment = get_object_or_404(Payment, id=payment_id, invoice=invoice) payment = get_object_or_404(Payment, id=payment_id, invoice=invoice)
# dynamic permission: users whose roles are among required approvers can delete payments
# who can delete payments
profile = getattr(request.user, 'profile', None)
is_broker = False
is_accountant = False
try: try:
req_role_ids = set(step.approver_requirements.values_list('role_id', flat=True)) is_broker = bool(profile and profile.has_role(UserRoles.BROKER))
user_roles_qs = getattr(getattr(request.user, 'profile', None), 'roles', Role.objects.none()) is_accountant = bool(profile and profile.has_role(UserRoles.ACCOUNTANT))
user_role_ids = set(user_roles_qs.values_list('id', flat=True))
if len(req_role_ids.intersection(user_role_ids)) == 0:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'})
except Exception: except Exception:
return JsonResponse({'success': False, 'message': 'شما مجوز حذف فیش را ندارید'}) is_broker = False
is_accountant = False
return JsonResponse({'success': False, 'message': 'شما مجوز افزودن فیش را ندارید'})
try: try:
# soft delete using project's BaseModel delete override # soft delete using project's BaseModel delete override
@ -556,43 +598,37 @@ def delete_quote_payment(request, instance_id, step_id, payment_id):
si.approvals.all().delete() si.approvals.all().delete()
except Exception: except Exception:
pass pass
# Reset ALL subsequent completed steps to in_progress
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance and subsequent_step_instance.status == 'completed':
# Bypass validation by using update() instead of save()
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
except Exception:
pass
except Exception:
pass
# If current step is ahead of this step, reset it back to this step
try:
if instance.current_step and instance.current_step.order > step.order:
instance.current_step = step
instance.save(update_fields=['current_step'])
except Exception:
pass
redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id]) redirect_url = reverse('invoices:quote_payment_step', args=[instance.id, step.id])
return JsonResponse({'success': True, 'redirect': redirect_url}) return JsonResponse({'success': True, 'redirect': redirect_url})
@require_POST
@login_required
def approve_payments(request, instance_id, step_id):
"""تایید مرحله پرداخت فیش‌ها و انتقال به مرحله بعد"""
instance = get_object_or_404(ProcessInstance, id=instance_id)
step = get_object_or_404(instance.process.steps, id=step_id)
quote = get_object_or_404(Quote, process_instance=instance)
is_fully_paid = quote.get_remaining_amount() <= 0
# تکمیل مرحله
step_instance, _ = StepInstance.objects.get_or_create(process_instance=instance, step=step)
step_instance.status = 'completed'
step_instance.completed_at = timezone.now()
step_instance.save()
# حرکت به مرحله بعد
next_step = instance.process.steps.filter(order__gt=step.order).first()
redirect_url = reverse('processes:request_list')
if next_step:
instance.current_step = next_step
instance.save()
redirect_url = reverse('processes:step_detail', args=[instance.id, next_step.id])
msg = 'پرداخت‌ها تایید شد'
if is_fully_paid:
msg += ' - مبلغ پیش‌فاکتور به طور کامل پرداخت شده است.'
else:
msg += ' - توجه: مبلغ پیش‌فاکتور به طور کامل پرداخت نشده است.'
return JsonResponse({'success': True, 'message': msg, 'redirect': redirect_url, 'is_fully_paid': is_fully_paid})
@login_required @login_required
def final_invoice_step(request, instance_id, step_id): def final_invoice_step(request, instance_id, step_id):
"""تجمیع اقلام پیش‌فاکتور با تغییرات نصب و صدور فاکتور نهایی""" """تجمیع اقلام پیش‌فاکتور با تغییرات نصب و صدور فاکتور نهایی"""
@ -1010,6 +1046,25 @@ def add_final_payment(request, instance_id, step_id):
si.save() si.save()
except Exception: except Exception:
pass pass
# Reset ALL subsequent completed steps to in_progress
try:
subsequent_steps = instance.process.steps.filter(order__gt=step.order)
for subsequent_step in subsequent_steps:
subsequent_step_instance = instance.step_instances.filter(step=subsequent_step).first()
if subsequent_step_instance and subsequent_step_instance.status == 'completed':
# Bypass validation by using update() instead of save()
instance.step_instances.filter(step=subsequent_step).update(
status='in_progress',
completed_at=None
)
# Clear previous approvals if the step requires re-approval
try:
subsequent_step_instance.approvals.all().delete()
except Exception:
pass
except Exception:
pass
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]), 'redirect': reverse('invoices:final_settlement_step', args=[instance.id, step_id]),

View file

@ -29,7 +29,7 @@ class AffairsAdmin(admin.ModelAdmin):
@admin.register(Broker) @admin.register(Broker)
class BrokerAdmin(admin.ModelAdmin): class BrokerAdmin(admin.ModelAdmin):
list_display = ['name', 'affairs', 'slug'] list_display = ['name', 'affairs', 'slug']
list_filter = ['affairs__county__city', 'affairs__county', 'affairs'] list_filter = ['affairs__county__city', 'affairs__county', 'affairs' ]
search_fields = ['name', 'affairs__name', 'affairs__county__name'] search_fields = ['name', 'affairs__name', 'affairs__county__name']
readonly_fields = ['deleted_at'] readonly_fields = ['deleted_at']
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}

View file

@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2025-09-07 10:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
('locations', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='broker',
name='company',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='accounts.company', verbose_name='شرکت'),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 5.2.4 on 2025-09-07 13:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('locations', '0002_broker_company'),
]
operations = [
migrations.RemoveField(
model_name='broker',
name='company',
),
]

View file

@ -50,10 +50,12 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
verbose_name_plural = "درخواست‌ها" verbose_name_plural = "درخواست‌ها"
list_display = [ list_display = [
'code', 'code',
'current_step',
'slug', 'slug',
'well_display', 'well_display',
'representative', 'representative',
'requester', 'requester',
'broker',
'process', 'process',
'status_display', 'status_display',
'priority_display', 'priority_display',
@ -65,7 +67,8 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
'status', 'status',
'priority', 'priority',
'created', 'created',
'well__representative' 'well__representative',
'broker'
] ]
search_fields = [ search_fields = [
'code', 'code',
@ -86,6 +89,7 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
'well', 'well',
'representative', 'representative',
'requester', 'requester',
'broker',
'process', 'process',
'current_step' 'current_step'
] ]
@ -99,7 +103,7 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
'fields': ('well', 'representative') 'fields': ('well', 'representative')
}), }),
('اطلاعات درخواست', { ('اطلاعات درخواست', {
'fields': ('requester', 'priority') 'fields': ('requester', 'broker', 'priority')
}), }),
('وضعیت و پیشرفت', { ('وضعیت و پیشرفت', {
'fields': ('status', 'current_step') 'fields': ('status', 'current_step')
@ -139,7 +143,7 @@ class ProcessInstanceAdmin(SimpleHistoryAdmin):
@admin.register(StepInstance) @admin.register(StepInstance)
class StepInstanceAdmin(SimpleHistoryAdmin): class StepInstanceAdmin(SimpleHistoryAdmin):
list_display = ['process_instance', 'step', 'assigned_to', 'status_display', 'rejection_count', 'started_at', 'completed_at'] list_display = ['process_instance', 'step', 'assigned_to', 'status_display', 'rejection_count', 'edit_count', 'started_at', 'completed_at']
list_filter = ['status', 'step__process', 'started_at'] list_filter = ['status', 'step__process', 'started_at']
search_fields = ['process_instance__name', 'step__name', 'assigned_to__username'] search_fields = ['process_instance__name', 'step__name', 'assigned_to__username']
readonly_fields = ['started_at', 'completed_at'] readonly_fields = ['started_at', 'completed_at']

View file

@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2025-09-07 13:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('locations', '0003_remove_broker_company'),
('processes', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='processinstance',
name='broker',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='process_instances', to='locations.broker', verbose_name='کارگزار'),
),
]

View file

@ -0,0 +1,56 @@
# Generated by Django 5.2.4 on 2025-09-08 08:18
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('processes', '0002_processinstance_broker'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='historicalstepinstance',
name='edit_count',
field=models.PositiveIntegerField(default=0, verbose_name='تعداد ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='historicalstepinstance',
name='edited_after_completion',
field=models.BooleanField(default=False, verbose_name='ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='historicalstepinstance',
name='last_edited_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='آخرین زمان ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='historicalstepinstance',
name='last_edited_by',
field=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='ویرایش توسط'),
),
migrations.AddField(
model_name='stepinstance',
name='edit_count',
field=models.PositiveIntegerField(default=0, verbose_name='تعداد ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='stepinstance',
name='edited_after_completion',
field=models.BooleanField(default=False, verbose_name='ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='stepinstance',
name='last_edited_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='آخرین زمان ویرایش پس از تکمیل'),
),
migrations.AddField(
model_name='stepinstance',
name='last_edited_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='step_instances_edited', to=settings.AUTH_USER_MODEL, verbose_name='ویرایش توسط'),
),
]

View file

@ -5,7 +5,7 @@ from simple_history.models import HistoricalRecords
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils import timezone from django.utils import timezone
from django.conf import settings from django.conf import settings
from accounts.models import Role from accounts.models import Role, Broker
from _helpers.utils import generate_unique_slug from _helpers.utils import generate_unique_slug
import random import random
@ -189,6 +189,15 @@ class ProcessInstance(SluggedModel):
verbose_name="اولویت" verbose_name="اولویت"
) )
broker = models.ForeignKey(
Broker,
on_delete=models.SET_NULL,
verbose_name="کارگزار",
blank=True,
null=True,
related_name='process_instances'
)
completed_at = models.DateTimeField( completed_at = models.DateTimeField(
null=True, null=True,
blank=True, blank=True,
@ -205,13 +214,6 @@ class ProcessInstance(SluggedModel):
return f"{self.process.name} - {self.well.water_subscription_number}" return f"{self.process.name} - {self.well.water_subscription_number}"
return f"{self.process.name} - {self.requester.get_full_name()}" return f"{self.process.name} - {self.requester.get_full_name()}"
def clean(self):
"""اعتبارسنجی مدل"""
if self.well and self.representative and self.well.representative != self.representative:
raise ValidationError("نماینده درخواست باید همان نماینده ثبت شده در چاه باشد")
if self.well and self.representative and self.requester == self.representative:
raise ValidationError("درخواست کننده نمی‌تواند نماینده چاه باشد")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Generate unique 5-digit numeric code if missing # Generate unique 5-digit numeric code if missing
@ -233,6 +235,13 @@ class ProcessInstance(SluggedModel):
if self.status == 'completed' and not self.completed_at: if self.status == 'completed' and not self.completed_at:
self.completed_at = timezone.now() self.completed_at = timezone.now()
# Auto-set broker if not already set
if not self.broker:
if self.well and hasattr(self.well, 'broker') and self.well.broker:
self.broker = self.well.broker
elif self.requester and hasattr(self.requester, 'profile') and self.requester.profile and hasattr(self.requester.profile, 'broker') and self.requester.profile.broker:
self.broker = self.requester.profile.broker
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_status_display_with_color(self): def get_status_display_with_color(self):
@ -318,6 +327,12 @@ class StepInstance(models.Model):
notes = models.TextField(verbose_name="یادداشت‌ها", blank=True) notes = models.TextField(verbose_name="یادداشت‌ها", blank=True)
started_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ شروع") started_at = models.DateTimeField(auto_now_add=True, verbose_name="تاریخ شروع")
completed_at = models.DateTimeField(null=True, blank=True, verbose_name="تاریخ تکمیل") completed_at = models.DateTimeField(null=True, blank=True, verbose_name="تاریخ تکمیل")
# Generic edit-tracking for post-completion modifications
edited_after_completion = models.BooleanField(default=False, verbose_name="ویرایش پس از تکمیل")
last_edited_at = models.DateTimeField(null=True, blank=True, verbose_name="آخرین زمان ویرایش پس از تکمیل")
last_edited_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='step_instances_edited', verbose_name="ویرایش توسط")
edit_count = models.PositiveIntegerField(default=0, verbose_name="تعداد ویرایش پس از تکمیل")
history = HistoricalRecords() history = HistoricalRecords()
class Meta: class Meta:

View file

@ -0,0 +1,275 @@
{% load common_tags %}
<!-- Modal for Instance Info -->
<div class="modal fade" id="{{ modal_id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">اطلاعات درخواست {{ instance.code }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-4">
<!-- Well Information -->
{% if well %}
<div class="col-12">
<div class="card border-0 bg-light">
<div class="card-header bg-label-primary text-white py-2">
<h6 class="mb-0">
<i class="bx bx-water me-2"></i>اطلاعات چاه
</h6>
</div>
<div class="card-body pt-3">
<div class="row g-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-droplet text-primary me-2"></i>
<strong>شماره اشتراک آب:</strong>
<span class="ms-2">{{ well.water_subscription_number|default:"-" }}</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-bolt text-warning me-2"></i>
<strong>شماره اشتراک برق:</strong>
<span class="ms-2">{{ well.electricity_subscription_number|default:"-" }}</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-barcode text-info me-2"></i>
<strong>سریال کنتور:</strong>
<span class="ms-2">{{ well.water_meter_serial_number|default:"-" }}</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-barcode-reader text-secondary me-2"></i>
<strong>سریال قدیمی:</strong>
<span class="ms-2">{{ well.water_meter_old_serial_number|default:"-" }}</span>
</div>
</div>
{% if well.water_meter_manufacturer %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-factory text-success me-2"></i>
<strong>سازنده کنتور:</strong>
<span class="ms-2">{{ well.water_meter_manufacturer.name }}</span>
</div>
</div>
{% endif %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-tachometer text-danger me-2"></i>
<strong>قدرت چاه:</strong>
<span class="ms-2">{{ well.well_power|default:"-" }}</span>
</div>
</div>
{% if well.utm_x and well.utm_y %}
<div class="col-12">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-map text-info me-2"></i>
<strong>مختصات:</strong>
<span class="ms-2">X: {{ well.utm_x }}, Y: {{ well.utm_y }}</span>
{% if well.utm_zone %}<span class="text-muted ms-2">(Zone: {{ well.utm_zone }})</span>{% endif %}
</div>
</div>
{% endif %}
{% if well.county %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-map-pin text-warning me-2"></i>
<strong>شهرستان:</strong>
<span class="ms-2">{{ well.county }}</span>
</div>
</div>
{% endif %}
{% if well.affairs %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-building text-primary me-2"></i>
<strong>امور:</strong>
<span class="ms-2">{{ well.affairs }}</span>
</div>
</div>
{% endif %}
{% if well.reference_letter_number %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-file text-secondary me-2"></i>
<strong>شماره معرفی نامه:</strong>
<span class="ms-2">{{ well.reference_letter_number }}</span>
{% if well.reference_letter_date %}
<span class="text-muted ms-2">({{ well.reference_letter_date|to_jalali }})</span>
{% endif %}
</div>
</div>
{% endif %}
{% if well.representative_letter_file %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-file text-secondary me-2"></i>
<strong>فایل نامه نمایندگی:</strong>
<a href="{{ well.representative_letter_file.url }}" class="ms-2">دانلود</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<!-- Representative Information -->
{% if representative %}
<div class="col-12">
<div class="card border-0 bg-light">
<div class="card-header bg-label-success text-white py-2">
<h6 class="mb-0">
<i class="bx bx-user me-2"></i>اطلاعات نماینده
</h6>
</div>
<div class="card-body pt-3">
<div class="row g-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-user-circle text-primary me-2"></i>
<strong>نام و نام خانوادگی:</strong>
<span class="ms-2">{{ representative.get_full_name|default:representative.username }}</span>
</div>
</div>
{% if representative.profile.national_code %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-id-card text-info me-2"></i>
<strong>کد ملی:</strong>
<span class="ms-2">{{ representative.profile.national_code }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.phone_number_1 %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-phone text-success me-2"></i>
<strong>تلفن اول:</strong>
<span class="ms-2">{{ representative.profile.phone_number_1 }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.phone_number_2 %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-phone text-success me-2"></i>
<strong>تلفن دوم:</strong>
<span class="ms-2">{{ representative.profile.phone_number_2 }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.bank_name %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-credit-card text-warning me-2"></i>
<strong>بانک:</strong>
<span class="ms-2">{{ representative.profile.get_bank_name_display }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.card_number %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-credit-card-alt text-secondary me-2"></i>
<strong>شماره کارت:</strong>
<span class="ms-2">{{ representative.profile.card_number }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.account_number %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-wallet text-info me-2"></i>
<strong>شماره حساب:</strong>
<span class="ms-2">{{ representative.profile.account_number }}</span>
</div>
</div>
{% endif %}
{% if representative.profile.address %}
<div class="col-md-6">
<div class="d-flex align-items-start mb-2">
<i class="bx bx-map text-danger me-2 mt-1"></i>
<div>
<strong>آدرس:</strong>
<p class="mb-0 ms-2 text-wrap">{{ representative.profile.address }}</p>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<!-- Process Information -->
<div class="col-12">
<div class="card border-0 bg-light">
<div class="card-header bg-label-info text-white py-2">
<h6 class="mb-0">
<i class="bx bx-cog me-2"></i>اطلاعات فرآیند
</h6>
</div>
<div class="card-body pt-3">
<div class="row g-3">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-list-ul text-primary me-2"></i>
<strong>نوع فرآیند:</strong>
<span class="ms-2">{{ instance.process.name }}</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-calendar text-success me-2"></i>
<strong>تاریخ ایجاد:</strong>
<span class="ms-2">{{ instance.jcreated }}</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-check-circle text-info me-2"></i>
<strong>وضعیت:</strong>
<span class="ms-2 badge bg-label-primary">{{ instance.get_status_display }}</span>
</div>
</div>
{% if instance.current_step %}
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<i class="bx bx-step-forward text-primary me-2"></i>
<strong>مرحله فعلی:</strong>
<span class="ms-2 badge bg-label-success">{{ instance.current_step.name }}</span>
</div>
</div>
{% endif %}
{% if instance.description %}
<div class="col-md-6">
<div class="d-flex align-items-start mb-2">
<i class="bx bx-note text-secondary me-2 mt-1"></i>
<div>
<strong>توضیحات:</strong>
<p class="mb-0 ms-2 text-wrap">{{ instance.description }}</p>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">بستن</button>
</div>
</div>
</div>
</div>

View file

@ -50,3 +50,57 @@ def stepper_header(instance, current_step=None):
} }
# moved to _base/common/templatetags/common_tags.py # moved to _base/common/templatetags/common_tags.py
@register.inclusion_tag('processes/includes/instance_info_modal.html')
def instance_info_modal(instance, modal_id=None):
"""
نمایش مدال اطلاعات کامل چاه و نماینده
استفاده:
{% load processes_tags %}
{% instance_info_modal instance %}
یا با modal_id سفارشی:
{% instance_info_modal instance "myCustomModal" %}
"""
if not isinstance(instance, ProcessInstance):
return {}
if not modal_id:
modal_id = f"instanceInfoModal_{instance.id}"
return {
'instance': instance,
'modal_id': modal_id,
'well': instance.well,
'representative': instance.representative,
}
@register.simple_tag
def instance_info(instance, modal_id=None):
"""
آیکون info برای نمایش مدال اطلاعات
استفاده:
{% load processes_tags %}
نام کاربر: {{ user.name }} {% instance_info_icon instance %}
یا با modal_id سفارشی:
{% instance_info_icon instance "myCustomModal" %}
"""
if not isinstance(instance, ProcessInstance):
return ""
if not modal_id:
modal_id = f"instanceInfoModal_{instance.id}"
html = f'''
اشتراک آب: {instance.well.water_subscription_number }
| نماینده: {instance.representative.profile.national_code }
<i class="bx bx-info-circle text-muted ms-1"
style="cursor: pointer; font-size: 14px;"
data-bs-toggle="modal"
data-bs-target="#{modal_id}"
title="اطلاعات کامل چاه و نماینده"></i>
'''
return mark_safe(html)

View file

@ -237,6 +237,7 @@ def create_request_with_entities(request):
well=well, well=well,
representative=representative_user, representative=representative_user,
requester=request.user, requester=request.user,
broker=request.user.profile.broker if request.user.profile else None,
status='pending', status='pending',
priority='medium', priority='medium',
) )