Compare commits

..
12 Commits
Author SHA1 Message Date
javad fe7a10cb61 block user in django admin 2026-10-14 12:27:55 +03:30
javad 089db0afa4 link view ticket for staff 2026-10-13 16:09:45 +03:30
javad 6b546aad76 estelam ticket, varizha , others 2026-10-13 15:48:24 +03:30
javad 35a1ec1794 fix fish variz name, dalilrad, sms, bale 2026-10-11 15:19:58 +03:30
javad 90931a5a64 fix payment UI UX 2026-10-07 14:06:14 +03:30
javad bb3ddd1610 fix payment 2026-10-07 13:39:29 +03:30
javad 4f7486152c fix resid taeid accsess 2026-10-06 15:06:35 +03:30
javad b53a3e0c5c delete option staff varizi 2026-10-06 12:30:28 +03:30
javad d99e47ecce fix UI UX 2026-10-06 12:08:16 +03:30
javad 1cbbbbd0dc fix payement link and gallery image 2026-10-06 11:18:31 +03:30
javad de3135b588 add payment_link and banks 2026-10-06 10:03:22 +03:30
javad 2ae79b1ca4 design-prototypes/chaharsu 2026-10-05 12:36:58 +03:30
255 changed files with 16369 additions and 360 deletions

No files matched your search

+810 -15
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
def generate_id_algorithm(id_code: str) -> str:
"""
Calculates the verification code based on the reverse-engineered logic:
1. Iterate digits from right to left (index i = 0, 1, 2...).
2. Weights:
- If i < 8: Weight = 8 - i (Sequence: 8, 7, 6, 5, 4, 3, 2, 1)
- If i >= 8: Weight = i + 1 (Sequence: 9, 10, ...)
3. Sum = Sum(Digit * Weight)
4. Check Digit = Sum % 99
"""
# تمیزکاری ورودی (حذف فاصله‌ها و اطمینان از عددی بودن)
if not id_code.isdigit():
return "Error: ورودی باید فقط عدد باشد."
total_sum = 0
# پیمایش از راست به چپ
# reversed(id_code) ارقام را برعکس می‌کند
for i, char in enumerate(reversed(id_code)):
digit = int(char)
# تعیین وزن بر اساس ایندکس (مکان رقم از راست)
if i < 8:
weight = 8 - i
else:
weight = i + 1
total_sum += digit * weight
# محاسبه باقی‌مانده بر 99
check_digit = total_sum % 99
# فرمت‌دهی دو رقمی (مثلاً 6 می‌شود 06)
check_digit_str = f"{check_digit:02}"
return f"{id_code}{check_digit_str}"
+3
View File
@@ -2,3 +2,6 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig): class AccountsConfig(AppConfig):
name = "apps.accounts" name = "apps.accounts"
def ready(self):
from . import customer_access # Register the login-session receiver.
+81
View File
@@ -0,0 +1,81 @@
"""Restrictions for full customer logins; staff and scoped links stay independent."""
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
from django.http import JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET
from django.utils.cache import add_never_cache_headers
from apps.customers.models import CustomerProfile
from .models import User
ACCESS_VERSION_KEY = "customer_login_access_version"
BLOCK_REASON_KEY = "customer_login_block_reason"
DEFAULT_BLOCK_REASON = "ورود شما مسدود شده است. برای پیگیری با پشتیبانی تماس بگیرید."
def is_customer_login(user):
return user.account_type == User.AccountType.CUSTOMER and not (user.is_staff or user.is_superuser)
def customer_login_profile(user):
if not is_customer_login(user):
return None
return CustomerProfile.objects.filter(user_id=user.pk).first()
def blocked_login_reason(mobile):
user = User.objects.filter(mobile=mobile).first()
profile = customer_login_profile(user) if user else None
if profile and profile.login_blocked:
return profile.login_block_reason.strip() or DEFAULT_BLOCK_REASON
return ""
@receiver(user_logged_in, dispatch_uid="customer_login_access_version")
def remember_customer_access(sender, request, user, **kwargs):
if not is_customer_login(user):
return
profile = customer_login_profile(user)
request.session[ACCESS_VERSION_KEY] = profile.login_access_version if profile else 0
request.session.pop(BLOCK_REASON_KEY, None)
class CustomerLoginAccessMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
profile = customer_login_profile(request.user)
if profile and (
profile.login_blocked
or request.session.get(ACCESS_VERSION_KEY, 0) != profile.login_access_version
):
logout(request)
if profile.login_blocked:
request.session[BLOCK_REASON_KEY] = profile.login_block_reason.strip() or DEFAULT_BLOCK_REASON
else:
messages.error(request, "نشست قبلی شما باطل شده است. لطفاً دوباره وارد شوید.")
if request.path == reverse("accounts:login_access"):
response = JsonResponse({"authenticated": False, "login_url": reverse("accounts:login")})
else:
response = redirect("accounts:login")
add_never_cache_headers(response)
return response
return self.get_response(request)
@never_cache
@require_GET
def customer_login_access(request):
return JsonResponse({
"authenticated": request.user.is_authenticated,
"login_url": reverse("accounts:login"),
})
+2 -4
View File
@@ -1,4 +1,5 @@
from django.utils import timezone from django.utils import timezone
from apps.core.session_access import grant_scoped_session_access
from .models import TicketMagicLink from .models import TicketMagicLink
@@ -8,10 +9,7 @@ MAGIC_LINK_SESSION_KEY = "ticket_magic_link_id"
def grant_ticket_magic_access(request, link): def grant_ticket_magic_access(request, link):
"""Grant an anonymous session access to exactly one ticket until link expiry.""" """Grant an anonymous session access to exactly one ticket until link expiry."""
request.session.cycle_key() grant_scoped_session_access(request, MAGIC_LINK_SESSION_KEY, link.pk, link.expires_at)
request.session[MAGIC_LINK_SESSION_KEY] = link.pk
remaining = max(1, int((link.expires_at - timezone.now()).total_seconds()))
request.session.set_expiry(remaining)
def get_ticket_magic_access(request, ticket=None): def get_ticket_magic_access(request, ticket=None):
+2
View File
@@ -1,9 +1,11 @@
from django.urls import path from django.urls import path
from . import views from . import views
from .customer_access import customer_login_access
app_name = "accounts" app_name = "accounts"
urlpatterns = [ urlpatterns = [
path("login/access/", customer_login_access, name="login_access"),
path("", views.home, name="home"), path("", views.home, name="home"),
path("login/", views.customer_login, name="login"), path("login/", views.customer_login, name="login"),
path("login/password/", views.customer_password_login, name="password_login"), path("login/password/", views.customer_password_login, name="password_login"),
+72 -13
View File
@@ -2,8 +2,11 @@ from django.contrib import messages
from django.conf import settings from django.conf import settings
from django.contrib.auth import login, logout from django.contrib.auth import login, logout
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.http import url_has_allowed_host_and_scheme
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_GET, require_http_methods, require_POST from django.views.decorators.http import require_GET, require_http_methods, require_POST
from django.views.decorators.cache import never_cache
from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm
from apps.customers.models import CustomerProfile from apps.customers.models import CustomerProfile
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@@ -11,14 +14,28 @@ from django.db import transaction
from django.db.models import F from django.db.models import F
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
from .magic_access import grant_ticket_magic_access from .magic_access import grant_ticket_magic_access
from .customer_access import BLOCK_REASON_KEY, blocked_login_reason
from apps.integrations.sms.providers import SMSResult, get_sms_provider from apps.integrations.sms.providers import SMSResult, get_sms_provider
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _remember_destination(request):
target = request.GET.get("next", "")
if target and target.startswith(("/chaharsu/", "/tickets/")) and url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
request.session["next"] = target
def _login_destination(request):
target = request.session.pop("next", "")
if target.startswith(("/chaharsu/", "/tickets/")) and url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
return target
return "staff_tickets:list" if request.user.account_type == User.AccountType.STAFF else "payments:home"
def _authenticated_home(request): def _authenticated_home(request):
if not request.user.is_authenticated: return None if not request.user.is_authenticated: return None
return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "tickets:list") return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "payments:home")
@require_GET @require_GET
def home(request): def home(request):
@@ -27,9 +44,14 @@ def home(request):
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def customer_login(request): def customer_login(request):
_remember_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form = MobileForm(request.POST or None) form = MobileForm(request.POST or None)
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
block_reason = blocked_login_reason(form.cleaned_data["mobile"])
if block_reason:
return render(request, "accounts/customer_login.html", {"form": form, "block_reason": block_reason})
challenge = None challenge = None
try: try:
challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request) challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request)
@@ -45,13 +67,18 @@ def customer_login(request):
challenge.delete() challenge.delete()
logger.exception("OTP SMS delivery failed") logger.exception("OTP SMS delivery failed")
form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.") form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.")
return render(request, "accounts/customer_login.html", {"form": form}) return render(request, "accounts/customer_login.html", {"form": form, "block_reason": block_reason})
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def verify_otp(request): def verify_otp(request):
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id")) challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id"))
form = OTPForm(request.POST or None) form = OTPForm(request.POST or None)
block_reason = blocked_login_reason(challenge.mobile)
if block_reason:
request.session.pop("otp_challenge_id", None)
request.session[BLOCK_REASON_KEY] = block_reason
return redirect("accounts:login")
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
if challenge.verify(form.cleaned_data["code"]): if challenge.verify(form.cleaned_data["code"]):
user, created = User.objects.get_or_create(mobile=challenge.mobile, defaults={"username": f"customer_{challenge.mobile[3:]}", "account_type": User.AccountType.CUSTOMER}) user, created = User.objects.get_or_create(mobile=challenge.mobile, defaults={"username": f"customer_{challenge.mobile[3:]}", "account_type": User.AccountType.CUSTOMER})
@@ -60,20 +87,25 @@ def verify_otp(request):
if not user.is_active: form.add_error(None, "حساب شما غیرفعال است.") if not user.is_active: form.add_error(None, "حساب شما غیرفعال است.")
else: else:
login(request, user, backend="django.contrib.auth.backends.ModelBackend") login(request, user, backend="django.contrib.auth.backends.ModelBackend")
return redirect(request.session.pop("next", "tickets:list")) return redirect(_login_destination(request))
else: form.add_error("code", "کد نامعتبر یا منقضی است.") else: form.add_error("code", "کد نامعتبر یا منقضی است.")
return render(request, "accounts/otp.html", {"form": form, "mobile": challenge.mobile}) return render(request, "accounts/otp.html", {"form": form, "mobile": challenge.mobile})
@require_http_methods(["GET","POST"]) @require_http_methods(["GET","POST"])
def customer_password_login(request): def customer_password_login(request):
_remember_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form=CustomerPasswordLoginForm(request.POST or None) form=CustomerPasswordLoginForm(request.POST or None)
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
if request.method=="POST" and form.is_valid(): if request.method=="POST" and form.is_valid():
block_reason = blocked_login_reason(form.cleaned_data["mobile"])
if block_reason:
return render(request, "accounts/customer_password_login.html", {"form": form, "block_reason": block_reason})
user=form.authenticate(request) user=form.authenticate(request)
if user and user.is_active: if user and user.is_active:
login(request,user); return redirect(request.session.pop("next","tickets:list")) login(request,user); return redirect(_login_destination(request))
form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.") form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.")
return render(request,"accounts/customer_password_login.html",{"form":form}) return render(request,"accounts/customer_password_login.html",{"form":form,"block_reason":block_reason})
@login_required @login_required
@require_http_methods(["GET","POST"]) @require_http_methods(["GET","POST"])
@@ -87,14 +119,28 @@ def customer_profile(request):
messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile") messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile")
return render(request,"accounts/profile.html",{"form":form}) return render(request,"accounts/profile.html",{"form":form})
def _staff_login_destination(request):
target = request.GET.get("next") or request.POST.get("next") or request.session.get("staff_login_next", "")
if target.startswith(("/vt/", "/staff/tickets/")) and url_has_allowed_host_and_scheme(
target, allowed_hosts={request.get_host()}, require_https=request.is_secure(),
):
request.session["staff_login_next"] = target
return target
request.session.pop("staff_login_next", None)
return reverse("staff_tickets:list")
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def staff_login(request): def staff_login(request):
destination = _staff_login_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form = StaffLoginForm(request.POST or None) form = StaffLoginForm(request.POST or None)
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
user = form.authenticate(request) user = form.authenticate(request)
if user and user.is_active and user.account_type == User.AccountType.STAFF: if user and user.is_active and user.account_type == User.AccountType.STAFF:
login(request, user); return redirect("staff_tickets:list") login(request, user)
request.session.pop("staff_login_next", None)
return redirect(destination)
form.add_error(None, "اطلاعات ورود صحیح نیست.") form.add_error(None, "اطلاعات ورود صحیح نیست.")
return render(request, "accounts/staff_login.html", {"form": form}) return render(request, "accounts/staff_login.html", {"form": form})
@@ -106,17 +152,30 @@ def logout_view(request):
response["Cache-Control"]="no-store" response["Cache-Control"]="no-store"
return response return response
@never_cache
@require_http_methods(["GET", "POST"])
def consume_magic_link(request, token): def consume_magic_link(request, token):
link = TicketMagicLink.objects.select_related("customer", "ticket").filter(token_hash=TicketMagicLink.digest(token)).first() link = TicketMagicLink.objects.select_related("customer", "ticket").filter(token_hash=TicketMagicLink.digest(token)).first()
if not link or link.revoked_at or link.expires_at <= timezone.now(): if not link or link.revoked_at or link.expires_at <= timezone.now() or not link.customer.is_active:
request.session["next"] = f"/tickets/{link.ticket.public_id}/" if link else "/tickets/" request.session["next"] = f"/tickets/{link.ticket.public_id}/" if link else "/tickets/"
messages.error(request, "این لینک منقضی شده یا اشتباه است. لطفاً با شماره موبایل خود وارد شوید تا بتوانید پیام‌ها را ببینید.") messages.error(request, "این لینک منقضی شده یا اشتباه است. لطفاً با شماره موبایل خود وارد شوید تا بتوانید پیام‌ها را ببینید.")
return redirect("accounts:login") response = redirect("accounts:login")
if request.user.is_authenticated: response["Referrer-Policy"] = "no-referrer"
if request.user.pk != link.customer_id: return response
from django.core.exceptions import PermissionDenied if request.user.is_authenticated and request.user.pk != link.customer_id:
raise PermissionDenied confirmed = (
else: request.method == "POST"
and request.POST.get("action") == "logout_and_open"
and request.POST.get("account_id") == str(request.user.pk)
)
if not confirmed:
response = render(request, "accounts/magic_link_confirmation.html")
# Same-origin form submissions need an Origin for Django CSRF checks.
response["Referrer-Policy"] = "same-origin"
return response
# Logout flushes the old session; grant access only in the new one.
logout(request)
if not request.user.is_authenticated:
grant_ticket_magic_access(request, link) grant_ticket_magic_access(request, link)
ip_address = request.META.get("REMOTE_ADDR") ip_address = request.META.get("REMOTE_ADDR")
user_agent = request.META.get("HTTP_USER_AGENT", "")[:500] user_agent = request.META.get("HTTP_USER_AGENT", "")[:500]
+2 -1
View File
@@ -54,4 +54,5 @@ class AttachmentPolicyAdmin(admin.ModelAdmin):
form = AttachmentPolicyAdminForm form = AttachmentPolicyAdminForm
admin.site.register(Attachment) from apps.tickets.response_admin import RelatedResponseContentAdmin
admin.site.register(Attachment, RelatedResponseContentAdmin)
+13
View File
@@ -19,3 +19,16 @@ def signed_media_redirect(relative_path, *, download=False, lifetime=300):
if download: if download:
query["download"] = "1" query["download"] = "1"
return HttpResponseRedirect(f"{uri}?{urlencode(query)}") return HttpResponseRedirect(f"{uri}?{urlencode(query)}")
def attachment_response(attachment, *, download=False):
"""Deliver authorized bytes; confidential responses never use bearer URLs."""
import mimetypes
from django.http import FileResponse
if settings.USE_NGINX_MEDIA and not attachment.relative_path.startswith("dispatch-responses/"):
return signed_media_redirect(attachment.relative_path, download=download)
content_type = attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
response = FileResponse(attachment.file.open("rb"), content_type=content_type,
as_attachment=download, filename=attachment.original_name)
response["X-Content-Type-Options"] = "nosniff"
return response
+4
View File
@@ -5,6 +5,10 @@ from django.db import models
from apps.core.models import PublicIdModel from apps.core.models import PublicIdModel
def attachment_path(instance,filename): def attachment_path(instance,filename):
ext=Path(filename).suffix.lower(); t=instance.message.ticket; m=instance.message ext=Path(filename).suffix.lower(); t=instance.message.ticket; m=instance.message
from apps.tickets.dispatch_responses import response_control
control = response_control(t.group_dispatch) if t.group_dispatch_id else None
if m.author_id == t.customer_id and control and control.mode == "SEALED":
return f"dispatch-responses/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}" return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
class AttachmentPolicy(models.Model): class AttachmentPolicy(models.Model):
attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True) attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True)
+9 -16
View File
@@ -1,30 +1,23 @@
import mimetypes
from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import FileResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from apps.tickets.policies import TicketPolicy from apps.tickets.policies import TicketPolicy
from apps.accounts.magic_access import get_ticket_magic_access from apps.accounts.magic_access import get_ticket_magic_access
from .delivery import signed_media_redirect from .delivery import attachment_response
from .models import Attachment from .models import Attachment
from django.views.decorators.cache import never_cache
def _authorized_attachment(request,public_id): def _authorized_attachment(request,public_id):
attachment=get_object_or_404(Attachment.objects.select_related("message__ticket"),public_id=public_id); ticket=attachment.message.ticket attachment=get_object_or_404(Attachment.objects.select_related("message__ticket"),public_id=public_id); ticket=attachment.message.ticket
magic_link = None if request.user.is_authenticated else get_ticket_magic_access(request,ticket) magic_link = None if request.user.is_authenticated else get_ticket_magic_access(request,ticket)
if not TicketPolicy.can_view(request.user,ticket) and not magic_link: raise PermissionDenied if not TicketPolicy.can_view(request.user,ticket) and not magic_link: raise PermissionDenied
from apps.tickets.dispatch_responses import can_read_response
actor = ticket.customer if magic_link else request.user
if not can_read_response(actor, attachment.message): raise PermissionDenied
if attachment.message.is_internal and (magic_link or getattr(request.user,"account_type",None)=="CUSTOMER"): raise PermissionDenied if attachment.message.is_internal and (magic_link or getattr(request.user,"account_type",None)=="CUSTOMER"): raise PermissionDenied
if attachment.message.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied if attachment.message.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied
return attachment return attachment
@never_cache
def view(request,public_id): def view(request,public_id):
attachment=_authorized_attachment(request,public_id) return attachment_response(_authorized_attachment(request, public_id))
if settings.USE_NGINX_MEDIA: @never_cache
return signed_media_redirect(attachment.relative_path)
content_type=attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
response=FileResponse(attachment.file.open("rb"),content_type=content_type)
response["Content-Disposition"]=f'inline; filename="{attachment.original_name}"'
response["X-Content-Type-Options"]="nosniff"
return response
def download(request,public_id): def download(request,public_id):
attachment=_authorized_attachment(request,public_id) return attachment_response(_authorized_attachment(request, public_id), download=True)
if settings.USE_NGINX_MEDIA:
return signed_media_redirect(attachment.relative_path, download=True)
return FileResponse(attachment.file.open("rb"),as_attachment=True,filename=attachment.original_name)
+1 -1
View File
@@ -64,7 +64,7 @@ def staff_operations_access(request):
"create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"), "create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"),
"contacts": True, "contacts": True,
"contact_groups": allowed("customers.view_contactgroup") or allowed("customers.add_contactgroup"), "contact_groups": allowed("customers.view_contactgroup") or allowed("customers.add_contactgroup"),
"dispatches": allowed("tickets.create_bulk_dispatch") or allowed("tickets.create_inquiry") or allowed("tickets.dispatch_group_tickets"), "dispatches": any(allowed(permission) for permission in ("tickets.create_bulk_dispatch", "tickets.create_inquiry", "tickets.dispatch_group_tickets", "tickets.view_all_bulk_dispatches", "tickets.view_all_inquiries")),
"templates": allowed("notifications.view_smstemplate") or allowed("notifications.add_smstemplate"), "templates": allowed("notifications.view_smstemplate") or allowed("notifications.add_smstemplate"),
"audit": user.has_perm("accounts.view_all_magic_links") or allowed("accounts.view_ticketmagiclink"), "audit": user.has_perm("accounts.view_all_magic_links") or allowed("accounts.view_ticketmagiclink"),
}} }}
@@ -16,6 +16,14 @@ from apps.core.production_seed import sha256_file
from apps.tickets.models import Ticket from apps.tickets.models import Ticket
def _load_seed_data(bundle_dir):
# Only the pristine-target guard in handle authorizes this replacement.
call_command("flush", interactive=False, verbosity=0)
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
from apps.notifications.dispatch_opening import ensure_opening_template
ensure_opening_template()
class Command(BaseCommand): class Command(BaseCommand):
help = "Import a verified production seed bundle into a pristine PostgreSQL database." help = "Import a verified production seed bundle into a pristine PostgreSQL database."
@@ -80,10 +88,7 @@ class Command(BaseCommand):
copied_files = [] copied_files = []
try: try:
with transaction.atomic(): with transaction.atomic():
# Migrations contain defaults. Flush those rows before loading _load_seed_data(bundle_dir)
# the authoritative SQLite snapshot; schema/migration history remains.
call_command("flush", interactive=False, verbosity=0)
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
# Old SQLite snapshots may predate unit-scoped queues. A # Old SQLite snapshots may predate unit-scoped queues. A
# category already belongs to exactly one unit, so it is a # category already belongs to exactly one unit, so it is a
# deterministic and safe source for repairing those rows. # deterministic and safe source for repairing those rows.
+1
View File
@@ -16,6 +16,7 @@ SEED_MODEL_LABELS = (
"tickets.SLAConfig", "tickets.SLAConfig",
"tickets.TicketTag", "tickets.TicketTag",
"tickets.GroupDispatch", "tickets.GroupDispatch",
"tickets.DispatchResponseControl",
"tickets.Ticket", "tickets.Ticket",
"tickets.TicketAccessGrant", "tickets.TicketAccessGrant",
"tickets.TicketEvent", "tickets.TicketEvent",
+12
View File
@@ -0,0 +1,12 @@
from django.utils import timezone
def grant_scoped_session_access(request, grant_key, grant_id, expires_at, *, preserve_expiry=False):
"""Rotate anonymous session keys; resource grants still validate their own expiry."""
session = request.session
session.cycle_key()
session[grant_key] = grant_id
seconds = max(1, int((expires_at - timezone.now()).total_seconds()))
if preserve_expiry:
seconds = max(session.get_expiry_age(), seconds)
session.set_expiry(seconds)
+8
View File
@@ -46,6 +46,14 @@ def jalali_date(value):
return _persian_digits(converted.strftime("%d %B %Y")) if converted else "" return _persian_digits(converted.strftime("%d %B %Y")) if converted else ""
@register.filter
def jalali_datetime_precise(value):
converted = _jalali(value)
if not isinstance(converted, jdatetime.datetime):
return ""
return _persian_digits(converted.strftime("%d %B %Y، ساعت %H:%M:%S"))
@register.filter @register.filter
def persian_time(value): def persian_time(value):
"""Display local Iran time using Persian digits.""" """Display local Iran time using Persian digits."""
+2 -2
View File
@@ -11,8 +11,8 @@ class ContactGroupAdmin(admin.ModelAdmin):
@admin.register(CustomerProfile) @admin.register(CustomerProfile)
class CustomerProfileAdmin(admin.ModelAdmin): class CustomerProfileAdmin(admin.ModelAdmin):
list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name") list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name", "login_blocked")
list_filter = ("stakeholder_type", "groups") list_filter = ("stakeholder_type", "groups", "login_blocked")
search_fields = ("user__mobile", "organization_name", "first_name", "last_name") search_fields = ("user__mobile", "organization_name", "first_name", "last_name")
filter_horizontal = ("groups",) filter_horizontal = ("groups",)
@@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2026-10-14 08:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customers', '0006_backfill_customer_profiles'),
]
operations = [
migrations.AddField(
model_name='customerprofile',
name='login_access_version',
field=models.PositiveIntegerField(default=0, editable=False),
),
migrations.AddField(
model_name='customerprofile',
name='login_block_reason',
field=models.TextField(blank=True, help_text='این متن به مشتری یا مخاطب نمایش داده می\u200cشود. ورود کارکنان و کارشناسان تحت تأثیر نیست.', verbose_name='دلیل مسدودی ورود'),
),
migrations.AddField(
model_name='customerprofile',
name='login_blocked',
field=models.BooleanField(default=False, verbose_name='مسدود کردن ورود مشتری / مخاطب'),
),
]
+30
View File
@@ -1,5 +1,7 @@
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db import transaction
from django.core.exceptions import ValidationError
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
@@ -51,6 +53,34 @@ class CustomerProfile(PersianTextNormalizationMixin, TimeStampedModel):
groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts") groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts")
province = models.CharField(max_length=100, blank=True) province = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True) city = models.CharField(max_length=100, blank=True)
login_blocked = models.BooleanField("مسدود کردن ورود مشتری / مخاطب", default=False)
login_block_reason = models.TextField(
"دلیل مسدودی ورود", blank=True,
help_text="این متن به مشتری یا مخاطب نمایش داده می‌شود. ورود کارکنان و کارشناسان تحت تأثیر نیست.",
)
login_access_version = models.PositiveIntegerField(default=0, editable=False)
def clean(self):
super().clean()
if self.login_blocked and not self.login_block_reason.strip():
raise ValidationError({"login_block_reason": "برای مسدود کردن ورود، دلیل مسدودی را بنویسید."})
def save(self, *args, **kwargs):
# Keep old sessions revoked even if the administrator later unblocks access.
with transaction.atomic():
previous = type(self).objects.select_for_update().filter(pk=self.pk).first()
if previous is None and self.login_blocked:
self.login_access_version = 1
if previous is not None:
self.login_access_version = previous.login_access_version
fields = kwargs.get("update_fields")
changes_access = fields is None or "login_blocked" in fields
if changes_access and self.login_blocked and not previous.login_blocked:
self.login_access_version += 1
if fields is not None:
kwargs["update_fields"] = set(fields) | {"login_access_version"}
super().save(*args, **kwargs)
def __str__(self): return (f"{self.first_name} {self.last_name}".strip() or self.user.mobile or self.user.username) def __str__(self): return (f"{self.first_name} {self.last_name}".strip() or self.user.mobile or self.user.username)
+20
View File
@@ -15,6 +15,26 @@ class ConsoleBaleProvider:
return NotificationResult(True, "console") return NotificationResult(True, "console")
class BaleBotProvider: class BaleBotProvider:
def send_photo(self, chat_id, photo, caption='', *, token=None, filename='receipt.jpg', mime_type='image/jpeg'):
"""Upload private evidence bytes; do not expose a media URL or log a bot token."""
token = settings.BALE_BOT_TOKEN if token is None else token
if not token or not chat_id:
return NotificationResult(False, error='توکن یا گروه بله تنظیم نشده است.')
field, method = ('photo', 'sendPhoto') if mime_type in ('image/jpeg', 'image/png') else ('document', 'sendDocument')
try:
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{token}/{method}",
data={'chat_id': chat_id, 'caption': caption}, files={field: (filename, photo, mime_type)},
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
if not response.ok:
return NotificationResult(False, error=f'خطای HTTP بله: {response.status_code}')
data = response.json()
result = data.get('result') if isinstance(data, dict) else None
if data.get('ok') is not True or not isinstance(result, dict) or not result.get('message_id'):
return NotificationResult(False, error='بله ارسال تصویر را تأیید نکرد.')
return NotificationResult(True, str(result['message_id']))
except (requests.RequestException, ValueError, AttributeError) as error:
return NotificationResult(False, error=f'خطا در ارتباط با بله ({type(error).__name__})')
def send_message(self, chat_id, text): def send_message(self, chat_id, text):
if not chat_id: return NotificationResult(False, error="Bale chat id is not configured") if not chat_id: return NotificationResult(False, error="Bale chat id is not configured")
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS) response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
+3 -1
View File
@@ -1,3 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Message,MessageMention,MessageRevision from .models import Message,MessageMention,MessageRevision
admin.site.register([Message,MessageMention,MessageRevision]) from apps.tickets.response_admin import ResponseContentAdmin, RelatedResponseContentAdmin
admin.site.register(Message, ResponseContentAdmin)
admin.site.register([MessageMention, MessageRevision], RelatedResponseContentAdmin)
+29
View File
@@ -0,0 +1,29 @@
"""Bounded parsing for explicit message-read acknowledgements."""
import json
import uuid
from django.core.exceptions import ValidationError
def message_read_ids(request):
if request.content_type == 'application/json':
try:
payload = json.loads(request.body or b'{}')
except (TypeError, ValueError):
raise ValidationError('invalid_json')
if not isinstance(payload, dict):
raise ValidationError('invalid_json')
values = payload.get('message_ids', [])
else:
values = request.POST.getlist('message_ids')
if not isinstance(values, list) or len(values) > 100:
raise ValidationError('invalid_message_ids')
identifiers = set()
for value in values:
if not isinstance(value, str) or len(value) > 45:
continue
try:
identifiers.add(uuid.UUID(value))
except ValueError:
continue
return identifiers
+4
View File
@@ -6,6 +6,8 @@ from apps.tickets.policies import TicketPolicy
from .models import Message,MessageRevision from .models import Message,MessageRevision
@transaction.atomic @transaction.atomic
def edit_message(message_id,user,body): def edit_message(message_id,user,body):
from apps.tickets.dispatch_responses import lock_dispatch_for_ticket
lock_dispatch_for_ticket(Message.objects.values_list("ticket_id", flat=True).get(pk=message_id))
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id) msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
if msg.author_id!=user.id or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied if msg.author_id!=user.id or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
if not (user.account_type=="CUSTOMER" or user.has_perm("messaging.edit_own_message")): raise PermissionDenied if not (user.account_type=="CUSTOMER" or user.has_perm("messaging.edit_own_message")): raise PermissionDenied
@@ -15,6 +17,8 @@ def edit_message(message_id,user,body):
MessageRevision.objects.create(message=msg,body=msg.body,edited_by=user); msg.body=body; msg.edited_at=timezone.now(); msg.save(update_fields=["body","edited_at","updated_at"]); return msg MessageRevision.objects.create(message=msg,body=msg.body,edited_by=user); msg.body=body; msg.edited_at=timezone.now(); msg.save(update_fields=["body","edited_at","updated_at"]); return msg
@transaction.atomic @transaction.atomic
def soft_delete_message(message_id,user): def soft_delete_message(message_id,user):
from apps.tickets.dispatch_responses import lock_dispatch_for_ticket
lock_dispatch_for_ticket(Message.objects.values_list("ticket_id", flat=True).get(pk=message_id))
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id) msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
may_delete=user.account_type=="CUSTOMER" or user.has_perm("messaging.delete_own_message") may_delete=user.account_type=="CUSTOMER" or user.has_perm("messaging.delete_own_message")
if msg.author_id!=user.id or not may_delete or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied if msg.author_id!=user.id or not may_delete or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
+2
View File
@@ -23,6 +23,8 @@ def delete(request,public_id):
msg=get_object_or_404(Message,public_id=public_id); soft_delete_message(msg.pk,request.user); return redirect(_safe_next(request,"/staff/")) msg=get_object_or_404(Message,public_id=public_id); soft_delete_message(msg.pk,request.user); return redirect(_safe_next(request,"/staff/"))
@login_required @login_required
def revisions(request,public_id): def revisions(request,public_id):
from apps.tickets.dispatch_responses import can_read_response
msg=get_object_or_404(Message,public_id=public_id) msg=get_object_or_404(Message,public_id=public_id)
if not can_read_response(request.user, msg): raise PermissionDenied
if not request.user.has_perm("messaging.view_message_revisions") or not TicketPolicy.can_view(request.user,msg.ticket): raise PermissionDenied if not request.user.has_perm("messaging.view_message_revisions") or not TicketPolicy.can_view(request.user,msg.ticket): raise PermissionDenied
return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")}) return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")})
+6 -1
View File
@@ -16,7 +16,12 @@ class AnnouncementJobAdmin(admin.ModelAdmin):
class SMSTemplateAdmin(admin.ModelAdmin): class SMSTemplateAdmin(admin.ModelAdmin):
list_display=("event_type","unit","title","is_active","updated_at") list_display=("event_type","unit","title","is_active","updated_at")
list_filter=("unit","is_active","event_type") list_filter=("unit","is_active","event_type")
readonly_fields=("allowed_tags",) readonly_fields=("allowed_tags_display",)
exclude=("allowed_tags",)
@admin.display(description='Allowed tags / تگ‌های مجاز')
def allowed_tags_display(self, obj):
return obj.supported_tags
@admin.register(NotificationPolicy) @admin.register(NotificationPolicy)
class NotificationPolicyAdmin(admin.ModelAdmin): class NotificationPolicyAdmin(admin.ModelAdmin):
list_display=("key","quiet_period_seconds","is_active","updated_at") list_display=("key","quiet_period_seconds","is_active","updated_at")
+42
View File
@@ -0,0 +1,42 @@
"""Isolated worker handler; existing events keep their delivery flow."""
from django.core.exceptions import ValidationError
from apps.tickets.models import DispatchResponseControl
DEFAULT_OPENING_SMS = (
"لوله گستر گلپایگان با سلام؛ پاسخ استعلام شما بازگشایی شد. جهت صیانت از رقابت عادلانه، تمامی پیشنهادات تا این لحظه کاملاً محرمانه بوده و هیچ‌کس دسترسی به مشاهده آن‌ها نداشته است. اکنون تمامی پاسخ‌ها به‌صورت هم‌زمان بازگشایی شدند تا سلامت فرآیند تضمین گردد.\n"
"از همراهی و اعتماد شما سپاسگزاریم. واحد فناوری اطلاعات لوله گستر گلپایگان"
)
def ensure_opening_template():
"""Legacy seed bundles may predate this event; never replace an existing body."""
from .models import SMSTemplate
return SMSTemplate.objects.get_or_create(unit=None, event_type="DISPATCH_RESPONSES_OPENED", defaults={
"title": "بازگشایی پاسخ‌های ارسال گروهی", "body": DEFAULT_OPENING_SMS, "allowed_tags": [], "is_active": True,
})[0]
def send_opening_sms(job, provider_factory):
control = DispatchResponseControl.objects.get(dispatch_id=job.payload.get("dispatch_id"))
permitted = (control.mode == "SEALED" and control.notify_sms
and control.recipient_count_snapshot <= 15 and control.opened_at
and job.ticket_id and job.ticket.group_dispatch_id == control.dispatch_id
and job.customer_id == job.ticket.customer_id
and str(job.customer.public_id) in control.respondents_snapshot)
if not permitted or job.payload.get("body") != control.sms_body_snapshot:
raise ValidationError("Opening notification does not match its authorized snapshot.")
return provider_factory().send_message(job.customer.mobile, control.sms_body_snapshot)
def send_dispatch_reply_bale(job, text, provider_factory):
"""Dispatch metadata goes to its creator's private destination, never a unit room."""
from apps.tickets.policies import DispatchPolicy
dispatch = job.ticket.group_dispatch
owner = dispatch.created_by
profile = getattr(owner, "staff_profile", None)
if not owner.is_active or not profile or not profile.is_active or not profile.bale_notifications_enabled:
return None
if not profile.bale_chat_id.strip() or not DispatchPolicy.can_view(owner, dispatch):
return None
return provider_factory().send_message(profile.bale_chat_id.strip(), text)
@@ -58,6 +58,9 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1,
message = message or ticket.ticket_messages.filter(is_internal=False).order_by("created_at", "id").first() message = message or ticket.ticket_messages.filter(is_internal=False).order_by("created_at", "id").first()
raw_excerpt = " ".join((message.body if message else "").split()) raw_excerpt = " ".join((message.body if message else "").split())
excerpt = f"{raw_excerpt[:277].rstrip()}…" if len(raw_excerpt)>280 else raw_excerpt or "بدون متن" excerpt = f"{raw_excerpt[:277].rstrip()}…" if len(raw_excerpt)>280 else raw_excerpt or "بدون متن"
from apps.tickets.dispatch_responses import response_control
if message and message.author_id == ticket.customer_id and ticket.group_dispatch_id and response_control(ticket.group_dispatch):
excerpt = "پاسخ ثبت شده است؛ محتوا فقط در پنل و با رعایت مجوز بازگشایی قابل مشاهده است."
local_created = timezone.localtime(message.created_at if message else ticket.created_at) local_created = timezone.localtime(message.created_at if message else ticket.created_at)
jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M") jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M")
lines=[ lines=[
@@ -72,13 +75,36 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1,
lines.extend([f"🕒 {jalali}", "", f"📝 {excerpt}", "", "🔗 مشاهده و پاسخ", f"{settings.PUBLIC_BASE_URL}/staff/tickets/{ticket.public_id}/"]) lines.extend([f"🕒 {jalali}", "", f"📝 {excerpt}", "", "🔗 مشاهده و پاسخ", f"{settings.PUBLIC_BASE_URL}/staff/tickets/{ticket.public_id}/"])
return "\n".join(lines) return "\n".join(lines)
def _prepare_sms(job):
if job.event_type == 'STAFF_REPLY':
return _prepare_staff_reply_sms(job)
if job.event_type == 'PAYMENT_NOTICE_CREATED' and NotificationJob.objects.filter(
pk=job.pk, payment_notification__notice__deleted_at__isnull=False).exists():
job.status = NotificationJob.Status.CANCELLED
job.last_error = 'اعلامیه واریز حذف شده است.'
job.save(update_fields=['status', 'last_error'])
return False
return True
def _send_sms(job): def _send_sms(job):
if job.event_type == "DISPATCH_RESPONSES_OPENED":
from apps.notifications.dispatch_opening import send_opening_sms
return send_opening_sms(job, get_sms_provider), None
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import send_receipt_alert
return send_receipt_alert(job, sms_provider_factory=get_sms_provider), None
if job.event_type in {"PAYMENT_NOTICE_CREATED", "PAYMENT_RECEIPT_APPROVED", "PAYMENT_RECEIPT_REJECTED"}:
from apps.payments.notifications import send_payment_sms
return send_payment_sms(job, provider_factory=get_sms_provider)
issued = None issued = None
if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET": if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET":
from apps.tickets.short_links import append_supervisor_ticket_link
recipient = job.payload.get("recipient_mobile", "") recipient = job.payload.get("recipient_mobile", "")
if not recipient: if not recipient:
raise ValueError("Unit supervisor mobile is not configured") raise ValueError("Unit supervisor mobile is not configured")
message = _render_sms(job.event_type, _sms_context(job), job.ticket.owning_unit) message = _render_sms(job.event_type, _sms_context(job), job.ticket.owning_unit)
message = append_supervisor_ticket_link(job.ticket, message)
return get_sms_provider().send_message(recipient, message), issued return get_sms_provider().send_message(recipient, message), issued
if job.event_type == "CUSTOMER_TICKET_CREATED": if job.event_type == "CUSTOMER_TICKET_CREATED":
issued, url = _magic_link(job, "customer_created_ticket") issued, url = _magic_link(job, "customer_created_ticket")
@@ -91,14 +117,28 @@ def _send_sms(job):
raise ValueError(f"Unsupported SMS event: {job.event_type}") raise ValueError(f"Unsupported SMS event: {job.event_type}")
return get_sms_provider().send_message(job.customer.mobile, message), issued return get_sms_provider().send_message(job.customer.mobile, message), issued
def _send_customer_reply_bale(job, unit_chat_id):
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first()
if not message:
raise ValueError("Customer reply message no longer exists")
text = _bale_ticket_text(job.ticket, message, is_reply=True,
message_count=job.payload.get("message_count", 1), reopened=job.event_type == "CUSTOMER_TICKET_REOPENED")
from apps.tickets.dispatch_responses import response_control
if job.ticket.group_dispatch_id and response_control(job.ticket.group_dispatch):
from apps.notifications.dispatch_opening import send_dispatch_reply_bale
return send_dispatch_reply_bale(job, text, get_bale_provider)
return get_bale_provider().send_message(unit_chat_id, text) if unit_chat_id else None
def _send_bale(job): def _send_bale(job):
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import send_receipt_alert
return send_receipt_alert(job)
unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip() unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip()
if job.event_type == "NEW_CUSTOMER_TICKET": if job.event_type == "NEW_CUSTOMER_TICKET":
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None
if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]: if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]:
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first() return _send_customer_reply_bale(job, unit_chat_id)
if not message: raise ValueError("Customer reply message no longer exists")
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED")) if unit_chat_id else None
if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured") if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured")
profile = job.staff_user.staff_profile profile = job.staff_user.staff_profile
if not profile.bale_notifications_enabled: return None if not profile.bale_notifications_enabled: return None
@@ -334,7 +374,11 @@ class Command(BaseCommand):
for job in jobs: for job in jobs:
issued_link = None issued_link = None
try: try:
if job.channel == NotificationJob.Channel.SMS and job.event_type == "STAFF_REPLY" and not _prepare_staff_reply_sms(job): if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import prepare_receipt_alert
if not prepare_receipt_alert(job):
continue
if job.channel == NotificationJob.Channel.SMS and not _prepare_sms(job):
continue continue
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job): if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
continue continue
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-05 10:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0016_announcementjob_requested_by_announcementjob_source_and_more'),
]
operations = [
migrations.AlterField(
model_name='smstemplate',
name='event_type',
field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50),
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.1.1 on 2026-10-06 05:38
from django.db import migrations, models
def align_template_tags(apps, schema_editor):
template = apps.get_model('notifications', 'SMSTemplate')
ticket_tags = ['magic_link', 'first_name', 'last_name', 'recipient_name', 'unit_name', 'company_name',
'ticket_number', 'subject', 'province', 'city', 'expert_name', 'customer_mobile', 'category_name']
payment_tags = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link']
for item in template.objects.all():
tags = payment_tags if item.event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'} else ticket_tags
if item.event_type == 'UNIT_SUPERVISOR_NEW_TICKET':
tags = [tag for tag in tags if tag != 'magic_link']
template.objects.filter(pk=item.pk).update(allowed_tags=tags)
class Migration(migrations.Migration):
dependencies = [
('notifications', '0017_alter_smstemplate_event_type'),
]
operations = [
migrations.AlterField(
model_name='smstemplate',
name='body',
field=models.TextField(help_text='تگ\u200cها بر اساس رویداد مشخص\u200cاند: {magic_link} برای تیکت و {payment_link} برای پرداخت.'),
),
migrations.RunPython(align_template_tags, migrations.RunPython.noop),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-11 11:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0018_alter_smstemplate_body'),
]
operations = [
migrations.AlterField(
model_name='smstemplate',
name='event_type',
field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('PAYMENT_RECEIPT_REJECTED', 'رد فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50),
),
]
@@ -0,0 +1,17 @@
from django.db import migrations
def seed_rejection_template(apps, schema_editor):
template = apps.get_model('notifications', 'SMSTemplate')
template.objects.using(schema_editor.connection.alias).get_or_create(
unit=None, event_type='PAYMENT_RECEIPT_REJECTED', defaults={
'title': 'رد فیش واریز', 'is_active': True,
'body': 'فیش واریزی {receipt_number} به مبلغ {amount_rial} ریال رد شد.\nدلیل کارشناس: {rejection_reason}\nاصلاح و ارسال دوباره فیش: {payment_link}\n{company_name}',
'allowed_tags': ['recipient_name', 'company_name', 'notice_number', 'receipt_number',
'amount_rial', 'invoice_number', 'payment_link', 'rejection_reason'],
})
class Migration(migrations.Migration):
dependencies = [('notifications', '0019_alter_smstemplate_event_type')]
operations = [migrations.RunPython(seed_rejection_template, migrations.RunPython.noop)]
@@ -0,0 +1,27 @@
# Generated by Django 5.1.1 on 2026-10-13 09:53
from django.db import migrations, models
def seed_opening_template(apps, schema_editor):
apps.get_model('notifications', 'SMSTemplate').objects.using(schema_editor.connection.alias).get_or_create(
unit=None, event_type='DISPATCH_RESPONSES_OPENED', defaults={
'title': 'بازگشایی پاسخ‌های ارسال گروهی', 'is_active': True, 'allowed_tags': [],
'body': 'لوله گستر گلپایگان با سلام؛ پاسخ استعلام شما بازگشایی شد. جهت صیانت از رقابت عادلانه، تمامی پیشنهادات تا این لحظه کاملاً محرمانه بوده و هیچ‌کس دسترسی به مشاهده آن‌ها نداشته است. اکنون تمامی پاسخ‌ها به‌صورت هم‌زمان بازگشایی شدند تا سلامت فرآیند تضمین گردد.\nاز همراهی و اعتماد شما سپاسگزاریم. واحد فناوری اطلاعات لوله گستر گلپایگان',
})
class Migration(migrations.Migration):
dependencies = [
('notifications', '0020_receipt_rejection_template'),
]
operations = [
migrations.SeparateDatabaseAndState(state_operations=[migrations.AlterField(
model_name='smstemplate',
name='event_type',
field=models.CharField(choices=[('DISPATCH_RESPONSES_OPENED', 'بازگشایی پاسخ\u200cهای ارسال گروهی'), ('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('PAYMENT_RECEIPT_REJECTED', 'رد فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50),
)], database_operations=[]),
migrations.RunPython(seed_opening_template, migrations.RunPython.noop),
]
+30 -2
View File
@@ -10,23 +10,51 @@ def sms_template_tags():
class SMSTemplate(PersianTextNormalizationMixin, models.Model): class SMSTemplate(PersianTextNormalizationMixin, models.Model):
persian_text_fields={"title":False,"body":True} persian_text_fields={"title":False,"body":True}
class Event(models.TextChoices): class Event(models.TextChoices):
DISPATCH_RESPONSES_OPENED="DISPATCH_RESPONSES_OPENED","بازگشایی پاسخ‌های ارسال گروهی"
CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری" CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری"
STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس" STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس"
STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس" STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس"
PAYMENT_NOTICE_CREATED="PAYMENT_NOTICE_CREATED","صدور اعلامیه واریز"
PAYMENT_RECEIPT_APPROVED="PAYMENT_RECEIPT_APPROVED","تأیید نهایی فیش واریز"
PAYMENT_RECEIPT_REJECTED="PAYMENT_RECEIPT_REJECTED","رد فیش واریز"
UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد" UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد"
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.") unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.")
event_type=models.CharField(max_length=50,choices=Event.choices) event_type=models.CharField(max_length=50,choices=Event.choices)
title=models.CharField(max_length=120) title=models.CharField(max_length=120)
body=models.TextField(help_text="تگ‌ها را مانند {magic_link} در متن قرار دهید.") body=models.TextField(help_text="تگ‌ها بر اساس رویداد مشخص‌اند: {magic_link} برای تیکت و {payment_link} برای پرداخت.")
allowed_tags=models.JSONField(default=sms_template_tags,blank=True) allowed_tags=models.JSONField(default=sms_template_tags,blank=True)
is_active=models.BooleanField(default=True) is_active=models.BooleanField(default=True)
updated_at=models.DateTimeField(auto_now=True) updated_at=models.DateTimeField(auto_now=True)
class Meta: class Meta:
constraints=[models.UniqueConstraint(fields=["unit","event_type"],name="unique_sms_template_per_unit"),models.UniqueConstraint(fields=["event_type"],condition=models.Q(unit__isnull=True),name="unique_global_sms_template_event")] constraints=[models.UniqueConstraint(fields=["unit","event_type"],name="unique_sms_template_per_unit"),models.UniqueConstraint(fields=["event_type"],condition=models.Q(unit__isnull=True),name="unique_global_sms_template_event")]
def __str__(self): return self.get_event_type_display() def __str__(self): return self.get_event_type_display()
@classmethod
def tags_for_event(cls, event_type):
if event_type == "DISPATCH_RESPONSES_OPENED":
return []
if event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED', 'PAYMENT_RECEIPT_REJECTED'}:
from apps.payments.notifications import TAGS
return list(TAGS)
tags = sms_template_tags()
return [tag for tag in tags if tag != 'magic_link'] if event_type == 'UNIT_SUPERVISOR_NEW_TICKET' else tags
@property
def supported_tags(self):
return ', '.join('{' + tag + '}' for tag in self.tags_for_event(self.event_type))
def save(self, *args, **kwargs):
self.allowed_tags = self.tags_for_event(self.event_type)
if kwargs.get('update_fields') is not None:
kwargs['update_fields'] = set(kwargs['update_fields']) | {'allowed_tags'}
return super().save(*args, **kwargs)
def clean(self): def clean(self):
self.allowed_tags = self.tags_for_event(self.event_type)
from string import Formatter from string import Formatter
tags={field for _,field,_,_ in Formatter().parse(self.body) if field} try:
tags={field for _,field,_,_ in Formatter().parse(self.body) if field}
except ValueError:
raise ValidationError({'body': 'ساختار تگ معتبر نیست؛ هر تگ باید بین { و } قرار بگیرد.'})
invalid=tags-set(self.allowed_tags) invalid=tags-set(self.allowed_tags)
if invalid: raise ValidationError({"body":f"تگ‌های غیرمجاز: {', '.join(sorted(invalid))}"}) if invalid: raise ValidationError({"body":f"تگ‌های غیرمجاز: {', '.join(sorted(invalid))}"})
View File
Whitespace-only changes.
+157
View File
@@ -0,0 +1,157 @@
"""A payment link never authenticates a user or grants access to their whole account."""
import hashlib
import uuid
from datetime import timedelta
from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils import timezone
from apps.core.session_access import grant_scoped_session_access
from apps.accounts.models import TicketMagicLink
from . import policies
from .models import PaymentAccessLink, PaymentNotice
SESSION_KEY = 'payment_access_link_id'
SHARE_SESSION_KEY = 'payment_page_share'
def token_digest(token):
return hashlib.sha256(token.encode()).hexdigest()
def issue_payment_link(*, notice=None, receipt=None):
target = receipt or notice
if target is None:
raise ValueError('Payment link requires a notice or receipt')
if receipt is not None:
notice = receipt.notice
for attempt in range(5):
token = TicketMagicLink.generate_secure_token(10)
try:
with transaction.atomic():
link = PaymentAccessLink.objects.create(customer=target.customer, notice=notice, receipt=receipt,
token_hash=token_digest(token),
expires_at=timezone.now() + timedelta(days=settings.PAYMENT_LINK_TTL_DAYS))
return link, settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token])
except IntegrityError:
if attempt == 4:
raise RuntimeError('عدم موفقیت در تولید لینک یکتای پرداخت.')
def notice_share_url(request, notice):
"""Reuse a validated capability; only a logged-in owner can issue a new one."""
shared = request.session.get(SHARE_SESSION_KEY, {})
token = shared.get('token', '')
link = active_links().filter(pk=shared.get('link_id'), notice=notice,
token_hash=token_digest(token)).first() if token else None
grant = getattr(request, 'payment_access_link', None)
if grant and (link is None or link.pk != grant.pk):
raise PermissionDenied('برای کپی لینک، دوباره لینک پرداخت دریافتی را باز کنید.')
if link is None:
from .services import audit
link, url = issue_payment_link(notice=notice)
token = url.rstrip('/').rsplit('/', 1)[-1]
request.session[SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
audit(request.user, 'PAYMENT_LINK_CREATED', link)
return settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token])
def active_links():
return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter(
notice__deleted_at__isnull=True,
revoked_at__isnull=True, expires_at__gt=timezone.now(), customer__is_active=True,
customer__account_type='CUSTOMER')
def grant_payment_access(request, link):
# The session may already contain a ticket grant; each grant checks its own expiry.
grant_scoped_session_access(request, SESSION_KEY, link.pk, link.expires_at, preserve_expiry=True)
def get_payment_access(request):
link_id = request.session.get(SESSION_KEY)
if not isinstance(link_id, int):
return None
link = active_links().filter(pk=link_id).first()
if link is None:
request.session.pop(SESSION_KEY, None)
return link
def visible_payments(request, queryset):
link = getattr(request, 'payment_access_link', None)
if link is None:
return policies.visible(queryset, request.user)
owned = queryset.filter(customer_id=link.customer_id)
if queryset.model == PaymentNotice:
return owned.filter(pk=link.notice_id)
scope = Q(pk=link.receipt_id) | Q(access_links=link)
if link.notice_id:
scope |= Q(notice_id=link.notice_id)
return owned.filter(scope).distinct()
def customer_context(request, **kwargs):
return {'payment_link_access': getattr(request, 'payment_access_link', None), **kwargs}
def prepare_payment_request(request, *, staff=False, allow_payment_link=False):
if request.user.is_authenticated:
customer = request.user.is_active and request.user.account_type == 'CUSTOMER'
permitted = policies.is_staff(request.user) if staff else customer
if staff is None:
permitted = customer or policies.is_staff(request.user)
if not permitted:
raise PermissionDenied
request.payment_customer = request.user
return None
had_link = SESSION_KEY in request.session
link = get_payment_access(request) if allow_payment_link else None
if link:
request.payment_access_link = link
request.payment_customer = link.customer
return None
if had_link and allow_payment_link:
return unavailable_link(request)
return redirect_to_login(request.get_full_path(), '/staff/login/' if staff else '/login/')
def receipt_initial(request, customer):
link = getattr(request, 'payment_access_link', None)
identifier = request.GET.get('notice')
initial = {}
if link and not identifier:
if request.GET.get('mode') == 'independent' or not link.notice_id or link.notice.cancelled_at:
return {'receipt_mode': 'independent'}
identifier = str(link.notice.public_id)
if not identifier:
return initial
try:
identifier = uuid.UUID(identifier)
except ValueError:
raise Http404
notices = visible_payments(request, PaymentNotice.objects.all()) if link else PaymentNotice.objects.filter(customer=customer)
notice = get_object_or_404(notices, public_id=identifier, cancelled_at__isnull=True)
return {'notice_id': notice.pk, 'destination_account': notice.account_id, 'deposit_id': notice.deposit_id}
def validate_link_submission(link, customer, data):
if not active_links().select_related(None).select_for_update(of=('self',)).filter(pk=link.pk, customer=customer).first():
raise PermissionDenied
notice = data.get('notice_id')
if notice is not None and notice.pk != link.notice_id:
raise PermissionDenied
def unavailable_link(request):
response = render(request, 'payments/link_unavailable.html', {'financial_page': True}, status=410)
response['Referrer-Policy'] = 'no-referrer'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
+216
View File
@@ -0,0 +1,216 @@
from django.contrib import admin
from django.core.exceptions import PermissionDenied
from django.db import connection
from . import models, policies, services
@admin.register(models.PaymentAccessLink)
class PaymentAccessLinkAdmin(admin.ModelAdmin):
list_display = ('customer', 'notice', 'receipt', 'created_at', 'expires_at', 'revoked_at', 'view_count')
list_filter = ('revoked_at', 'expires_at')
exclude = ('token_hash',)
readonly_fields = ('customer', 'notice', 'receipt', 'created_at', 'expires_at', 'revoked_at', 'view_count', 'last_used_at', 'submitted_receipts')
actions = ['revoke_links']
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.action(description='ابطال لینک‌های انتخاب‌شده')
def revoke_links(self, request, queryset):
from django.utils import timezone
for link in queryset.filter(revoked_at__isnull=True):
link.revoked_at = timezone.now()
link.save(update_fields=['revoked_at'])
services.audit(request.user, 'PAYMENT_LINK_REVOKED', link)
from .forms import BankAccountForm
class AuditedConfigurationAdmin(admin.ModelAdmin):
permission = 'manage_payment_approval_policy'
singleton_configuration = False
def has_view_permission(self, request, obj=None):
return policies.has_permission(request.user,self.permission)
def has_add_permission(self, request):
return self.has_view_permission(request) and not (self.singleton_configuration and self.model.objects.exists())
def has_change_permission(self, request, obj=None):
return self.has_view_permission(request,obj)
def has_delete_permission(self, request, obj=None):
return False
def save_model(self, request, obj, form, change):
if not self.has_change_permission(request,obj):
raise PermissionDenied
current = self.model.objects.select_for_update().get(pk=obj.pk) if change else None
before = services.configuration_snapshot(current) if current else {}
super().save_model(request,obj,form,change)
services.audit(request.user,'CONFIG_SAVED',obj,{'fields': form.changed_data,
'before':before,'after':services.configuration_snapshot(obj)})
def save_related(self, request, form, formsets, change):
super().save_related(request,form,formsets,change)
if isinstance(form.instance,models.PaymentApprovalPolicy):
services.audit(request.user,'POLICY_ASSIGNMENTS_CHANGED',form.instance,{'reviewers':list(form.instance.reviewers.values_list('pk',flat=True)), 'seniors':list(form.instance.seniors.values_list('pk',flat=True))})
@admin.register(models.PaymentApprovalPolicy)
class ApprovalPolicyAdmin(AuditedConfigurationAdmin):
list_display = ['name','unit','is_active']
filter_horizontal = ['reviewers','seniors']
def formfield_for_manytomany(self, db_field, request, **kwargs):
from apps.accounts.models import User
if db_field.name in ['reviewers','seniors']:
kwargs['queryset'] = User.objects.filter(account_type='STAFF',is_active=True).exclude(staff_profile__is_active=False)
return super().formfield_for_manytomany(db_field,request,**kwargs)
@admin.register(models.PaymentAccessConfiguration)
class AccessAdmin(AuditedConfigurationAdmin):
permission = 'manage_payment_access'
singleton_configuration = True
@admin.register(models.CompanyBankAccount)
class AccountAdmin(AuditedConfigurationAdmin):
form = BankAccountForm
list_display = ['name','bank','requires_deposit_id','is_active','available_to_staff','available_to_customers']
permission = 'manage_company_bankaccounts'
def has_view_permission(self, request, obj=None):
return policies.can_manage_banks(request.user)
def get_queryset(self, request):
queryset = super().get_queryset(request)
# Django wraps POST change forms in atomic(): lock before token validation.
if request.method == 'POST' and connection.in_atomic_block:
return queryset.select_for_update(of=('self',))
return queryset
@admin.register(models.BankInstitution)
class BankAdmin(AccountAdmin):
form = admin.ModelAdmin.form
list_display = ['name','code','is_active','historical']
class EvidenceAdmin(admin.ModelAdmin):
def has_view_permission(self, request, obj=None):
if self.model in [models.PaymentNotice, models.PaymentReceipt]:
return super().has_view_permission(request, obj)
return policies.has_permission(request.user, 'view_payment_audit')
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
def get_queryset(self, request):
queryset = super().get_queryset(request)
if queryset.model in [models.PaymentNotice,models.PaymentReceipt]:
return policies.visible(queryset,request.user)
return queryset if policies.has_permission(request.user,'view_payment_audit') else queryset.none()
for model in [models.PaymentNotice,models.PaymentReceipt,models.PaymentReceiptRevision,models.ReceiptApprovalCycle,
models.ReceiptApprovalAssignment,models.ReceiptDecision,models.PaymentAuditEvent,
models.PaymentNotification,models.PaymentNotificationAttempt]:
admin.site.register(model,EvidenceAdmin)
class ReceiptAlertRecipientInline(admin.TabularInline):
model = models.PaymentReceiptAlertRecipient
extra = 1
can_delete = False
def has_view_permission(self, request, obj=None):
return policies.has_permission(request.user, 'manage_receipt_alerts')
has_add_permission = has_view_permission
has_change_permission = has_view_permission
@admin.register(models.PaymentReceiptAlertConfiguration)
class ReceiptAlertConfigurationAdmin(AuditedConfigurationAdmin):
from .forms import ReceiptAlertConfigurationForm
form = ReceiptAlertConfigurationForm
permission = 'manage_receipt_alerts'
singleton_configuration = True
list_display = ('__str__', 'bale_enabled', 'sms_enabled', 'notify_revisions', 'updated_at')
readonly_fields = ('updated_at',)
inlines = [ReceiptAlertRecipientInline]
fieldsets = (
('بله — تصویر فیش', {'fields': ('bale_enabled', 'bale_token', 'clear_bale_token', 'bale_chat_id', 'bale_caption')}),
('پیامک مدیران', {'fields': ('sms_enabled', 'sms_template')}),
('فیش اصلاح‌شده', {'fields': ('notify_revisions', 'updated_at')}),
)
def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
services.audit(request.user, 'RECEIPT_ALERT_RECIPIENTS_CHANGED', form.instance,
{'recipients': list(form.instance.recipients.values('id', 'name', 'mobile', 'is_active'))})
@admin.register(models.PaymentReceiptAlertRecipient)
class ReceiptAlertRecipientAdmin(AuditedConfigurationAdmin):
permission = 'manage_receipt_alerts'
list_display = ('name', 'mobile', 'is_active')
list_filter = ('is_active',)
search_fields = ('name', 'mobile')
@admin.register(models.PaymentReceiptAlert)
class ReceiptAlertAdmin(EvidenceAdmin):
list_display = ('receipt_number', 'recipient_name', 'recipient_mobile', 'channel', 'status', 'attempts', 'delivery_status')
list_select_related = ('job', 'revision__receipt', 'recipient')
readonly_fields = ('channel', 'status', 'attempts', 'delivery_status', 'last_error', 'sent_at', 'delivery_description')
def has_view_permission(self, request, obj=None):
return policies.has_permission(request.user, 'manage_receipt_alerts') or super().has_view_permission(request, obj)
def get_queryset(self, request):
if policies.has_permission(request.user, 'manage_receipt_alerts'):
return admin.ModelAdmin.get_queryset(self, request)
return super().get_queryset(request)
@admin.display(description='فیش')
def receipt_number(self, obj):
return obj.revision.receipt.number
@admin.display(description='کانال')
def channel(self, obj):
return obj.job.get_channel_display()
@admin.display(description='وضعیت ارسال')
def status(self, obj):
return obj.job.get_status_display()
@admin.display(description='تعداد تلاش')
def attempts(self, obj):
return obj.job.attempts
@admin.display(description='دریافت پیامک')
def delivery_status(self, obj):
return obj.job.get_delivery_status_display() or 'استعلام ثبت نشده'
@admin.display(description='آخرین خطا')
def last_error(self, obj):
return obj.job.last_error
@admin.display(description='زمان ارسال')
def sent_at(self, obj):
return obj.job.sent_at
@admin.display(description='شرح دریافت')
def delivery_description(self, obj):
return obj.job.delivery_description
+22
View File
@@ -0,0 +1,22 @@
from string import Formatter
from django.core.exceptions import ValidationError
ALERT_TAGS = ('amount_rial', 'customer_name', 'customer_mobile', 'received_at', 'paid_date',
'receipt_number', 'receipt_link', 'revision_version', 'manager_name')
DEFAULT_RECEIPT_SMS = 'فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.'
DEFAULT_RECEIPT_CAPTION = ('فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\n'
'مبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\n'
'شماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}')
ALERT_TEMPLATE_HELP = 'تگ‌های مجاز: ' + '، '.join('{' + tag + '}' for tag in ALERT_TAGS)
def validate_alert_template(value):
if not value.strip():
raise ValidationError('متن اعلان نمی‌تواند خالی باشد.')
try:
for _, field, spec, conversion in Formatter().parse(value):
if field is not None and (field not in ALERT_TAGS or spec or conversion):
raise ValueError
except ValueError:
raise ValidationError('قالب معتبر نیست؛ فقط تگ‌های معرفی‌شده و بدون قالب‌بندی اضافی مجازند.')
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.payments'
verbose_name = 'واریزها و تأیید مالی'
+15
View File
@@ -0,0 +1,15 @@
from django.db import OperationalError, ProgrammingError
from . import policies
from .models import PaymentReceipt
def payment_navigation(request):
if not policies.is_staff(request.user):
return {}
try:
return {'payment_can_create': policies.can_create(request.user),
'payment_pending_count': policies.visible(PaymentReceipt.objects.all(),request.user).filter(status__in=['PENDING','PARTIAL']).count(),
'payment_can_manage_banks': policies.can_manage_banks(request.user),
'payment_can_view': policies.visible(PaymentReceipt.objects.all(),request.user).exists() or policies.config().open_view or request.user.has_perm('payments.view_paymentreceipt') or request.user.is_superuser}
except (OperationalError, ProgrammingError):
return {}
+77
View File
@@ -0,0 +1,77 @@
"""Reuse the contact directory's scoped history; never render all customer choices."""
from uuid import UUID
from django.db.models import Prefetch, Q
from django.http import Http404
from django.urls import reverse
from apps.accounts.models import normalize_mobile
from apps.core.persian_text import normalize_persian_text
from apps.tickets.selectors import staff_ticket_rows
from .validators import digits
def customer_rows(user, search=''):
# The existing directory projection counts only tickets visible to this staff user.
from apps.tickets.views import _customer_rows
rows = _customer_rows(user=user).filter(is_active=True)
if search:
terms_query = Q()
for term in search.split():
mobile = term
try:
mobile = normalize_mobile(term)
except ValueError:
if term.isascii() and term.isdecimal() and term.startswith('0'):
mobile = term[1:]
terms_query &= (Q(mobile__icontains=mobile) | Q(username__icontains=term) |
Q(first_name__icontains=term) | Q(last_name__icontains=term) |
Q(customer_profile__first_name__icontains=term) |
Q(customer_profile__last_name__icontains=term) |
Q(customer_profile__organization_name__icontains=term))
history = staff_ticket_rows(user).filter(
Q(ticket_number__icontains=search) | Q(subject__icontains=search) |
Q(subject__icontains=normalize_persian_text(search)))
rows = rows.filter(terms_query | Q(customer_tickets__pk__in=history.values('pk'))).distinct()
return rows.prefetch_related(Prefetch('customer_tickets',
queryset=staff_ticket_rows(user).order_by('-created_at', '-pk')[:3],
to_attr='payment_recent_tickets'))
def customer_data(customer):
profile = getattr(customer, 'customer_profile', None)
name = ' '.join(filter(None, [getattr(profile, 'first_name', ''), getattr(profile, 'last_name', '')]))
return {'value': str(customer.pk), 'id': str(customer.public_id),
'name': name or customer.get_full_name() or 'نام ثبت نشده',
'mobile': customer.local_mobile, 'organization': getattr(profile, 'organization_name', ''),
'location': '، '.join(filter(None, [getattr(profile, 'city', ''), getattr(profile, 'province', '')])),
'ticket_count': customer.ticket_count, 'active_count': customer.active_count,
'history': [{'number': ticket.ticket_number, 'subject': ticket.subject,
'status': ticket.get_status_display(), 'url': reverse('staff_tickets:detail', args=[ticket.public_id])}
for ticket in customer.payment_recent_tickets]}
def initial_customer(request):
public_id = request.GET.get('customer', '')
if not public_id or request.method != 'GET':
return {}
try:
public_id = UUID(public_id)
except (ValueError, TypeError, AttributeError):
raise Http404('مشتری معتبر نیست.')
customer = customer_rows(request.user).filter(public_id=public_id).first()
if customer is None:
raise Http404('مشتری فعال پیدا نشد.')
return {'customer': customer.pk}
def picker_context(request, form):
value = str(form['customer'].value() or '')
selected = customer_rows(request.user).filter(pk=value).first() if value.isascii() and value.isdecimal() else None
return {'customer_selection': customer_data(selected) if selected else None}
def search_results(user, query):
query = digits(query).translate(str.maketrans('يك', 'یک'))[:100]
if len(query) < 2:
return {'results': [], 'has_more': False}
rows = list(customer_rows(user, query)[:21])
return {'results': [customer_data(customer) for customer in rows[:20]], 'has_more': len(rows) > 20}
+48
View File
@@ -0,0 +1,48 @@
"""Deposit identifier algorithm copied from repository alghortim_shenase.py.
The input is the customer local mobile number, retaining its leading zero.
This is a deterministic identifier, not a random or unique-per-notice value.
"""
def normalize_deposit_identifier(value):
"""Remove leading zeroes from deposit IDs only; retain internal/check digits."""
value = str(value or '').translate(str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')).strip()
return (value.lstrip('0') or '0') if value else ''
def generate_id_algorithm(id_code: str) -> str:
"""
Calculates the verification code based on the reverse-engineered logic:
1. Iterate digits from right to left (index i = 0, 1, 2...).
2. Weights:
- If i < 8: Weight = 8 - i (Sequence: 8, 7, 6, 5, 4, 3, 2, 1)
- If i >= 8: Weight = i + 1 (Sequence: 9, 10, ...)
3. Sum = Sum(Digit * Weight)
4. Check Digit = Sum % 99
"""
# تمیزکاری ورودی (حذف فاصله‌ها و اطمینان از عددی بودن)
if not id_code.isdigit():
return "Error: ورودی باید فقط عدد باشد."
total_sum = 0
# پیمایش از راست به چپ
# reversed(id_code) ارقام را برعکس می‌کند
for i, char in enumerate(reversed(id_code)):
digit = int(char)
# تعیین وزن بر اساس ایندکس (مکان رقم از راست)
if i < 8:
weight = 8 - i
else:
weight = i + 1
total_sum += digit * weight
# محاسبه باقی‌مانده بر 99
check_digit = total_sum % 99
# فرمت‌دهی دو رقمی (مثلاً 6 می‌شود 06)
check_digit_str = f"{check_digit:02}"
return normalize_deposit_identifier(f"{id_code}{check_digit_str}")
+160
View File
@@ -0,0 +1,160 @@
import uuid
import jdatetime
from django import forms
from django.db.models import Q
from django.utils import timezone
from apps.accounts.models import User
from . import validators
from .models import BankInstitution, CompanyBankAccount, PaymentNotice
from .identity import customer_name, depositor_name
from .deposit_identifier import generate_id_algorithm
class ReceiptAlertConfigurationForm(forms.ModelForm):
clear_bale_token = forms.BooleanField(label='پاک کردن توکن ذخیره‌شده', required=False)
class Meta:
from .models import PaymentReceiptAlertConfiguration
model = PaymentReceiptAlertConfiguration
fields = '__all__'
widgets = {'bale_token': forms.PasswordInput(render_value=False)}
help_texts = {'bale_token': 'برای حفظ توکن فعلی این فیلد را خالی بگذارید؛ برای جایگزینی، توکن تازه وارد کنید.'}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.initial['bale_token'] = ''
def clean(self):
data = super().clean()
if data.get('clear_bale_token'):
data['bale_token'] = ''
elif not data.get('bale_token') and self.instance.pk:
data['bale_token'] = self.instance.bale_token
return data
class BankAccountForm(forms.ModelForm):
revision_token = forms.CharField(required=False, widget=forms.HiddenInput)
class Meta:
model = CompanyBankAccount
fields = ['revision_token', 'name', 'bank', 'holder', 'account_number', 'iban', 'card_number', 'requires_deposit_id', 'is_active', 'available_to_staff', 'available_to_customers', 'sort_order']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.initial['revision_token'] = self.instance.updated_at.isoformat()
def clean_account_number(self):
return validators.bank_number(self.cleaned_data['account_number'])
def clean_card_number(self):
value = validators.bank_number(self.cleaned_data['card_number'])
if value and (len(value) != 16 or sum((int(n) * (2 if i % 2 == 0 else 1)) // 10 + (int(n) * (2 if i % 2 == 0 else 1)) % 10 for i, n in enumerate(value)) % 10):
raise forms.ValidationError('شماره کارت ۱۶ رقمی معتبر وارد کنید.')
return value
def clean_iban(self):
return validators.iban(self.cleaned_data['iban'])
def clean(self):
data = super().clean()
if self.instance.pk and data.get('revision_token') != self.instance.updated_at.isoformat():
raise forms.ValidationError('حساب از زمان باز کردن فرم تغییر کرده است؛ صفحه را تازه کنید و تغییرات را دوباره بررسی کنید.')
if not any(data.get(key) for key in ('account_number', 'iban', 'card_number')):
raise forms.ValidationError('حداقل شماره حساب، شبا یا کارت را وارد کنید.')
return data
class NoticeForm(forms.Form):
submission_key = forms.UUIDField(widget=forms.HiddenInput)
customer = forms.ModelChoiceField(label='مشتری', queryset=User.objects.none(), widget=forms.HiddenInput,
error_messages={'required': 'مشتری را از نتایج جست‌وجو انتخاب کنید.',
'invalid_choice': 'مشتری انتخاب‌شده معتبر یا فعال نیست؛ دوباره جست‌وجو کنید.'})
account = forms.ModelChoiceField(label='حساب مقصد', queryset=CompanyBankAccount.objects.none())
subject = forms.CharField(label='موضوع', max_length=200)
amount = forms.CharField(label='مبلغ (ریال)')
deposit_id = forms.CharField(label='شناسه واریز', max_length=100, required=False)
invoice_number = forms.CharField(label='شماره فاکتور', max_length=100, required=False)
description = forms.CharField(label='توضیحات', required=False, widget=forms.Textarea)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.initial.setdefault('submission_key', uuid.uuid4())
self.fields['customer'].queryset = User.objects.filter(account_type='CUSTOMER', is_active=True).select_related('customer_profile')
self.fields['customer'].label_from_instance = lambda u: f'{getattr(u, "customer_profile", u)} — {u.local_mobile}'
self.fields['account'].queryset = CompanyBankAccount.objects.filter(is_active=True, available_to_staff=True).select_related('bank')
def clean_amount(self):
return validators.amount(self.cleaned_data['amount'])
def clean(self):
data = super().clean()
if data.get('account') and data['account'].requires_deposit_id and not data.get('deposit_id', '').strip():
self.add_error('deposit_id', 'این حساب شناسه‌دار است؛ شناسه واریز مشتری را حتماً وارد کنید.')
return data
class ReceiptForm(forms.Form):
depositor_name = forms.CharField(label='نام واریزکننده', max_length=150)
receipt_mode = forms.ChoiceField(label='نوع واریز', choices=[('linked', 'واریز مرتبط (پیشنهادی)'), ('independent', 'واریز مستقل')], widget=forms.RadioSelect, initial='linked')
notice_id = forms.ModelChoiceField(label='اعلامیه واریز', queryset=PaymentNotice.objects.none(), required=False)
paid_amount = forms.CharField(label='مبلغ پرداخت‌شده (ریال)')
destination_account = forms.ModelChoiceField(label='به کدام حساب ما واریز کردید؟', queryset=CompanyBankAccount.objects.none())
deposit_id = forms.CharField(label='شناسه واریز که با آن پرداخت انجام داده‌اید (اختیاری)', max_length=100, required=False)
origin_bank = forms.ModelChoiceField(label='بانک مبدأ (اختیاری)', queryset=BankInstitution.objects.filter(is_active=True), required=False)
origin_other = forms.CharField(label='نام بانک دیگر (اختیاری)', max_length=100, required=False)
paid_day = forms.ChoiceField(label='روز', choices=[('', 'روز')] + [(str(i), str(i)) for i in range(1, 32)])
paid_month = forms.ChoiceField(label='ماه', choices=[('', 'ماه')] + list(zip(map(str, range(1, 13)), ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'])))
paid_year = forms.ChoiceField(label='سال')
receipt_image = forms.FileField(label='تصویر فیش', widget=forms.ClearableFileInput(attrs={'accept': 'image/*'}))
tracking_number = forms.CharField(label='شماره پیگیری (اختیاری)', max_length=100, required=False)
description = forms.CharField(label='توضیحات (اختیاری)', required=False, widget=forms.Textarea)
submission_key = forms.UUIDField(widget=forms.HiddenInput)
def __init__(self, *args, customer, notice_ids=None, **kwargs):
super().__init__(*args, **kwargs)
self.initial.setdefault('depositor_name', customer_name(customer))
notices = PaymentNotice.objects.filter(customer=customer, cancelled_at__isnull=True)
if notice_ids is not None:
notices = notices.filter(pk__in=notice_ids)
self.fields['notice_id'].queryset = notices
self.fields['notice_id'].label_from_instance = lambda n: f'{n.subject} — {n.amount:,} ریال — {n.invoice_number}'
accounts = Q(is_active=True, available_to_customers=True) | Q(is_active=True, paymentnotice__in=notices)
self.fields['destination_account'].queryset = CompanyBankAccount.objects.filter(accounts).distinct()
self.account_warnings = {str(a.pk): {'required': a.requires_deposit_id, 'bank': a.bank.name}
for a in self.fields['destination_account'].queryset.select_related('bank')}
self.customer_deposit_id = generate_id_algorithm(customer.local_mobile) if customer.local_mobile else ''
current = jdatetime.date.fromgregorian(date=timezone.localdate())
self.fields['paid_year'].choices = [('', 'سال')] + [(str(y), str(y)) for y in range(current.year, current.year - 11, -1)]
self.initial.setdefault('submission_key', uuid.uuid4())
self.initial.setdefault('paid_month', str(current.month))
self.initial.setdefault('paid_year', str(current.year))
def clean_paid_amount(self):
return validators.amount(self.cleaned_data['paid_amount'])
def clean_depositor_name(self):
return depositor_name(self.cleaned_data['depositor_name'])
def clean_receipt_image(self):
upload = self.cleaned_data['receipt_image']
self.image_metadata = validators.image(upload)
return upload
def clean(self):
data = super().clean()
notice = data.get('notice_id') if data.get('receipt_mode') == 'linked' else None
data['notice_id'] = notice
if data.get('receipt_mode') == 'linked' and not notice:
self.add_error('notice_id', 'اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.')
try:
data['paid_date'] = validators.paid_date(data.get('paid_day'), data.get('paid_month'), data.get('paid_year'))
if self.data.get('paid_date') and self.data['paid_date'] != data['paid_date'].isoformat():
raise forms.ValidationError('تاریخ ارسالی با تاریخ انتخاب‌شده سازگار نیست؛ دوباره انتخاب کنید.')
except forms.ValidationError as error:
self.add_error('paid_day', error)
account = data.get('destination_account')
if account and not account.available_to_customers and (not notice or notice.account_id != account.pk):
self.add_error('destination_account', 'این حساب فقط برای اعلامیه مربوط به آن قابل انتخاب است.')
return data
+28
View File
@@ -0,0 +1,28 @@
"""Customer identity and the payer recorded on each receipt are independent."""
from django.core.exceptions import ValidationError
from apps.core.persian_text import normalize_persian_text
def customer_name(customer):
profile = getattr(customer, 'customer_profile', None)
name = f'{profile.first_name} {profile.last_name}'.strip() if profile else ''
return normalize_persian_text(name or customer.get_full_name())
def depositor_name(value):
name = normalize_persian_text(value)
if not name.replace('\u200c', '').replace('\u200b', '').strip():
raise ValidationError('نام واریزکننده را وارد کنید.')
if len(name) > 150:
raise ValidationError('نام واریزکننده باید حداکثر ۱۵۰ کاراکتر باشد.')
return name
def receipt_identity(customer, payer):
name = customer_name(customer)
payer = normalize_persian_text(payer)
if not name:
return payer or 'نام ثبت نشده'
if not payer or name == payer:
return name
return f'{name} (واریزکننده: {payer})'
+43
View File
@@ -0,0 +1,43 @@
import re
from django.contrib.auth import logout
from django.db import transaction
from django.db.models import F
from django.shortcuts import redirect, render
from django.utils import timezone
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_http_methods
from . import access, services
from .models import PaymentAccessLink
@never_cache
@require_http_methods(['GET', 'POST'])
def open_link(request, token):
# Keep previously issued long links valid until their original expiry/revocation.
link = access.active_links().filter(token_hash=access.token_digest(token)).first() if re.fullmatch(r'(?:[a-z0-9]{10}|[A-Za-z0-9_-]{43})', token) else None
if link is None:
return access.unavailable_link(request)
if request.user.is_authenticated and request.user.pk != link.customer_id:
confirmed = request.method == 'POST' and request.POST.get('action') == 'logout_and_open' and request.POST.get('account_id') == str(request.user.pk)
if not confirmed:
response = render(request, 'payments/link_confirmation.html')
response['Referrer-Policy'] = 'same-origin'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
logout(request)
if not request.user.is_authenticated:
access.grant_payment_access(request, link)
# Retain the token across redirects in the session; the link model still stores only its hash.
request.session[access.SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
with transaction.atomic():
PaymentAccessLink.objects.filter(pk=link.pk).update(view_count=F('view_count') + 1, last_used_at=timezone.now())
services.audit(None, 'PAYMENT_LINK_OPENED', link, {
'ip_address': request.META.get('REMOTE_ADDR', ''),
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:500]})
target = link.receipt or link.notice
response = redirect('payments:receipt_detail' if link.receipt_id else 'payments:notice_detail', public_id=target.public_id)
response['Referrer-Policy'] = 'no-referrer'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
+53
View File
@@ -0,0 +1,53 @@
"""Audit administrative financial grant changes with the actual request actor.
Only selected non-secret fields and payments permissions are captured."""
from django.apps import apps
from django.db import OperationalError, ProgrammingError
from .models import PaymentAuditEvent
TARGETS = {'accounts/user': 'accounts.User', 'auth/group': 'auth.Group',
'staff/organizationalunit': 'staff.OrganizationalUnit', 'staff/unitmembership': 'staff.UnitMembership',
'staff/staffprofile': 'staff.StaffProfile'}
def access_snapshot(obj):
fields = ['is_active','account_type','user_id','unit_id','ticket_scope','role','primary_unit_id']
result = {field:getattr(obj,field) for field in fields if hasattr(obj,field)}
for name in ['permissions','user_permissions']:
relation = getattr(obj,name,None)
if relation is not None:
result[name] = sorted(relation.filter(content_type__app_label='payments').values_list('codename',flat=True))
if hasattr(obj,'groups'):
result['groups'] = sorted(obj.groups.filter(permissions__content_type__app_label='payments').values_list('pk',flat=True).distinct())
return result
class FinancialGrantAuditMiddleware:
def __init__(self,get_response):
self.get_response=get_response
def __call__(self,request):
target = self.target(request)
before = access_snapshot(target) if target else None
response = self.get_response(request)
if target and response.status_code < 400:
try:
target.refresh_from_db()
after=access_snapshot(target)
if before != after:
from .services import audit
audit(request.user,'FINANCIAL_ACCESS_CHANGED',target,{'before':before,'after':after})
except target.DoesNotExist:
from .services import audit
audit(request.user,'FINANCIAL_ACCESS_REMOVED',target,{'before':before})
return response
def target(self,request):
if request.method != 'POST' or not request.user.is_authenticated or not request.path.startswith('/x5admin/'):
return None
parts=request.path.strip('/').split('/')
if len(parts)<5 or not parts[3].isdecimal():
return None
label=TARGETS.get('/'.join(parts[1:3]))
if not label:
return None
return apps.get_model(label).objects.filter(pk=parts[3]).first()
+269
View File
@@ -0,0 +1,269 @@
# Generated by Django 5.1.1 on 2026-10-05 10:44
import apps.payments.models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('notifications', '0017_alter_smstemplate_event_type'),
('staff', '0012_organizationalunit_supervisor_mobile_5'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BankInstitution',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(unique=True)),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('historical', models.BooleanField(default=False)),
],
options={
'ordering': ['historical', 'name'],
},
),
migrations.CreateModel(
name='PaymentAccessConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('singleton', models.PositiveSmallIntegerField(default=1, editable=False, unique=True)),
('open_view', models.BooleanField(default=True, verbose_name='مشاهده برای همه کارشناسان فعال')),
('open_create', models.BooleanField(default=True, verbose_name='ایجاد برای همه کارشناسان فعال')),
],
options={
'permissions': [('manage_payment_access', 'Manage payment access')],
},
),
migrations.CreateModel(
name='CompanyBankAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=120, verbose_name='نام نمایشی حساب')),
('holder', models.CharField(max_length=150, verbose_name='صاحب حساب')),
('account_number', models.CharField(blank=True, max_length=40, verbose_name='شماره حساب')),
('iban', models.CharField(blank=True, max_length=26, verbose_name='شبا')),
('card_number', models.CharField(blank=True, max_length=16, verbose_name='شماره کارت')),
('requires_deposit_id', models.BooleanField(default=False, verbose_name='شناسه واریز الزامی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال کلی')),
('available_to_staff', models.BooleanField(default=True, verbose_name='قابل انتخاب کارشناسان')),
('available_to_customers', models.BooleanField(default=True, verbose_name='قابل انتخاب مشتریان')),
('sort_order', models.PositiveIntegerField(default=0)),
('bank', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution', verbose_name='بانک')),
],
options={
'ordering': ['sort_order', 'name', 'pk'],
'permissions': [('manage_company_bankaccounts', 'Manage company bank accounts')],
},
),
migrations.CreateModel(
name='PaymentAuditEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('actor_name', models.CharField(max_length=150)),
('action', models.CharField(max_length=50)),
('object_type', models.CharField(max_length=50)),
('object_id', models.CharField(max_length=64)),
('details', models.JSONField(default=dict)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
'permissions': [('view_payment_audit', 'View financial audit')],
},
),
migrations.CreateModel(
name='PaymentNotice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('account_snapshot', models.JSONField()),
('subject', models.CharField(max_length=200)),
('amount', models.BigIntegerField()),
('deposit_id', models.CharField(blank=True, max_length=100)),
('invoice_number', models.CharField(blank=True, max_length=100)),
('description', models.TextField(blank=True)),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_payment_notices', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notices', to=settings.AUTH_USER_MODEL)),
('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
],
options={
'ordering': ['-created_at', '-pk'],
},
),
migrations.CreateModel(
name='PaymentNotification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('context', models.JSONField()),
('rendered_body', models.TextField(blank=True)),
('template_snapshot', models.JSONField(default=dict)),
('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notification', to='notifications.notificationjob')),
('notice', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')),
('unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
],
),
migrations.CreateModel(
name='PaymentNotificationAttempt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('started_at', models.DateTimeField(auto_now_add=True)),
('finished_at', models.DateTimeField(null=True)),
('success', models.BooleanField(null=True)),
('provider_code', models.CharField(blank=True, max_length=50)),
('provider_id', models.CharField(blank=True, max_length=500)),
('error', models.TextField(blank=True)),
('notification', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attempt_log', to='payments.paymentnotification')),
],
),
migrations.CreateModel(
name='PaymentReceipt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], db_index=True, default='PENDING', max_length=12)),
('submission_key', models.UUIDField(unique=True)),
('current_version', models.PositiveIntegerField(default=1)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_receipts', to=settings.AUTH_USER_MODEL)),
('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='receipts', to='payments.paymentnotice')),
('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
('submitted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='submitted_payment_receipts', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
'permissions': [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')],
},
),
migrations.CreateModel(
name='PaymentReceiptRevision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.PositiveIntegerField()),
('amount', models.BigIntegerField()),
('paid_date', models.DateField()),
('account_snapshot', models.JSONField()),
('origin_other', models.CharField(blank=True, max_length=100)),
('deposit_id', models.CharField(blank=True, max_length=100)),
('tracking_number', models.CharField(blank=True, max_length=100)),
('description', models.TextField(blank=True)),
('destination_mismatch', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('image', models.FileField(max_length=500, upload_to=apps.payments.models.receipt_path)),
('mime_type', models.CharField(max_length=100)),
('sha256', models.CharField(max_length=64)),
('original_name', models.CharField(max_length=255)),
('file_size', models.PositiveBigIntegerField()),
('destination', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')),
('origin_bank', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution')),
('receipt', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='revisions', to='payments.paymentreceipt')),
],
),
migrations.CreateModel(
name='ReceiptApprovalCycle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('policy_snapshot', models.JSONField(default=dict)),
('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], default='PENDING', max_length=12)),
('finalized_at', models.DateTimeField(blank=True, null=True)),
('finalized_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('revision', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='cycle', to='payments.paymentreceiptrevision')),
],
),
migrations.CreateModel(
name='ReceiptApprovalAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_senior', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='payments.receiptapprovalcycle')),
],
),
migrations.AddField(
model_name='paymentnotification',
name='cycle',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.receiptapprovalcycle'),
),
migrations.CreateModel(
name='ReceiptDecision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')], max_length=20)),
('reason', models.TextField(blank=True)),
('actor_name', models.CharField(max_length=150)),
('created_at', models.DateTimeField(auto_now_add=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='decisions', to='payments.receiptapprovalcycle')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='PaymentApprovalPolicy',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=100, verbose_name='عنوان سیاست')),
('is_active', models.BooleanField(default=True)),
('reviewers', models.ManyToManyField(related_name='payment_review_policies', to=settings.AUTH_USER_MODEL, verbose_name='افراد الزامی تأیید')),
('seniors', models.ManyToManyField(blank=True, related_name='payment_senior_policies', to=settings.AUTH_USER_MODEL, verbose_name='تأییدکنندگان ارشد')),
('unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit', verbose_name='واحد (خالی: عمومی)')),
],
options={
'permissions': [('manage_payment_approval_policy', 'Manage payment approval policy')],
'constraints': [models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', False)), fields=('unit',), name='pay_active_unit_policy'), models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', True)), fields=('is_active',), name='pay_active_global_policy')],
},
),
migrations.AddIndex(
model_name='paymentnotice',
index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_bc6e3f_idx'),
),
migrations.AddIndex(
model_name='paymentnotice',
index=models.Index(fields=['owning_unit', '-created_at'], name='payments_pa_owning__f2b6af_idx'),
),
migrations.AddConstraint(
model_name='paymentnotice',
constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_notice_amount'),
),
migrations.AddIndex(
model_name='paymentreceipt',
index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_e266cc_idx'),
),
migrations.AddIndex(
model_name='paymentreceipt',
index=models.Index(fields=['status', '-created_at'], name='payments_pa_status_5cd50b_idx'),
),
migrations.AddConstraint(
model_name='paymentreceiptrevision',
constraint=models.UniqueConstraint(fields=('receipt', 'version'), name='pay_receipt_revision'),
),
migrations.AddConstraint(
model_name='paymentreceiptrevision',
constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_receipt_amount'),
),
migrations.AddConstraint(
model_name='receiptapprovalassignment',
constraint=models.UniqueConstraint(fields=('cycle', 'user', 'is_senior'), name='pay_cycle_assignment'),
),
migrations.AddConstraint(
model_name='receiptdecision',
constraint=models.UniqueConstraint(fields=('cycle', 'user'), name='pay_one_vote_per_user'),
),
]
@@ -0,0 +1,23 @@
from django.db import migrations
BANKS = [["melli","ملی ایران"],["mellat","ملت"],["saderat","صادرات ایران"],["sepah","سپه"],["tejarat","تجارت"],["refah","رفاه کارگران"],["keshavarzi","کشاورزی"],["maskan","مسکن"],["sanat-madan","صنعت و معدن"],["tosee-saderat","توسعه صادرات ایران"],["tosee-taavon","توسعه تعاون"],["post","پست بانک ایران"],["pasargad","پاسارگاد"],["parsian","پارسیان"],["saman","سامان"],["eghtesad-novin","اقتصاد نوین"],["karafarin","کارآفرین"],["sina","سینا"],["shahr","شهر"],["sarmayeh","سرمایه"],["dey","دی"],["gardeshgari","گردشگری"],["iran-zamin","ایران زمین"],["khavarmianeh","خاورمیانه"],["resalat","قرض‌الحسنه رسالت"],["mehr-iran","قرض‌الحسنه مهر ایران"],["iran-venezuela","مشترک ایران و ونزوئلا"],["melal","مؤسسه اعتباری ملل"],["ayandeh","آینده"],["ansar","انصار"],["ghavamin","قوامین"],["hekmat","حکمت ایرانیان"],["mehr-eghtesad","مهر اقتصاد"],["kosar","مؤسسه کوثر"],["noor","مؤسسه نور"],["tosee","مؤسسه توسعه"]]
TAGS = ['recipient_name','company_name','notice_number','receipt_number','amount_rial','invoice_number','payment_link']
def seed(apps, schema_editor):
Bank = apps.get_model('payments', 'BankInstitution')
Template = apps.get_model('notifications', 'SMSTemplate')
for code, name in BANKS:
Bank.objects.get_or_create(code=code, defaults={'name':name, 'historical':code in {'ayandeh','ansar','ghavamin','hekmat','mehr-eghtesad','kosar','noor','tosee'}})
Bank.objects.get_or_create(code='other', defaults={'name': 'سایر'})
for event, title, body in [
('PAYMENT_NOTICE_CREATED','اعلامیه واریز','{recipient_name}، اعلامیه واریز {notice_number} به مبلغ {amount_rial} ریال برای شما صادر شد.\nمشاهده اطلاعات: {payment_link}\n{company_name}'),
('PAYMENT_RECEIPT_APPROVED','تأیید فیش','{recipient_name}، فیش واریز شما به شماره {receipt_number} و مبلغ {amount_rial} ریال تأیید شد.\nپیگیری: {payment_link}\n{company_name}'),
]:
Template.objects.get_or_create(unit=None,event_type=event, defaults={'title':title,'body':body,'allowed_tags':TAGS,'is_active':True})
apps.get_model('payments','PaymentAccessConfiguration').objects.get_or_create(singleton=1)
class Migration(migrations.Migration):
dependencies = [('payments','0001_initial'), ('notifications','0017_alter_smstemplate_event_type')]
operations = [migrations.RunPython(seed, migrations.RunPython.noop)]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.1 on 2026-10-05 11:05
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0002_initial_configuration'),
]
operations = [
migrations.AddField(
model_name='paymentnotice',
name='issuance_key',
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 5.1.1 on 2026-10-06 05:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0003_paymentnotice_issuance_key'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PaymentAccessLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_hash', models.CharField(editable=False, max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('revoked_at', models.DateTimeField(blank=True, null=True)),
('view_count', models.PositiveIntegerField(default=0)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')),
('receipt', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceipt')),
('submitted_receipts', models.ManyToManyField(blank=True, related_name='access_links', to='payments.paymentreceipt')),
],
options={
'verbose_name': 'لینک موقت پرداخت',
'verbose_name_plural': 'لینک\u200cهای موقت پرداخت',
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='paymentnotification',
name='access_link',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentaccesslink'),
),
migrations.AddConstraint(
model_name='paymentaccesslink',
constraint=models.CheckConstraint(condition=models.Q(('notice__isnull', False), ('receipt__isnull', False), _connector='OR'), name='payment_link_has_target'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-06 08:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0004_paymentaccesslink_paymentnotification_access_link_and_more'),
]
operations = [
migrations.AddField(
model_name='paymentnotice',
name='deleted_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]
@@ -0,0 +1,17 @@
from django.db import migrations
def normalize_existing_identifiers(apps, schema_editor):
digit_map = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
for name in ('PaymentNotice', 'PaymentReceiptRevision'):
records = apps.get_model('payments', name).objects.using(schema_editor.connection.alias)
for pk, original in records.exclude(deposit_id='').values_list('pk', 'deposit_id').iterator(chunk_size=500):
value = original.translate(digit_map).strip()
normalized = (value.lstrip('0') or '0') if value else ''
if normalized != original:
records.filter(pk=pk).update(deposit_id=normalized)
class Migration(migrations.Migration):
dependencies = [('payments', '0005_paymentnotice_deleted_at')]
operations = [migrations.RunPython(normalize_existing_identifiers, migrations.RunPython.noop)]
@@ -0,0 +1,73 @@
# Generated by Django 5.1.1 on 2026-10-07 09:49
import apps.payments.alert_templates
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0018_alter_smstemplate_body'),
('payments', '0006_normalize_deposit_identifiers'),
]
operations = [
migrations.CreateModel(
name='PaymentReceiptAlertConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bale_enabled', models.BooleanField(default=False, verbose_name='ارسال تصویر فیش به بله')),
('bale_token', models.CharField(blank=True, max_length=500, verbose_name='توکن ربات بله پرداخت')),
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='شناسه گروه بله')),
('bale_caption', models.TextField(default='فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\nمبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\nشماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=3000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن همراه تصویر')),
('sms_enabled', models.BooleanField(default=False, verbose_name='پیامک فیش جدید به مدیران')),
('sms_template', models.TextField(default='فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=1000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن پیامک مدیران')),
('notify_revisions', models.BooleanField(default=True, verbose_name='اطلاع\u200cرسانی فیش اصلاح\u200cشده مشتری')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='آخرین ویرایش')),
],
options={
'verbose_name': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
'verbose_name_plural': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
'permissions': [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')],
'constraints': [models.CheckConstraint(condition=models.Q(('pk', 1)), name='pay_receipt_alert_singleton')],
},
),
migrations.CreateModel(
name='PaymentReceiptAlertRecipient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=150, verbose_name='نام مدیر')),
('mobile', models.CharField(max_length=20, verbose_name='شماره موبایل مدیر')),
('is_active', models.BooleanField(default=True, verbose_name='دریافت پیامک فعال')),
('configuration', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipients', to='payments.paymentreceiptalertconfiguration')),
],
options={
'verbose_name': 'مدیر دریافت\u200cکننده پیامک فیش',
'verbose_name_plural': 'مدیران دریافت\u200cکننده پیامک فیش',
},
),
migrations.CreateModel(
name='PaymentReceiptAlert',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recipient_name', models.CharField(blank=True, max_length=150, verbose_name='نام گیرنده هنگام ثبت')),
('recipient_mobile', models.CharField(blank=True, max_length=20, verbose_name='موبایل گیرنده هنگام ثبت')),
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='گروه هنگام ثبت')),
('context', models.JSONField()),
('message_template', models.TextField(verbose_name='قالب هنگام ثبت')),
('rendered_body', models.TextField(blank=True, verbose_name='متن ارسال')),
('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='receipt_alert', to='notifications.notificationjob')),
('revision', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='alerts', to='payments.paymentreceiptrevision')),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceiptalertrecipient')),
],
options={
'verbose_name': 'سابقه اعلان فیش مشتری',
'verbose_name_plural': 'سوابق اعلان فیش مشتری',
},
),
migrations.AddConstraint(
model_name='paymentreceiptalertrecipient',
constraint=models.UniqueConstraint(fields=('configuration', 'mobile'), name='pay_receipt_alert_mobile'),
),
]
@@ -0,0 +1,12 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [('payments', '0007_receipt_alerts')]
operations = [
migrations.AddField(
model_name='paymentreceiptrevision', name='depositor_name',
field=models.CharField('نام واریزکننده', max_length=150, default=''),
preserve_default=False,
),
]
Whitespace-only changes.
+327
View File
@@ -0,0 +1,327 @@
"""Financial records are independent of tickets; issued instructions and evidence are snapshots."""
import uuid
from pathlib import Path
from django.conf import settings
from django.db import models
from apps.core.models import PublicIdModel, TimeStampedModel
from .deposit_identifier import normalize_deposit_identifier
from .alert_templates import DEFAULT_RECEIPT_SMS, DEFAULT_RECEIPT_CAPTION, ALERT_TEMPLATE_HELP, validate_alert_template
MAX_AMOUNT = 9_007_199_254_740_991
class BankInstitution(models.Model):
code = models.SlugField(unique=True)
name = models.CharField(max_length=100)
is_active = models.BooleanField(default=True)
historical = models.BooleanField(default=False)
class Meta:
ordering = ['historical', 'name']
def __str__(self):
return self.name
class CompanyBankAccount(PublicIdModel, TimeStampedModel):
name = models.CharField('نام نمایشی حساب', max_length=120)
bank = models.ForeignKey(BankInstitution, on_delete=models.PROTECT, verbose_name='بانک')
holder = models.CharField('صاحب حساب', max_length=150)
account_number = models.CharField('شماره حساب', max_length=40, blank=True)
iban = models.CharField('شبا', max_length=26, blank=True)
card_number = models.CharField('شماره کارت', max_length=16, blank=True)
requires_deposit_id = models.BooleanField('شناسه واریز الزامی', default=False)
is_active = models.BooleanField('فعال کلی', default=True)
available_to_staff = models.BooleanField('قابل انتخاب کارشناسان', default=True)
available_to_customers = models.BooleanField('قابل انتخاب مشتریان', default=True)
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['sort_order', 'name', 'pk']
permissions = [('manage_company_bankaccounts', 'Manage company bank accounts')]
def __str__(self):
return self.name
def snapshot(self):
return {key: getattr(self, key) for key in ('name', 'holder', 'account_number', 'iban', 'card_number', 'requires_deposit_id')} | {'bank': self.bank.name, 'id': str(self.public_id)}
class PaymentAccessConfiguration(models.Model):
singleton = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
open_view = models.BooleanField('مشاهده برای همه کارشناسان فعال', default=True)
open_create = models.BooleanField('ایجاد برای همه کارشناسان فعال', default=True)
class Meta:
permissions = [('manage_payment_access', 'Manage payment access')]
class PaymentApprovalPolicy(TimeStampedModel):
name = models.CharField('عنوان سیاست', max_length=100)
unit = models.ForeignKey('staff.OrganizationalUnit', null=True, blank=True, on_delete=models.PROTECT, verbose_name='واحد (خالی: عمومی)')
reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_review_policies', verbose_name='افراد الزامی تأیید')
seniors = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_senior_policies', blank=True, verbose_name='تأییدکنندگان ارشد')
is_active = models.BooleanField(default=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['unit'], condition=models.Q(is_active=True, unit__isnull=False), name='pay_active_unit_policy'),
models.UniqueConstraint(fields=['is_active'], condition=models.Q(is_active=True, unit__isnull=True), name='pay_active_global_policy'),
]
permissions = [('manage_payment_approval_policy', 'Manage payment approval policy')]
def __str__(self):
return self.name
class PaymentNotice(PublicIdModel, TimeStampedModel):
issuance_key = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_notices')
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='issued_payment_notices')
owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
account = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
account_snapshot = models.JSONField()
subject = models.CharField(max_length=200)
amount = models.BigIntegerField()
deposit_id = models.CharField(max_length=100, blank=True)
invoice_number = models.CharField(max_length=100, blank=True)
description = models.TextField(blank=True)
cancelled_at = models.DateTimeField(null=True, blank=True)
deleted_at = models.DateTimeField(null=True, blank=True, editable=False)
def save(self, *args, **kwargs):
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
super().save(*args, **kwargs)
class Meta:
ordering = ['-created_at', '-pk']
constraints = [models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_notice_amount')]
indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['owning_unit', '-created_at'])]
@property
def number(self):
return f'PN-{self.public_id.hex[:12].upper()}'
class PaymentReceipt(PublicIdModel, TimeStampedModel):
class Status(models.TextChoices):
PENDING = 'PENDING', 'در انتظار بررسی'
PARTIAL = 'PARTIAL', 'تأیید ناقص'
APPROVED = 'APPROVED', 'تأیید شده'
REJECTED = 'REJECTED', 'رد شده'
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_receipts')
submitted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='submitted_payment_receipts')
notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT, related_name='receipts')
owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
status = models.CharField(max_length=12, choices=Status.choices, default=Status.PENDING, db_index=True)
submission_key = models.UUIDField(unique=True)
current_version = models.PositiveIntegerField(default=1)
class Meta:
ordering = ['-created_at', '-pk']
indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['status', '-created_at'])]
permissions = [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')]
@property
def number(self):
return f'PR-{self.public_id.hex[:12].upper()}'
def receipt_path(instance, filename):
return f'payments/{instance.created_at:%Y/%m}/{uuid.uuid4().hex}{Path(filename).suffix.lower()}'
class PaymentReceiptRevision(models.Model):
receipt = models.ForeignKey(PaymentReceipt, on_delete=models.PROTECT, related_name='revisions')
version = models.PositiveIntegerField()
depositor_name = models.CharField('نام واریزکننده', max_length=150)
amount = models.BigIntegerField()
paid_date = models.DateField()
destination = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
account_snapshot = models.JSONField()
origin_bank = models.ForeignKey(BankInstitution, null=True, blank=True, on_delete=models.PROTECT)
origin_other = models.CharField(max_length=100, blank=True)
deposit_id = models.CharField(max_length=100, blank=True)
tracking_number = models.CharField(max_length=100, blank=True)
description = models.TextField(blank=True)
destination_mismatch = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
image = models.FileField(upload_to=receipt_path, max_length=500)
mime_type = models.CharField(max_length=100)
sha256 = models.CharField(max_length=64)
original_name = models.CharField(max_length=255)
file_size = models.PositiveBigIntegerField()
def save(self, *args, **kwargs):
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
super().save(*args, **kwargs)
class Meta:
constraints = [models.UniqueConstraint(fields=['receipt', 'version'], name='pay_receipt_revision'), models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_receipt_amount')]
class ReceiptApprovalCycle(models.Model):
revision = models.OneToOneField(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='cycle')
policy_snapshot = models.JSONField(default=dict)
status = models.CharField(max_length=12, choices=PaymentReceipt.Status.choices, default=PaymentReceipt.Status.PENDING)
finalized_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT)
finalized_at = models.DateTimeField(null=True, blank=True)
class ReceiptApprovalAssignment(models.Model):
cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='assignments')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
is_senior = models.BooleanField(default=False)
class Meta:
constraints = [models.UniqueConstraint(fields=['cycle', 'user', 'is_senior'], name='pay_cycle_assignment')]
class ReceiptDecision(models.Model):
cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='decisions')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
action = models.CharField(max_length=20, choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')])
reason = models.TextField(blank=True)
actor_name = models.CharField(max_length=150)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [models.UniqueConstraint(fields=['cycle', 'user'], name='pay_one_vote_per_user')]
class PaymentAuditEvent(models.Model):
actor = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL)
actor_name = models.CharField(max_length=150)
action = models.CharField(max_length=50)
object_type = models.CharField(max_length=50)
object_id = models.CharField(max_length=64)
details = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ['-created_at', '-pk']
permissions = [('view_payment_audit', 'View financial audit')]
class PaymentAccessLink(models.Model):
"""Revocable capability, separate from customer login and ticket magic links."""
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT)
receipt = models.ForeignKey(PaymentReceipt, null=True, blank=True, on_delete=models.PROTECT)
token_hash = models.CharField(max_length=64, unique=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
revoked_at = models.DateTimeField(null=True, blank=True)
submitted_receipts = models.ManyToManyField(PaymentReceipt, blank=True, related_name='access_links')
view_count = models.PositiveIntegerField(default=0)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
constraints = [models.CheckConstraint(
condition=models.Q(notice__isnull=False) | models.Q(receipt__isnull=False),
name='payment_link_has_target')]
verbose_name = 'لینک موقت پرداخت'
verbose_name_plural = 'لینک‌های موقت پرداخت'
class PaymentNotification(models.Model):
job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='payment_notification')
notice = models.ForeignKey(PaymentNotice, null=True, on_delete=models.PROTECT)
cycle = models.ForeignKey(ReceiptApprovalCycle, null=True, on_delete=models.PROTECT)
unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
context = models.JSONField()
rendered_body = models.TextField(blank=True)
template_snapshot = models.JSONField(default=dict)
access_link = models.ForeignKey(PaymentAccessLink, null=True, blank=True, on_delete=models.PROTECT)
class PaymentNotificationAttempt(models.Model):
notification = models.ForeignKey(PaymentNotification, on_delete=models.PROTECT, related_name='attempt_log')
started_at = models.DateTimeField(auto_now_add=True)
finished_at = models.DateTimeField(null=True)
success = models.BooleanField(null=True)
provider_code = models.CharField(max_length=50, blank=True)
provider_id = models.CharField(max_length=500, blank=True)
error = models.TextField(blank=True)
class PaymentReceiptAlertConfiguration(models.Model):
"""Independent, initially disabled destinations for customer receipt uploads."""
bale_enabled = models.BooleanField('ارسال تصویر فیش به بله', default=False)
bale_token = models.CharField('توکن ربات بله پرداخت', max_length=500, blank=True)
bale_chat_id = models.CharField('شناسه گروه بله', max_length=150, blank=True)
bale_caption = models.TextField('متن همراه تصویر', default=DEFAULT_RECEIPT_CAPTION,
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=3000)
sms_enabled = models.BooleanField('پیامک فیش جدید به مدیران', default=False)
sms_template = models.TextField('متن پیامک مدیران', default=DEFAULT_RECEIPT_SMS,
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=1000)
notify_revisions = models.BooleanField('اطلاع‌رسانی فیش اصلاح‌شده مشتری', default=True)
updated_at = models.DateTimeField('آخرین ویرایش', auto_now=True)
class Meta:
verbose_name = 'تنظیمات اطلاع‌رسانی فیش مشتری'
verbose_name_plural = 'تنظیمات اطلاع‌رسانی فیش مشتری'
permissions = [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')]
constraints = [models.CheckConstraint(condition=models.Q(pk=1), name='pay_receipt_alert_singleton')]
def __str__(self):
return 'اطلاع‌رسانی فیش مشتری'
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
def clean(self):
from django.core.exceptions import ValidationError
self.pk = 1
self.bale_token = self.bale_token.strip()
self.bale_chat_id = self.bale_chat_id.strip()
if self.bale_token and any(char.isspace() or char in '/?#' for char in self.bale_token):
raise ValidationError({'bale_token': 'توکن معتبر ربات را وارد کنید.'})
if self.bale_enabled and (not self.bale_token or not self.bale_chat_id):
raise ValidationError('برای فعال کردن بله، توکن ربات و شناسه گروه را تکمیل کنید.')
class PaymentReceiptAlertRecipient(models.Model):
configuration = models.ForeignKey(PaymentReceiptAlertConfiguration, on_delete=models.PROTECT, related_name='recipients')
name = models.CharField('نام مدیر', max_length=150)
mobile = models.CharField('شماره موبایل مدیر', max_length=20)
is_active = models.BooleanField('دریافت پیامک فعال', default=True)
class Meta:
verbose_name = 'مدیر دریافت‌کننده پیامک فیش'
verbose_name_plural = 'مدیران دریافت‌کننده پیامک فیش'
constraints = [models.UniqueConstraint(fields=['configuration', 'mobile'], name='pay_receipt_alert_mobile')]
def __str__(self):
return self.name
def clean(self):
from apps.accounts.models import normalize_mobile
from django.core.exceptions import ValidationError
from .validators import digits
try:
self.mobile = normalize_mobile(digits(self.mobile))
except ValueError:
raise ValidationError({'mobile': 'شماره موبایل معتبر مدیر را وارد کنید.'})
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
class PaymentReceiptAlert(models.Model):
job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='receipt_alert')
revision = models.ForeignKey(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='alerts')
recipient = models.ForeignKey(PaymentReceiptAlertRecipient, null=True, blank=True, on_delete=models.PROTECT)
recipient_name = models.CharField('نام گیرنده هنگام ثبت', max_length=150, blank=True)
recipient_mobile = models.CharField('موبایل گیرنده هنگام ثبت', max_length=20, blank=True)
bale_chat_id = models.CharField('گروه هنگام ثبت', max_length=150, blank=True)
context = models.JSONField()
message_template = models.TextField('قالب هنگام ثبت')
rendered_body = models.TextField('متن ارسال', blank=True)
class Meta:
verbose_name = 'سابقه اعلان فیش مشتری'
verbose_name_plural = 'سوابق اعلان فیش مشتری'
+91
View File
@@ -0,0 +1,91 @@
from django.conf import settings
from django.db import transaction
from django.urls import reverse
from django.utils import timezone
from apps.notifications.services import enqueue
from .models import PaymentNotification, PaymentNotificationAttempt
EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED', 'PAYMENT_RECEIPT_REJECTED'}
TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link', 'rejection_reason']
def notice_sms_pending(notice):
return PaymentNotification.objects.filter(notice=notice, job__status__in=['PENDING', 'PROCESSING', 'RETRYING']).exists()
def context(customer, notice=None, receipt=None, amount=0):
profile = getattr(customer, 'customer_profile', None)
return {'recipient_name': str(profile) if profile else customer.username, 'company_name': settings.COMPANY_NAME,
'notice_number': notice.number if notice else '', 'receipt_number': receipt.number if receipt else '',
'amount_rial': f'{amount:,}', 'invoice_number': notice.invoice_number if notice else '', 'rejection_reason': '',
'payment_link': settings.PUBLIC_BASE_URL + (reverse('payments:receipt_detail', args=[receipt.public_id]) if receipt else reverse('payments:notices') + f'?notice={notice.public_id}')}
def queue_notice(notice):
job = enqueue('SMS', 'PAYMENT_NOTICE_CREATED', None, f'payment-notice:{notice.public_id}', customer=notice.customer, staff_user=notice.created_by)
PaymentNotification.objects.get_or_create(job=job, defaults={'notice': notice, 'unit': notice.owning_unit, 'context': context(notice.customer, notice=notice, amount=notice.amount)})
def queue_approval(receipt, cycle):
job = enqueue('SMS', 'PAYMENT_RECEIPT_APPROVED', None, f'payment-approved:{cycle.pk}', customer=receipt.customer, staff_user=cycle.finalized_by)
PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit, 'context': context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)})
def queue_rejection(receipt, cycle, reason):
job = enqueue('SMS', 'PAYMENT_RECEIPT_REJECTED', None, f'payment-rejected:{cycle.pk}', customer=receipt.customer, staff_user=cycle.finalized_by)
details = context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)
PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit,
'context': {**details, 'rejection_reason': reason}})
def payment_sms_body(job, notification):
"""Editable templates must always include the customer's rejection reason."""
from apps.notifications.management.commands.process_notifications import _render_sms
body = _render_sms(job.event_type, notification.context, notification.unit)
reason = notification.context.get('rejection_reason', '')
if reason and reason not in body:
body += '\nدلیل کارشناس: ' + reason
return body
def send_payment_sms(job, provider=None, provider_factory=None):
from apps.notifications.models import SMSTemplate
notification = PaymentNotification.objects.get(job=job)
# Persist the attempt before rendering/provider construction: failed preparation is history too.
attempt = PaymentNotificationAttempt.objects.create(notification=notification)
phase = 'آماده‌سازی قالب پیامک'
try:
with transaction.atomic():
notification = PaymentNotification.objects.select_for_update().get(pk=notification.pk)
if not notification.access_link_id:
from .access import issue_payment_link
receipt = notification.cycle.revision.receipt if notification.cycle_id else None
link, url = issue_payment_link(notice=notification.notice, receipt=receipt)
notification.access_link = link
old_url = notification.context.get('payment_link', '')
if old_url and notification.rendered_body:
notification.rendered_body = notification.rendered_body.replace(old_url, url)
notification.context = {**notification.context, 'payment_link': url}
notification.save(update_fields=['context', 'access_link', 'rendered_body'])
if not notification.rendered_body:
body = payment_sms_body(job, notification)
template = SMSTemplate.objects.filter(event_type=job.event_type, is_active=True, unit=notification.unit).first() if notification.unit_id else None
template = template or SMSTemplate.objects.get(event_type=job.event_type, is_active=True, unit__isnull=True)
notification.rendered_body = body
notification.template_snapshot = {'id': template.pk, 'updated_at': template.updated_at.isoformat()}
notification.save(update_fields=['rendered_body', 'template_snapshot', 'context', 'access_link'])
phase = 'ارتباط با ارائه‌دهنده پیامک'
provider = provider if provider is not None else provider_factory()
result = provider.send_message(job.customer.mobile, notification.rendered_body)
attempt.success = result.success
attempt.provider_id = str(result.provider_id or '')[:500]
attempt.provider_code = str(result.provider_code or '')[:50]
attempt.error = str(result.error or '')[:1000]
return result, None
except Exception:
attempt.success = False
attempt.error = 'خطا در ' + phase
raise
finally:
attempt.finished_at = timezone.now()
attempt.save()
+86
View File
@@ -0,0 +1,86 @@
from django.db.models import Q
from .models import PaymentAccessConfiguration, PaymentNotice
def is_staff(user):
profile = getattr(user, 'staff_profile', None)
return bool(user.is_authenticated and user.is_active and user.account_type == 'STAFF' and (profile is None or profile.is_active))
def has_permission(user, codename, unit=None):
if not is_staff(user):
return False
if user.has_perm(f'payments.{codename}'):
return True
if unit is None or not unit.is_active:
return False
return user.unit_memberships.filter(is_active=True, unit=unit).filter(
Q(permissions__content_type__app_label='payments', permissions__codename=codename) |
Q(unit__permissions__content_type__app_label='payments', unit__permissions__codename=codename)
).exists()
def config():
# No get_or_create on a read request; defaults keep initial view/create open only.
return PaymentAccessConfiguration.objects.first() or PaymentAccessConfiguration()
def can_create(user, unit=None):
if unit is None and is_staff(user):
from apps.tickets.policies import TicketPolicy
unit = TicketPolicy.primary_unit(user)
return is_staff(user) and (config().open_create or has_permission(user, 'add_paymentnotice', unit))
def can_manage_banks(user):
profile = getattr(user, 'staff_profile', None)
return is_staff(user) and (user.is_superuser or getattr(profile, 'role', '') == 'CEO' or has_permission(user, 'manage_company_bankaccounts'))
def may_delete_notice(user, notice):
role = getattr(getattr(user, 'staff_profile', None), 'role', '')
return is_staff(user) and (notice.created_by_id == user.pk or user.is_superuser or role in {'CEO', 'GENERAL_ADMIN'})
def can_delete_notice(user, notice):
return may_delete_notice(user, notice) and not notice.deleted_at and not notice.receipts.exists()
def notice_actions(queryset, user):
"""Allow deletion actors to reach this notice without broadening other lists."""
queryset = queryset.filter(deleted_at__isnull=True)
if not is_staff(user):
return queryset.none()
role = getattr(getattr(user, 'staff_profile', None), 'role', '')
if user.is_superuser or role in {'CEO', 'GENERAL_ADMIN'}:
return queryset
return queryset.filter(Q(created_by=user) | Q(pk__in=visible(queryset, user).values('pk')))
def visible(queryset, user):
if queryset.model == PaymentNotice:
queryset = queryset.filter(deleted_at__isnull=True)
if not user.is_authenticated or not user.is_active:
return queryset.none()
if user.account_type == 'CUSTOMER':
return queryset.filter(customer=user)
if not is_staff(user):
return queryset.none()
if config().open_view or user.has_perm('payments.view_all_payments') or user.is_superuser:
return queryset
permission = f'view_{queryset.model._meta.model_name}'
if user.has_perm(f'payments.{permission}'):
return queryset
units = user.unit_memberships.filter(is_active=True, unit__is_active=True).filter(
Q(permissions__content_type__app_label='payments', permissions__codename=permission) |
Q(unit__permissions__content_type__app_label='payments', unit__permissions__codename=permission)
).values_list('unit_id', flat=True)
return queryset.filter(owning_unit_id__in=units).distinct()
def can_decide(user, receipt, cycle, senior=False):
# Selection in the approval policy grants the role for this cycle. Requiring
# an additional Django permission made selected non-admin staff unable to vote.
return (is_staff(user) and receipt.submitted_by_id != user.pk and
visible(type(receipt).objects.filter(pk=receipt.pk), user).exists() and
cycle.assignments.filter(user=user, is_senior=senior).exists())
+106
View File
@@ -0,0 +1,106 @@
"""Receipt-only outbox handlers. No network operation runs during receipt registration."""
import logging
from pathlib import Path
from django.conf import settings
from django.db import transaction
from django.urls import reverse
from apps.core.templatetags.jalali import jalali_date, jalali_datetime
from apps.integrations.sms.base import SMSResult
from apps.notifications.services import enqueue
from .alert_templates import validate_alert_template
from .models import PaymentReceiptAlert, PaymentReceiptAlertConfiguration
logger = logging.getLogger(__name__)
RECEIPT_ALERT_EVENT = 'PAYMENT_RECEIPT_RECEIVED'
def receipt_alert_context(revision):
from .identity import receipt_identity
customer = revision.receipt.customer
return {'customer_name': receipt_identity(customer, revision.depositor_name),
'depositor_name': revision.depositor_name,
'customer_mobile': customer.local_mobile, 'amount_rial': f'{revision.amount:,}',
'received_at': jalali_datetime(revision.created_at), 'paid_date': jalali_date(revision.paid_date),
'receipt_number': revision.receipt.number, 'revision_version': str(revision.version), 'manager_name': '',
'receipt_link': settings.PUBLIC_BASE_URL.rstrip('/') + reverse('staff_payments:receipt_detail', args=[revision.receipt.public_id])}
def queue_receipt_alerts(revision, actor):
# Submission/revision services authorize staff before reaching this queue.
# Keep customer submissions restricted to their own receipt.
if actor.account_type != 'STAFF' and (actor.account_type != 'CUSTOMER' or actor.pk != revision.receipt.customer_id):
return
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
if not configuration or not (configuration.bale_enabled or configuration.sms_enabled) or (revision.version > 1 and not configuration.notify_revisions):
return
context = receipt_alert_context(revision)
destinations = []
if configuration.bale_enabled:
destinations.append(('BALE', None, configuration.bale_caption))
if configuration.sms_enabled:
destinations.extend(('SMS', recipient, configuration.sms_template) for recipient in configuration.recipients.filter(is_active=True))
for channel, recipient, template in destinations:
try:
with transaction.atomic():
job = enqueue(channel, RECEIPT_ALERT_EVENT, None,
f'receipt-alert:{revision.pk}:{channel}:{recipient.pk if recipient else "group"}', customer=revision.receipt.customer)
PaymentReceiptAlert.objects.get_or_create(job=job, defaults={'revision': revision, 'recipient': recipient,
'recipient_name': recipient.name if recipient else '', 'recipient_mobile': recipient.mobile if recipient else '',
'bale_chat_id': configuration.bale_chat_id if channel == 'BALE' else '', 'message_template': template,
'context': {**context, 'manager_name': recipient.name if recipient else ''}})
except Exception as error:
logger.error('Receipt alert destination queue failed revision=%s channel=%s type=%s', revision.pk, channel, type(error).__name__)
def safe_queue_receipt_alerts(revision, actor):
# A failed configuration lookup/queue write rolls back only this savepoint.
try:
with transaction.atomic():
queue_receipt_alerts(revision, actor)
except Exception as error:
logger.error('Receipt alert queue failed revision=%s type=%s', revision.pk, type(error).__name__)
def prepare_receipt_alert(job):
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
alert = PaymentReceiptAlert.objects.select_related('recipient').get(job=job)
if job.channel == 'BALE':
enabled = configuration and configuration.bale_enabled and configuration.bale_chat_id == alert.bale_chat_id
else:
enabled = configuration and configuration.sms_enabled and alert.recipient and alert.recipient.is_active and alert.recipient.mobile == alert.recipient_mobile
if enabled:
return True
job.status = 'CANCELLED'
job.last_error = 'اطلاع‌رسانی این کانال یا گیرنده غیرفعال شده یا مقصد تغییر کرده است.'
job.save(update_fields=['status', 'last_error'])
return False
def send_receipt_alert(job, sms_provider_factory=None):
"""A destination failure is returned to the worker's existing bounded retry policy."""
try:
alert = PaymentReceiptAlert.objects.select_related('revision').get(job=job)
if not alert.rendered_body:
validate_alert_template(alert.message_template)
alert.rendered_body = alert.message_template.format(**alert.context)
if alert.context.get('depositor_name') and '{customer_name}' not in alert.message_template:
alert.rendered_body += '\nمشتری / واریزکننده: ' + alert.context['customer_name']
alert.save(update_fields=['rendered_body'])
if job.channel == 'SMS':
from apps.integrations.sms.providers import get_sms_provider
return (sms_provider_factory or get_sms_provider)().send_message(alert.recipient_mobile, alert.rendered_body)
from apps.integrations.bale.providers import BaleBotProvider
configuration = PaymentReceiptAlertConfiguration.objects.get(pk=1)
if not configuration.bale_token:
return SMSResult(False, error='توکن ربات بله پرداخت تنظیم نشده است.')
if len(alert.rendered_body) > 4096:
return SMSResult(False, error='متن همراه تصویر بله بیش از ۴۰۹۶ کاراکتر است.')
with alert.revision.image.open('rb') as image:
return BaleBotProvider().send_photo(alert.bale_chat_id, image, alert.rendered_body,
token=configuration.bale_token, filename=f'receipt-{alert.revision.pk}{Path(alert.revision.image.name).suffix}',
mime_type=alert.revision.mime_type)
except Exception as error:
# HTTP exception strings can contain credentials in the request URL. Never persist them.
return SMSResult(False, error=f'خطا در ارسال اعلان فیش ({type(error).__name__})')
+145
View File
@@ -0,0 +1,145 @@
"""Bounded-query projections use only the current evidence revision for financial totals."""
from datetime import datetime, time, timedelta
from django.urls import reverse
from django.db.models import BigIntegerField, Count, F, OuterRef, Q, Subquery, Sum, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from .models import PaymentReceipt, PaymentReceiptRevision
def daily_payment_summary(receipts, notices):
"""Summarize permission-scoped, unfiltered querysets in two aggregate queries.
Submission means initial receipt creation. Final outcomes use the current
revision's finalization time, including receipts submitted on earlier days.
Superseded revisions and partial votes never count as final outcomes.
"""
local_timezone = timezone.get_default_timezone()
today = timezone.localdate(timezone=local_timezone)
start = timezone.make_aware(datetime.combine(today, time.min), local_timezone)
end = timezone.make_aware(datetime.combine(today + timedelta(days=1), time.min), local_timezone)
current = PaymentReceiptRevision.objects.filter(
receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
receipts = receipts.annotate(
summary_amount=Subquery(current.values('amount')[:1]),
summary_finalized_at=Subquery(current.values('cycle__finalized_at')[:1]))
submitted = Q(created_at__gte=start, created_at__lt=end)
finalized = Q(summary_finalized_at__gte=start, summary_finalized_at__lt=end)
approved = finalized & Q(status=PaymentReceipt.Status.APPROVED)
rejected = finalized & Q(status=PaymentReceipt.Status.REJECTED)
pending = Q(status__in=[PaymentReceipt.Status.PENDING, PaymentReceipt.Status.PARTIAL])
summary = receipts.aggregate(
submitted_count=Count('pk', filter=submitted),
submitted_amount=Sum('summary_amount', filter=submitted, default=0),
approved_count=Count('pk', filter=approved),
approved_amount=Sum('summary_amount', filter=approved, default=0),
rejected_count=Count('pk', filter=rejected),
rejected_amount=Sum('summary_amount', filter=rejected, default=0),
pending_count=Count('pk', filter=pending),
pending_amount=Sum('summary_amount', filter=pending, default=0))
summary['notice_count'] = notices.filter(created_at__gte=start, created_at__lt=end).count()
summary['date'] = today
return summary
def notice_sms_history(notice):
"""Separate gateway acceptance from final handset delivery; preserve every send attempt."""
from django.db.models import Prefetch
from .models import PaymentNotificationAttempt
notifications = notice.paymentnotification_set.select_related('job__staff_user__staff_profile').prefetch_related(
Prefetch('attempt_log', queryset=PaymentNotificationAttempt.objects.order_by('-started_at', '-pk'), to_attr='send_attempts')
).order_by('-job__created_at', '-pk')
for notification in notifications:
job = notification.job
notification.manual_resend = ':resend:' in job.dedupe_key
notification.delivery_class = 'info'
if job.status != 'SENT':
notification.delivery_label = 'هنوز ارسال موفق ثبت نشده است.'
elif not job.provider_message_id:
notification.delivery_label = 'شناسه رهگیری ارائه نشده؛ دریافت توسط گوشی قابل تأیید نیست.'
elif not job.delivery_status:
notification.delivery_label = 'دریافت توسط گوشی قابل تأیید نیست.'
elif job.delivery_is_final:
notification.delivery_label = job.get_delivery_status_display()
notification.delivery_class = 'approved' if job.delivery_status == 'DELIVERED' else 'rejected'
elif job.delivery_status == 'PENDING':
notification.delivery_label = 'در انتظار استعلام دریافت توسط گوشی'
else:
suffix = 'در حال پیگیری' if job.delivery_next_check_at else 'دریافت توسط گوشی تأیید نشده است'
notification.delivery_label = f'نتیجه غیرقطعی: {job.get_delivery_status_display()}؛ {suffix}'
return notifications
def notices_with_totals(queryset):
approved = PaymentReceiptRevision.objects.filter(
receipt__notice_id=OuterRef('pk'), receipt__status='APPROVED',
version=F('receipt__current_version'), destination_mismatch=False
).values('receipt__notice_id').annotate(total=Sum('amount')).values('total')
return queryset.annotate(approved_total=Coalesce(Subquery(approved), Value(0), output_field=BigIntegerField()))
def receipts_with_current_values(queryset):
current = PaymentReceiptRevision.objects.filter(receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
return queryset.annotate(current_amount=Subquery(current.values('amount')[:1]),
current_depositor_name=Subquery(current.values('depositor_name')[:1]),
current_paid_date=Subquery(current.values('paid_date')[:1]))
def notice_form_data(notice, staff=False):
"""Customer-owned notice summary; account numbers stay on the notice page."""
return {'id': str(notice.pk), 'subject': notice.subject, 'amount': str(notice.amount),
'account': notice.account_id, 'deposit': notice.deposit_id,
'invoice': notice.invoice_number,
'url': reverse('staff_payments:notice_detail' if staff else 'payments:notice_detail', args=[notice.public_id])}
def receipt_review_summaries(receipts):
"""Attach current-version decisions in one query, preserving snapshot actor names."""
from .models import ReceiptDecision
receipts = list(receipts)
indexed = {receipt.pk: receipt for receipt in receipts}
for receipt in receipts:
receipt.review_decisions = []
if indexed:
decisions = ReceiptDecision.objects.filter(
cycle__revision__receipt_id__in=indexed,
cycle__revision__version=F('cycle__revision__receipt__current_version')
).select_related('cycle__revision').order_by('created_at', 'pk')
for decision in decisions:
indexed[decision.cycle.revision.receipt_id].review_decisions.append(decision)
return receipts
def attach_notice_receipts(notices, visible_receipts):
"""Only fetch linked receipts visible to the current viewer, for this page."""
notices = list(notices)
indexed = {notice.pk: notice for notice in notices}
for notice in notices:
notice.related_receipts = []
if indexed:
receipts = receipt_review_summaries(receipts_with_current_values(
visible_receipts.filter(notice_id__in=indexed)))
for receipt in receipts:
indexed[receipt.notice_id].related_receipts.append(receipt)
return notices
def recent_pending_notice_choices(form, retain_original=None):
"""Limit the visible menu, keeping server-side ownership validation unsliced.
An explicitly selected pending notice replaces the last option when needed.
A revision retains its original notice even if it has since been settled.
This does not change which notices the financial service may validate.
"""
field = form.fields['notice_id']
pending = notices_with_totals(field.queryset).filter(amount__gt=F('approved_total'))
available = list(pending.order_by('-created_at', '-pk')[:10])
selected = form['notice_id'].value()
if selected and not any(str(n.pk) == str(selected) for n in available):
source = field.queryset if str(selected) == str(retain_original) else pending
selected_notice = source.filter(pk=selected).first() if str(selected).isdecimal() and len(str(selected)) <= 19 and int(selected) <= 9223372036854775807 else None
if selected_notice:
available = [selected_notice] + available[:9]
field.widget.choices = [('', field.empty_label)] + [(n.pk, field.label_from_instance(n)) for n in available]
return available
+355
View File
@@ -0,0 +1,355 @@
"""All business writes go through these atomic, permission-checked operations."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.core import serializers
import json
from django.db import transaction
from django.utils import timezone
from apps.accounts.models import User
from apps.tickets.policies import TicketPolicy
from . import policies, validators
from .models import (CompanyBankAccount, PaymentNotice, PaymentReceipt, PaymentReceiptRevision,
PaymentApprovalPolicy, ReceiptApprovalCycle, ReceiptApprovalAssignment,
ReceiptDecision, PaymentAuditEvent)
def actor_name(actor):
profile = getattr(actor, 'staff_profile', None)
return getattr(profile, 'display_name', '') or actor.get_full_name() or actor.username
@transaction.atomic
def resend_notice_sms(actor, notice, request_key):
from uuid import UUID
from .notifications import context, notice_sms_pending
from .models import PaymentNotification
from apps.notifications.services import enqueue
if not policies.can_create(actor, notice.owning_unit) or not policies.notice_actions(PaymentNotice.objects.filter(pk=notice.pk), actor).exists():
raise PermissionDenied
try:
key = str(UUID(str(request_key)))
except (ValueError, TypeError, AttributeError):
raise ValidationError('درخواست ارسال معتبر نیست؛ صفحه را دوباره باز کنید.')
# Same customer -> notice lock order as issuance, receipt submission and deletion.
customer = User.objects.select_for_update().get(pk=notice.customer_id)
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk)
if notice.deleted_at or notice.cancelled_at or not customer.is_active or not customer.mobile:
raise ValidationError('این اعلامیه یا مشتری امکان ارسال پیامک ندارد.')
dedupe_key = f'payment-notice:{notice.public_id}:resend:{key}'
previous = PaymentNotification.objects.filter(notice=notice, job__dedupe_key=dedupe_key).select_related('job').first()
if previous:
return previous.job
if notice_sms_pending(notice):
raise ValidationError('پیامک این اعلامیه در صف یا در حال ارسال است؛ تا پایان تلاش جاری منتظر بمانید.')
job = enqueue('SMS', 'PAYMENT_NOTICE_CREATED', None, dedupe_key, customer=customer, staff_user=actor)
PaymentNotification.objects.create(job=job, notice=notice, unit=notice.owning_unit,
context=context(customer, notice=notice, amount=notice.amount))
audit(actor, 'NOTICE_SMS_RESEND_REQUESTED', notice, {'job_id': job.pk})
return job
def audit(actor, action, obj, details=None):
return PaymentAuditEvent.objects.create(actor=actor, actor_name=actor_name(actor) if actor else 'SYSTEM',
action=action, object_type=obj._meta.label_lower, object_id=str(getattr(obj, 'public_id', obj.pk)), details=details or {})
def configuration_snapshot(obj):
"""Serialize only the financial configuration passed here, including flags and M2M IDs."""
snapshot = json.loads(serializers.serialize('json', [obj]))[0]['fields']
if 'bale_token' in snapshot:
snapshot['bale_token'] = '[configured]' if snapshot['bale_token'] else ''
return snapshot
@transaction.atomic
def delete_notice(actor, notice):
# Receipt submission locks the same customer, then notice. Do not lock access
# links here: anonymous submission already holds its link before the customer.
actor = User.objects.select_related('staff_profile').get(pk=actor.pk)
User.objects.select_for_update().get(pk=notice.customer_id)
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk)
if not policies.may_delete_notice(actor, notice):
raise PermissionDenied
if notice.receipts.exists():
raise ValidationError('برای این اعلامیه فیش ثبت شده است؛ حذف آن امکان‌پذیر نیست، حتی اگر فیش رد شده باشد.')
if notice.deleted_at:
return
notice.deleted_at = notice.cancelled_at = timezone.now()
notice.save(update_fields=['deleted_at', 'cancelled_at', 'updated_at'])
from apps.notifications.models import NotificationJob
NotificationJob.objects.filter(payment_notification__notice=notice,
status__in=['PENDING', 'RETRYING', 'FAILED']).update(status='CANCELLED',
last_error='اعلامیه واریز حذف شده است.')
audit(actor, 'NOTICE_DELETED', notice, {'number': notice.number, 'customer_id': notice.customer_id,
'subject': notice.subject, 'amount': notice.amount})
@transaction.atomic
def issue_notice(actor, data):
unit = TicketPolicy.primary_unit(actor)
if not policies.can_create(actor, unit):
raise PermissionDenied
# Keep lock order consistent with receipt submission: customer, then account.
customer = User.objects.select_for_update().get(pk=data['customer'].pk, account_type='CUSTOMER', is_active=True)
account = CompanyBankAccount.objects.select_for_update(of=('self',)).select_related('bank').get(pk=data['account'].pk)
if not account.is_active or not account.available_to_staff:
raise ValidationError('این حساب دیگر برای صدور اعلامیه فعال نیست.')
from .deposit_identifier import normalize_deposit_identifier
deposit_id = normalize_deposit_identifier(data.get('deposit_id', ''))
if account.requires_deposit_id and not deposit_id:
raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.')
key = data.get('submission_key')
if key:
existing = PaymentNotice.objects.filter(issuance_key=key).first()
if existing:
if existing.created_by_id != actor.pk or existing.customer_id != customer.pk:
raise PermissionDenied
expected = {'account_id': account.pk, 'amount': validators.amount(data['amount']),
'deposit_id': deposit_id, 'subject': data['subject'],
'invoice_number': data.get('invoice_number', ''), 'description': data.get('description', '')}
if any(getattr(existing, field) != value for field, value in expected.items()):
raise ValidationError('این اعلامیه قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
return existing
notice = PaymentNotice.objects.create(customer=customer, created_by=actor, owning_unit=unit, account=account,
account_snapshot=account.snapshot(), amount=validators.amount(data['amount']), deposit_id=deposit_id,
subject=data['subject'], invoice_number=data.get('invoice_number', ''), description=data.get('description', ''),
**({'issuance_key': key} if key else {}))
audit(actor, 'NOTICE_ISSUED', notice, {'account': account.snapshot(), 'amount': str(notice.amount), 'deposit_id': deposit_id})
from .notifications import queue_notice
queue_notice(notice)
return notice
def policy_for(unit):
policy = PaymentApprovalPolicy.objects.filter(is_active=True, unit=unit).first() if unit else None
return policy or PaymentApprovalPolicy.objects.filter(is_active=True, unit__isnull=True).first()
def assign_cycle(revision, actor):
policy = policy_for(revision.receipt.owning_unit)
reviewer_ids = list(policy.reviewers.values_list('pk', flat=True)) if policy else []
senior_ids = list(policy.seniors.values_list('pk', flat=True)) if policy else []
selected = User.objects.filter(pk__in=set(reviewer_ids + senior_ids))
if policy and (not reviewer_ids or any(not policies.is_staff(user) for user in selected)):
raise ValidationError('سیاست تأیید معتبر نیست؛ مدیر باید افراد فعال تأییدکننده را تعیین کند.')
cycle = ReceiptApprovalCycle.objects.create(revision=revision, policy_snapshot={
'policy_id': policy.pk if policy else None, 'name': policy.name if policy else 'هنوز تعیین نشده',
'method': 'ALL_SELECTED', 'reviewers': reviewer_ids, 'seniors': senior_ids})
ReceiptApprovalAssignment.objects.bulk_create([
ReceiptApprovalAssignment(cycle=cycle, user_id=pk, is_senior=senior)
for senior, ids in [(False, reviewer_ids), (True, senior_ids)] for pk in ids])
audit(actor, 'CYCLE_STARTED', revision.receipt, {'version': revision.version, 'policy': cycle.policy_snapshot})
return cycle
def receipt_metadata(data):
from .deposit_identifier import normalize_deposit_identifier
from .identity import depositor_name
return {'depositor_name': depositor_name(data.get('depositor_name')), 'amount': data['paid_amount'], 'paid_date': data['paid_date'], 'destination': data['destination_account'],
'origin_bank': data.get('origin_bank'), 'origin_other': data.get('origin_other', ''),
'deposit_id': normalize_deposit_identifier(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''),
'description': data.get('description', '')}
def validate_destination(account, notice):
if not account.is_active:
raise ValidationError('این حساب غیرفعال شده است؛ برای ثبت پرداخت قبلی با کارشناس هماهنگ کنید.')
if not account.available_to_customers and (not notice or notice.account_id != account.pk):
raise ValidationError('حساب مقصد برای این واریز قابل انتخاب نیست.')
def _submit_receipt(actor, customer, data, file_info, created_files):
fields = receipt_metadata(data)
# Same-customer lock serializes retries of a submission nonce before inserting it.
User.objects.select_for_update().get(pk=customer.pk)
existing = PaymentReceipt.objects.filter(submission_key=data['submission_key']).first()
if existing:
revision = existing.revisions.get(version=1)
if existing.customer_id != customer.pk or existing.submitted_by_id != actor.pk:
raise PermissionDenied
if (revision.amount != data['paid_amount'] or revision.paid_date != data['paid_date'] or
revision.destination_id != data['destination_account'].pk or revision.sha256 != file_info['sha256'] or
existing.notice_id != getattr(data.get('notice_id'), 'pk', None) or
revision.depositor_name != fields['depositor_name']):
raise ValidationError('این ارسال قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
return existing
notice = data.get('notice_id')
if notice:
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk, customer=customer)
if notice.cancelled_at:
raise ValidationError('اعلامیه لغو شده است؛ واریز مستقل را انتخاب کنید.')
account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk)
validate_destination(account, notice)
receipt = PaymentReceipt.objects.create(customer=customer, submitted_by=actor, notice=notice,
owning_unit=notice.owning_unit if notice else (TicketPolicy.primary_unit(actor) if policies.is_staff(actor) else None), submission_key=data['submission_key'])
fields['destination'] = account
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
revision = PaymentReceiptRevision(receipt=receipt, version=1,
account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(),
destination_mismatch=bool(notice and notice.account_id != account.pk), image=data['receipt_image'], **file_info, **fields)
try:
revision.save()
finally:
if revision.image and revision.image._committed:
created_files.append((revision.image.storage, revision.image.name))
assign_cycle(revision, actor)
audit(actor, 'RECEIPT_SUBMITTED', receipt, {'version': 1, 'amount': str(revision.amount), 'sha256': revision.sha256})
from .receipt_alerts import safe_queue_receipt_alerts
safe_queue_receipt_alerts(revision, actor)
return receipt
def submit_receipt(actor, customer, data, *, access_link=None):
if not actor.is_active or customer.account_type != 'CUSTOMER' or not customer.is_active:
raise PermissionDenied
if actor.pk != customer.pk and not policies.can_create(actor):
raise PermissionDenied
if actor.account_type == 'CUSTOMER' and actor.pk != customer.pk:
raise PermissionDenied
data = dict(data)
data['paid_amount'] = validators.amount(data['paid_amount'])
if data['paid_date'] > timezone.localdate():
raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.')
file_info = validators.image(data['receipt_image'])
# File storage is not transactional. Remove only the file created by this failed write.
created_files = []
try:
with transaction.atomic():
if access_link:
from .access import validate_link_submission
validate_link_submission(access_link, customer, data)
result = _submit_receipt(actor, customer, data, file_info, created_files)
if access_link:
access_link.submitted_receipts.add(result)
return result
except Exception:
for storage, name in created_files:
storage.delete(name)
raise
@transaction.atomic
def decide_receipt(actor, receipt_id, version, action, reason='', *, rejection_confirmed=True):
# Lock only the receipt: PostgreSQL cannot lock the nullable side of a LEFT JOIN.
receipt = PaymentReceipt.objects.select_for_update(of=('self',)).select_related('owning_unit').get(public_id=receipt_id)
# Never authorize a financial vote using a stale User/profile/permission cache.
actor = User.objects.select_for_update().get(pk=actor.pk)
if receipt.current_version != version:
raise ValidationError('اطلاعات تغییر کرده است؛ نسخه تازه را دوباره بررسی کنید.')
revision = receipt.revisions.get(version=version)
cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision)
if action not in {'APPROVE', 'REJECT', 'SENIOR_APPROVE'}:
raise ValidationError('نوع تصمیم معتبر نیست.')
allowed = policies.can_decide(actor, receipt, cycle, senior=action == 'SENIOR_APPROVE')
# Assigned seniors may also reject with a reason; all permission, visibility,
# own-submission and assignment checks remain in can_decide.
if not allowed and action == 'REJECT':
allowed = policies.can_decide(actor, receipt, cycle, senior=True)
if not allowed:
raise PermissionDenied
if action == 'REJECT' and not rejection_confirmed:
raise ValidationError('برای رد فیش، ارسال دلیل رد به مشتری را تأیید کنید.')
reason = reason.strip()
if action in {'REJECT', 'SENIOR_APPROVE'} and not reason:
raise ValidationError('دلیل این تصمیم را بنویسید.')
old = cycle.decisions.filter(user=actor).first()
if old:
if old.action == action and old.reason == reason:
return receipt
raise ValidationError('تصمیم شما ثبت شده است؛ تغییر آن نیازمند چرخه جدید است.')
if receipt.status not in {'PENDING', 'PARTIAL'}:
raise ValidationError('بررسی این فیش پایان یافته است.')
if not cycle.assignments.filter(is_senior=False).exists():
raise ValidationError('مدیر باید ابتدا افراد تأییدکننده را تعیین کند.')
if revision.destination_mismatch and action != 'REJECT' and not reason:
raise ValidationError('حساب مقصد با اعلامیه متفاوت است؛ نتیجه بررسی مغایرت را بنویسید.')
ReceiptDecision.objects.create(cycle=cycle, user=actor, action=action, reason=reason, actor_name=actor_name(actor))
if action == 'REJECT':
status = 'REJECTED'
elif action == 'SENIOR_APPROVE':
status = 'APPROVED'
else:
required = set(cycle.assignments.filter(is_senior=False).values_list('user_id', flat=True))
voted = set(cycle.decisions.filter(action='APPROVE').values_list('user_id', flat=True))
status = 'APPROVED' if required <= voted else 'PARTIAL'
cycle.status = receipt.status = status
if status in {'APPROVED', 'REJECTED'}:
cycle.finalized_by = actor
cycle.finalized_at = timezone.now()
cycle.save(update_fields=['status', 'finalized_by', 'finalized_at'])
receipt.save(update_fields=['status', 'updated_at'])
audit(actor, action, receipt, {'version': version, 'reason': reason, 'status': status, 'cycle': cycle.pk})
if status == 'APPROVED':
from .notifications import queue_approval
queue_approval(receipt, cycle)
elif status == 'REJECTED':
from .notifications import queue_rejection
queue_rejection(receipt, cycle, reason)
return receipt
@transaction.atomic
def bind_unassigned_cycle(actor, receipt):
"""An empty cycle cannot accept votes. An authorized manager can bind a newly configured policy."""
if not policies.has_permission(actor, 'manage_payment_approval_policy'):
raise PermissionDenied
receipt = PaymentReceipt.objects.select_for_update().get(pk=receipt.pk)
revision = receipt.revisions.get(version=receipt.current_version)
cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision)
if cycle.assignments.exists() or cycle.decisions.exists() or receipt.status != 'PENDING':
raise ValidationError('سیاست این چرخه قبلاً ثبت شده است و قابل بازنویسی نیست.')
policy = policy_for(receipt.owning_unit)
if not policy or not policy.reviewers.exists():
raise ValidationError('ابتدا یک سیاست فعال با افراد تأییدکننده ایجاد کنید.')
users = list(policy.reviewers.all()) + list(policy.seniors.all())
if any(not policies.is_staff(user) for user in users):
raise ValidationError('همه تأییدکنندگان باید فعال باشند.')
cycle.policy_snapshot = {'policy_id': policy.pk, 'name': policy.name, 'method': 'ALL_SELECTED', 'reviewers': list(policy.reviewers.values_list('pk', flat=True)), 'seniors': list(policy.seniors.values_list('pk', flat=True))}
cycle.save(update_fields=['policy_snapshot'])
ReceiptApprovalAssignment.objects.bulk_create([ReceiptApprovalAssignment(cycle=cycle, user=user, is_senior=senior) for senior, selected in [(False, policy.reviewers.all()), (True, policy.seniors.all())] for user in selected])
audit(actor, 'EMPTY_CYCLE_ASSIGNED', receipt, cycle.policy_snapshot)
def revise_receipt(actor, receipt_id, version, data):
data = dict(data)
data['paid_amount'] = validators.amount(data['paid_amount'])
if data['paid_date'] > timezone.localdate():
raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.')
file_info = validators.image(data['receipt_image'])
created_files = []
try:
with transaction.atomic():
receipt = PaymentReceipt.objects.select_for_update().get(public_id=receipt_id)
if actor.pk != receipt.customer_id and not policies.has_permission(actor,'submit_customer_receipt',receipt.owning_unit):
raise PermissionDenied
if not actor.is_active or receipt.status != 'REJECTED' or receipt.current_version != version:
raise ValidationError('فقط نسخه جاری فیش ردشده قابل اصلاح است.')
# Link identity is fixed for a revision; changing notice requires a distinct receipt.
if getattr(data.get('notice_id'),'pk',None) != receipt.notice_id:
raise ValidationError('ارتباط اعلامیه این فیش قابل تغییر نیست.')
account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk)
validate_destination(account,receipt.notice)
fields=receipt_metadata(data)
fields['destination']=account
notice = receipt.notice
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
new_version=version+1
revision=PaymentReceiptRevision(receipt=receipt,version=new_version,
account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(),
image=data['receipt_image'],destination_mismatch=bool(receipt.notice and receipt.notice.account_id!=account.pk),**file_info,**fields)
try:
revision.save()
finally:
if revision.image and revision.image._committed:
created_files.append((revision.image.storage,revision.image.name))
assign_cycle(revision,actor)
receipt.current_version=new_version
receipt.status='PENDING'
receipt.save(update_fields=['current_version','status','updated_at'])
audit(actor,'RECEIPT_REVISED',receipt,{'version':new_version,'previous_version':version,'sha256':revision.sha256})
from .receipt_alerts import safe_queue_receipt_alerts
safe_queue_receipt_alerts(revision, actor)
return receipt
except Exception:
for storage,name in created_files:
storage.delete(name)
raise
+24
View File
@@ -0,0 +1,24 @@
from django.urls import path
from . import views
app_name = 'staff_payments'
urlpatterns = [
path('', views.desk, name='desk'),
path('count/', views.counts, name='count'),
path('customers/search/', views.customer_search, name='customer_search'),
path('notices/generate-identifier/', views.generate_deposit_identifier, name='generate_identifier'),
path('notices/new/', views.notice_new, name='notice_new'),
path('notices/<uuid:public_id>/', views.notice_detail, name='notice_detail'),
path('notices/<uuid:public_id>/delete/', views.notice_delete, name='notice_delete'),
path('notices/<uuid:public_id>/resend-sms/', views.notice_resend_sms, name='notice_resend_sms'),
path('notices/<uuid:public_id>/payment-link/', views.notice_payment_link, name='notice_payment_link'),
path('receipts/new/<uuid:customer_id>/', views.staff_receipt_new, name='receipt_new'),
path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'),
path('receipts/<uuid:public_id>/', views.staff_receipt_detail, name='receipt_detail'),
path('receipts/<uuid:public_id>/decide/', views.decision, name='decision'),
path('receipts/<uuid:public_id>/assign/', views.assign_policy, name='assign_policy'),
path('banks/', views.banks, name='banks'),
path('banks/new/', views.bank_edit, name='bank_new'),
path('banks/bulk/', views.bank_bulk, name='bank_bulk'),
path('banks/<uuid:public_id>/', views.bank_edit, name='bank_edit'),
path('banks/<uuid:public_id>/delete/', views.bank_delete, name='bank_delete'),
]
+15
View File
@@ -0,0 +1,15 @@
from django.urls import path
from . import views, link_views
app_name = 'payments'
urlpatterns = [
path('link/<str:token>/', link_views.open_link, name='open_link'),
path('', views.home, name='home'),
path('bank-accounts/', views.notices, name='notices'),
path('bank-accounts/<uuid:public_id>/', views.customer_notice_detail, name='notice_detail'),
path('bank-accounts/<uuid:public_id>/share-link/', views.customer_notice_share_link, name='notice_share_link'),
path('payment-receipt/', views.receipt_new, name='receipt_new'),
path('receipts/', views.receipt_list, name='receipt_list'),
path('receipts/<uuid:public_id>/', views.receipt_detail, name='receipt_detail'),
path('receipts/<uuid:public_id>/revise/', views.receipt_revise, name='receipt_revise'),
path('receipts/<uuid:public_id>/image/', views.receipt_image, name='receipt_image'),
]
+93
View File
@@ -0,0 +1,93 @@
import hashlib
import re
import warnings
from pathlib import Path
import jdatetime
import magic
from PIL import Image, UnidentifiedImageError
from django.core.exceptions import ValidationError
from django.utils import timezone
from apps.attachments.models import AttachmentPolicy
from .models import MAX_AMOUNT
DIGIT_MAP = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
def digits(value):
return str(value or '').translate(DIGIT_MAP).strip()
def amount(value):
value = digits(value)
if not re.fullmatch(r'\d+|\d{1,3}(?:[,٬ ]\d{3})+', value):
raise ValidationError('مبلغ را به ریال، فقط با عدد صحیح وارد کنید.')
value = int(re.sub(r'[,٬ ]', '', value))
if not 0 < value <= MAX_AMOUNT:
raise ValidationError('مبلغ باید بیشتر از صفر و در محدوده مجاز باشد.')
return value
def bank_number(value):
value = re.sub(r'[\s-]', '', digits(value))
if value and not value.isascii():
raise ValidationError('شماره را با ارقام معتبر وارد کنید.')
if value and not value.isdecimal():
raise ValidationError('شماره حساب یا کارت باید فقط عدد باشد.')
return value
def iban(value):
value = re.sub(r'\s', '', digits(value)).upper()
if not value:
return ''
if not re.fullmatch(r'IR\d{24}', value) or int(value[4:] + '1827' + value[2:4]) % 97 != 1:
raise ValidationError('شماره شبای معتبر ایرانی وارد کنید؛ IR و ۲۴ رقم.')
return value
def paid_date(day, month, year):
if not all((day, month, year)):
raise ValidationError('تاریخ را کامل کنید؛ روز، ماه و سال لازم‌اند.')
try:
value = jdatetime.date(int(digits(year)), int(digits(month)), int(digits(day))).togregorian()
except (ValueError, TypeError, OverflowError):
raise ValidationError('تاریخ شمسی معتبر انتخاب کنید.')
today = timezone.localdate()
if value > today:
raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.')
if jdatetime.date.fromgregorian(date=value).year < jdatetime.date.fromgregorian(date=today).year - 10:
raise ValidationError('تاریخ خارج از بازه ده سال گذشته است.')
return value
def image(upload):
policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first()
if not policy:
raise ValidationError('ارسال تصویر فعلاً فعال نیست؛ با کارشناس هماهنگ کنید.')
if not upload.size or upload.size > policy.max_file_size:
raise ValidationError('تصویر خالی یا بزرگ‌تر از حجم مجاز است.')
ext = Path(upload.name).suffix.lower()
if ext not in (policy.allowed_extensions or ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
raise ValidationError('فقط تصویر مجاز فیش را انتخاب کنید.')
head = upload.read(8192)
upload.seek(0)
mime = magic.from_buffer(head, mime=True)
expected = {'.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.png':'image/png', '.gif':'image/gif', '.webp':'image/webp'}
if expected.get(ext) != mime or (policy.allowed_mime_types and mime not in policy.allowed_mime_types):
raise ValidationError('محتوای واقعی فایل با نوع تصویر سازگار نیست.')
try:
with warnings.catch_warnings():
warnings.simplefilter('error', Image.DecompressionBombWarning)
with Image.open(upload) as picture:
if picture.width * picture.height > 25_000_000:
raise ValidationError('ابعاد تصویر بیش از حد مجاز است.')
picture.verify()
except (UnidentifiedImageError, OSError, SyntaxError, Image.DecompressionBombError, Image.DecompressionBombWarning):
raise ValidationError('تصویر خراب است یا باز نمی‌شود؛ تصویر دیگری انتخاب کنید.')
finally:
upload.seek(0)
digest = hashlib.sha256()
for chunk in upload.chunks():
digest.update(chunk)
upload.seek(0)
return {'mime_type': mime, 'sha256': digest.hexdigest(), 'original_name': Path(upload.name).name[:255], 'file_size': upload.size}
+538
View File
@@ -0,0 +1,538 @@
from functools import wraps
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q, Count, Sum, F, Exists, OuterRef
from django.db.models.deletion import ProtectedError
from django.http import FileResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_POST
from . import policies, services, validators, selectors, customer_picker, access
from .forms import BankAccountForm, NoticeForm, ReceiptForm
from .models import (CompanyBankAccount, PaymentNotice, PaymentReceipt, PaymentReceiptRevision,
PaymentAuditEvent, ReceiptDecision)
def financial_required(staff=False, allow_payment_link=False):
def decorate(view):
@wraps(view)
def wrapped(request, *args, **kwargs):
blocked = access.prepare_payment_request(request, staff=staff, allow_payment_link=allow_payment_link)
if blocked is not None:
return blocked
response = view(request, *args, **kwargs)
if getattr(request, 'payment_access_link', None):
response['Referrer-Policy'] = 'same-origin'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
return wrapped
return decorate
def staff_context(**kwargs):
return {'active_staff_section': 'payments', 'active_tab_title': 'اعلامیه‌ها و فیش‌های واریزی', **kwargs}
@financial_required()
def home(request):
return render(request, 'payments/home.html', {'financial_page': True})
def complete_notice(notice):
from .deposit_identifier import normalize_deposit_identifier
notice.deposit_id = normalize_deposit_identifier(notice.deposit_id)
snapshot = notice.account_snapshot
approved = getattr(notice, 'approved_total', None)
if approved is None:
approved = notice.receipts.filter(status='APPROVED', revisions__version=F('current_version'), revisions__destination_mismatch=False).aggregate(total=Sum('revisions__amount'))['total'] or 0
notice.approved_amount = approved
notice.remaining_amount = max(0, notice.amount-approved)
notice.settlement_label = 'تسویه شده' if approved >= notice.amount else ('پرداخت جزئی' if approved else 'در انتظار فیش تأییدشده')
# Complete copy uses immutable data, including fields absent from the compact UI.
from apps.core.templatetags.jalali import jalali_datetime
notice.copy_text = '\n'.join(f'{label}: {value}' for label, value in [
('اعلامیه', notice.number), ('تاریخ صدور', jalali_datetime(notice.created_at)), ('موضوع', notice.subject), ('مبلغ (ریال)', f'{notice.amount:,}'),
('نام حساب', snapshot.get('name')), ('بانک', snapshot.get('bank')), ('صاحب حساب', snapshot.get('holder')),
('شماره حساب', snapshot.get('account_number')), ('شبا', snapshot.get('iban')),
('کارت', snapshot.get('card_number')), ('شناسه واریز', notice.deposit_id),
('فاکتور', notice.invoice_number), ('توضیحات', notice.description)] if value)
if notice.deposit_id:
notice.copy_text += '\nوارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است؛ آن را در فرم بانکی، موبایل‌بانک یا اینترنت‌بانک وارد کنید.'
return notice
@financial_required()
def notices(request):
items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.all(), request.user))
query = request.GET.get('q', '').strip()
if query:
items = items.filter(Q(subject__icontains=query) | Q(invoice_number__icontains=validators.digits(query)))
page = Paginator(items, 20).get_page(request.GET.get('page'))
page.object_list = selectors.attach_notice_receipts(page.object_list, policies.visible(PaymentReceipt.objects.all(), request.user))
for notice in page:
complete_notice(notice)
params = request.GET.copy()
params.pop('page', None)
return render(request, 'payments/notices.html', {'page_obj': page, 'query': query, 'query_string':params.urlencode(), 'open_notice': request.GET.get('notice', ''), 'financial_page': True})
@financial_required(allow_payment_link=True)
def customer_notice_detail(request, public_id):
notice = get_object_or_404(selectors.notices_with_totals(
access.visible_payments(request, PaymentNotice.objects.all())), public_id=public_id)
selectors.attach_notice_receipts([notice], access.visible_payments(request, PaymentReceipt.objects.all()))
return render(request, 'payments/customer_notice_detail.html', access.customer_context(request,
notice=complete_notice(notice), open_notice=str(notice.public_id), financial_page=True))
@require_POST
@financial_required(allow_payment_link=True)
def customer_notice_share_link(request, public_id):
notice = get_object_or_404(access.visible_payments(request, PaymentNotice.objects.all()),
public_id=public_id, customer__is_active=True)
url = access.notice_share_url(request, notice)
response = JsonResponse({'url': url})
response['Cache-Control'] = 'private, no-store'
return response
def receipt_form_page(request, customer, staff=False):
link = getattr(request, 'payment_access_link', None)
initial = access.receipt_initial(request, customer)
form = ReceiptForm(request.POST or None, request.FILES or None, customer=customer, initial=initial,
notice_ids=[link.notice_id] if link else None)
if request.method == 'POST' and form.is_valid():
try:
receipt = services.submit_receipt(customer if link else request.user, customer, form.cleaned_data, access_link=link)
messages.success(request, 'فیش ثبت شد و در انتظار بررسی است.')
if request.headers.get('Accept') == 'application/json':
return JsonResponse({'redirect_url': reverse('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', args=[receipt.public_id])})
return redirect('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', public_id=receipt.public_id)
except ValidationError as error:
form.add_error(None, error)
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
selected = form['notice_id'].value()
available = list(form.fields['notice_id'].queryset) if staff else selectors.recent_pending_notice_choices(form)
notice_data = [selectors.notice_form_data(n, staff=staff) for n in available]
from apps.attachments.models import AttachmentPolicy
image_policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first()
ctx = {'form': form, 'selected_notice': selected, 'notice_data': notice_data, 'financial_page': True,
'max_bytes': image_policy.max_file_size if image_policy else 0, 'staff_form': staff, 'customer': customer,
'account_requirements': form.account_warnings, 'customer_deposit_id': form.customer_deposit_id}
return render(request, 'payments/receipt_form.html', staff_context(**ctx) if staff else access.customer_context(request, **ctx))
@financial_required(allow_payment_link=True)
def receipt_new(request):
return receipt_form_page(request, request.payment_customer)
@financial_required(allow_payment_link=True)
def receipt_list(request):
items = selectors.receipts_with_current_values(access.visible_payments(request, PaymentReceipt.objects.all()).select_related('notice'))
return render(request, 'payments/receipt_list.html', access.customer_context(request, financial_page=True,
page_obj=Paginator(items, 20).get_page(request.GET.get('page'))))
@financial_required(staff=True)
def staff_receipt_customer(request):
if not policies.can_create(request.user):
raise PermissionDenied
from django import forms
form = forms.Form(request.POST or None, initial=customer_picker.initial_customer(request))
# Reuse the same active-customer selector and display labels as notice issuance.
form.fields['customer'] = NoticeForm().fields['customer']
if request.method == 'POST' and form.is_valid():
return redirect('staff_payments:receipt_new', customer_id=form.cleaned_data['customer'].public_id)
return render(request, 'payments/staff_form.html',staff_context(form=form,heading='ثبت فیش برای مشتری',submit_label='ادامه', **customer_picker.picker_context(request, form)))
@financial_required(staff=True)
def customer_search(request):
if not policies.can_create(request.user):
raise PermissionDenied
response = JsonResponse(customer_picker.search_results(request.user, request.GET.get('q', '')))
response['Cache-Control'] = 'private, no-store'
return response
@financial_required(staff=True)
def staff_receipt_new(request, customer_id):
if not policies.can_create(request.user):
raise PermissionDenied
from apps.accounts.models import User
customer = get_object_or_404(User.objects.select_related('customer_profile'), public_id=customer_id, account_type='CUSTOMER', is_active=True)
return receipt_form_page(request, customer, staff=True)
def receipt_context(request, public_id, staff):
receipt = get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.select_related('customer__customer_profile', 'notice', 'owning_unit')), public_id=public_id)
revision = receipt.revisions.select_related('destination', 'origin_bank', 'cycle').get(version=receipt.current_version)
cycle = revision.cycle
services.audit(request.payment_customer, 'RECEIPT_VIEWED', receipt)
decisions = ReceiptDecision.objects.filter(cycle__revision__receipt=receipt).select_related('user', 'cycle__revision').order_by('created_at', 'pk')
return {'receipt': receipt, 'revision': revision, 'cycle': cycle, 'decisions': decisions,
'payment_link_access': getattr(request, 'payment_access_link', None),
'revision_events': PaymentAuditEvent.objects.filter(object_type='payments.paymentreceipt', object_id=str(receipt.public_id), action__in=['RECEIPT_SUBMITTED', 'RECEIPT_REVISED']).order_by('created_at', 'pk'),
'can_approve': staff and policies.can_decide(request.user, receipt, cycle),
'can_senior': staff and policies.can_decide(request.user, receipt, cycle, senior=True),
'can_assign': staff and not cycle.assignments.exists() and policies.has_permission(request.user, 'manage_payment_approval_policy'),
'staff_detail': staff, 'financial_page': True, 'versions': receipt.revisions.select_related('cycle').order_by('version'),
'duplicate_image': PaymentReceiptRevision.objects.filter(sha256=revision.sha256,
receipt__in=policies.visible(PaymentReceipt.objects.all(),request.user)).exclude(receipt=receipt).exists() if staff else False}
@financial_required(allow_payment_link=True)
def receipt_detail(request, public_id):
return render(request, 'payments/receipt_detail.html', receipt_context(request, public_id, False))
@financial_required(staff=True)
def staff_receipt_detail(request, public_id):
return render(request, 'payments/receipt_detail.html', staff_context(**receipt_context(request, public_id, True)))
@financial_required(staff=None, allow_payment_link=True)
def receipt_image(request, public_id):
try:
version = int(request.GET.get('version', '1'))
except ValueError:
from django.http import Http404
raise Http404
revision = get_object_or_404(PaymentReceiptRevision.objects.select_related('receipt'), receipt__public_id=public_id, version=version)
if not access.visible_payments(request, PaymentReceipt.objects.filter(pk=revision.receipt_id)).exists():
raise PermissionDenied
services.audit(request.payment_customer, 'RECEIPT_FILE_REQUESTED', revision.receipt, {'version': revision.version})
# Authorize EVERY access, including debug: financial media never uses MEDIA_URL.
response = FileResponse(revision.image.open('rb'), content_type=revision.mime_type,
as_attachment=request.GET.get('download') == '1', filename=revision.original_name)
response['Cache-Control'] = 'private, no-store'
response['X-Content-Type-Options'] = 'nosniff'
return response
@financial_required(staff=True)
@require_POST
def decision(request, public_id):
get_object_or_404(policies.visible(PaymentReceipt.objects.all(),request.user),public_id=public_id)
try:
version = int(request.POST.get('version', '0'))
receipt = services.decide_receipt(request.user, public_id, version, request.POST.get('action'), request.POST.get('reason', ''),
rejection_confirmed=request.POST.get('confirm_rejection') == '1')
messages.success(request, 'تصمیم ثبت شد. ' + receipt.get_status_display())
except (ValidationError, ValueError) as error:
messages.error(request, '؛ '.join(error.messages) if isinstance(error, ValidationError) else 'نسخه فیش معتبر نیست.')
return redirect('staff_payments:receipt_detail', public_id=public_id)
@financial_required(staff=True)
@require_POST
def assign_policy(request, public_id):
receipt = get_object_or_404(policies.visible(PaymentReceipt.objects.all(), request.user), public_id=public_id)
try:
services.bind_unassigned_cycle(request.user, receipt)
messages.success(request, 'افراد تأییدکننده تعیین شدند.')
except ValidationError as error:
messages.error(request, '؛ '.join(error.messages))
return redirect('staff_payments:receipt_detail', public_id=public_id)
def filtered(queryset, request):
query = validators.digits(request.GET.get('q', ''))
if query:
condition = Q(customer__username__icontains=query) | Q(customer__mobile__icontains=query) | Q(customer__customer_profile__first_name__icontains=query) | Q(customer__customer_profile__last_name__icontains=query) | Q(customer__customer_profile__province__icontains=query) | Q(customer__customer_profile__city__icontains=query)
# PN/PR numbers are UUID prefixes, not database columns.
prefix = query.upper().removeprefix('PN-').removeprefix('PR-').replace('-', '')
if prefix and len(prefix) <= 32 and all(c in '0123456789ABCDEF' for c in prefix):
uuid_prefix = '-'.join(prefix[start:end] for start, end in [(0, 8), (8, 12), (12, 16), (16, 20), (20, 32)] if len(prefix) > start)
condition |= Q(public_id__istartswith=uuid_prefix)
if queryset.model == PaymentNotice:
condition |= Q(subject__icontains=query) | Q(invoice_number__icontains=query)
else:
condition |= Q(notice__invoice_number__icontains=query) | Q(revisions__tracking_number__icontains=query)
queryset = queryset.filter(condition)
for key, field in [('account', 'account_id' if queryset.model == PaymentNotice else 'revisions__destination_id'), ('unit', 'owning_unit_id'), ('customer', 'customer_id')]:
value = request.GET.get(key, '')
if value.isdecimal():
queryset = queryset.filter(**{field: value})
for key, op in [('from', 'gte'), ('to', 'lte')]:
value = request.GET.get(key, '')
if value:
try:
import datetime
parsed = datetime.date.fromisoformat(value)
queryset = queryset.filter(**{f'created_at__date__{op}': parsed})
except ValueError:
messages.error(request, 'بازه تاریخ معتبر نیست.')
return queryset.distinct()
@financial_required(staff=True)
def desk(request):
tab = request.GET.get('tab', 'receipts')
if tab not in {'receipts', 'notices'}:
tab = 'receipts'
mine = tab == 'receipts' and (request.GET.get('mine') == '1' or ('tab' not in request.GET and 'mine' not in request.GET))
receipts = selectors.receipts_with_current_values(policies.visible(PaymentReceipt.objects.select_related('customer__customer_profile', 'notice__created_by__staff_profile'), request.user))
notice_items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.select_related('customer__customer_profile', 'created_by__staff_profile', 'account'), request.user))
daily_summary = selectors.daily_payment_summary(receipts, notice_items)
if tab == 'notices':
items = filtered(notice_items, request)
state = request.GET.get('notice_state', '')
if state == 'settled':
items = items.filter(cancelled_at__isnull=True, approved_total__gte=F('amount'))
elif state == 'pending':
items = items.filter(cancelled_at__isnull=True, approved_total__lt=F('amount'))
elif state == 'cancelled':
items = items.filter(cancelled_at__isnull=False)
else:
items = filtered(receipts, request)
status = request.GET.get('status', '')
if status in PaymentReceipt.Status.values:
items = items.filter(status=status)
if request.GET.get('kind') == 'independent':
items = items.filter(notice__isnull=True)
elif request.GET.get('kind') == 'linked':
items = items.filter(notice__isnull=False)
if mine:
my_decision = ReceiptDecision.objects.filter(cycle__revision__receipt_id=OuterRef('pk'),
cycle__revision__version=OuterRef('current_version'), user=request.user)
items = items.filter(status__in=['PENDING','PARTIAL'], revisions__version=F('current_version'),
revisions__cycle__assignments__user=request.user).annotate(
my_decision_exists=Exists(my_decision)).filter(my_decision_exists=False).distinct()
page = Paginator(items.order_by('-created_at','-pk'), 25).get_page(request.GET.get('page'))
if tab == 'notices':
page.object_list = selectors.attach_notice_receipts(page.object_list, policies.visible(PaymentReceipt.objects.all(), request.user))
for notice in page.object_list:
complete_notice(notice)
else:
page.object_list = selectors.receipt_review_summaries(page.object_list)
query = request.GET.copy()
query.pop('page', None)
query['tab'] = tab
if mine:
query['mine'] = '1'
else:
query.pop('mine', None)
return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, mine=mine, statuses=PaymentReceipt.Status.choices,
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode(),
daily_summary=daily_summary))
@financial_required(staff=True)
@require_POST
def generate_deposit_identifier(request):
if not policies.can_create(request.user):
raise PermissionDenied
# Resolve the same active customer as NoticeForm; never trust a posted mobile.
try:
customer = NoticeForm().fields['customer'].clean(request.POST.get('customer'))
mobile = validators.digits(customer.local_mobile)
import re
if not re.fullmatch(r'09[0-9]{9}', mobile):
raise ValidationError('شماره موبایل مشتری معتبر نیست؛ ابتدا اطلاعات مشتری را اصلاح کنید.')
except ValidationError as error:
return JsonResponse({'error': '؛ '.join(error.messages)}, status=400)
from .deposit_identifier import generate_id_algorithm
response = JsonResponse({'identifier': generate_id_algorithm(mobile), 'customer': str(customer.pk)})
response['Cache-Control'] = 'no-store'
return response
@financial_required(staff=True)
def notice_new(request):
if not policies.can_create(request.user):
raise PermissionDenied
form = NoticeForm(request.POST or None, initial=customer_picker.initial_customer(request))
if request.method == 'POST' and form.is_valid():
try:
notice = services.issue_notice(request.user, form.cleaned_data)
messages.success(request, 'اعلامیه صادر شد و پیامک در صف اطلاع‌رسانی قرار گرفت.')
return redirect('staff_payments:notice_detail', public_id=notice.public_id)
except ValidationError as error:
form.add_error(None, error)
return render(request, 'payments/staff_form.html', staff_context(form=form, heading='ایجاد اعلامیه واریز', submit_label='صدور و اطلاع‌رسانی', identifier_accounts=list(CompanyBankAccount.objects.filter(requires_deposit_id=True).values_list('pk',flat=True)), **customer_picker.picker_context(request, form)))
@financial_required(staff=True)
def notice_detail(request, public_id):
notice = complete_notice(get_object_or_404(policies.notice_actions(PaymentNotice.objects.select_related('customer'), request.user), public_id=public_id))
services.audit(request.user, 'NOTICE_VIEWED', notice)
share = request.session.get('payment_link_share', {})
if share.get('notice') == str(public_id):
request.session.pop('payment_link_share', None)
else:
share = {}
from uuid import uuid4
from .notifications import notice_sms_pending
return render(request, 'payments/notice_detail.html', staff_context(notice=notice,
can_delete_notice=policies.can_delete_notice(request.user, notice),
may_delete_notice=policies.may_delete_notice(request.user, notice),
can_share_payment_link=policies.can_create(request.user) and notice.customer.is_active,
payment_share_url=share.get('url', ''),
can_resend_notice_sms=policies.can_create(request.user, notice.owning_unit) and notice.customer.is_active and not notice.cancelled_at,
notice_sms_pending=notice_sms_pending(notice), notice_sms_request_key=str(uuid4()),
receipts=policies.visible(notice.receipts.all(), request.user), notifications=selectors.notice_sms_history(notice)))
@financial_required(staff=True)
@require_POST
def notice_resend_sms(request, public_id):
notice = get_object_or_404(policies.notice_actions(PaymentNotice.objects.all(), request.user), public_id=public_id)
try:
services.resend_notice_sms(request.user, notice, request.POST.get('request_key', ''))
messages.success(request, 'درخواست ارسال مجدد پیامک در صف ثبت شد؛ نتیجه ارسال و دریافت در سوابق نمایش داده می‌شود.')
except ValidationError as error:
messages.error(request, '؛ '.join(error.messages))
return redirect('staff_payments:notice_detail', public_id=public_id)
@financial_required(staff=True)
@require_POST
def notice_delete(request, public_id):
notice = get_object_or_404(policies.notice_actions(PaymentNotice.objects.all(), request.user), public_id=public_id)
if not policies.may_delete_notice(request.user, notice):
raise PermissionDenied
if request.POST.get('confirm') != 'delete':
messages.error(request, 'برای حذف اعلامیه، ابتدا هشدار را بخوانید و حذف را تأیید کنید.')
return redirect('staff_payments:notice_detail', public_id=public_id)
try:
services.delete_notice(request.user, notice)
except ValidationError as error:
messages.error(request, '؛ '.join(error.messages))
return redirect('staff_payments:notice_detail', public_id=public_id)
messages.success(request, 'اعلامیه واریز حذف شد و لینک‌های آن دیگر قابل استفاده نیستند.')
return redirect(reverse('staff_payments:desk') + '?tab=notices')
@financial_required(staff=True)
@require_POST
def notice_payment_link(request, public_id):
if not policies.can_create(request.user):
raise PermissionDenied
notice = get_object_or_404(policies.visible(PaymentNotice.objects.all(), request.user),
public_id=public_id, customer__is_active=True)
link, url = access.issue_payment_link(notice=notice)
services.audit(request.user, 'PAYMENT_LINK_CREATED', link)
request.session['payment_link_share'] = {'notice': str(public_id), 'url': url}
return redirect('staff_payments:notice_detail', public_id=public_id)
@financial_required(staff=True)
def counts(request):
count = policies.visible(PaymentReceipt.objects.all(), request.user).filter(status__in=['PENDING', 'PARTIAL']).count()
response = JsonResponse({'pending_receipts': count})
response['Cache-Control'] = 'private, no-store'
return response
@financial_required(staff=True)
def banks(request):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
items = CompanyBankAccount.objects.select_related('bank')
query = request.GET.get('q','').strip()
if query:
items = items.filter(Q(name__icontains=query) | Q(bank__name__icontains=query) | Q(holder__icontains=query))
for field in ['is_active', 'available_to_staff', 'available_to_customers']:
if request.GET.get(field) in ['0','1']:
items = items.filter(**{field: request.GET[field] == '1'})
params = request.GET.copy()
params.pop('page', None)
return render(request, 'payments/banks.html', staff_context(page_obj=Paginator(items,25).get_page(request.GET.get('page')),
query_string=params.urlencode(), bank_flags=[{'key':key,'label':label,'value':request.GET.get(key,'')} for key,label in [('is_active','فعال کلی'),('available_to_staff','پنل کارشناسان'),('available_to_customers','پنل مشتریان')]], active_staff_section='banks', active_tab_title='بانک‌ها'))
@financial_required(staff=True)
def bank_edit(request, public_id=None):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
instance = get_object_or_404(CompanyBankAccount, public_id=public_id) if public_id else None
before = services.configuration_snapshot(instance) if instance else {}
form = BankAccountForm(request.POST or None, instance=instance)
if request.method == 'POST' and form.is_valid():
with transaction.atomic():
if instance:
current = CompanyBankAccount.objects.select_for_update().get(pk=instance.pk)
if form.cleaned_data['revision_token'] != current.updated_at.isoformat():
form.add_error(None, 'حساب هم‌زمان تغییر کرده است؛ صفحه را تازه کنید.')
return render(request,'payments/staff_form.html',staff_context(form=form,bank_account=instance,
heading='ویرایش حساب',submit_label='ذخیره حساب',active_staff_section='banks'))
before = services.configuration_snapshot(current)
account = form.save()
services.audit(request.user, 'BANK_SAVED', account, {'before': before, 'after': services.configuration_snapshot(account)})
return redirect('staff_payments:banks')
return render(request,'payments/staff_form.html',staff_context(form=form, bank_account=instance, heading='ویرایش حساب' if instance else 'ایجاد حساب', submit_label='ذخیره حساب', active_staff_section='banks'))
@financial_required(staff=True)
@require_POST
def bank_bulk(request):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
field = request.POST.get('field')
if field not in ['is_active','available_to_staff','available_to_customers'] or request.POST.get('value') not in ['0','1']:
raise ValidationError('عملیات معتبر نیست.')
ids = [value for value in request.POST.getlist('accounts') if value.isdecimal()]
with transaction.atomic():
for account in CompanyBankAccount.objects.select_for_update().filter(pk__in=ids):
before = getattr(account, field)
setattr(account,field,request.POST['value'] == '1')
account.save(update_fields=[field,'updated_at'])
services.audit(request.user,'BANK_FLAG_CHANGED',account,{'field':field,'before':before,'after':getattr(account,field)})
return redirect('staff_payments:banks')
@financial_required(staff=True)
@require_POST
def bank_delete(request, public_id):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
account = get_object_or_404(CompanyBankAccount,public_id=public_id)
try:
with transaction.atomic():
services.audit(request.user,'BANK_DELETED',account,account.snapshot())
account.delete()
except ProtectedError:
messages.error(request,'این حساب سابقه مالی دارد؛ به جای حذف آن را غیرفعال کنید.')
return redirect('staff_payments:banks')
@financial_required(allow_payment_link=True)
def receipt_revise(request, public_id):
link = getattr(request, 'payment_access_link', None)
receipt=get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.all()),public_id=public_id,customer=request.payment_customer,status='REJECTED')
import jdatetime
current = receipt.revisions.get(version=receipt.current_version)
from .identity import customer_name
paid = jdatetime.date.fromgregorian(date=current.paid_date)
initial={'receipt_mode':'linked' if receipt.notice_id else 'independent','notice_id':receipt.notice_id,
'depositor_name':current.depositor_name or customer_name(request.payment_customer),
'paid_amount':str(current.amount),'destination_account':current.destination_id,'deposit_id':current.deposit_id,
'paid_day':str(paid.day),'paid_month':str(paid.month),'paid_year':str(paid.year),
'origin_bank':current.origin_bank_id,'origin_other':current.origin_other,
'tracking_number':current.tracking_number,'description':current.description}
form=ReceiptForm(request.POST or None,request.FILES or None,customer=request.payment_customer,initial=initial,
notice_ids=[link.notice_id] if link else None)
if request.method=='POST' and form.is_valid():
try:
services.revise_receipt(request.payment_customer,receipt.public_id,int(request.POST.get('version','0')),form.cleaned_data)
messages.success(request,'نسخه اصلاحی ثبت شد و دوباره بررسی می‌شود.')
if request.headers.get('Accept') == 'application/json':
return JsonResponse({'redirect_url': reverse('payments:receipt_detail', args=[receipt.public_id])})
return redirect('payments:receipt_detail',public_id=receipt.public_id)
except (ValidationError,ValueError) as error:
form.add_error(None,error if isinstance(error,ValidationError) else 'نسخه فیش معتبر نیست.')
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
from apps.attachments.models import AttachmentPolicy
policy=AttachmentPolicy.objects.filter(attachment_type='IMAGE',is_enabled=True).first()
notice_data=[selectors.notice_form_data(n) for n in selectors.recent_pending_notice_choices(form, retain_original=receipt.notice_id)]
return render(request,'payments/receipt_form.html',{'form':form,'notice_data':notice_data,'max_bytes':policy.max_file_size if policy else 0,
'payment_link_access': link,
'account_requirements':form.account_warnings, 'customer_deposit_id':form.customer_deposit_id,
'financial_page':True,'revision_version':receipt.current_version})
+2 -2
View File
@@ -14,14 +14,14 @@ class UnitMembershipInline(admin.TabularInline):
@admin.register(OrganizationalUnit) @admin.register(OrganizationalUnit)
class OrganizationalUnitAdmin(admin.ModelAdmin): class OrganizationalUnitAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at") list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at")
list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active") list_filter = ("supervisor_sms_ticket_link_enabled", "show_in_customer_form", "accepts_external_tickets", "is_active")
search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5") search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5")
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active") list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
filter_horizontal = ("permissions",) filter_horizontal = ("permissions",)
inlines = (UnitMembershipInline,) inlines = (UnitMembershipInline,)
fieldsets = ( fieldsets = (
("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}), ("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}),
("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5")}), ("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5", "supervisor_sms_ticket_link_enabled")}),
("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}), ("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}),
) )
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-13 12:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('staff', '0012_organizationalunit_supervisor_mobile_5'),
]
operations = [
migrations.AddField(
model_name='organizationalunit',
name='supervisor_sms_ticket_link_enabled',
field=models.BooleanField(default=False, help_text='لینک کوتاه مشاهده و پاسخ با اعتبار ۷ روز؛ ورود کارشناسان و مجوز دسترسی به تیکت الزامی است.', verbose_name='افزودن لینک تیکت به پیامک سرپرستان'),
),
]
+5
View File
@@ -38,6 +38,11 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم") supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم")
supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم") supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم")
supervisor_mobile_5 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست پنجم") supervisor_mobile_5 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست پنجم")
supervisor_sms_ticket_link_enabled = models.BooleanField(
default=False,
verbose_name="افزودن لینک تیکت به پیامک سرپرستان",
help_text="لینک کوتاه مشاهده و پاسخ با اعتبار ۷ روز؛ ورود کارشناسان و مجوز دسترسی به تیکت الزامی است.",
)
class Meta: class Meta:
ordering = ["sort_order", "name", "pk"] ordering = ["sort_order", "name", "pk"]
+40 -1
View File
@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketCategory,TicketEvent,TicketReadState,TicketTag from .models import DispatchResponseControl,GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketCategory,TicketEvent,TicketReadState,TicketTag
@admin.register(Ticket) @admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin): class TicketAdmin(admin.ModelAdmin):
list_display=("ticket_number","subject","owning_unit","delivery_type","status","priority","owner","last_activity_at") list_display=("ticket_number","subject","owning_unit","delivery_type","status","priority","owner","last_activity_at")
@@ -7,6 +7,15 @@ class TicketAdmin(admin.ModelAdmin):
search_fields=("ticket_number","subject","customer__mobile","customer__customer_profile__organization_name") search_fields=("ticket_number","subject","customer__mobile","customer__customer_profile__organization_name")
filter_horizontal=("tags",) filter_horizontal=("tags",)
def get_readonly_fields(self, request, obj=None):
if obj and obj.group_dispatch_id and DispatchResponseControl.objects.filter(dispatch_id=obj.group_dispatch_id).exists():
return ("group_dispatch", "delivery_type", "customer", "created_by")
return super().get_readonly_fields(request, obj)
def get_queryset(self, request):
from .dispatch_responses import managed_ticket_q
return super().get_queryset(request).filter(managed_ticket_q(request.user)).distinct()
class DispatchTicketInline(admin.TabularInline): class DispatchTicketInline(admin.TabularInline):
model = Ticket model = Ticket
@@ -27,6 +36,36 @@ class GroupDispatchAdmin(admin.ModelAdmin):
readonly_fields = ("recipient_count", "dispatched_at") readonly_fields = ("recipient_count", "dispatched_at")
inlines = (DispatchTicketInline,) inlines = (DispatchTicketInline,)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
if obj and DispatchResponseControl.objects.filter(dispatch_id=obj.pk).exists():
return fields + ("unit", "operation_type", "created_by", "contact_group")
return fields
def get_queryset(self, request):
from .policies import DispatchPolicy
from django.db.models import Q
return super().get_queryset(request).filter(Q(response_control__isnull=True) | Q(pk__in=DispatchPolicy.visible_dispatches(request.user)))
@admin.register(DispatchResponseControl)
class DispatchResponseControlAdmin(admin.ModelAdmin):
list_display = ("dispatch", "mode", "notify_sms", "opened_at", "opened_by")
readonly_fields = tuple(field.name for field in DispatchResponseControl._meta.fields)
def get_queryset(self, request):
from .policies import DispatchPolicy
return super().get_queryset(request).filter(dispatch__in=DispatchPolicy.visible_dispatches(request.user))
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(TicketCategory) @admin.register(TicketCategory)
class TicketCategoryAdmin(admin.ModelAdmin): class TicketCategoryAdmin(admin.ModelAdmin):
+35
View File
@@ -0,0 +1,35 @@
"""Read-only option metadata for the staff dispatch composer."""
from django.db.models import Count, Q
from .models import GroupDispatch, TicketCategory
from .policies import DispatchPolicy
def dispatch_form_options(user, units, groups):
"""Expose only permitted units; recipient counts follow the sending service."""
units = list(units)
eligible = (
Q(contacts__user__is_active=True, contacts__user__mobile__isnull=False)
& ~Q(contacts__user__mobile="")
)
return {
"units": [
{
"id": unit.pk,
"name": unit.name,
"operations": [
kind for kind in GroupDispatch.OperationType.values
if DispatchPolicy.can_create(user, unit, kind)
],
}
for unit in units
],
"groups": list(groups.annotate(recipient_count=Count("contacts", filter=eligible)).values(
"id", "name", "unit_id", "recipient_count",
)),
"categories": list(TicketCategory.objects.filter(
Q(unit_id__in=[unit.pk for unit in units]) | Q(unit__isnull=True),
is_active=True,
).order_by("title").values("id", "title", "unit_id")),
}
+45
View File
@@ -0,0 +1,45 @@
from django.core import signing
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_http_methods
from .dispatch_responses import can_open_responses, open_dispatch_responses, render_opening_sms, response_control
from .models import GroupDispatch
from apps.notifications.models import SMSTemplate
from .views import staff_required
@staff_required
@require_http_methods(["GET", "POST"])
@never_cache
def open_responses(request, public_id):
dispatch = get_object_or_404(GroupDispatch.objects.select_related("unit"), public_id=public_id)
control = response_control(dispatch)
if not control or control.mode != "SEALED" or not can_open_responses(request.user, dispatch):
raise PermissionDenied
if control.opened_at:
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
if request.method == "POST":
try:
proof = signing.loads(request.POST.get("confirmation", ""), salt="dispatch-opening", max_age=600)
if proof["user"] != request.user.pk or proof["dispatch"] != str(public_id):
raise signing.BadSignature
open_dispatch_responses(dispatch, request.user, proof["sms"])
except (signing.BadSignature, KeyError):
messages.error(request, "تأیید معتبر نیست یا مهلت آن پایان یافته؛ دوباره بررسی کنید.")
except (ValidationError, ValueError, SMSTemplate.DoesNotExist) as error:
messages.error(request, str(error))
else:
messages.success(request, "پاسخ‌ها هم‌زمان بازگشایی شدند؛ وضعیت پیامک را در گزارش بررسی کنید.")
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
try:
sms = render_opening_sms(dispatch) if control.notify_sms else ""
except (SMSTemplate.DoesNotExist, ValueError):
messages.error(request, "قالب پیامک بازگشایی معتبر یا فعال نیست؛ تنظیمات قالب را بررسی کنید.")
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
from .dispatch_responses import _respondents
proof = signing.dumps({"user": request.user.pk, "dispatch": str(public_id), "sms": sms}, salt="dispatch-opening")
return render(request, "tickets/dispatch_open_confirm.html", {"dispatch": dispatch, "control": control,
"opening_sms": sms, "respondent_count": len(_respondents(dispatch)), "confirmation": proof})
+139
View File
@@ -0,0 +1,139 @@
"""Response confidentiality is independent of ordinary ticket permissions."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Count, F, Max, Min, Q
from django.utils import timezone
from .models import DispatchResponseControl, GroupDispatch
from .policies import DispatchPolicy, TicketPolicy
OPENING_EVENT = "DISPATCH_RESPONSES_OPENED"
def response_control(dispatch):
if dispatch is None:
return None
return DispatchResponseControl.objects.filter(dispatch_id=dispatch.pk).first()
def responses_locked(ticket):
control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None
return bool(control and control.mode == control.Mode.SEALED and control.opened_at is None)
def sealed_message_q(prefix=""):
return Q(**{
prefix + "ticket__group_dispatch__response_control__mode": "SEALED",
prefix + "ticket__group_dispatch__response_control__opened_at__isnull": True,
prefix + "author_id": F(prefix + "ticket__customer_id"),
})
def can_read_response(user, message):
if message.author_id != message.ticket.customer_id:
return True
if getattr(user, "account_type", None) == "CUSTOMER" and user.pk == message.author_id:
return True
return not responses_locked(message.ticket)
def filter_response_messages(query, user):
if getattr(user, "account_type", None) == "CUSTOMER":
return query.exclude(sealed_message_q() & ~Q(author_id=user.pk))
return query.exclude(sealed_message_q())
def can_record_ticket_read(ticket, user):
if not TicketPolicy.can_view(user, ticket):
return False
if not responses_locked(ticket):
return True
return not ticket.ticket_messages.filter(author_id=ticket.customer_id, deleted_at__isnull=True).exists()
def can_open_responses(user, dispatch):
if not user.is_authenticated or not user.is_active or not DispatchPolicy.can_view(user, dispatch):
return False
profile = getattr(user, "staff_profile", None)
if getattr(user, "account_type", None) != "STAFF" or (profile and not profile.is_active):
return False
return bool(user.is_superuser or dispatch.created_by_id == user.pk or user.has_perm("tickets.open_all_dispatch_responses"))
def render_opening_sms(dispatch):
from apps.notifications.management.commands.process_notifications import _render_sms
return _render_sms(OPENING_EVENT, {}, dispatch.unit)
def _respondents(dispatch):
# A withdrawn/deleted message is not a current response; attachment-only replies count.
return list(dispatch.tickets.filter(ticket_messages__author_id=F("customer_id"),
ticket_messages__deleted_at__isnull=True).distinct().order_by("pk"))
def _queue_opening_sms(control, tickets):
from apps.notifications.services import enqueue
from apps.notifications.models import NotificationJob
for ticket in tickets:
enqueue(NotificationJob.Channel.SMS, OPENING_EVENT, ticket,
f"dispatch:{control.dispatch_id}:opened:{ticket.customer_id}",
customer=ticket.customer,
payload={"dispatch_id": control.dispatch_id, "body": control.sms_body_snapshot})
@transaction.atomic
def open_dispatch_responses(dispatch, user, expected_sms=""):
# Replies take the same control lock before locking their ticket.
control = DispatchResponseControl.objects.select_for_update(of=("self",)).select_related("dispatch__unit").get(dispatch=dispatch)
dispatch = control.dispatch
if not can_open_responses(user, dispatch):
raise PermissionDenied
if control.mode != control.Mode.SEALED:
raise ValidationError("این ارسال در حالت مشاهده جداگانه است.")
if control.opened_at:
return control, False
text = render_opening_sms(dispatch) if control.notify_sms else ""
if text != expected_sms:
raise ValidationError("قالب پیامک تغییر کرده است؛ متن جدید را بررسی و دوباره تأیید کنید.")
tickets = _respondents(dispatch)
control.opened_at = timezone.now()
control.opened_by = user
control.sms_body_snapshot = text
control.respondents_snapshot = [str(ticket.customer.public_id) for ticket in tickets]
control.save(update_fields=["opened_at", "opened_by", "sms_body_snapshot", "respondents_snapshot"])
if control.notify_sms:
_queue_opening_sms(control, tickets)
return control, True
def lock_dispatch_for_ticket(ticket_id):
"""Must be called inside the caller's transaction, before its ticket lock."""
return DispatchResponseControl.objects.select_for_update(of=("self",)).filter(dispatch__tickets__pk=ticket_id).first()
def managed_ticket_q(user):
"""Explicitly related tickets or dispatch visibility; unit-wide scope alone is insufficient."""
dispatches = DispatchPolicy.visible_dispatches(user)
direct = Q(created_by=user) | Q(owner=user) | Q(selected_expert=user)
direct |= Q(access_grants__user=user, access_grants__revoked_at__isnull=True)
return Q(group_dispatch__response_control__isnull=True) | direct | Q(group_dispatch__in=dispatches)
def populate_report_responses(dispatch, tickets, locked):
"""Load report responses in batches; before opening, select metadata only."""
from apps.messaging.models import Message
responses = Message.objects.filter(ticket__group_dispatch=dispatch, author_id=F("ticket__customer_id"), deleted_at__isnull=True)
metadata = {row["ticket_id"]: row for row in responses.values("ticket_id").annotate(
count=Count("pk"), first=Min("created_at"), last=Max("created_at"))}
by_ticket = {}
if not locked:
for message in responses.order_by("created_at", "pk").prefetch_related("attachments"):
by_ticket.setdefault(message.ticket_id, []).append(message)
for ticket in tickets:
row = metadata.get(ticket.pk, {})
ticket.response_count = row.get("count", 0)
ticket.first_response_at = row.get("first")
ticket.last_response_at = row.get("last")
ticket.report_customer_responses = by_ticket.get(ticket.pk, [])
ticket.latest_customer_response = ticket.report_customer_responses[-1] if ticket.report_customer_responses else None
+16
View File
@@ -140,6 +140,8 @@ class StaffCreateTicketForm(forms.Form):
class GroupDispatchForm(forms.Form): class GroupDispatchForm(forms.Form):
response_mode = forms.ChoiceField(label="نحوه مشاهده پاسخ‌ها", choices=[("SEALED", "بازگشایی یکجا"), ("INDIVIDUAL", "مشاهده جداگانه")], initial="SEALED", required=False, widget=forms.RadioSelect)
notify_opening_sms = forms.BooleanField(label="اطلاع‌رسانی بازگشایی با پیامک", initial=True, required=False)
operation_type = forms.ChoiceField(label="نوع عملیات", choices=(), required=False) operation_type = forms.ChoiceField(label="نوع عملیات", choices=(), required=False)
unit = forms.ModelChoiceField(label="واحد ارسال‌کننده", queryset=OrganizationalUnit.objects.none()) unit = forms.ModelChoiceField(label="واحد ارسال‌کننده", queryset=OrganizationalUnit.objects.none())
contact_group = forms.ModelChoiceField(label="گروه مخاطبان", queryset=ContactGroup.objects.none()) contact_group = forms.ModelChoiceField(label="گروه مخاطبان", queryset=ContactGroup.objects.none())
@@ -177,6 +179,11 @@ class GroupDispatchForm(forms.Form):
data["operation_type"] = GroupDispatch.OperationType.BULK data["operation_type"] = GroupDispatch.OperationType.BULK
unit = data.get("unit") unit = data.get("unit")
group = data.get("contact_group") group = data.get("contact_group")
data["response_mode"] = data.get("response_mode") or "SEALED"
if data["response_mode"] == "INDIVIDUAL":
data["notify_opening_sms"] = False
if group and group.contacts.filter(user__is_active=True, user__mobile__isnull=False).exclude(user__mobile="").count() > 15:
data["notify_opening_sms"] = False
if unit and group and group.unit_id != unit.pk: if unit and group and group.unit_id != unit.pk:
self.add_error("contact_group", "این گروه متعلق به واحد انتخاب‌شده نیست.") self.add_error("contact_group", "این گروه متعلق به واحد انتخاب‌شده نیست.")
category=data.get("category") category=data.get("category")
@@ -264,3 +271,12 @@ class UnitSMSTemplateForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["unit"].queryset = OrganizationalUnit.objects.filter(pk__in=unit_ids if unit_ids is not None else TicketPolicy.unit_ids(user), is_active=True) self.fields["unit"].queryset = OrganizationalUnit.objects.filter(pk__in=unit_ids if unit_ids is not None else TicketPolicy.unit_ids(user), is_active=True)
self.fields["unit"].required = True self.fields["unit"].required = True
def clean(self):
data = super().clean()
if data.get('event_type') in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}:
from apps.payments.notifications import TAGS
self.instance.allowed_tags = list(TAGS)
elif data.get('event_type'):
self.instance.allowed_tags = SMSTemplate._meta.get_field('allowed_tags').default()
return data
@@ -0,0 +1,43 @@
# Generated by Django 5.1.1 on 2026-10-13 09:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def preserve_used_response_controls(apps, schema_editor):
from django.db.migrations.exceptions import IrreversibleError
if apps.get_model('tickets', 'DispatchResponseControl').objects.using(schema_editor.connection.alias).exists():
raise IrreversibleError('Response controls are in use; refusing to delete confidentiality and opening history.')
class Migration(migrations.Migration):
dependencies = [
('tickets', '0017_ticketaccessgrant_first_viewed_at_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DispatchResponseControl',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mode', models.CharField(choices=[('SEALED', 'بازگشایی یکجا'), ('INDIVIDUAL', 'مشاهده جداگانه')], default='SEALED', max_length=12)),
('notify_sms', models.BooleanField(default=True)),
('recipient_count_snapshot', models.PositiveIntegerField()),
('opened_at', models.DateTimeField(blank=True, null=True)),
('sms_body_snapshot', models.TextField(blank=True)),
('respondents_snapshot', models.JSONField(blank=True, default=list)),
('dispatch', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='response_control', to='tickets.groupdispatch')),
('opened_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'تنظیمات و سابقه بازگشایی ارسال',
'verbose_name_plural': 'تنظیمات و سوابق بازگشایی ارسال‌ها',
'permissions': [('open_all_dispatch_responses', 'Can open responses of all authorized dispatches')],
'constraints': [models.CheckConstraint(condition=models.Q(('notify_sms', False), models.Q(('mode', 'SEALED'), ('recipient_count_snapshot__lte', 15)), _connector='OR'), name='dispatch_opening_sms_limit'), models.CheckConstraint(condition=models.Q(('mode__in', ['SEALED', 'INDIVIDUAL'])), name='dispatch_response_mode_valid'), models.CheckConstraint(condition=models.Q(models.Q(('opened_at__isnull', True), ('opened_by__isnull', True)), models.Q(('mode', 'SEALED'), ('opened_at__isnull', False), ('opened_by__isnull', False)), _connector='OR'), name='dispatch_opening_audit_complete')],
},
),
migrations.RunPython(migrations.RunPython.noop, preserve_used_response_controls),
]
@@ -0,0 +1,30 @@
# Generated by Django 5.1.1 on 2026-10-13 12:22
import apps.tickets.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0018_dispatch_response_opening'),
]
operations = [
migrations.CreateModel(
name='StaffTicketShortLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('token', models.CharField(default=apps.tickets.models.staff_ticket_link_token, editable=False, max_length=10, unique=True)),
('expires_at', models.DateTimeField(default=apps.tickets.models.staff_ticket_link_expiry)),
('ticket', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='staff_short_link', to='tickets.ticket')),
],
options={
'verbose_name': 'لینک کوتاه تیکت کارشناسان',
'verbose_name_plural': 'لینک\u200cهای کوتاه تیکت کارشناسان',
},
),
]
+52
View File
@@ -1,10 +1,35 @@
import uuid import uuid
import secrets
import string
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.utils import timezone
from apps.core.models import PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel from apps.core.models import PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel
def staff_ticket_link_token():
return "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(10))
def staff_ticket_link_expiry():
return timezone.now() + timedelta(days=7)
class StaffTicketShortLink(TimeStampedModel):
"""A short navigation URL; it never authenticates or grants ticket access."""
ticket = models.OneToOneField("Ticket", on_delete=models.CASCADE, related_name="staff_short_link")
token = models.CharField(max_length=10, unique=True, default=staff_ticket_link_token, editable=False)
expires_at = models.DateTimeField(default=staff_ticket_link_expiry)
class Meta:
verbose_name = "لینک کوتاه تیکت کارشناسان"
verbose_name_plural = "لینک‌های کوتاه تیکت کارشناسان"
class TicketCategory(PersianTextNormalizationMixin, TimeStampedModel): class TicketCategory(PersianTextNormalizationMixin, TimeStampedModel):
persian_text_fields = {"title": False, "description": True, "search_keywords": True} persian_text_fields = {"title": False, "description": True, "search_keywords": True}
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="ticket_categories",verbose_name="واحد سازمانی",help_text="برای دسته‌های قدیمی عمومی می‌تواند خالی باشد؛ دسته‌های جدید باید واحد داشته باشند.") unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="ticket_categories",verbose_name="واحد سازمانی",help_text="برای دسته‌های قدیمی عمومی می‌تواند خالی باشد؛ دسته‌های جدید باید واحد داشته باشند.")
@@ -176,3 +201,30 @@ class MessageShare(PublicIdModel, models.Model):
class TicketReadState(models.Model): class TicketReadState(models.Model):
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="read_states"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); last_read_at=models.DateTimeField(); last_read_message=models.ForeignKey("messaging.Message",on_delete=models.SET_NULL,null=True,blank=True) ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="read_states"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); last_read_at=models.DateTimeField(); last_read_message=models.ForeignKey("messaging.Message",on_delete=models.SET_NULL,null=True,blank=True)
class Meta: constraints=[models.UniqueConstraint(fields=["ticket","user"],name="unique_ticket_read_state")] class Meta: constraints=[models.UniqueConstraint(fields=["ticket","user"],name="unique_ticket_read_state")]
class DispatchResponseControl(models.Model):
"""Absent for legacy dispatches: their existing visibility is preserved."""
class Mode(models.TextChoices):
SEALED = "SEALED", "بازگشایی یکجا"
INDIVIDUAL = "INDIVIDUAL", "مشاهده جداگانه"
dispatch = models.OneToOneField(GroupDispatch, on_delete=models.PROTECT, related_name="response_control")
mode = models.CharField(max_length=12, choices=Mode.choices, default=Mode.SEALED)
notify_sms = models.BooleanField(default=True)
recipient_count_snapshot = models.PositiveIntegerField()
opened_at = models.DateTimeField(null=True, blank=True)
opened_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True)
sms_body_snapshot = models.TextField(blank=True)
respondents_snapshot = models.JSONField(default=list, blank=True)
class Meta:
verbose_name = "تنظیمات و سابقه بازگشایی ارسال"
verbose_name_plural = "تنظیمات و سوابق بازگشایی ارسال‌ها"
permissions = [("open_all_dispatch_responses", "Can open responses of all authorized dispatches")]
constraints = [
models.CheckConstraint(condition=Q(notify_sms=False) | Q(mode="SEALED", recipient_count_snapshot__lte=15), name="dispatch_opening_sms_limit"),
models.CheckConstraint(condition=Q(mode__in=["SEALED", "INDIVIDUAL"]), name="dispatch_response_mode_valid"),
models.CheckConstraint(condition=Q(opened_at__isnull=True, opened_by__isnull=True) | Q(mode="SEALED", opened_at__isnull=False, opened_by__isnull=False), name="dispatch_opening_audit_complete"),
]
+46
View File
@@ -70,6 +70,20 @@ class TicketPolicy:
} }
@classmethod @classmethod
def can_view(cls,user,ticket): def can_view(cls,user,ticket):
from .models import DispatchResponseControl
if ticket.group_dispatch_id and DispatchResponseControl.objects.filter(dispatch_id=ticket.group_dispatch_id).exists():
if not user.is_authenticated:
return False
if cls.is_customer(user):
return ticket.customer_id == user.pk
if DispatchPolicy.can_view(user, ticket.group_dispatch):
return True
return bool(ticket.created_by_id == user.pk or ticket.owner_id == user.pk or ticket.selected_expert_id == user.pk
or ticket.access_grants.filter(user=user, revoked_at__isnull=True).exists())
return cls._legacy_can_view(user, ticket)
@classmethod
def _legacy_can_view(cls,user,ticket):
if not user.is_authenticated: return False if not user.is_authenticated: return False
if cls.is_customer(user): return ticket.customer_id==user.id if cls.is_customer(user): return ticket.customer_id==user.id
if user.is_superuser or user.has_perm("tickets.view_all_tickets"): return True if user.is_superuser or user.has_perm("tickets.view_all_tickets"): return True
@@ -83,6 +97,11 @@ class TicketPolicy:
return False return False
@classmethod @classmethod
def viewable_q(cls, user): def viewable_q(cls, user):
from .dispatch_responses import managed_ticket_q
return (cls._legacy_viewable_q(user) | Q(group_dispatch__in=DispatchPolicy.visible_dispatches(user))) & managed_ticket_q(user)
@classmethod
def _legacy_viewable_q(cls, user):
"""Database equivalent of can_view; use it for every staff ticket collection.""" """Database equivalent of can_view; use it for every staff ticket collection."""
if user.is_superuser or user.has_perm("tickets.view_all_tickets"): if user.is_superuser or user.has_perm("tickets.view_all_tickets"):
return Q() return Q()
@@ -169,5 +188,32 @@ class DispatchPolicy:
@classmethod @classmethod
def can_view(cls, user, dispatch): def can_view(cls, user, dispatch):
if dispatch.created_by_id == user.id: if dispatch.created_by_id == user.id:
from .models import DispatchResponseControl
if DispatchResponseControl.objects.filter(dispatch_id=dispatch.pk).exists():
return bool(user.is_authenticated and user.is_active and getattr(user, "account_type", None) == "STAFF")
return cls.can_create(user, dispatch.unit, dispatch.operation_type) return cls.can_create(user, dispatch.unit, dispatch.operation_type)
return cls.can_view_all(user, dispatch.operation_type, dispatch.unit) return cls.can_view_all(user, dispatch.operation_type, dispatch.unit)
@classmethod
def visible_dispatches(cls, user):
from .models import GroupDispatch
if user.is_superuser:
return GroupDispatch.objects.all()
visibility = Q(created_by=user, response_control__isnull=False)
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
for operation_type in GroupDispatch.OperationType.values:
if cls.can_view_all(user, operation_type):
visibility |= Q(operation_type=operation_type)
continue
view_units = [item.unit_id for item in memberships if cls.can_view_all(user, operation_type, item.unit)]
create_units = [item.unit_id for item in memberships if cls.can_create(user, item.unit, operation_type)]
visibility |= Q(operation_type=operation_type, unit_id__in=view_units)
visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user)
return GroupDispatch.objects.filter(visibility)
@classmethod
def can_list(cls, user):
if any(cls.can_view_all(user, kind) for kind in cls.VIEW_ALL_PERMISSIONS):
return True
units = user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit")
return any(cls.can_view_all(user, kind, item.unit) for item in units for kind in cls.VIEW_ALL_PERMISSIONS)
+22
View File
@@ -0,0 +1,22 @@
"""Even superusers use the response-content gate in the application admin."""
from django.contrib import admin
from django.db.models import Q
from .dispatch_responses import sealed_message_q
from .models import Ticket
from .policies import TicketPolicy
class ResponseContentAdmin(admin.ModelAdmin):
message_prefix = ""
def get_queryset(self, request):
query = super().get_queryset(request).exclude(sealed_message_q(self.message_prefix))
ticket_path = self.message_prefix + "ticket"
allowed = Ticket.objects.filter(TicketPolicy.viewable_q(request.user)).values("pk")
return query.filter(Q(**{ticket_path + "__group_dispatch__response_control__isnull": True})
| Q(**{ticket_path + "__in": allowed})).distinct()
class RelatedResponseContentAdmin(ResponseContentAdmin):
message_prefix = "message__"
+4 -3
View File
@@ -1,7 +1,7 @@
from django.db.models import Case, F, IntegerField, Q, Value, When from django.db.models import Case, Exists, IntegerField, OuterRef, Q, Value, When
from django.utils import timezone from django.utils import timezone
from django.utils.dateparse import parse_date from django.utils.dateparse import parse_date
from .models import Ticket from .models import Ticket, TicketReadState
from .policies import TicketPolicy from .policies import TicketPolicy
def customer_tickets(user): return Ticket.objects.filter(customer=user).select_related("category","owner__staff_profile","selected_expert__staff_profile") def customer_tickets(user): return Ticket.objects.filter(customer=user).select_related("category","owner__staff_profile","selected_expert__staff_profile")
def staff_ticket_rows(user): def staff_ticket_rows(user):
@@ -56,7 +56,8 @@ def filter_staff_tickets(qs, user, params):
if tab == "shared": if tab == "shared":
qs=qs.filter(Q(access_grants__user=user,access_grants__revoked_at__isnull=True,access_grants__first_viewed_at__isnull=True) | Q(pk__in=received_messages(user).filter(first_viewed_at__isnull=True).values("message__ticket_id"))) qs=qs.filter(Q(access_grants__user=user,access_grants__revoked_at__isnull=True,access_grants__first_viewed_at__isnull=True) | Q(pk__in=received_messages(user).filter(first_viewed_at__isnull=True).values("message__ticket_id")))
else: else:
qs=qs.filter(Q(read_states__user=user,read_states__last_read_at__lt=F("last_activity_at"))|Q(read_states__isnull=True)) read_by_user=TicketReadState.objects.filter(ticket_id=OuterRef('pk'),user=user,last_read_at__gte=OuterRef('last_activity_at'))
qs=qs.alias(_read_by_current_user=Exists(read_by_user)).filter(_read_by_current_user=False)
qs=qs.distinct() qs=qs.distinct()
if tab in ("", "waiting_staff"): if tab in ("", "waiting_staff"):
return qs.annotate( return qs.annotate(
+22 -1
View File
@@ -63,6 +63,7 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
) )
if not contacts: if not contacts:
raise ValidationError("گروه انتخاب‌شده مخاطب فعال دارای شماره همراه ندارد.") raise ValidationError("گروه انتخاب‌شده مخاطب فعال دارای شماره همراه ندارد.")
from .models import DispatchResponseControl
dispatch = GroupDispatch.objects.create( dispatch = GroupDispatch.objects.create(
unit=unit, unit=unit,
operation_type=operation_type, operation_type=operation_type,
@@ -74,6 +75,9 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
status=GroupDispatch.Status.QUEUED, status=GroupDispatch.Status.QUEUED,
recipient_count=len(contacts), recipient_count=len(contacts),
) )
DispatchResponseControl.objects.create(dispatch=dispatch, mode="SEALED",
notify_sms=len(contacts) <= 15,
recipient_count_snapshot=len(contacts))
tickets=[] tickets=[]
for contact in contacts: for contact in contacts:
for uploaded in files: for uploaded in files:
@@ -97,6 +101,20 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
return dispatch, tickets return dispatch, tickets
@transaction.atomic
def create_group_dispatch(*, response_mode="SEALED", notify_opening_sms=True, **dispatch_fields):
"""Configure responses without changing the existing creation API."""
from .models import DispatchResponseControl
if response_mode not in DispatchResponseControl.Mode.values:
raise ValidationError("روش مشاهده پاسخ‌ها معتبر نیست.")
dispatch, tickets = dispatch_group_tickets(**dispatch_fields)
control = DispatchResponseControl.objects.get(dispatch=dispatch)
control.mode = response_mode
control.notify_sms = bool(notify_opening_sms and response_mode == "SEALED" and control.recipient_count_snapshot <= 15)
control.save(update_fields=["mode", "notify_sms"])
return dispatch, tickets
def refresh_group_dispatch_status(dispatch_id): def refresh_group_dispatch_status(dispatch_id):
"""Persist delivery state independently of whether a report page is opened.""" """Persist delivery state independently of whether a report page is opened."""
dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first() dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first()
@@ -194,6 +212,8 @@ def revoke_access(ticket,user,target):
@transaction.atomic @transaction.atomic
def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()): def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()):
from .dispatch_responses import lock_dispatch_for_ticket
control = lock_dispatch_for_ticket(ticket_id)
ticket=Ticket.objects.select_for_update().get(pk=ticket_id) ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
if not TicketPolicy.can_reply(user,ticket): raise PermissionDenied if not TicketPolicy.can_reply(user,ticket): raise PermissionDenied
if internal and (TicketPolicy.is_customer(user) or not user.has_perm("messaging.add_internal_note")): raise PermissionDenied if internal and (TicketPolicy.is_customer(user) or not user.has_perm("messaging.add_internal_note")): raise PermissionDenied
@@ -204,7 +224,8 @@ def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_user
if not TicketPolicy.can_view(target,ticket): if not TicketPolicy.can_view(target,ticket):
if TicketPolicy.can_share(user,ticket): share_ticket(ticket,user,target) if TicketPolicy.can_share(user,ticket): share_ticket(ticket,user,target)
else: raise PermissionDenied("کارشناس Mention‌شده به تیکت دسترسی ندارد.") else: raise PermissionDenied("کارشناس Mention‌شده به تیکت دسترسی ندارد.")
MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":body[:150]}) excerpt = "پاسخ محرمانه ثبت شد." if control and control.mode == "SEALED" and not control.opened_at and user.pk == ticket.customer_id else body[:150]
MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":excerpt})
now=timezone.now(); ticket.last_activity_at=now now=timezone.now(); ticket.last_activity_at=now
if not internal: if not internal:
if TicketPolicy.is_customer(user): if TicketPolicy.is_customer(user):
+5 -1
View File
@@ -66,6 +66,9 @@ def share_staff_ticket(ticket, actor, target, level, note=""):
@transaction.atomic @transaction.atomic
def share_staff_message(message, actor, target, note=""): def share_staff_message(message, actor, target, note=""):
from .dispatch_responses import can_read_response
if not can_read_response(actor, message):
raise PermissionDenied("پاسخ تا بازگشایی قابل اشتراک نیست.")
ticket = Ticket.objects.select_for_update().get(pk=message.ticket_id) ticket = Ticket.objects.select_for_update().get(pk=message.ticket_id)
require_share_authority(actor, ticket) require_share_authority(actor, ticket)
validate_recipient(actor, target) validate_recipient(actor, target)
@@ -147,11 +150,12 @@ def staff_message_context(ticket, actor, conversation=()):
def sharing_context(ticket, actor): def sharing_context(ticket, actor):
from .dispatch_responses import sealed_message_q
if not TicketPolicy.can_share(actor, ticket): if not TicketPolicy.can_share(actor, ticket):
return {} return {}
return { return {
"grants": ticket.access_grants.select_related("user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile").order_by("-created_at", "-pk"), "grants": ticket.access_grants.select_related("user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile").order_by("-created_at", "-pk"),
"message_shares": MessageShare.objects.filter(message__ticket=ticket).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"), "message_shares": MessageShare.objects.filter(message__ticket=ticket).exclude(sealed_message_q("message__")).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"),
"sharing_events": sharing_events(ticket), "sharing_events": sharing_events(ticket),
} }
+27 -18
View File
@@ -1,25 +1,30 @@
"""Routes available only to staff, including isolated message attachment delivery.""" """Routes available only to staff, including isolated message attachment delivery."""
import mimetypes
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.db import transaction
from django.utils import timezone
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.http import FileResponse, JsonResponse from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.cache import never_cache from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from apps.attachments.delivery import signed_media_redirect from apps.attachments.delivery import attachment_response
from apps.attachments.models import Attachment from apps.attachments.models import Attachment
from .models import MessageShare, Ticket from .models import MessageShare, Ticket, TicketReadState
from .policies import TicketPolicy
from apps.messaging.read_receipts import message_read_ids
from .staff_conversation import received_messages, unread_shares, visible_messages from .staff_conversation import received_messages, unread_shares, visible_messages
from .sharing import mark_share_opened, revoke_staff_share from .sharing import mark_share_opened, revoke_staff_share
from .views import staff_required from .views import staff_required, mark_messages_read
def received_message_share(request, share_id): def received_message_share(request, share_id):
from .dispatch_responses import can_read_response
grant = get_object_or_404(MessageShare.objects.select_related("message__author__staff_profile", "granted_by__staff_profile"), public_id=share_id) grant = get_object_or_404(MessageShare.objects.select_related("message__author__staff_profile", "granted_by__staff_profile"), public_id=share_id)
profile = getattr(request.user, "staff_profile", None) profile = getattr(request.user, "staff_profile", None)
if not can_read_response(request.user, grant.message):
raise PermissionDenied
if (grant.user_id != request.user.pk or grant.revoked_at or grant.message.deleted_at if (grant.user_id != request.user.pk or grant.revoked_at or grant.message.deleted_at
or not request.user.is_active or not profile or not profile.is_active): or not request.user.is_active or not profile or not profile.is_active):
raise PermissionDenied raise PermissionDenied
@@ -66,18 +71,27 @@ def shared_message(request, share_id):
@staff_required @staff_required
@require_POST @require_POST
@never_cache @never_cache
@transaction.atomic
def read_shared_messages(request, public_id): def read_shared_messages(request, public_id):
ticket = get_object_or_404(Ticket, public_id=public_id) from .dispatch_responses import can_record_ticket_read
ticket = get_object_or_404(Ticket.objects.select_for_update(), public_id=public_id)
allowed = visible_messages(ticket, request.user) allowed = visible_messages(ticket, request.user)
ids = request.POST.getlist("message_ids")[:100] try:
# Filter strings without parsing attacker-controlled UUIDs in the ORM. ids = message_read_ids(request)
visible = {str(value) for value in allowed.values_list("public_id", flat=True)} except ValidationError:
accepted = set(ids) & visible return JsonResponse({"ok": False, "error": "invalid_message_ids"}, status=400)
acknowledged = list(allowed.filter(public_id__in=ids, deleted_at__isnull=True))
mark_messages_read(acknowledged, request.user)
accepted = [item.public_id for item in acknowledged]
grants = received_messages(request.user, ticket).filter(message__public_id__in=accepted) grants = received_messages(request.user, ticket).filter(message__public_id__in=accepted)
for grant in grants: for grant in grants:
if grant.first_viewed_at is None: if grant.first_viewed_at is None:
mark_share_opened(grant, request.user) mark_share_opened(grant, request.user)
return JsonResponse({"unread_shares": unread_shares(request.user)}) latest = allowed.filter(deleted_at__isnull=True).order_by('-created_at', '-pk').first()
if latest and latest.public_id in accepted and can_record_ticket_read(ticket, request.user):
TicketReadState.objects.update_or_create(ticket=ticket, user=request.user,
defaults={'last_read_at': timezone.now(), 'last_read_message': latest})
return JsonResponse({"unread_shares": unread_shares(request.user), "read_ids": [str(value) for value in accepted]})
@staff_required @staff_required
@@ -93,9 +107,4 @@ def unread_count(request):
def shared_attachment(request, share_id, attachment_id, download=False): def shared_attachment(request, share_id, attachment_id, download=False):
grant = received_message_share(request, share_id) grant = received_message_share(request, share_id)
attachment = get_object_or_404(Attachment, public_id=attachment_id, message_id=grant.message_id) attachment = get_object_or_404(Attachment, public_id=attachment_id, message_id=grant.message_id)
if settings.USE_NGINX_MEDIA: return attachment_response(attachment, download=download)
return signed_media_redirect(attachment.relative_path, download=download)
content_type = attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
response = FileResponse(attachment.file.open("rb"), content_type=content_type, as_attachment=download, filename=attachment.original_name)
response["X-Content-Type-Options"] = "nosniff"
return response
+38
View File
@@ -0,0 +1,38 @@
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET
from apps.accounts.models import User
from .models import StaffTicketShortLink
from .policies import TicketPolicy
def append_supervisor_ticket_link(ticket, message):
unit = ticket.owning_unit
if not unit or not unit.supervisor_sms_ticket_link_enabled:
return message
link, _ = StaffTicketShortLink.objects.get_or_create(ticket=ticket)
if link.expires_at <= timezone.now():
return message
url = settings.PUBLIC_BASE_URL.rstrip("/") + reverse("staff_ticket_short_link", args=[link.token])
return f"{message}\nمشاهده و پاسخ: {url}"
@never_cache
@require_GET
@login_required(login_url="/staff/login/")
def open_staff_ticket_link(request, token):
if not request.user.is_active or request.user.account_type != User.AccountType.STAFF:
raise PermissionDenied
link = get_object_or_404(
StaffTicketShortLink.objects.select_related("ticket"),
token=token, expires_at__gt=timezone.now(),
)
if not TicketPolicy.can_view(request.user, link.ticket):
raise PermissionDenied
return redirect("staff_tickets:detail", public_id=link.ticket.public_id)
+4 -1
View File
@@ -12,7 +12,9 @@ from .sharing import staff_message_context
def received_messages(user, ticket=None): def received_messages(user, ticket=None):
from .dispatch_responses import sealed_message_q
grants = MessageShare.objects.filter(user=user, revoked_at__isnull=True, message__deleted_at__isnull=True) grants = MessageShare.objects.filter(user=user, revoked_at__isnull=True, message__deleted_at__isnull=True)
grants = grants.exclude(sealed_message_q("message__"))
return grants.filter(message__ticket=ticket) if ticket is not None else grants return grants.filter(message__ticket=ticket) if ticket is not None else grants
@@ -28,7 +30,8 @@ def visible_messages(ticket, user):
profile = getattr(user, "staff_profile", None) profile = getattr(user, "staff_profile", None)
if not user.is_active or (profile is not None and not profile.is_active): if not user.is_active or (profile is not None and not profile.is_active):
raise PermissionDenied raise PermissionDenied
query = ticket.ticket_messages.all() from .dispatch_responses import filter_response_messages
query = filter_response_messages(ticket.ticket_messages.all(), user)
if TicketPolicy.can_view(user, ticket): if TicketPolicy.can_view(user, ticket):
return query return query
grants = received_messages(user, ticket) grants = received_messages(user, ticket)
+2 -1
View File
@@ -1,9 +1,10 @@
from django.urls import path from django.urls import path
from . import views, sharing_views from . import views, sharing_views, dispatch_response_views
app_name="staff_tickets" app_name="staff_tickets"
urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contacts/<uuid:public_id>/call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/announcement/now/",views.staff_announcement_now,name="announcement_now"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")] urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contacts/<uuid:public_id>/call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/announcement/now/",views.staff_announcement_now,name="announcement_now"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
urlpatterns += [ urlpatterns += [
path("dispatches/<uuid:public_id>/open-responses/", dispatch_response_views.open_responses, name="dispatch_open_responses"),
path("shares/count/", sharing_views.unread_count, name="shares_count"), path("shares/count/", sharing_views.unread_count, name="shares_count"),
path("tickets/<uuid:public_id>/shares/read/", sharing_views.read_shared_messages, name="read_shares"), path("tickets/<uuid:public_id>/shares/read/", sharing_views.read_shared_messages, name="read_shares"),
path("shares/", sharing_views.inbox, name="shares"), path("shares/", sharing_views.inbox, name="shares"),
+65 -54
View File
@@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied,ValidationError
from django.http import FileResponse, HttpResponse, JsonResponse from django.http import FileResponse, HttpResponse, JsonResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.db import transaction from django.db import transaction
from django.db.models import Count, Prefetch, Q, Sum from django.db.models import Count, Max, Prefetch, Q, Sum
from django.shortcuts import get_object_or_404,redirect,render from django.shortcuts import get_object_or_404,redirect,render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@@ -32,13 +32,13 @@ from apps.core.persian_text import normalize_persian_text
from apps.integrations.voip import place_voip_call from apps.integrations.voip import place_voip_call
from apps.staff.models import OrganizationalUnit, StaffProfile from apps.staff.models import OrganizationalUnit, StaffProfile
from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm
from .models import GroupDispatch,MessageShare,Ticket,TicketCategory,TicketReadState,TicketTag from .models import GroupDispatch,MessageShare,Ticket,TicketCategory,TicketTag
from .forms import StaffMessageShareForm, StaffTicketShareForm from .forms import StaffMessageShareForm, StaffTicketShareForm
from .sharing import mark_ticket_share_opened, require_share_authority, revoke_staff_share, share_staff_message, share_staff_ticket, sharing_context, staff_message_context from .sharing import mark_ticket_share_opened, require_share_authority, revoke_staff_share, share_staff_message, share_staff_ticket, sharing_context, staff_message_context
from .policies import DispatchPolicy, TicketPolicy from .policies import DispatchPolicy, TicketPolicy
from .staff_conversation import limited_poll, message_context, shared_ticket_rows, visible_messages from .staff_conversation import limited_poll, message_context, shared_ticket_rows, visible_messages
from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows
from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,transfer_ticket from .services import change_priority,change_status,claim_ticket,create_ticket,create_group_dispatch,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,transfer_ticket
MESSAGE_PAGE_SIZE=50 MESSAGE_PAGE_SIZE=50
SMS_RETRYABLE_DELIVERY_STATUSES=( SMS_RETRYABLE_DELIVERY_STATUSES=(
NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.UNDELIVERED,
@@ -97,7 +97,7 @@ def conversation_page(queryset,before=None):
return conversation,has_older,(conversation[0].public_id if conversation and has_older else None) return conversation,has_older,(conversation[0].public_id if conversation and has_older else None)
def mark_messages_read(conversation,user): def mark_messages_read(conversation,user):
MessageReadReceipt.objects.bulk_create( MessageReadReceipt.objects.bulk_create(
[MessageReadReceipt(message=item,user=user) for item in conversation if item.author_id != user.id], [MessageReadReceipt(message=item,user=user) for item in conversation if item.author_id != user.id and not item.deleted_at],
ignore_conflicts=True, ignore_conflicts=True,
) )
def customer_required(view): def customer_required(view):
@@ -118,14 +118,21 @@ def customer_ticket_actor(request,ticket):
return request.user,False return request.user,False
link=get_ticket_magic_access(request,ticket) link=get_ticket_magic_access(request,ticket)
return (link.customer,True) if link else (None,False) return (link.customer,True) if link else (None,False)
def conversation_version(ticket,latest): def conversation_version(ticket,latest,include_internal=False):
query=ticket.ticket_messages.all()
if not include_internal: query=query.filter(is_internal=False)
edits=query.aggregate(edited=Max('edited_at'),deleted=Max('deleted_at'))
from .dispatch_responses import response_control
control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None
revision=hashlib.sha256(repr((edits, control.opened_at if control else None)).encode()).hexdigest()[:20]
source=f"{ticket.updated_at.isoformat()}:{latest.public_id if latest else ''}:{latest.updated_at.isoformat() if latest else ''}" source=f"{ticket.updated_at.isoformat()}:{latest.public_id if latest else ''}:{latest.updated_at.isoformat() if latest else ''}"
return hashlib.sha256(source.encode()).hexdigest()[:20] return hashlib.sha256(source.encode()).hexdigest()[:20]+':'+revision
def conversation_poll_response(request,ticket,actor,include_internal=False): def conversation_poll_response(request,ticket,actor,include_internal=False):
qs=ticket.ticket_messages.all() from .dispatch_responses import filter_response_messages
qs=filter_response_messages(ticket.ticket_messages.all(), actor)
if not include_internal: qs=qs.filter(is_internal=False) if not include_internal: qs=qs.filter(is_internal=False)
latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first() latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first()
version=conversation_version(ticket,latest) version=conversation_version(ticket,latest,include_internal=include_internal)
requested_cursor = request.GET.get("cursor", "") requested_cursor = request.GET.get("cursor", "")
latest_cursor = str(latest.public_id) if latest else "" latest_cursor = str(latest.public_id) if latest else ""
# A client can have the newest version token but still miss the DOM update # A client can have the newest version token but still miss the DOM update
@@ -136,7 +143,7 @@ def conversation_poll_response(request,ticket,actor,include_internal=False):
response["Cache-Control"]="private, no-cache, no-store, must-revalidate" response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
return response return response
cursor=requested_cursor cursor=requested_cursor
replace=not cursor or not latest or str(latest.public_id)==cursor replace=not cursor or not latest or str(latest.public_id)==cursor or request.GET.get('version','').partition(':')[2]!=version.partition(':')[2]
messages_qs=qs.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile") messages_qs=qs.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")
if not replace: if not replace:
cursor_message=qs.filter(public_id=cursor).only("created_at","id").first() cursor_message=qs.filter(public_id=cursor).only("created_at","id").first()
@@ -148,14 +155,14 @@ def conversation_poll_response(request,ticket,actor,include_internal=False):
recent=list(messages_qs.order_by("-created_at","-id")[:MESSAGE_PAGE_SIZE]); conversation=list(reversed(recent)) recent=list(messages_qs.order_by("-created_at","-id")[:MESSAGE_PAGE_SIZE]); conversation=list(reversed(recent))
else: else:
conversation=list(messages_qs[:MESSAGE_PAGE_SIZE]) conversation=list(messages_qs[:MESSAGE_PAGE_SIZE])
if include_internal: mark_messages_read(conversation,actor)
context={"conversation":conversation,"ticket":ticket,"viewer_user":actor} context={"conversation":conversation,"ticket":ticket,"viewer_user":actor}
template="tickets/_messages.html" template="tickets/_messages.html"
if include_internal: if include_internal:
context.update(message_context(ticket,actor,conversation)) context.update(message_context(ticket,actor,conversation))
template="tickets/_staff_messages.html" template="tickets/_staff_messages.html"
html=render_to_string(template,context,request=request) html=render_to_string(template,context,request=request)
response=JsonResponse({"changed":True,"version":version,"cursor":str(latest.public_id) if latest else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()}) delivered = conversation[-1] if conversation else latest
response=JsonResponse({"changed":True,"version":version,"cursor":str(delivered.public_id) if delivered else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()})
response["Cache-Control"]="private, no-cache, no-store, must-revalidate" response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
return response return response
@customer_required @customer_required
@@ -327,17 +334,19 @@ def customer_poll(request,public_id):
if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login")) if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login"))
return conversation_poll_response(request,ticket,actor,include_internal=False) return conversation_poll_response(request,ticket,actor,include_internal=False)
@require_POST @require_POST
@transaction.atomic
def customer_read(request,public_id): def customer_read(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id) from apps.messaging.read_receipts import message_read_ids
ticket=get_object_or_404(Ticket.objects.select_for_update(),public_id=public_id)
actor,_=customer_ticket_actor(request,ticket) actor,_=customer_ticket_actor(request,ticket)
if actor is None: return JsonResponse({"ok":False},status=401) if actor is None: return JsonResponse({"ok":False},status=401)
try: message_ids=json.loads(request.body or b"{}").get("message_ids",[]) try: message_ids=message_read_ids(request)
except (TypeError,ValueError): return JsonResponse({"ok":False,"error":"invalid_json"},status=400) except ValidationError: return JsonResponse({"ok":False,"error":"invalid_message_ids"},status=400)
messages_qs=ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False).exclude(author=actor) acknowledged=list(ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False,deleted_at__isnull=True))
MessageReadReceipt.objects.bulk_create([MessageReadReceipt(message=item,user=actor) for item in messages_qs],ignore_conflicts=True) mark_messages_read(acknowledged,actor)
from apps.notifications.services import cancel_staff_reply_sms_if_read from apps.notifications.services import cancel_staff_reply_sms_if_read
cancel_staff_reply_sms_if_read(ticket,actor) cancel_staff_reply_sms_if_read(ticket,actor)
return JsonResponse({"ok":True,"read":messages_qs.count()}) return JsonResponse({"ok":True,"read":sum(item.author_id != actor.pk for item in acknowledged),"read_ids":[str(item.public_id) for item in acknowledged]})
@require_POST @require_POST
def customer_reply(request,public_id): def customer_reply(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id); form=ReplyForm(request.POST,request.FILES) ticket=get_object_or_404(Ticket,public_id=public_id); form=ReplyForm(request.POST,request.FILES)
@@ -444,16 +453,15 @@ def staff_customer_search(request):
@staff_required @staff_required
@never_cache @never_cache
def staff_detail(request,public_id): def staff_detail(request,public_id):
from apps.payments import policies as payment_policies, selectors as payment_selectors
from apps.payments.models import PaymentNotice
ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id) ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id)
visible=visible_messages(ticket,request.user) visible=visible_messages(ticket,request.user)
conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")) conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
mark_messages_read(conversation,request.user)
mark_ticket_share_opened(ticket,request.user) mark_ticket_share_opened(ticket,request.user)
message_sharing=message_context(ticket,request.user,conversation) message_sharing=message_context(ticket,request.user,conversation)
if TicketPolicy.can_view(request.user,ticket): history=Ticket.objects.filter(TicketPolicy.viewable_q(request.user), customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at").distinct()[:5]
last=conversation[-1] if conversation else None
TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")) sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at"))
announcement_jobs=list(AnnouncementJob.objects.filter(Q(message_id__in=visible.values("pk")) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk")) announcement_jobs=list(AnnouncementJob.objects.filter(Q(message_id__in=visible.values("pk")) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk"))
can_retry_sms=TicketPolicy.can_reply(request.user,ticket) can_retry_sms=TicketPolicy.can_reply(request.user,ticket)
@@ -464,9 +472,13 @@ def staff_detail(request,public_id):
sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job) sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job)
latest=conversation[-1] if conversation else None latest=conversation[-1] if conversation else None
group_dispatch = ticket.group_dispatch group_dispatch = ticket.group_dispatch
message_sharing["can_view_dispatch"] = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch))
can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket"))) can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket")))
group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0 group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)}) customer_notices = payment_selectors.notices_with_totals(
payment_policies.visible(PaymentNotice.objects.filter(customer=ticket.customer), request.user)
).order_by('-created_at', '-pk')[:3]
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"customer_notices":customer_notices,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest,include_internal=True),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)})
@staff_required @staff_required
@@ -508,7 +520,6 @@ def staff_sms_retry(request,public_id,job_id):
def staff_messages(request,public_id): def staff_messages(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id) ticket=get_object_or_404(Ticket,public_id=public_id)
conversation,has_older,older_cursor=conversation_page(visible_messages(ticket,request.user).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before")) conversation,has_older,older_cursor=conversation_page(visible_messages(ticket,request.user).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before"))
mark_messages_read(conversation,request.user)
return render(request,"tickets/_staff_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"viewer_user":request.user,**message_context(ticket,request.user,conversation)}) return render(request,"tickets/_staff_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"viewer_user":request.user,**message_context(ticket,request.user,conversation)})
@staff_required @staff_required
def staff_poll(request,public_id): def staff_poll(request,public_id):
@@ -606,10 +617,19 @@ def staff_delete(request, public_id):
@staff_required @staff_required
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def staff_dispatch_create(request): def staff_dispatch_create(request):
from .dispatch_presentation import dispatch_form_options
create_unit_ids = {
unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True)
if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values)
}
base_dispatches = _visible_dispatches(request.user)
if not create_unit_ids and (request.method == "POST" or not (DispatchPolicy.can_list(request.user) or base_dispatches.exists())):
raise PermissionDenied
form = GroupDispatchForm(request.POST or None, request.FILES or None, user=request.user) form = GroupDispatchForm(request.POST or None, request.FILES or None, user=request.user)
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
try: try:
dispatch, _ = dispatch_group_tickets( dispatch, _ = create_group_dispatch(
user=request.user, user=request.user,
unit=form.cleaned_data["unit"], unit=form.cleaned_data["unit"],
contact_group=form.cleaned_data["contact_group"], contact_group=form.cleaned_data["contact_group"],
@@ -617,6 +637,8 @@ def staff_dispatch_create(request):
subject=form.cleaned_data["subject"], subject=form.cleaned_data["subject"],
body=form.cleaned_data["body"], body=form.cleaned_data["body"],
operation_type=form.cleaned_data["operation_type"], operation_type=form.cleaned_data["operation_type"],
response_mode=form.cleaned_data["response_mode"],
notify_opening_sms=form.cleaned_data["notify_opening_sms"],
tags=form.cleaned_data["tags"], tags=form.cleaned_data["tags"],
files=request.FILES.getlist("files"), files=request.FILES.getlist("files"),
) )
@@ -624,13 +646,6 @@ def staff_dispatch_create(request):
return redirect("staff_tickets:dispatch_detail", public_id=dispatch.public_id) return redirect("staff_tickets:dispatch_detail", public_id=dispatch.public_id)
except (PermissionDenied, ValidationError) as exc: except (PermissionDenied, ValidationError) as exc:
form.add_error(None, str(exc)) form.add_error(None, str(exc))
create_unit_ids = {
unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True)
if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values)
}
if not create_unit_ids:
raise PermissionDenied
base_dispatches = _visible_dispatches(request.user)
metrics_row = base_dispatches.aggregate(total=Count("id"), recipients=Sum("recipient_count")) metrics_row = base_dispatches.aggregate(total=Count("id"), recipients=Sum("recipient_count"))
metrics = { metrics = {
"total": metrics_row["total"] or 0, "total": metrics_row["total"] or 0,
@@ -665,19 +680,27 @@ def staff_dispatch_create(request):
return render(request, "tickets/staff_dispatch_create.html", { return render(request, "tickets/staff_dispatch_create.html", {
"form": form, "dispatches": page.object_list, "page": page, "metrics": metrics, "form": form, "dispatches": page.object_list, "page": page, "metrics": metrics,
"units": units, "groups": groups, "dispatch_statuses": GroupDispatch.Status.choices, "units": units, "groups": groups, "dispatch_statuses": GroupDispatch.Status.choices,
"dispatch_options": dispatch_form_options(request.user, units, groups),
"can_create_dispatch": bool(create_unit_ids),
"history_units": OrganizationalUnit.objects.filter(pk__in=base_dispatches.values("unit_id")),
"history_groups": ContactGroup.objects.filter(pk__in=base_dispatches.values("contact_group_id")),
"active_staff_section": "dispatches", "active_tab_title": "ارسال گروهی و ردیابی", "active_staff_section": "dispatches", "active_tab_title": "ارسال گروهی و ردیابی",
}) })
@staff_required @staff_required
def staff_dispatch_detail(request, public_id): def staff_dispatch_detail(request, public_id):
from .dispatch_responses import response_control, can_open_responses, populate_report_responses
dispatch = get_object_or_404( dispatch = get_object_or_404(
GroupDispatch.objects.select_related("unit", "contact_group", "created_by"), GroupDispatch.objects.select_related("unit", "contact_group", "created_by"),
public_id=public_id, public_id=public_id,
) )
if not DispatchPolicy.can_view(request.user, dispatch): if not DispatchPolicy.can_view(request.user, dispatch):
raise PermissionDenied raise PermissionDenied
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id")) control = response_control(dispatch)
locked = bool(control and control.mode == "SEALED" and not control.opened_at)
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "notificationjob_set").order_by("customer_id"))
populate_report_responses(dispatch, tickets, locked)
delivery_failures = { delivery_failures = {
NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.UNDELIVERED,
NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.EXPIRED,
@@ -689,7 +712,6 @@ def staff_dispatch_detail(request, public_id):
for ticket in tickets: for ticket in tickets:
ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None) ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None)
ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at))) ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at)))
ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None)
links = list(ticket.magic_links.all()) links = list(ticket.magic_links.all())
ticket.magic_total_views = sum(link.view_count for link in links) ticket.magic_total_views = sum(link.view_count for link in links)
ticket.magic_link_count = len(links) ticket.magic_link_count = len(links)
@@ -697,17 +719,17 @@ def staff_dispatch_detail(request, public_id):
ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None) ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None)
job = ticket.sms_job job = ticket.sms_job
if not job: if not job:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب job پیامک پیدا نشد.", "neutral" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب سابقه ارسال پیامک ثبت نشده است.", "neutral"
elif job.status == NotificationJob.Status.FAILED: elif job.status == NotificationJob.Status.FAILED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", f"ارسال خودکار پس از {1 + settings.SMS_MAX_RETRIES} تلاش متوقف شده است.", "danger" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", "ارسال خودکار متوقف شده است؛ علت خطا و امکان تلاش مجدد را در ادامه بررسی کنید.", "danger"
elif job.status == NotificationJob.Status.RETRYING: elif job.status == NotificationJob.Status.RETRYING:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "worker پیامک را در موعد بعدی دوباره ارسال می‌کند.", "warning" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "سامانه پیامک را در موعد بعدی دوباره ارسال می‌کند.", "warning"
elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING): elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING):
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info"
elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED: elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success"
elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at: elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست و worker استعلام را ادامه می‌دهد.", "warning" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست؛ پیگیری خودکار تحویل ادامه دارد.", "warning"
elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED: elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger"
elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED: elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED:
@@ -725,14 +747,15 @@ def staff_dispatch_detail(request, public_id):
tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count() tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count()
opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count() opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count()
total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0 total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0
replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count() replied = sum(bool(ticket.response_count) for ticket in tickets)
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"]) refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"])
pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING)) pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING))
delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0 delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0
metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent} metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent}
open_ticket_count = sum(ticket.status not in (Ticket.Status.RESOLVED, Ticket.Status.CLOSED) for ticket in tickets) open_ticket_count = sum(ticket.status not in (Ticket.Status.RESOLVED, Ticket.Status.CLOSED) for ticket in tickets)
can_resolve_dispatch = request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, dispatch.unit, "tickets.change_ticket") can_resolve_dispatch = request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, dispatch.unit, "tickets.change_ticket")
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"}) opening_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, event_type="DISPATCH_RESPONSES_OPENED").values("status").annotate(count=Count("pk"))
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches", "response_control": control, "responses_locked": locked, "can_open_responses": can_open_responses(request.user, dispatch), "opening_jobs": list(opening_jobs)})
@staff_required @staff_required
@@ -775,19 +798,7 @@ def _unit_ids_with_permission(user, permission):
def _visible_dispatches(user): def _visible_dispatches(user):
if user.is_superuser: return DispatchPolicy.visible_dispatches(user)
return GroupDispatch.objects.all()
visibility = Q(pk__in=[])
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
for operation_type in GroupDispatch.OperationType.values:
if DispatchPolicy.can_view_all(user, operation_type):
visibility |= Q(operation_type=operation_type)
continue
view_all_units = [item.unit_id for item in memberships if DispatchPolicy.can_view_all(user, operation_type, item.unit)]
create_units = [item.unit_id for item in memberships if DispatchPolicy.can_create(user, item.unit, operation_type)]
visibility |= Q(operation_type=operation_type, unit_id__in=view_all_units)
visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user)
return GroupDispatch.objects.filter(visibility)
CONTACT_AUDIT_FIELDS = { CONTACT_AUDIT_FIELDS = {
@@ -1021,4 +1032,4 @@ def staff_sms_templates(request, template_id=None):
raise PermissionDenied raise PermissionDenied
form.save(); messages.success(request, "الگوی پیامک واحد ذخیره شد.") form.save(); messages.success(request, "الگوی پیامک واحد ذخیره شد.")
return redirect("staff_tickets:sms_templates") return redirect("staff_tickets:sms_templates")
return render(request, "tickets/staff_sms_templates.html", {"form": form, "templates": templates, "allowed_tags": SMSTemplate._meta.get_field("allowed_tags").default(), "active_staff_section": "templates", "active_tab_title": "الگوهای پیامک"}) return render(request, "tickets/staff_sms_templates.html", {"form": form, "templates": templates, "allowed_tags": SMSTemplate.tags_for_event(form['event_type'].value()), "sms_tag_map": {event: SMSTemplate.tags_for_event(event) for event in SMSTemplate.Event.values}, "active_staff_section": "templates", "active_tab_title": "الگوهای پیامک"})
+6 -4
View File
@@ -21,20 +21,21 @@ DEBUG = env_bool("DJANGO_DEBUG")
ALLOWED_HOSTS = list(dict.fromkeys(env_list("DJANGO_ALLOWED_HOSTS") + ["localhost", "127.0.0.1"])) ALLOWED_HOSTS = list(dict.fromkeys(env_list("DJANGO_ALLOWED_HOSTS") + ["localhost", "127.0.0.1"]))
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.humanize",
"apps.core", "apps.accounts", "apps.customers", "apps.staff", "apps.tickets", "apps.core", "apps.accounts", "apps.customers", "apps.staff", "apps.tickets",
"apps.messaging", "apps.attachments", "apps.notifications", "apps.phone_contacts_api", "apps.messaging", "apps.attachments", "apps.notifications", "apps.phone_contacts_api", "apps.payments",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "apps.accounts.customer_access.CustomerLoginAccessMiddleware",
"apps.payments.middleware.FinancialGrantAuditMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True,
"OPTIONS": {"context_processors": ["django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "apps.core.context_processors.static_asset_version", "apps.core.context_processors.site_branding", "apps.core.context_processors.staff_operations_access"]}}] "OPTIONS": {"context_processors": ["django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "apps.core.context_processors.static_asset_version", "apps.core.context_processors.site_branding", "apps.core.context_processors.staff_operations_access", "apps.payments.context_processors.payment_navigation"]}}]
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.asgi.application" ASGI_APPLICATION = "config.asgi.application"
database_url = os.getenv("DATABASE_URL", "").strip() database_url = os.getenv("DATABASE_URL", "").strip()
@@ -95,6 +96,7 @@ OTP_RATE_LIMIT_PER_IP = int(os.getenv("OTP_RATE_LIMIT_PER_IP", "30"))
DEV_OTP_MOBILE = "" DEV_OTP_MOBILE = ""
DEV_OTP_CODE = "" DEV_OTP_CODE = ""
MAGIC_LINK_TTL_DAYS = int(os.getenv("MAGIC_LINK_TTL_DAYS", "7")) MAGIC_LINK_TTL_DAYS = int(os.getenv("MAGIC_LINK_TTL_DAYS", "7"))
PAYMENT_LINK_TTL_DAYS = int(os.getenv("PAYMENT_LINK_TTL_DAYS", str(MAGIC_LINK_TTL_DAYS)))
MAX_NOTIFICATION_ATTEMPTS = int(os.getenv("MAX_NOTIFICATION_ATTEMPTS", "5")) MAX_NOTIFICATION_ATTEMPTS = int(os.getenv("MAX_NOTIFICATION_ATTEMPTS", "5"))
SMS_MAX_RETRIES = int(os.getenv("SMS_MAX_RETRIES", "3")) SMS_MAX_RETRIES = int(os.getenv("SMS_MAX_RETRIES", "3"))
SMS_RETRY_DELAY_SECONDS = int(os.getenv("SMS_RETRY_DELAY_SECONDS", "180")) SMS_RETRY_DELAY_SECONDS = int(os.getenv("SMS_RETRY_DELAY_SECONDS", "180"))
+16 -2
View File
@@ -9,6 +9,8 @@ from django.urls import include, path
from django.views.static import serve from django.views.static import serve
from apps.core.models import SiteBranding from apps.core.models import SiteBranding
from apps.payments import link_views as payment_link_views
from apps.tickets.short_links import open_staff_ticket_link
def healthz(request): def healthz(request):
@@ -39,7 +41,7 @@ def web_manifest(request):
icon_url = static_url("images/pwa-icon.png") icon_url = static_url("images/pwa-icon.png")
response = JsonResponse({ response = JsonResponse({
"id": "/", "name": branding.pwa_name, "short_name": branding.pwa_title, "id": "/", "name": branding.pwa_name, "short_name": branding.pwa_title,
"description": branding.pwa_description, "start_url": "/tickets/", "scope": "/", "description": branding.pwa_description, "start_url": "/", "scope": "/",
"display": "standalone", "background_color": "#eeeeee", "theme_color": "#08376b", "display": "standalone", "background_color": "#eeeeee", "theme_color": "#08376b",
"lang": "fa", "dir": "rtl", "lang": "fa", "dir": "rtl",
"icons": [{"src": icon_url, "sizes": "1024x1024", "type": "image/png", "purpose": "any maskable"}], "icons": [{"src": icon_url, "sizes": "1024x1024", "type": "image/png", "purpose": "any maskable"}],
@@ -48,6 +50,8 @@ def web_manifest(request):
return response return response
urlpatterns = [ urlpatterns = [
path("vt/<str:token>", open_staff_ticket_link, name="staff_ticket_short_link"),
path("p/<str:token>/", payment_link_views.open_link, name="payment_short_link"),
path("healthz/", healthz, name="healthz"), path("healthz/", healthz, name="healthz"),
path("media/branding/<path:path>", public_branding, name="public_branding"), path("media/branding/<path:path>", public_branding, name="public_branding"),
path("service-worker.js", service_worker, name="service_worker"), path("service-worker.js", service_worker, name="service_worker"),
@@ -55,11 +59,21 @@ urlpatterns = [
path("x5admin/", admin.site.urls), path("x5admin/", admin.site.urls),
path("", include("apps.accounts.urls")), path("", include("apps.accounts.urls")),
path("tickets/", include("apps.tickets.customer_urls")), path("tickets/", include("apps.tickets.customer_urls")),
path("chaharsu/", include("apps.payments.urls")),
path("staff/payments/", include("apps.payments.staff_urls")),
path("staff/", include("apps.tickets.staff_urls")), path("staff/", include("apps.tickets.staff_urls")),
path("messages/", include("apps.messaging.urls")), path("messages/", include("apps.messaging.urls")),
path("files/", include("apps.attachments.urls")), path("files/", include("apps.attachments.urls")),
path("api/phone-contacts", include("apps.phone_contacts_api.urls")), path("api/phone-contacts", include("apps.phone_contacts_api.urls")),
] ]
def debug_media(request, path):
import posixpath
if posixpath.normpath(path.replace("\\", "/")).lstrip("/").startswith(("payments/", "dispatch-responses/")):
from django.http import Http404
raise Http404
return serve(request, path, document_root=settings.MEDIA_ROOT)
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += [path("media/<path:path>", debug_media)]
+166
View File
@@ -0,0 +1,166 @@
# چهارسو — نمونهٔ مستقل با تمرکز بر خدمات
فایل `index.html` را مستقیم در Chrome یا Edge باز کنید. اجرای Django، ورود به حساب و اتصال به دیتابیس لازم نیست.
## راهنمای طراحی‌های بعدی
[راهنمای سبک طراحی چهارسو](STYLE_GUIDE.md) شامل توکن‌ها، تایپوگرافی، فاصله‌ها، آیکون‌ها، اجزا، مثال‌ها و موارد نیازمند یکدست‌سازی است. برای طراحی جدید این سند را مبنا قرار دهید؛ توالی تصمیم‌های قدیمی README جایگزین آن نیست.
## نسخهٔ اصلاح‌شده
صفحهٔ اول فقط برای شناخت و انتخاب خدمات است:
- عنوان کوتاه «خدمات چهارسو».
- سه کارت خدمات: «ارتباط با کارشناسان»، «ارسال فیش واریزی» و «شماره حساب‌ها». کارت ارتباط با کارشناسان دو اقدام مستقل و مشخص دارد: ثبت درخواست جدید و مشاهده درخواست‌های من.
- هیچ فهرست درخواست، آمار درخواست‌ها، بخش «پیگیری‌ها» یا یادآوری تکمیل پروفایل در خانه نمایش داده نمی‌شود.
- بنر بزرگ و خوشامدگویی جدا حذف شده‌اند تا خدمات بالاتر دیده شوند.
- نوار پایین به سه مقصد «خدمات، درخواست‌ها، پروفایل» محدود شده؛ خانه و خدمات یک مقصد هستند.
- «درخواست‌های من» نمای جدا در همین نمونه دارد؛ انتخاب کارت یا منو آن را باز می‌کند. بازگشت به خدمات و دکمه‌های Back/Forward مرورگر کار می‌کنند.
- وضعیت خدمات مالی آینده با «به‌زودی» مشخص است؛ کلیک روی آن‌ها به دو فایل HTML مستقل می‌رود؛ هیچ پنجرهٔ modal برای خدمات مالی باز نمی‌شود.
نام چهارسو محدودیتی برای تعداد خدمات ایجاد نمی‌کند. خدمات بعدی می‌توانند به شبکهٔ کارت‌ها اضافه شوند؛ برای هر خدمت جدید نیازی به افزودن گزینه به نوار پایین نیست.
## فایل‌های بازبینی
- `index.html`: نمونهٔ تعاملی خانه
- `payment-receipt.html`: صفحهٔ مستقل ارسال فیش واریزی
- `bank-accounts.html`: صفحهٔ مستقل شماره حساب‌ها
- `service-pages.css`: سبک‌های مشترک دو صفحهٔ خدمات مالی
- `receipt-mobile.png` و `accounts-mobile.png`: تصاویر موبایل صفحات مالی
- `mobile.png`: صفحهٔ نخست موبایل
- `desktop.png`: صفحهٔ نخست دسکتاپ
- `requests-mobile.png`: نمای مستقل درخواست‌ها
نمای جدا در نمونه با `index.html#requests` نیز باز می‌شود. این یک مسیر اجرایی جدید در Django نیست.
## مبنای بررسی معماری
در مرحلهٔ نخست، Graphify برای معماری و Ripwire برای تطبیق با سورس فعلی استفاده شد. گراف موجود مربوط به گزارش ۲۰۲۶/۱۰/۰۴ و commit `39e64854` بود؛ تصمیم‌های طراحی با سورس فعلی تطبیق داده شدند. گراف بازسازی یا تغییر داده نشد.
- پروژه Django Modular Monolith است. حساب‌ها، مشتریان، کارکنان، درخواست‌ها، پیام‌ها، پیوست‌ها، اعلان‌ها و core مسئولیت‌های جدا دارند.
- مسیر `/tickets/` به `customer_list` می‌رسد و مخصوص مشتری است. `customer_tickets` درخواست‌های همان مشتری را برمی‌گرداند.
- صفحه فعلی از `templates/base.html` ارث می‌برد. برند و متن‌های معرفی با SiteBranding و عنوان‌ها با PageTitle تنظیم می‌شوند.
- پوسته فارسی و RTL است و از وزیرمتن، Tailwind 4، HTMX و Alpine استفاده می‌کند. Compressor نیز در پروژه موجود است.
- شماره حساب، شناسه واریز و ثبت فیش در مسیرهای فعلی مشتری وجود ندارند؛ خدمات مالی در این طراحی امکانات آینده‌اند.
- این نمونه برند را از فایل لوگوی محلی نمایش می‌دهد و متن‌های پیشنهادی دارد؛ دیتابیس را نمی‌خواند.
منابع: `config/settings/base.py`، `config/urls.py`، `apps/tickets/customer_urls.py`، `apps/tickets/views.py:customer_list`، `apps/tickets/selectors.py:customer_tickets`، `apps/core/context_processors.py`، `templates/base.html`، `templates/tickets/customer_list.html`، `static/src/tailwind.css`، `package.json` و `docs/THEMING.md`.
## منطق UI/UX
خانه یک منوی خدمات قابل‌فهم است. کاربر پیش از دیدن سوابق، خدمت موردنیاز را انتخاب می‌کند. کارت مشترک «ارتباط با کارشناسان» سرمه‌ای با تأکید نارنجی است. عنوان مشترک و دو اقدام کوتاه «ثبت درخواست» و «درخواست‌های من» کاربردهای این خدمت را روشن می‌کنند؛ جملهٔ کامل راهنمای پرسش، درخواست، پیگیری و هماهنگی در موبایل و دسکتاپ نمایش داده می‌شود. ثبت درخواست آبی و مشاهده سوابق با حاشیهٔ روشن نمایش داده می‌شود. خود کارت مقصد کلیک ندارد تا انتخاب کاربر مبهم نشود. سایر کارت‌ها سفیدند؛ خدمات آینده برچسب متنی دارند و توضیحشان خوانا است.
سوابق فقط پس از انتخاب «درخواست‌های من» دیده می‌شوند. جست‌وجو، فیلتر «همه / در جریان / بسته» و نمایش همه، پیشنهادهای این نمونه‌اند؛ آمار نمایشی نباید به حساب واقعی نسبت داده شود.
در موبایل کارت مشترک ارتباط با کارشناسان تمام عرض را می‌گیرد و دو اقدام آن کنار هم هستند؛ خدمات مالی در ردیف بعدی دو ستون دارند. در دسکتاپ کارت مشترک دو ستون از شبکهٔ چهارستونه را می‌گیرد و دو اقدام آن کنار هم هستند.
## وفاداری و جداسازی
- فونت وزیرمتن، لوگو، فایل CSS کامپایل‌شده Tailwind و رنگ‌های فعلی پروژه استفاده می‌شوند.
- سرمه‌ای `#08376b`، آبی `#0693e3`، نارنجی `#fa9f00`، زمینه `#eeeeee` و کارت‌های سفید حفظ شده‌اند.
- CSS و JavaScript خانه داخل HTML نمونه هستند؛ دو صفحهٔ مالی از `service-pages.css` مستقل در همین پوشه استفاده می‌کنند.
- URL دارایی‌ها نسخه دارد؛ مقدار نسخه هنگام ساخت بر اساس الگوریتم فعلی `static_asset_version`، یعنی بیشترین زمان تغییر فایل‌های static و templates، محاسبه شده است.
- هیچ وابستگی، فونت یا CDN جدید اضافه نشده و build اجرا نشده است.
- Service Worker ثبت نمی‌شود. کش و PWA برنامه اصلی تغییر نکرده‌اند.
- همهٔ فایل‌های نمونه در `design-prototypes/chaharsu/` هستند؛ فایل‌های فعلی templates، static و بک‌اند دست‌نخورده‌اند.
## تعامل‌های نمونه
ثبت درخواست لینک به نمونهٔ مستقل موجود فرم در `../ticket-new-wizard/index.html` دارد. درخواست‌های نمونه پنجره توضیح دارند؛ مکالمه واقعی باز نمی‌شود. پروفایل پنجره توضیح دارد. «ارسال فیش واریزی» و «شماره حساب‌ها» با لینک معمولی به صفحات HTML مستقل می‌روند و هر دو صفحه لینک بازگشت به خانه و دسترسی به خدمت مالی دیگر دارند. انتخاب فیش فقط نام فایل را نشان می‌دهد؛ محتوای تصویر برای اعتبارسنجی در مرورگر خوانده می‌شود؛ بارگذاری یا ذخیره نمی‌شود. تمام داده‌ها نمایشی‌اند.
پنجره‌ها با Escape، دکمه بستن و کلیک بیرون بسته می‌شوند. dialog بومی مدیریت فوکوس را انجام می‌دهد. جابه‌جایی نما فوکوس را به عنوان آن می‌برد.
## بررسی نسخهٔ اصلاح‌شده
Playwright موجود در محیط مجازی پروژه برای بررسی مرورگر نمونه استفاده شد:
- خانه فقط خدمات را نشان می‌دهد و سوابق در آن مخفی‌اند.
- کارت مشترک و دو اقدام آن در موبایل ۳۹۰×۸۴۴ بررسی شدند؛ هر سه کارت خدمات بالاتر از نوار پایین قرار دارند.
- نمای جدا درخواست‌ها، جست‌وجو، فیلتر، حالت بدون نتیجه، نمایش همه و بازگشت به خدمات بررسی شدند.
- Back/Forward مرورگر، ناوبری واقعی به دو صفحهٔ مالی، بازگشت به خانه، انتخاب فایل بدون ارسال و حذف انتخاب بررسی شدند.
- در عرض‌های ۳۲۰، ۳۶۰، ۳۹۰، ۶۴۰، ۷۶۸، ۱۰۲۴ و ۱۴۴۰ اسکرول افقی مشاهده نشد.
- خطای JavaScript مشاهده نشد.
- تصاویر خانه و نمای درخواست‌ها به‌روز شدند.
این بررسی‌ها مخصوص نمونهٔ مستقل‌اند؛ تست یا کد اصلی Django تغییر نکرده است.
## قرارداد پیاده‌سازی صفحات مالی
در HTML خانه کنار لینک‌های دو خدمت و در ابتدای هر صفحهٔ مستقل کامنت IMPLEMENTATION NOTE درج شده است:
- برای ارسال فیش و شماره حساب‌ها باید URL نام‌گذاری‌شده، view و template مستقل در Django تعریف شود.
- این لینک‌ها نباید به modal یا data-dialog تبدیل شوند.
- مسیرهای فعلی فایل HTML فقط مسیرهای نمونه‌اند؛ نام مسیرهای نهایی Django هنوز تعیین نشده است.
- پوستهٔ نهایی باید از base.html ارث ببرد و دارایی‌ها با static_asset_version نسخه‌گذاری شوند.
- دکمهٔ ارسال فیش تا پیاده‌سازی بک‌اند، اعتبارسنجی و رسید ثبت غیرفعال است. انتخاب فایل به معنی ثبت پرداخت نیست.
- حساب‌ها و شناسهٔ واریز باید از منبع معتبر بک‌اند بیایند؛ هیچ شمارهٔ واقعی یا ساختگی در نمونه درج نشده است.
متن کارت فیش: «تصویر فیش پرداخت خود را برای بررسی بفرستید.»
متن کارت شماره حساب‌ها: «شماره حساب، شبا و شناسهٔ واریز را دریافت کنید.»
## فشرده‌سازی برای گوشی کوچک
کارت ارتباط با کارشناسان عنوان، توضیح کامل و دو اقدام کنار هم دارد. توضیح تکراری زیر دکمه‌ها حذف شده است. عنوان خانه، فاصله‌ها و متن خدمات مالی نیز کوتاه‌تر شده‌اند؛ هدف دیده شدن همهٔ خدمات پیش از نوار پایین در صفحهٔ نخست گوشی‌های کوچک است. دکمه‌ها حداقل ۴۴ پیکسل ارتفاع دارند.
بررسی نسخهٔ فشرده: در اندازه‌های ۳۲۰×۵۶۸، ۳۶۰×۶۴۰، ۳۷۵×۶۶۷، ۳۹۰×۸۴۴ و ۴۱۲×۷۳۲ هر سه کارت به‌طور کامل، بدون اسکرول و بالاتر از نوار پایین دیده شدند. عرض‌های ۶۴۰، ۷۶۸، ۱۰۲۴ و ۱۴۴۰ نیز بدون سرریز افقی بررسی شدند. ارتفاع هر دو دکمهٔ درخواست حداقل ۴۴ پیکسل است. مسیر ثبت درخواست، سوابق و صفحات مستقل مالی پس از فشرده‌سازی مجدداً بررسی شدند. تصویر `mobile-small.png` مربوط به گوشی ۳۲۰×۵۶۸ است.
## عنوان خدمت و امضای توسعه‌دهنده
عنوان کارت مشترک «ارتباط با کارشناسان» است؛ ثبت درخواست و مشاهدهٔ درخواست‌های من دو مسیر داخل آن هستند. توضیح «سؤال بپرسید، درخواست ثبت کنید یا برای پیگیری و هماهنگی با کارشناسان در ارتباط باشید.» در تمام اندازه‌ها، از جمله موبایل، نمایش داده می‌شود تا سایر خدمات همچنان در نگاه اول دیده شوند. امضای پایین هر سه صفحه: «طراحی و توسعه: محمد جواد فصاحت 09202202129». شماره لینک تماس دارد؛ هیچ تماس خودکاری برقرار نمی‌شود. کامنت HTML یادآوری می‌کند که در نسخهٔ اصلی امضا از تنظیم developer_credit موجود در برند تأمین شود.
## خوانایی در آخرین نسخه
عنوان کارت «ارتباط با کارشناسان» است و جملهٔ «سؤال بپرسید، درخواست ثبت کنید یا برای پیگیری و هماهنگی با کارشناسان در ارتباط باشید.» در همهٔ اندازه‌ها دیده می‌شود. توضیحات و دکمه‌ها حداقل ۱۴ پیکسل‌اند، عنوان کارت در موبایل ۱۸ و در دسکتاپ ۲۰ پیکسل است، و برچسب‌های وضعیت، ناوبری و امضا ۱۲ پیکسل‌اند. خط توضیح با ضریب ۱٫۶۵ یا بیشتر تنظیم شده است. روی موبایل متن کارت‌های مالی کوتاه‌تر است و اقدام تکراری انتهای کارت نمایش داده نمی‌شود؛ خود کارت همچنان لینک صفحهٔ مستقل است. در عرض کمتر از ۳۶۰ پیکسل آیکون دکمه‌ها حذف می‌شود تا متن با همان اندازه خوانا جا بگیرد. اندازه‌های ۳۲۰×۵۶۸ تا دسکتاپ بررسی شدند: متن کامل نمایش داده شد، هیچ عنوانی سرریز نداشت و تمام خدمات بالاتر از نوار پایین موبایل قرار داشتند.
## هماهنگی خوانایی صفحات مالی
هر دو صفحهٔ شماره حساب‌ها و ارسال فیش به همان الگوی خوانایی خانه ارتقا یافته‌اند: متن‌ها، اطلاعات حساب و اقدام‌ها حداقل ۱۴ پیکسل؛ عنوان بخش‌ها ۱۸ پیکسل در موبایل و ۲۰ پیکسل در دسکتاپ؛ عنوان صفحه ۲۴/۲۸ پیکسل؛ برچسب‌ها و امضا ۱۲ پیکسل. در موبایل فاصله‌ها کمتر و متن‌های تکراری حذف شده‌اند؛ متن مهم پنهان نشده است. توضیح اصلی تمام عرض را می‌گیرد تا به ستون باریک کنار آیکون محدود نشود. اقدام‌های فعال و انتخاب فایل حداقل ۴۴ پیکسل ارتفاع دارند. اندازه و خوانایی متن با فشرده‌سازی فضا قربانی نشده است.
بررسی صفحات مالی: هر دو صفحه در عرض‌های ۳۲۰، ۳۶۰، ۳۷۵، ۳۹۰، ۶۴۰، ۷۶۸ و ۱۴۴۰ پیکسل، بدون اسکرول افقی یا بریدگی متن بررسی شدند. اندازهٔ متن‌های اصلی حداقل ۱۴ پیکسل و ارتفاع لینک‌های اصلی و انتخاب فایل حداقل ۴۴ پیکسل بود. انتخاب فایل، نام بسیار بلند فایل، حذف انتخاب، غیرفعال‌بودن ارسال و لینک‌های بین دو خدمت و خانه بررسی شدند؛ خطای JavaScript دیده نشد. تصاویر `accounts-small.png` و `receipt-small.png` برای گوشی ۳۲۰×۵۶۸ و تصاویر `accounts-desktop.png` و `receipt-desktop.png` برای دسکتاپ اضافه شدند. صفحات جزئیات مالی به اندازهٔ محتوایشان اسکرول عمودی طبیعی دارند؛ شرط دیده‌شدن همهٔ خدمات در نگاه اول همچنان مربوط به خانهٔ خدمات است.
## یکدست‌سازی نمای درخواست‌ها
نمای درخواست‌ها از نوار پایین و کارت کارشناسان به یک مقصد می‌رسد. عنوان ردیف‌ها در موبایل ۱۴ و دسکتاپ ۱۶ پیکسل، شماره درخواست ۱۴، وضعیت و اطلاعات ثانویه ۱۲ پیکسل‌اند. فیلترها، اقدام‌های اصلی و جست‌وجو حداقل ۴۴ پیکسل ارتفاع دارند. جست‌وجو ۱۶ پیکسل است. در موبایل موضوع و اطلاعات در خط بالایی و وضعیت/شماره در ردیف بعد قرار می‌گیرند و امکان wrapping حفظ می‌شود. حالت فعال «درخواست‌ها» در نوار پایین سرمه‌ای روشن‌تر است تا سفید روی آن با قواعد کنتراست راهنما هماهنگ باشد. پنجرهٔ جزئیات نمونهٔ درخواست هم متن اصلی ۱۴ پیکسل دارد.
## جریان اعلامیهٔ واریز و فیش مرتبط
نسخهٔ جدید `bank-accounts.html` فهرست عمومی حساب‌های شرکت نیست؛ فقط اعلامیه‌های واریزی را نشان می‌دهد که کارشناس برای همین مشتری فرستاده است. ابتدا موضوع، مبلغ، فاکتور، تاریخ و وضعیت فیش نمایش داده می‌شود. با باز کردن هر کارت، بانک، صاحب حساب، شماره حساب، شبا، کارت، شناسه، مبلغ، فاکتور و توضیحات همان اعلامیه دیده می‌شود. فقط مبلغ، شماره حساب، شبا، شماره کارت و شناسهٔ واریز موجود دکمهٔ کپی تکی دارند؛ مبلغ بدون جداکننده و بدون واحد کپی می‌شود. کپی کامل متن چندخطی شامل همهٔ اطلاعات و واحد ریال است. مقادیر اعلام‌نشده کپی ندارند. بازخورد کپی به موفقیت واقعی وابسته است؛ در صورت منع Clipboard، روش انتخاب دستی متن در پنجرهٔ دسترس‌پذیر ارائه می‌شود.
`payment-demo-data.js` فقط سه fixture ساختگی با حساب‌های نامعتبر دارد؛ دیتابیس نیست. `payment-services.js` رفتار محلی هر دو صفحه را مدیریت می‌کند؛ هیچ ارسال، ذخیره یا وابستگی شبکه‌ای ندارد. دادهٔ واقعی باید از دیتابیس تأمین و به مشتری مخاطب محدود شود. پنهان بودن جزئیات کارت جای کنترل دسترسی سمت سرور را نمی‌گیرد.
«ارسال فیش این واریز» فقط شناسهٔ اعلامیه را با پارامتر `notice` به فرم می‌برد. فرم همان مورد را انتخاب می‌کند و مبلغ مورد انتظار، فاکتور، بانک و شناسه را نشان می‌دهد؛ لینک بازگشت، جزئیات همان مورد را باز می‌کند. ورود مستقیم به فرم حالت مرتبط را به‌عنوان گزینهٔ اول و پیشنهادی نشان می‌دهد؛ مستقل همیشه قابل انتخاب است. انتخاب مرتبط بدون مورد، پیام راهنما دارد؛ شناسهٔ نامعتبر با خطای روشن نمایش داده می‌شود و هرگز به اعلامیهٔ دیگری وصل نمی‌شود. تغییر به مستقل، شناسهٔ ارتباط را از فرم و URL حذف می‌کند. مبلغ پرداخت‌شدهٔ مشتری و فایل انتخاب‌شده هنگام تغییر نوع/اعلامیه حفظ می‌شوند. هیچ موفقیت ارسال ساختگی نشان داده نمی‌شود؛ دکمهٔ نمونه برای بررسی کلاینت فعال است؛ ارسال واقعی تا پیاده‌سازی اصلی انجام نمی‌شود.
شماره‌ها و مبلغ می‌توانند در هر اعلامیه متفاوت باشند. پنل کارشناس، مدل داده، API، مجوزها و ذخیرهٔ فیش در محدودهٔ این طراحی نیستند. یادآوری قراردادهای ضروری در کامنت HTML هر دو صفحه آمده است.
بررسی نسخهٔ اعلامیه‌ها: هفت عرض ۳۲۰، ۳۶۰، ۳۹۰، ۶۴۰، ۷۶۸، ۱۰۲۴ و ۱۴۴۰، کپی واقعی شبا/مبلغ/متن کامل، باز و بسته شدن کارت‌ها، جست‌وجو، نبود شماره کارت، اتصال دوطرفهٔ اعلامیه و فرم، حذف ارتباط با انتخاب مستقل، حفظ مبلغ/فایل، شناسهٔ نامعتبر، انتخاب دستی هنگام منع Clipboard و Escape بررسی شدند. خطای JavaScript یا درخواست HTTP خارجی مشاهده نشد. `accounts-list-mobile.png` حالت اولیهٔ بسته و تصاویر `accounts-*.png` جزئیات باز را نشان می‌دهند. این بررسی جای تست اتصال واقعی سرور یا تأیید کامل WCAG را نمی‌گیرد.
## فشرده‌سازی کارت‌ها و اولویت واریز مرتبط
بانک و صاحب حساب خلاصه‌اند؛ فاکتور و توضیحات در «اطلاعات تکمیلی و توضیحات» همیشه در کارت بازشده دیده می‌شوند. کپی تکی فقط برای پنج نوع مقدار پرداخت موجود است. کپی کامل از تمام داده‌های اعلامیه ساخته می‌شود و بسته بودن بخش تکمیلی بر آن اثر ندارد؛ کامنت اجرای اصلی در HTML و JavaScript ثبت شده است. راهنمای کوتاه درخواست شماره حساب و شناسه از کارشناس در بالای فهرست وجود دارد. در فرم فیش، مرتبط اولین گزینه و پیش‌فرض پیشنهادی است؛ راهنمای غیر اجباری ایجاد اعلامیه همیشه دیده می‌شود. مستقل همچنان قابل انتخاب است.
## اطلاعات تکمیلی ثابت و تاریخ شمسی فیش
اطلاعات تکمیلی و توضیحات اکنون بخش ثابت داخل کارت بازشده هستند؛ نیازی به باز کردن بخش دوم نیست و کاربر نمی‌تواند آن بخش را جمع کند. کپی کامل همچنان از تمام داده‌ها تولید می‌شود.
`receipt-date.js` انتخابگر تاریخ شمسی با سه select استاندارد روز/ماه/سال است؛ کتابخانه یا فایل خارجی ندارد. ماه و سال جاری برای سهولت انتخاب آماده‌اند، ولی روز ابتدا خالی است تا تاریخ واقعی فیش با تأیید کاربر ثبت شود. «امروز» بر اساس منطقهٔ زمانی ایران تاریخ کامل را انتخاب می‌کند. طول ماه‌ها و اسفند با تقویم Persian در Intl مرورگر بررسی می‌شوند؛ تغییر به ماه/سال کوتاه‌تر، روز نامعتبر را پاک و راهنمای انتخاب دوباره را اعلام می‌کند. تاریخ انتخاب‌شده هنگام تغییر نوع فیش یا اعلامیه حفظ می‌شود. در نمونه سال‌های ده سال قبل تا یک سال بعد قابل انتخاب‌اند؛ بازهٔ نسخهٔ اصلی باید با نیاز واقعی تعیین شود.
مقدار قابل ارسال `paid_date` تاریخ میلادی `YYYY-MM-DD` با ارقام ASCII است؛ انتخابگر همچنان شمسی نمایش داده می‌شود. قرارداد اعتبارسنجی و تبدیل با ابزار جلالی موجود پروژه در کامنت HTML آمده است؛ در نمونه هیچ تاریخ یا فایلی ارسال/ذخیره نمی‌شود.
بررسی تاریخ: طول ۱۴۴ ماه در تمام سال‌های قابل انتخاب با jdatetime موجود پروژه تطبیق داده شد؛ اسفند کبیسه، حذف روز نامعتبر، دکمهٔ امروز با ساعت ایران، حفظ تاریخ بین حالت‌ها و خوانایی کنترل‌ها در هفت اندازهٔ نمایش بررسی شدند. تصاویر مالی به‌روز شدند.
## اعتبارسنجی کلاینت فیش
`receipt-validation.js` فرم را بدون ارسال شبکه‌ای بررسی می‌کند. مبلغ صحیح مثبت ریال (پذیرش ارقام فارسی/عربی و جداکنندهٔ معتبر)، روز/ماه/سال کامل، تاریخ غیرآینده و تصویر فیش الزامی‌اند. مبلغ آمادهٔ ارسال در `paid_amount` رشتهٔ عدد ASCII بدون جداکننده است؛ `paid_date` میلادی و `receipt_image` فایل است. فیش مرتبط به `notice_id` معتبر نیاز دارد؛ در حالت مستقل این select غیرفعال است و شناسه به FormData وارد نمی‌شود.
حد نمونهٔ تصویر مطابق پیش‌فرض IMAGE در bootstrap پروژه، ۱۰ مگابایت و JPG/JPEG/PNG/GIF/WEBP است. علاوه بر پسوند و MIME، امضای فایل و باز شدن تصویر در مرورگر بررسی می‌شوند؛ نتیجهٔ بررسی فایل در حافظه و فقط برای همان File نگهداری می‌شود. فایل خالی، بزرگ، خراب یا دارای نوع ناسازگار پیام مشخص دارد. محدودیت واقعی در نسخهٔ اصلی باید از AttachmentPolicy سرور دریافت شود، نه این عدد نمونه.
خطا زیر فیلد، aria-invalid و فهرست خطاهای لینک‌دار نمایش داده می‌شود. با بررسی ناموفق، اولین فیلد دارای خطا فوکوس می‌گیرد و به مرکز نمایش می‌آید؛ برای ورودی فایل پنهان، کنترل فارسی انتخاب تصویر در معرض دید قرار می‌گیرد. پس از اصلاح، خطا پاک می‌شود. ارسال دوباره حین بررسی کنترل می‌شود؛ اگر کاربر هنگام بررسی فایل فرم را تغییر دهد، نتیجهٔ قدیمی معتبر اعلام نمی‌شود. در حالت معتبر، متن صریح «این پیش‌نمایش است؛ فیش ارسال یا ذخیره نشده» نمایش داده می‌شود. اعتبارسنجی مرورگر جای اعتبارسنجی سرور نیست و کامنت قرارداد در HTML آمده است.
## نمایش مبلغ و انتخاب بانک‌ها
`receipt-amount.js` هنگام تایپ مبلغ ریالی، جداکنندهٔ هزارگان فارسی اضافه می‌کند. ارقام فارسی، عربی و انگلیسی پذیرفته می‌شوند؛ ورودی غیرعددی/اعشاری به عدد دیگری تبدیل نمی‌شود و اعتبارسنجی خطا می‌دهد. محل انتخاب و نشانگر بر اساس تعداد ارقام حفظ می‌شود؛ Backspace/Delete روی جداکننده گیر نمی‌کنند. `paid_amount` همچنان ریالی و بدون جداکننده است.
زیر مبلغ، معادل تومان به حروف نمایش داده می‌شود: ۲۸۰٬۰۰۰٬۰۰۰ ریال → «بیست و هشت میلیون تومان». محاسبه با BigInt و تقسیم/باقی‌ماندهٔ دقیق انجام می‌شود؛ مثلاً ۱۲۳۴۵ ریال → «هزار و دویست و سی و چهار و پنج دهم تومان»، بدون گرد کردن.
`receipt-banks.js` فهرست محلی بانک مبدأ با نام‌های شناخته‌شده، گروه نام‌های قدیمی روی فیش‌ها و «سایر» دارد. مبدأ اختیاری است؛ با سایر، نام بانک اختیاری نیز قابل ورود می‌شود. فهرست بانک‌ها در نسخهٔ اصلی باید به‌صورت متمرکز به‌روز نگه داشته شود.
حساب مقصد اجباری است و کاربر خودش انتخاب می‌کند؛ انتخاب اعلامیه یا نوع فیش مقصد را تغییر نمی‌دهد. فهرست مقصد فقط نام حساب‌های شرکت را نشان می‌دهد، نه شماره حساب/کارت/شبا. `chaharsuCompanyAccounts` در `payment-demo-data.js` صرفاً fixture با ID و نام است؛ در نسخهٔ اصلی این فهرست باید از دیتابیس حساب‌های فعال/مجاز شرکت بیاید. سرور ID انتخابی را دوباره اعتبارسنجی و نام حساب را از دیتابیس بخواند. این قرارداد در HTML و فایل داده کامنت شده است.
بررسی مرورگر: گروه‌بندی زنده و اصلاح وسط متن، حذف کنار جداکننده، جایگزینی انتخاب، ارقام عربی، صفرهای ابتدایی، معادل دقیق تومان، خطای مقصد و فوکوس، نام‌های بدون شماره، مبدأ اختیاری/سایر، حفظ انتخاب‌ها و FormData ریالی/میلادی بررسی شدند؛ هفت اندازهٔ نمایش بدون سرریز و هیچ منبع خارجی بودند.
+554
View File
@@ -0,0 +1,554 @@
# راهنمای سبک طراحی چهارسو
نسخه ۱ · تاریخ ۲۰۲۶/۱۰/۰۵ · برای طراحی صفحات جدید و بازبینی طرح‌های بعدی
این راهنما زبان بصری نمونهٔ چهارسو را استخراج می‌کند و قواعد استفادهٔ مجدد از آن را مشخص می‌کند. مبنا، **نمای نهایی در مرورگر** است؛ نه اولین قانون مشابه در CSS. خانه، شماره حساب‌ها، ارسال فیش، نمای سوابق و پنجره‌های نمونه بررسی شده‌اند.
**وضعیت این سند:** راهنمای طراحی است؛ هیچ تغییر اجرایی در صفحات یا کد اصلی پروژه اعمال نمی‌کند. موارد «پیشنهادی» هنوز در همهٔ اجزای نمونه پیاده نشده‌اند. این سند تأییدیهٔ دسترس‌پذیری کل برنامه نیست.
## ۱. هویت و اصول طراحی
سبک چهارسو فارسی، راست‌به‌چپ، روشن، ساده و مبتنی بر کارت خدمات است:
- زمینهٔ خاکستری روشن؛ کارت سفید؛ عنوان سرمه‌ای؛ آبی برای تعامل؛ نارنجی برای تأکید محدود.
- صفحهٔ نخست به انتخاب خدمت اختصاص دارد. سوابق و فرم‌های طولانی بعد از انتخاب خدمت نمایش داده می‌شوند.
- هر کارت باید با عنوان، آیکون و توضیح کوتاه قابل فهم باشد؛ رنگ یا آیکون به‌تنهایی حامل معنی نباشند.
- یک مسیر اصلی مشخص و مسیرهای فرعی خوانا داشته باشید.
- برای جمع‌وجور شدن موبایل ابتدا متن تکراری، تزئین و فاصلهٔ اضافی را حذف کنید؛ متن مهم را کوچک یا مخفی نکنید.
- فضای بیشتر دسکتاپ اجازهٔ توضیح و فاصلهٔ بیشتر می‌دهد؛ معنای خدمت در موبایل و دسکتاپ باید یکسان بماند.
### ساختار مرجع خانه
| خدمت | ساختار | رفتار |
|---|---|---|
| ارتباط با کارشناسان | کارت سرمه‌ای مشترک با دو اقدام داخلی | ثبت درخواست / درخواست‌های من |
| ارسال فیش واریزی | کارت سفید با آیکون فیش و وضعیت خدمت | رفتن به صفحهٔ مستقل |
| شماره حساب‌ها | کارت سفید با آیکون بانک و وضعیت خدمت | رفتن به صفحهٔ مستقل |
کارت مشترک کارشناسان خودش مقصد کلیک ندارد؛ دو کنترل داخل آن مقصد را مشخص می‌کنند. کارت‌های مالی لینک هستند. خدمات بعدی می‌توانند به شبکه اضافه شوند؛ نام چهارسو محدودیت چهارتایی ایجاد نمی‌کند.
## ۲. منابع و شیوهٔ استخراج
| منبع | کاربرد |
|---|---|
| [index.html](index.html) | خانه، کارت‌ها، ناوبری، SVGها، نمای سوابق، پنجره‌ها |
| [service-pages.css](service-pages.css) | سبک مشترک دو صفحهٔ مالی |
| [bank-accounts.html](bank-accounts.html) | اطلاعات حساب و شناسهٔ واریز |
| [payment-receipt.html](payment-receipt.html) | فرم فیش مستقل و مرتبط |
| [payment-services.js](payment-services.js) | باز شدن اعلامیه، کپی و ارتباط فرم |
| [payment-demo-data.js](payment-demo-data.js) | سه اعلامیهٔ ساختگی، بدون حساب واقعی |
| [منبع تم پروژه](../../static/src/tailwind.css) | توکن‌های رنگ، فونت و رفتار پایهٔ موجود |
| [توضیح تم](../../docs/THEMING.md) | نقش رنگ‌های برند |
| [README نمونه](README.md) | تصمیم‌های UX و مرز نمونه با برنامهٔ اصلی |
مقادیر اصلی با Playwright و `getComputedStyle` در عرض‌های ۳۲۰ و ۱۴۴۰ پیکسل استخراج شدند. بخش سوابق نیز در حالت فعال بررسی شد. قواعد responsive از سورس CSS تطبیق داده شدند. نسبت‌های کنتراست جدول این سند از رنگ‌های ثابت محاسبه شده‌اند.
در HTML خانه قوانین قدیمی بنر، یادآوری پروفایل و overrideهای مراحل قبلی باقی مانده‌اند. وجود یک selector به معنی استفاده شدن آن در طرح نهایی نیست. برای طراحی جدید، قواعد نهایی همین سند را مبنا قرار دهید و CSS انباشتهٔ نمونه را کامل کپی نکنید.
## ۳. رنگ‌ها و نقش معنایی آن‌ها
### پالت موجود
| نقش | توکن موجود / نام در نمونه | مقدار | کاربرد |
|---|---|---|---|
| متن و ساختار اصلی | `--color-ink` / `--ink` | `#08376b` | عنوان، کارت کارشناسان، ناوبری |
| سرمه‌ای روشن‌تر | `--color-ink-soft` | `#0b4c8c` | عمق و حالت تعاملی تیره |
| آبی برند | `--color-sea` / `--sea` | `#0693e3` | آیکون، سطح فعال، تأکید تعاملی |
| آبی تیره | `--color-sea-dark` / `--color-blue` | `#1977ce` | لینک، hover، سطح آیکون |
| تأکید نارنجی | `--color-coral` | `#fa9f00` | خط پایین کارت اصلی |
| زمینهٔ صفحه | `--color-cream` | `#eeeeee` | زمینهٔ عمومی |
| زمینهٔ فرعی | `--color-soft` | `#eaeaea` | سطح ثانویه |
| سطح کارت | `--color-paper` | `#ffffff` | کارت و فرم |
| آبی بسیار روشن | `--color-mint` | `#e5f4ff` | سطح آیکون، انتخاب فایل |
| زرد پشتیبان | `--color-sun` | `#ffc451` | پالت موجود؛ در طرح نهایی مصرف محدود |
| متن ثانویه | `--muted` در خانه | `#526c86` | توضیح، برچسب، امضا |
| مرز پایه | `--line` در خانه | `#dce5ed` | کادر کارت‌ها |
| متن روی سرمه‌ای | مقدار محلی | `#cadbea` | توضیح کارت کارشناسان |
### رنگ‌های اجزای خاص
| جزء | متن / خط | زمینه |
|---|---|---|
| برچسب «به‌زودی» | متن `#805300`، خط `#f3ddae` | `#fff3da` |
| اطلاع‌رسانی خدمت آینده | متن `#805300`، خط `#f3ddae` | `#fff8eb` |
| ناحیه انتخاب تصویر | خط dashed `#91bfdd` | `#f6fbff` |
| کارت شناسهٔ واریز | خط `#cde6f7` | `#f6fbff` |
| جداکننده اطلاعات | `#e7edf2` | سطح کارت |
| سطح آیکون خدمت آینده | متن `#526c86` | `#f0f3f6` |
نارنجی را به رنگ عمومی متن و همهٔ دکمه‌ها تبدیل نکنید. خاکستری روشن برای سطح و مرز است، نه متن اصلی.
### کنتراست: مشاهده و قاعدهٔ طراحی بعدی
برای متن معمولی نسبت حداقل ۴٫۵:۱ و برای متن بزرگِ واجد شرایط حداقل ۳:۱ معیار WCAG است. ۱۴ پیکسل با وزن بالا همچنان متن بزرگ محسوب نمی‌شود. [مرجع W3C: کنتراست متن](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)
| متن / زمینه | نسبت محاسبه‌شده، تقریبی | نتیجه برای متن معمولی |
|---|---:|---|
| سفید / سرمه‌ای `#08376b` | ۱۱٫۸۷:۱ | مناسب |
| `#cadbea` / سرمه‌ای | ۸٫۳۸:۱ | مناسب |
| `#526c86` / سفید | ۵٫۴۶:۱ | مناسب |
| `#526c86` / `#eeeeee` | ۴٫۷۰:۱ | مناسب |
| `#805300` / `#fff3da` | ۶٫۰۵:۱ | مناسب |
| سفید / آبی روشن `#0693e3` | ۳٫۳۴:۱ | برای متن کوچک کافی نیست |
| سفید / آبی تیره `#1977ce` | ۴٫۵۹:۱ | روی همین زمینهٔ ثابت مناسب |
| `#1977ce` / سفید | ۴٫۵۹:۱ | روی همین زمینهٔ ثابت مناسب |
| `#1977ce` / `#eeeeee` | ۳٫۹۶:۱ | برای متن کوچک کافی نیست |
| `#1977ce` / `#e5f4ff` | ۴٫۰۹:۱ | برای متن کوچک کافی نیست |
این اعداد برای جفت‌رنگ ثابت‌اند؛ شفافیت، hover، disabled و پس‌زمینهٔ واقعی باید جدا بررسی شوند.
**قاعدهٔ پیشنهادی برای طراحی جدید:** دکمهٔ اصلی با متن سفید روی سرمه‌ای ساخته شود. اگر دکمه آبی لازم است، سفید روی آبی تیره انتخاب و در حالت‌های مختلف بررسی شود. لینک متنی روی زمینهٔ خاکستری یا آبی روشن از سرمه‌ای استفاده کند. آبی روشن همچنان برای سطح آیکون، تزئین و نشان فعال قابل استفاده است.
دکمهٔ آبی و ناوبری فعال نمونه هنوز از سفید روی آبی روشن استفاده می‌کنند؛ این ظاهر به معنی عبور از معیار کنتراست نیست و نباید بی‌بررسی تکثیر شود. برچسب انتخاب تصویر در نسخهٔ جدید مالی به کنترل فارسی با سفید روی سرمه‌ای تبدیل شده است.
## ۴. تایپوگرافی
### خانوادهٔ فونت
وزیرمتن متغیر، وزن ۱۰۰ تا ۹۰۰، با `font-display: swap`.
فایل‌های موجود:
- `static/fonts/vazirmatn-arabic-wght-normal.woff2`
- `static/fonts/vazirmatn-latin-wght-normal.woff2`
نام خانواده در برنامه `Vazirmatn` است؛ نمونه برای جداسازی از `ChaharsuVazirmatn` و `ChaharsuLatin` استفاده می‌کند. این نام‌ها فونت‌های جدید نیستند. کتابخانه یا فونت خارجی لازم نیست.
### مقیاس مرجع
| نقش | موبایل | دسکتاپ | وزن | ارتفاع خط |
|---|---:|---:|---:|---:|
| عنوان خانه | ۲۲px | ۲۸px | ۹۰۰ | ۱٫۶ |
| عنوان صفحهٔ خدمت | ۲۴px | ۲۸px | ۹۰۰ | ۱٫۵ |
| عنوان بخش / کارت اصلی | ۱۸px | ۲۰px | ۹۰۰ | ۱٫۶ |
| عنوان کارت مالی | ۱۴px | ۱۷px | ۹۰۰ | حدود ۱٫۷ تا ۱٫۸ |
| توضیح و اطلاعات حساب | ۱۴px | ۱۴px | ۴۰۰ | ۱٫۶۵ تا ۱٫۷ |
| دکمه و لینک اصلی | ۱۴px | ۱۴px | ۷۰۰ تا ۸۰۰ | حدود ۱٫۵ تا ۱٫۷ |
| label انتخاب فایل | ۱۶px | ۱۶px | ۸۰۰ | ۱٫۶ |
| وضعیت، مسیر صفحه، ناوبری موبایل | ۱۲px | ۱۲px | ۴۰۰ تا ۷۰۰ | ۱٫۵ تا ۱٫۸ |
| امضای توسعه‌دهنده | ۱۲px | ۱۲px | ۴۰۰ | ۱٫۸ تا ۱٫۹ |
اندازه‌ها تصمیم طراحی چهارسو هستند؛ WCAG یک حداقل عمومی «۱۴ پیکسل» برای همهٔ متن‌ها تعیین نمی‌کند. خوانایی به فونت، کنتراست، طول خط، فاصله و امکان بزرگ‌نمایی نیز وابسته است. برای متن طولانی‌تر، ۱۶px با ارتفاع خط حدود ۱٫۷ پیشنهاد می‌شود؛ این حالت در نمونهٔ فعلی صفحهٔ متن طولانی آزمایش نشده است.
قواعد:
- متن عملیاتی، توضیح اصلی، عنوان دکمه و عدد حساب را از ۱۴px کوچک‌تر نکنید.
- ۱۲px را فقط برای اطلاعات کم‌اولویت به کار ببرید؛ مبلغ، شماره حساب یا پیام خطا اطلاعات کم‌اولویت نیستند.
- حروف فارسی را با letter-spacing فشرده نکنید.
- عنوان‌ها ۸۰۰ یا ۹۰۰؛ متن عادی ۴۰۰؛ دکمه‌ها ۷۰۰ یا ۸۰۰.
- متن چندخطی با خط مناسب نمایش داده شود؛ برای ارتفاع ثابت کارت بریده نشود.
- به‌جای تعداد خط ثابت، توضیح را کوتاه بنویسید و اجازهٔ رشد ارتفاع بدهید.
- واحدهای `rem` برای الگوی جدید ترجیح دارند: ۱۴px برابر ۰٫۸۷۵rem در ریشهٔ ۱۶px. اندازهٔ ریشه را برای جا دادن محتوا کاهش ندهید.
### نمونهٔ متن
عنوان: **ارتباط با کارشناسان**
توضیح، در موبایل و دسکتاپ:
> سؤال بپرسید، درخواست ثبت کنید یا برای پیگیری و هماهنگی با کارشناسان در ارتباط باشید.
دو اقدام: **ثبت درخواست** / **درخواست‌های من**
متن موبایل کارت مالی می‌تواند کوتاه‌تر باشد؛ اما جملهٔ اصلی کارت کارشناسان باید کامل و در هر دو اندازه دیده شود.
## ۵. فاصله‌ها و هندسه
مقیاس پایهٔ پیشنهادی برای اجزای جدید: **۴، ۸، ۱۲، ۱۶، ۲۰، ۲۴، ۳۲px**. نمونه در چند نقطه برای چیدمان فشرده از ۶، ۱۰، ۱۴ و ۲۲px هم استفاده می‌کند. همهٔ این مقادیر توکن سراسری موجود نیستند.
| مورد مشاهده‌شده | موبایل | دسکتاپ |
|---|---|---|
| فاصله افقی خانه از لبه | ۱۶px؛ زیر ۳۶۰px، ۱۲px | ۲۴px داخل container |
| فاصله افقی صفحه مالی | ۱۲px | ۲۴px |
| فاصله کارت‌های خانه | ۱۰px | ۱۶px |
| padding کارت کارشناسان | ۱۲px عمودی / ۱۴px افقی | ۲۰px |
| padding کارت مالی خانه | ۱۱px عمودی / ۱۲px افقی | ۲۴px |
| padding کارت جزئیات مالی | ۱۶px؛ زیر ۳۶۰px، ۱۴px | ۲۲px |
| فاصله عنوان تا توضیح | ۶px | ۶ تا ۷px |
| فاصله دو اقدام داخلی | ۸px | ۱۰px |
| فاصله قبل از اقدام‌ها | ۱۰px | ۱۴px |
| ردیف اطلاعات حساب | ۹px عمودی، gap ۸px | ۱۲px عمودی، gap ۱۲px |
| ناحیه انتخاب فایل | ۱۴px عمودی / ۱۲px افقی | ۲۲px عمودی / ۱۸px افقی |
### گوشه‌ها، خط و سایه
- کارت خانه: radius ۲۰px موبایل / ۲۲px دسکتاپ.
- کارت جزئیات: ۲۰px موبایل / ۲۳px دسکتاپ.
- دکمهٔ داخلی: حدود ۱۱–۱۲px؛ دکمهٔ پایهٔ فرم موجود ۱۶px.
- ورودی و وضعیت انتخاب فایل: ۱۲px.
- ناحیه upload: ۱۸px و خط dashed یک‌پیکسلی.
- نوار پایین: ۲۲px؛ گزینهٔ فعال داخل آن ۱۶px.
- برچسب وضعیت: گرد یا pill، حدود ۲۰px.
- مرز کارت: ۱px؛ خط تأکید کارت اصلی: ۳px نارنجی.
- کارت‌ها عموماً بدون سایهٔ دائمی‌اند. نوار ثابت یا پنجره به سایه نیاز دارد؛ همهٔ سطوح را برجسته نکنید.
- hover کارت مستقل: حداکثر جابه‌جایی ۳px و سایهٔ ملایم. کارت مشترک با دو اقدام داخلی جابه‌جا نشود.
برای طراحی جدید مقادیر نزدیک را با خانوادهٔ منظم توکن‌ها مدیریت کنید؛ تفاوت ۲۲/۲۳px از نمونه الزام هویتی نیست.
## ۶. چیدمان و responsive
| محدوده | رفتار مرجع |
|---|---|
| کمتر از ۳۶۰px | فاصله افقی ۱۲px؛ حذف آیکون کنار دو دکمهٔ کارشناسان؛ متن ۱۴px حفظ شود |
| تا ۶۳۹px | پوستهٔ موبایل، شبکهٔ دو ستون، ناوبری پایین |
| ۶۴۰ تا ۸۹۹px | شبکهٔ دو ستون؛ ناوبری دسکتاپ |
| از ۹۰۰px | شبکهٔ چهار ستون؛ کارت کارشناسان دو ستون را می‌گیرد |
حداکثر عرض خانه و header: **۱۱۲۰px**. حداکثر عرض صفحهٔ مالی: **۹۰۰px**. این اندازه‌ها شامل padding و border-box هستند.
در موبایل، توضیح کارت کارشناسان زیر عنوان تمام عرض را می‌گیرد. متن را در ستون باریک کنار آیکون قرار ندهید. همین الگو برای عنوان و subtitle صفحات مالی استفاده شده است.
دو اقدام کارشناسان کنار هم باقی می‌مانند. اگر در یک زبان، بزرگ‌نمایی یا عرض خاص متن جا نشد، ابتدا آیکون غیرضروری حذف و سپس دکمه‌ها به یک ستون تبدیل شوند؛ اندازهٔ متن کاهش نیابد. تبدیل در بزرگ‌نمایی قاعدهٔ پیشنهادی است و هنوز در نمونه اجرا/اعتبارسنجی نشده است.
**هدف خانه:** هر سه خدمت در viewport ۳۲۰×۵۶۸ و اندازه‌های معمول موبایل بالاتر از نوار پایین دیده شوند. این هدف با سه خدمت فعلی آزمایش شده است؛ تضمین برای تعداد نامحدود خدمات یا همهٔ تنظیمات فونت نیست.
**صفحهٔ جزئیات:** اسکرول عمودی طبیعی مجاز است. فرم، اطلاعات حساب و توضیحات را برای حذف کامل اسکرول کوچک نکنید.
## ۷. آیکون‌ها و لوگو
### الزام قطعی: تمام دارایی‌ها داخل پروژه
هیچ صفحه‌ای نباید برای آیکون، فونت، CSS، JavaScript، تصویر یا لوگو به CDN یا فایل روی دامنهٔ خارجی وابسته باشد. این الزام برای طراحی و پیاده‌سازی آینده نیز پابرجاست.
آیکون‌های این نمونه با مسیرهای SVG مستقیماً در HTML نوشته شده‌اند و از بستهٔ بیرونی یا سرویس آیکون دریافت نمی‌شوند. تعریف در `<symbol id="i-bank">…</symbol>` و نمایش با `<use href="#i-bank">` انجام می‌شود؛ ارجاع فقط به شناسهٔ داخل همان سند است. `xmlns="http://www.w3.org/2000/svg"` شناسهٔ استاندارد فضای نام SVG است، نه نشانی دانلود؛ مرورگر برای آن درخواست شبکه نمی‌فرستد.
اگر در آینده آیکون‌ها در یک فایل مشترک یا include جمع شدند، آن فایل باید در خود مخزن نگهداری و از static همان پروژه سرو شود. URL دارایی‌های محلی برنامه باید با static_asset_version نسخه‌گذاری شود. کتابخانهٔ موردنیاز نیز باید با خروجی محلی داخل پروژه ارائه شود؛ لینک CDN، import از URL، Google Fonts، icon API یا fallback شبکه‌ای مجاز نیست.
فونت وزیرمتن در static/fonts، لوگو و favicon در static/images و Tailwind کامپایل‌شده در static/css موجودند. JavaScript نمونه داخل HTML است.
لینک‌های W3C در این سند فقط منابع مطالعه‌اند و وابستگی اجرایی صفحات نیستند. برای بازبینی بعدی، صفحه باید با قطع دسترسی اینترنت و منابع محلی بارگذاری شود؛ در برنامهٔ اصلی اتصال به سرور خود پروژه همچنان ممکن است لازم باشد.
آیکون‌های نمونه SVG خطی محلی‌اند؛ بستهٔ آیکون یا فونت آیکون خارجی استفاده نشده است.
الگوی پایه:
- `viewBox="0 0 24 24"`
- `fill="none"`
- `stroke="currentColor"`
- `stroke-width="1.7"`
- `stroke-linecap="round"` و `stroke-linejoin="round"`
- اندازه متداول ۲۰–۲۴px؛ مقدار پایهٔ نمونه ۲۳px.
- آیکون داخل دکمه ۱۶–۲۰px؛ آیکون نوار پایین ۲۰px.
- قاب آیکون خانه ۲۸–۳۰px موبایل؛ قاب عنوان صفحه ۳۶px موبایل / ۴۸px دسکتاپ.
- اندازهٔ آیکون با اندازهٔ ناحیهٔ قابل لمس یکی نیست.
| شناسه موجود | مفهوم |
|---|---|
| `i-chat` | ارتباط با کارشناسان |
| `i-plus` | ثبت درخواست |
| `i-history` | درخواست‌های من |
| `i-receipt` | فیش واریزی |
| `i-bank` | شماره حساب‌ها |
| `i-grid` | خدمات |
| `i-user` | پروفایل |
| `i-arrow` | رفتن به مقصد |
| `i-upload` | انتخاب تصویر |
| `i-search` | جست‌وجو |
| `i-home`، `i-shield` | در sprite موجودند؛ در خانهٔ نهایی نقش اصلی ندارند |
آیکون کنار متن `aria-hidden="true"` باشد. کنترل فقط‌آیکون باید نام قابل فهم مانند `aria-label="بستن پنجره"` داشته باشد. از emoji یا کاراکتر تصادفی به‌عنوان سبک آیکون جدید استفاده نکنید.
لوگو از `static/images/company-logo.png` یا منبع برند برنامه می‌آید. نسبت طول به عرض حفظ شود؛ `height:auto` و در صورت نیاز `object-fit:contain`. اندازهٔ مشاهده‌شده در desktop ۸۵px است؛ نمونه‌های موبایل خانه و صفحات مالی برای جا دادن header اندازه‌های متفاوت دارند. لوگو را برای ساخت آیکون خدمت تغییر شکل ندهید.
## ۸. اجزا و رفتار تعاملی
### کارت خدمت
عنوان واضح + آیکون مرتبط + توضیح کوتاه + وضعیت، در صورت لزوم.
- یک مقصد: کل کارت لینک `a` باشد.
- دو اقدام مستقل: ظرف `section` و دو کنترل جدا؛ لینک تو‌در‌تو نسازید.
- «به‌زودی» وضعیت خدمت است؛ برای نمونه می‌تواند به صفحهٔ preview برود.
- قابلیت آماده‌نشده را با شماره حساب یا موفقیت جعلی واقعی جلوه ندهید.
- عنوان کارت به‌جای زبان داخلی سیستم، نتیجهٔ قابل فهم برای مشتری را بیان کند.
### دکمه‌ها
- ارتفاع اقدام اصلی حداقل ۴۴px؛ عرض کنترل نیز برای لمس مناسب باشد.
- ۴۴×۴۴px یک هدف طراحی و معیار **Target Size (Enhanced)، سطح AAA** است؛ همهٔ لینک‌های متنی بدون استثنا ملزم به این ابعاد نیستند. [مرجع W3C](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html)
- اقدام اصلی پررنگ؛ اقدام ثانویه با border یا زمینهٔ ملایم.
- متن دکمه یک فعل/مقصد مشخص داشته باشد: «ثبت درخواست»، «مشاهده حساب‌ها»، «حذف انتخاب».
- disabled با ویژگی واقعی HTML اعمال شود؛ دلیل غیرفعال بودن نزدیک آن نوشته شود.
- لینک برای ناوبری، button برای اقدام یا بازکردن پنجره.
### فرم انتخاب فیش
label ۱۶px؛ ورودی و راهنما ۱۴px؛ ناحیه روشن با کادر dashed.
- به‌جای چند جمله تکراری، یک راهنمای کیفیت تصویر و یک توضیح دربارهٔ نتیجهٔ انتخاب فایل کافی است.
- نام بلند فایل با `overflow-wrap:anywhere` قابل نمایش باشد.
- انتخاب فایل به معنی ارسال یا تأیید پرداخت نیست.
- در نمونه دکمه فقط اعتبارسنجی می‌کند و فیش ارسال نمی‌شود؛ در برنامهٔ نهایی پس از ثبت واقعی نتیجه نمایش داده شود.
- کنترل file فعلی بومی مرورگر است؛ زبان و ظاهر بخشی از آن وابسته به مرورگر/سیستم عامل است. عبارت انگلیسی آن را جزء زبان بصری فارسی پروژه تلقی نکنید.
### اطلاعات حساب
از `dl / dt / dd` برای ارتباط برچسب و مقدار استفاده کنید. متن مقدار حساب ۱۴px یا بیشتر باشد. حساب، شبا و شناسه با جهت LTR و بدون بریدگی نمایش داده شوند. نمونه اکنون اعداد ساختگی نامعتبر دارد؛ نمایش و کپی این مقادیر در اندازه‌های موبایل و دسکتاپ بررسی شده است. دادهٔ حساب واقعی همچنان فقط باید از بک‌اند بیاید.
کپی جداگانهٔ فیلدها و کپی کامل متن اکنون در نمونه وجود دارد؛ کنترل نام مشخص و بازخورد قابل مشاهده/اعلان وضعیت دارد. در صورت منع کپی خودکار، انتخاب دستی متن ارائه می‌شود. قواعد کامل در بخش «الگوی مالی» آمده‌اند.
### ناوبری
خانهٔ خدمات، درخواست‌ها و پروفایل سه مقصد نوار موبایل هستند. از `aria-current="page"` برای مقصد فعال استفاده شود.
در نمونه:
- نوار پایین با فاصلهٔ افقی ۱۲px و فاصلهٔ پایین حداقل ۱۰px قرار دارد.
- safe-area با `env(safe-area-inset-bottom)` لحاظ می‌شود.
- ارتفاع هر گزینه حداقل ۵۲px است؛ کل نوار در بررسی ۳۲۰px حدود ۶۷px بود.
- خانه برای پایین صفحه ۹۵px padding دارد تا امکان اسکرول محتوا بالاتر از نوار فراهم شود.
در طراحی جدید، فضای رزروشده باید با ارتفاع واقعی نوار و safe-area هماهنگ باشد. فوکوس، پیام خطا و پایان فرم نباید زیر آن پنهان بمانند.
### پنجره و وضعیت‌ها
پنجره فقط برای کار کوتاه و محدود مناسب است. خدمات مالی صفحهٔ مستقل دارند و به modal تبدیل نشوند.
الگوی موجود: `dialog` بومی، backdrop تیره، عرض حداکثر ۵۲۰px، بستن با Escape/دکمه/کلیک بیرون، اسکرول داخلی در محتوای بلند. برای کنترل بستن جدید، ناحیهٔ لمس ۴۴×۴۴px پیشنهاد می‌شود؛ نمونهٔ فعلی ۴۰×۴۰px است.
فوکوس نمایان باشد. برای حالت loading، خطا، موفقیت و empty علاوه بر رنگ، متن معنادار نمایش داده شود. سبک loading و خطای کامل فرم مالی هنوز در نمونه تعریف نشده است؛ از ساخت چند نسخهٔ متفاوت بدون توکن مشترک پرهیز کنید.
## ۹. متن، راست‌به‌چپ و امضا
- سند `lang="fa" dir="rtl"` داشته باشد.
- اصطلاحات فعلی: «ارتباط با کارشناسان»، «ثبت درخواست»، «درخواست‌های من»، «ارسال فیش واریزی»، «شماره حساب‌ها».
- خانه محل فهرست سوابق نیست؛ آن‌ها در نمای درخواست‌ها قرار دارند.
- جملهٔ راهنمای کارشناسان در تمام اندازه‌ها قابل نمایش بماند.
- متن کارت‌های مالی در موبایل کوتاه‌تر می‌شود؛ وضعیت و مقصد حذف نمی‌شوند.
- از نام متغیر، API، اصطلاح backend یا توضیح پیاده‌سازی در متن محصول استفاده نکنید.
- برای دادهٔ LTR از `bdi dir="ltr"` یا عنصر مستقل با `dir="ltr"` استفاده کنید.
- در CSS جدید `padding-inline`، `margin-inline` و `text-align:start` را ترجیح دهید.
- مثال: `<bdi dir="ltr">IR…</bdi>`؛ این مثال شماره حساب واقعی نیست.
امضای پایین صفحات:
> طراحی و توسعه: محمد جواد فصاحت 09202202129
اندازه ۱۲px، وزن ۴۰۰، متن ثانویه و شماره با جهت LTR. لینک تماس `tel:09202202129` فقط با انتخاب کاربر اجرا شود. در نسخهٔ اصلی محتوا از `developer_credit` تنظیمات برند دریافت شود؛ مقدار را در همهٔ templateها تکثیر نکنید.
## ۱۰. مثال برای ساخت جزء جدید
این مثال **پیشنهاد برای طراحی بعدی** است؛ کلاس‌هایش در CSS فعلی تعریف نشده‌اند. رنگ دکمه و لینک به‌قصد رفع موارد کنتراست بخش ۳ انتخاب شده است.
### توکن‌ها و CSS نمونه
```css
:root {
--ui-ink: var(--color-ink, #08376b);
--ui-page: var(--color-cream, #eeeeee);
--ui-surface: var(--color-paper, #ffffff);
--ui-muted: #526c86;
--ui-border: #dce5ed;
--ui-action: var(--color-ink, #08376b);
--ui-action-hover: var(--color-ink-soft, #0b4c8c);
--ui-link: var(--color-ink, #08376b);
--ui-space-2: .5rem;
--ui-space-3: .75rem;
--ui-space-4: 1rem;
--ui-radius-card: 1.25rem;
}
.ui-card {
padding: 1rem;
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius-card);
background: var(--ui-surface);
color: var(--ui-ink);
}
.ui-title {
margin: 0;
font-size: 1.125rem;
font-weight: 900;
line-height: 1.6;
}
.ui-description {
margin: .375rem 0 0;
color: var(--ui-muted);
font-size: .875rem;
line-height: 1.7;
}
.ui-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .5rem;
min-height: 2.75rem;
min-width: 2.75rem;
padding: .5rem .75rem;
border: 1px solid transparent;
border-radius: .75rem;
font: inherit;
font-size: .875rem;
font-weight: 800;
background: var(--ui-action);
color: #fff;
}
.ui-button:hover { background: var(--ui-action-hover); }
.ui-button:focus-visible {
outline: 3px solid var(--color-sea, #0693e3);
outline-offset: 4px;
}
.ui-icon {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
}
@media (min-width: 640px) {
.ui-card { padding: 1.375rem; }
.ui-title { font-size: 1.25rem; }
}
@media (prefers-reduced-motion: reduce) {
.ui-button { transition: none; }
}
```
### HTML نمونهٔ صفحهٔ مستقل در Django
```django
{% extends "base.html" %}
{% load static %}
{% block content %}
<section class="ui-card" aria-labelledby="receipt-heading">
<h1 class="ui-title" id="receipt-heading">ارسال فیش واریزی</h1>
<p class="ui-description">
تصویر فیش را برای بررسی پرداخت ارسال کنید.
</p>
</section>
{% endblock %}
```
برای CSS جدید از نقطهٔ توسعهٔ مناسب پوسته استفاده شود و URL آن به شکل
`{% static 'css/…' %}?v={{ static_asset_version }}` باشد. `…` مسیر مثال است، نه فایل موجود. این قطعه فرم اجرایی، route یا ذخیرهٔ فایل را پیاده نمی‌کند.
نمونهٔ آیکون، بعد از تعریف sprite در همان سند:
```html
<svg class="ui-icon" aria-hidden="true" focusable="false">
<use href="#i-receipt"></use>
</svg>
```
برای ساخت خدمت مستقل، لینک Django باید به URL نام‌گذاری‌شده همان خدمت متصل شود؛ نام routeهای مالی هنوز در پروژه تعریف نشده است. نام فرضی route را به‌عنوان مسیر موجود کپی نکنید.
## ۱۱. ارتباط با فناوری موجود
پروژه Tailwind 4، Alpine، HTMX و Compressor را دارد؛ ظاهر این راهنما وابسته به افزودن کتابخانهٔ دیگری نیست. SVGهای محلی و وزیرمتن موجود برای ادامه کافی‌اند.
در پیاده‌سازی برنامه:
- رنگ‌های برند از توکن‌های مرکزی موجود مصرف شوند.
- کامپوننت مشترک و کلاس‌های معنادار ایجاد شوند؛ CSS تکراری یا overrideهای مرحله‌ای تکثیر نشوند.
- پوستهٔ صفحه از `base.html` و برند از تنظیمات پروژه بیاید.
- فایل‌های محلی با `static_asset_version` نسخه‌گذاری شوند.
- از ثبت Service Worker جدا در صفحهٔ یک خدمت پرهیز شود؛ چرخهٔ PWA متعلق به پوستهٔ برنامه است.
- تغییرات کش/PWA باید با تست‌های مقرر پروژه بررسی شوند.
- نمونهٔ مستقل خارج از templates/static است؛ انتقال آن به برنامهٔ اصلی یک کار اجرایی جدا است.
## ۱۲. مواردی که نباید از نمونه تکثیر شوند
| مشاهده | وضعیت واقعی | قاعدهٔ طراحی بعدی |
|---|---|---|
| سفید روی آبی روشن دکمه/ناوبری | کنتراست حدود ۳٫۳۴ | رنگ متن/سطح طبق بخش ۳ اصلاح و دوباره سنجیده شود |
| لینک آبی تیره روی خاکستری روشن | کنتراست حدود ۳٫۹۶ | سرمه‌ای یا رنگ آزموده‌شده مناسب همان زمینه |
| label انتخاب فایل | اصلاح شد: کنترل فارسی سفید روی سرمه‌ای، ارتفاع حداقل ۴۴px | وضعیت focus ورودی پنهان روی label نیز دیده شود |
| نام برند فرعی خانه | ۱۱px؛ صفحات مالی ۱۲px | برای اجزای جدید ۱۲px |
| متن‌های نمای سوابق | اصلاح شد: عنوان ۱۴/۱۶، شماره ۱۴، metadata و وضعیت ۱۲px | همین مقیاس در صفحات بعدی حفظ شود |
| فیلتر سوابق / ورودی جست‌وجو | اصلاح شد: حداقل ارتفاع ۴۴px؛ متن فیلتر ۱۴ و جست‌وجو ۱۶px | برای کنترل جدید حداقل ۴۴px هدف‌گذاری شود |
| بستن پنجره / دکمه پروفایل موبایل | ۴۰px | کنترل جدید ۴۴×۴۴px |
| پنجره‌ها | متن عادی ۱۳ و یادداشت ۱۱px | برای پنجره جدید متن اصلی ۱۴، فرعی ۱۲px |
| قوانین بنر و پروفایل قدیمی | هنوز در CSS، ولی خانه نهایی استفاده نمی‌کند | جزء مرجع نهایی محسوب نشوند |
| اعداد ثابت و اطلاعات نمونه | دادهٔ نمایشی، بدون backend | فقط منبع معتبر حساب/مشتری در نسخهٔ اصلی |
| متن ۱۴px برای همه شرایط | تصمیم نمونه، نه تضمین خوانایی همگانی | بزرگ‌نمایی، متن بلند و دستگاه واقعی نیز بررسی شوند |
موارد دارای عبارت «اصلاح شد» اکنون در نمونه اجرا شده‌اند؛ سایر موارد همچنان نیازمند بررسی یا اصلاح‌اند.
## ۱۳. معیار بازبینی طرح بعدی
- [ ] عنوان و مقصد هر خدمت با نگاه اول مشخص است.
- [ ] متن کامل راهنمای کارشناسان روی موبایل و دسکتاپ دیده می‌شود.
- [ ] متن اصلی و داده‌های مهم حداقل ۱۴px؛ metadata کم‌اولویت حداقل ۱۲px است.
- [ ] کنتراست متن و کنترل روی زمینهٔ واقعی، در حالت عادی و تعاملی سنجیده شده است.
- [ ] رنگ تنها نشانهٔ خطا، انتخاب یا وضعیت نیست.
- [ ] هدف لمس کنترل‌های اصلی ۴۴×۴۴px است.
- [ ] فونت، وزن، stroke آیکون، رنگ و گوشه‌ها با خانوادهٔ این راهنما هماهنگ‌اند.
- [ ] خانه در ۳۲۰×۵۶۸، ۳۶۰×۶۴۰، ۳۹۰×۸۴۴ و دسکتاپ بازبینی شده است.
- [ ] متن بلند، عنوان چندخطی، نام فایل بلند و عدد LTR سرریز ندارند.
- [ ] صفحهٔ جزئیات اسکرول طبیعی دارد و فرم پشت نوار ثابت پنهان نیست.
- [ ] Tab، Enter و Escape، فوکوس نمایان و بازگشت مرورگر بررسی شده‌اند.
- [ ] بزرگ‌نمایی متن تا ۲۰۰٪ بدون از دست‌رفتن محتوا یا عملکرد بررسی شده است.
- [ ] حرکت با `prefers-reduced-motion` محدود می‌شود.
- [ ] خدمت مستقل با لینک و صفحه مستقل پیاده می‌شود؛ modal جای آن را نگرفته است.
- [ ] label ورودی، نام کنترل فقط‌آیکون، اعلان نتیجه و پیام خطا وجود دارند.
- [ ] دادهٔ نمایشی، placeholder و وضعیت به‌زودی از اطلاعات واقعی قابل تشخیص‌اند.
- [ ] برند، امضای توسعه‌دهنده و نسخهٔ دارایی‌ها از منبع مشترک برنامه می‌آیند.
بزرگ‌نمایی ۲۰۰٪ معیار مستقل W3C است؛ تعیین font-size مناسب جای بررسی آن را نمی‌گیرد. [مرجع W3C: تغییر اندازه متن](https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html)
**حدود بررسی فعلی:** استخراج computed style، اندازه‌های نمایش، نمونه‌های رنگ و بررسی‌های مرورگر قبلی مبنای سند هستند. تست کامل screen reader، تمامی حالت‌های خطا/loading، بزرگ‌نمایی ۲۰۰٪، حساب واقعی و ارزیابی کامل WCAG در این نوبت انجام نشده‌اند.
## ۱۴. نمونه‌های بصری
برای مشاهدهٔ زبان بصری فعلی، با درنظرگرفتن موارد نیازمند اصلاح بخش ۱۲:
- [خانه در گوشی کوچک](mobile-small.png)
- [خانه دسکتاپ](desktop.png)
- [شماره حساب‌ها در گوشی کوچک](accounts-small.png)
- [شماره حساب‌ها دسکتاپ](accounts-desktop.png)
- [ارسال فیش در گوشی کوچک](receipt-small.png)
- [ارسال فیش دسکتاپ](receipt-desktop.png)
برای هر صفحهٔ جدید، ابتدا همین نقش‌ها و اجزا را انتخاب کنید؛ سپس فقط آنچه نیاز مشخص صفحه است اضافه کنید.
### به‌روزرسانی نمای درخواست‌ها
نمای درخواست‌ها با مقیاس تایپوگرافی این سند هماهنگ شد. شمارهٔ درخواست از metadata مهم‌تر است و ۱۴ پیکسل باقی می‌ماند. روی موبایل عنوان و اطلاعات بالاتر از وضعیت و شماره قرار می‌گیرند تا متن مجبور به ستون باریک نشود. رنگ فعال گزینهٔ درخواست‌ها در نوار پایین از آبی روشن به ink-soft تغییر کرده است؛ رنگ فعال سایر گزینه‌ها هنوز باید طبق جدول کنتراست بررسی شود.
### الگوی مالی: اعلامیهٔ واریز اختصاصی مشتری
صفحهٔ «شماره حساب‌ها» فقط موارد اعلام‌شده توسط کارشناس به مشتری را نمایش می‌دهد؛ حساب عمومی یا حساب پیش‌فرض جایگزین ندارد. هر اعلامیه یک کارت بازشونده با موضوع، مبلغ و واحد، فاکتور، تاریخ و وضعیت فیش است. شماره‌های بانکی در فهرست بسته نمایش داده نمی‌شوند؛ باز شدن کارت همهٔ جزئیات همان مورد را نشان می‌دهد. هویت حساب و مبلغ به هر اعلامیه تعلق دارند، نه به کل صفحه.
- مبلغ کارت: موبایل ۱۸px، دسکتاپ ۲۰px، وزن ۹۰۰؛ شماره‌های جزئیات ۱۴/۱۶px، راست‌چین با جهت LTR و شکست خط امن.
- عنوان کارت ۱۴/۱۶px، توضیح و کنترل کپی ۱۴px؛ label فرم و مقدار ورودی ۱۶px؛ وضعیت و تاریخ ۱۲px.
- فاصلهٔ کارت‌ها ۱۲px؛ گوشه ۲۰px؛ ردیف اطلاعات در دسکتاپ label/value/copy و در موبایل label بالای value با دکمهٔ کپی کنار آن است.
- دکمهٔ «کپی» حداقل ۴۴px؛ aria-label نام همان فیلد را مشخص کند. مقدار خالی «اعلام نشده» نمایش داده شود و دکمهٔ کپی نداشته باشد.
- عدد کپی‌شده فاقد جداکنندهٔ نمایشی است؛ مبلغِ کپی جداگانه بدون واحد است. «کپی کامل اطلاعات» متن چندخطی خوانا شامل موضوع، تاریخ، شناسهٔ اعلامیه، مبلغ و واحد، بانک، صاحب حساب، شبا، حساب، کارت، شناسهٔ واریز، فاکتور و توضیحات فراهم کند. در نمونه، هشدار ساختگی بودن هم در متن کپی قرار دارد.
- پیام «کپی شد» فقط پس از کپی موفق نمایش داده شود، به screen reader اعلام شود و تمرکز روی کنترل حفظ شود. اگر کپی خودکار ممکن نیست، textarea فقط‌خواندنی برای انتخاب دستی در dialog با عنوان، کنترل بستن و پشتیبانی Escape نمایش داده شود.
- وضعیت «فیش در حال بررسی» به معنی تأیید پرداخت نیست؛ هر وضعیت نام متنی داشته باشد و به رنگ وابسته نباشد.
- ارسال فیش عمل اصلی و کپی کامل عمل ثانویه است؛ هر دو در پایین جزئیات همان کارت باشند. در موبایل تمام عرض و در دو ردیف قرار گیرند.
### الگوی فرم فیش مستقل و مرتبط
دو گزینهٔ radio با label واضح و تمام سطح قابل لمس: «واریز مستقل» و «واریز مرتبط». ورود از اعلامیه، گزینهٔ مرتبط و همان اعلامیه را از ابتدا انتخاب می‌کند؛ ورود مستقیم گزینهٔ مرتبط را به‌عنوان اولین گزینهٔ پیشنهادی انتخاب می‌کند؛ مستقل همواره قابل انتخاب است. انتخاب مرتبط، select و خلاصهٔ مبلغ اعلام‌شده، فاکتور، بانک و شناسه را نشان دهد. لینک مشاهدهٔ جزئیات به همان اعلامیه بازگردد. انتخاب مستقل ارتباط قبلی را حذف کند؛ فایل و مبلغ پرداخت‌شده را بی‌دلیل پاک نکند.
مبلغ مورد انتظار و مبلغ واقعاً پرداخت‌شده دو مفهوم متفاوت‌اند. مبلغ مشتری را با تغییر اعلامیه خودکار بازنویسی نکنید. واحد «ریال» کنار label مبلغ بماند. ورودی مبلغ و پیگیری در موبایل صفحه‌کلید عددی دارند. کنترل انتخاب تصویر، عنوان فارسی و سطح حداقل ۴۴px دارد؛ نام فایل انتخاب‌شده و عبارت «ارسال نشده» مستقل نمایش داده شوند. اطلاعات طولانی wrap شوند؛ حذف اسکرول عمودی با کوچک کردن متن مجاز نیست.
رنگ متن و لینک این اجزا روی زمینهٔ خاکستری/آبی روشن سرمه‌ای است؛ دکمهٔ اصلی سفید روی سرمه‌ای، ثانویه سرمه‌ای روی سفید با مرز مشخص. فایل‌ها و SVGها کاملاً محلی هستند. این الگو دیتابیس یا مجوزهای سرور را پیاده‌سازی نمی‌کند؛ قرارداد مربوط به دریافت اختصاصی اعلامیه و ارتباط معتبر فیش در کامنت HTML ثبت شده است.
### نسخهٔ فشردهٔ اطلاعات مالی
در کارت بازشده فقط مبلغ، حساب، شبا، کارت و شناسهٔ واریز موجود، دکمهٔ کپی تکی دارند. بانک و صاحب حساب در سطر خلاصه نمایش داده شوند؛ فاکتور و توضیحات در بخش ثابت با عنوان «اطلاعات تکمیلی و توضیحات» نمایش داده شوند؛ این بخش کنترل جمع‌شونده ندارد. شماره‌های اعلام‌نشده از فهرست اصلی حذف شوند. کنترل باز و بسته شدن خود کارت اعلامیه حداقل ۴۴px و با صفحه‌کلید قابل استفاده باشد. اطلاعات تکمیلی ۱۴px، راهنمای صفحه ۱۴px با ارتفاع خط ۱٫۷؛ تعداد دکمه‌ها با حذف کپی‌های کم‌کاربرد کاهش یابد، نه با کوچک کردن سطح لمس.
«کپی کامل اطلاعات» همواره از مدل کامل اعلامیه تولید شود، مستقل از نمایش یا پنهان بودن هر بخش. بانک، صاحب حساب، فاکتور، توضیحات و تمام مقادیر اعلام‌شده باید در متن کامل باقی بمانند. این قرارداد در HTML و JavaScript نیز کامنت دارد.
در فرم فیش، «واریز مرتبط» همیشه در ترتیب DOM و نمایش اولین گزینه، با نشان متنی «پیشنهادی» و تأکید بصری محدود است. پیش‌فرض ورود مستقیم مرتبط است؛ مستقل برای مشتری بدون اعلامیه نیز در دسترس بماند. راهنمای کوتاه و همیشه قابل مشاهده توضیح دهد: ایجاد اعلامیه توسط کارشناس برای ثبت دقیق‌تر توصیه می‌شود و اجباری نیست.
### تاریخ شمسی و اطلاعات تکمیلی همیشه نمایان
اطلاعات تکمیلی هر اعلامیه، پس از باز کردن کارت اصلی، بدون کنترل بازشوندهٔ دیگری نمایش داده شود. عنوان بخش ۱۴px/وزن ۸۰۰، متن ۱۴px با ارتفاع خط ۱٫۸؛ همچنان کپی تکی مخصوص مقادیر پرداخت و کپی کامل شامل تمام داده‌هاست.
انتخابگر تاریخ فیش: سه select استاندارد «روز، ماه، سال»، RTL، labelهای ۱۴px، مقادیر ۱۶px و ارتفاع حداقل ۴۴px. فاصلهٔ ستون‌ها ۸px موبایل / ۱۲px دسکتاپ؛ نام ماه‌ها و ارقام نمایش فارسی باشند. این انتخاب برای ورود تاریخ معلوم روی فیش ساده است و تعامل‌های پیچیدهٔ wheel سفارشی یا تقویم بزرگ را به فرم اضافه نمی‌کند. ظاهر فهرست بازشدهٔ select تابع مرورگر/سیستم‌عامل است؛ در برخی گوشی‌ها کنترل بومی ممکن است به‌صورت چرخشی نمایش داده شود. هیچ wheel سفارشی، CDN یا کتابخانهٔ تاریخ لازم نیست.
روز ابتدا خالی؛ ماه/سال جاری برای سهولت آماده‌اند. دکمهٔ «امروز» با منطقهٔ زمانی `Asia/Tehran` و یک بازخورد متنی کامل مثل «۱۳ مهر ۱۴۰۵» انتخاب را روشن می‌کند. طول ماه‌ها از تقویم شمسی مرورگر بررسی شود، نه فرض کبیسهٔ هر چهار سال. روز نامعتبر با تغییر ماه/سال پاک شود و پیام ۱۴px با aria-live کاربر را به انتخاب دوباره راهنمایی کند؛ روز واقعی پرداخت نباید خودکار به روز دیگری تبدیل شود.
دادهٔ خروجی نمونه تاریخ میلادیِ فقط‌روز در `paid_date` است؛ سرور باید آن را اعتبارسنجی کند. تاریخ پرداخت مستقل از تاریخ اعلامیه است و با تغییر اعلامیه یا نوع فیش نباید بازنویسی شود.
### خطا و اعتبارسنجی فرم فیش
مبلغ، تاریخ کامل و تصویر با برچسب اجباری مشخص شوند. خطای هر فیلد با متن فارسی سادهٔ ۱۴px و ارتفاع خط ۱٫۸، رنگ `#9f1f1f` و aria-describedby به کنترل متصل شود. کنترل نامعتبر aria-invalid و مرز قرمز داشته باشد؛ رنگ به‌تنهایی کافی نیست. خطا با اصلاح مقدار به‌روز/پاک شود؛ خطای همهٔ فیلدها قبل از تعامل نشان داده نشود.
پس از تلاش ناموفق، فهرست لینک‌دار خطاها در بالای فرم و فوکوس روی اولین کنترل نامعتبر ارائه شود. لینک خطا همان کنترل را فوکوس و به نمایش بیاورد؛ برای فایل پنهان، label فارسی قابل لمس نیز در معرض دید قرار گیرد. اسکرول با prefers-reduced-motion هماهنگ باشد. همهٔ مقادیر واردشده حفظ شوند.
نمونه دکمهٔ «بررسی و ادامه» دارد؛ پس از بررسی معتبر، به‌روشنی اعلام می‌کند هیچ فایلی ارسال نشده است. نتیجهٔ معتبر نباید با موفقیت ثبت پرداخت اشتباه شود. محدودیت فایل در UI و اعتبارسنجی از یک منبع معتبر سرور در نسخهٔ اصلی تغذیه شوند.
### مبلغ با هزارگان و بانک‌های پرداخت
مبلغ همیشه با label ریال نمایش داده شود. جداکنندهٔ هزارگان فقط نمایش است؛ مقدار آمادهٔ ارسال ریالی و بدون جداکننده باقی می‌ماند. زیر ورودی، معادل به حروف تومان با متن ۱۴px، وزن ۷۰۰، رنگ سرمه‌ای و ارتفاع خط ۱٫۸ نمایش داده شود. تبدیل از عدد صحیح دقیق و باقی‌مانده استفاده کند؛ بخش کمتر از یک تومان را به «دهم تومان» بیان کند و گرد نکند. هنگام ویرایش، نشانگر و انتخاب متن باید نسبت به ارقام حفظ شوند و حذف روی جداکننده گیر نکند.
بانک مبدأ با select اختیاری و گزینهٔ «سایر»؛ حساب مقصد با select اجباری و label «به کدام حساب ما واریز کردید؟». گزینه‌های مقصد فقط نام نمایشی حساب را نشان دهند؛ شماره‌های بانکی در فهرست نباشند. حساب از دیتابیس شرکت تأمین شود و ID آن ارسال شود؛ انتخاب مشتری با تغییر اعلامیه/نوع فیش بازنویسی نشود. فیلدها در دسکتاپ دو ستون و در موبایل یک ستون، متن کنترل ۱۶px و ارتفاع حداقل ۴۴px باشند.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

@@ -0,0 +1,42 @@
<!doctype html>
<html lang="fa" dir="rtl" data-static-version="1791111429240969400">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#08376b"><title>شماره حساب‌ها · چهارسو</title>
<link rel="icon" href="../../static/images/favicon.ico?v=1791111429240969400"><link rel="stylesheet" href="../../static/css/tailwind.css?v=1791111429240969400"><link rel="stylesheet" href="service-pages.css?v=1791111429240969400">
<script defer src="payment-demo-data.js?v=1791111429240969400"></script><script defer src="payment-services.js?v=1791111429240969400"></script></head>
<body data-finance-page="accounts">
<!-- DESIGN PROTOTYPE ONLY: صفحهٔ مستقل با URL/view/template مستقل در پیاده‌سازی اصلی؛ به modal خانه تبدیل نشود.
پوسته از base.html و دارایی‌ها از static_asset_version استفاده کنند. هیچ اتصال به دیتابیس یا ارسال واقعی وجود ندارد.
اطلاعات پرداخت تنها برای مشتری مخاطب اعلامیه از بک‌اند تأمین شود؛ فهرست عمومی حساب‌ها نمایش داده نشود.
پنهان کردن شماره‌ها در UI کنترل دسترسی نیست: مجوز مشاهده/ثبت فیش و ارتباط اعلامیه با مشتری باید در سرور بررسی شود.
پارامتر notice فقط شناسهٔ اعلامیه است، نه مبلغ یا شماره حساب؛ سرور دادهٔ معتبر را دوباره دریافت کند. -->
<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<symbol id="i-receipt" viewBox="0 0 24 24"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2ZM9 8h6M9 12h6"/></symbol>
<symbol id="i-bank" viewBox="0 0 24 24"><path d="m3 7 9-4 9 4v3H3ZM5 10v7M10 10v7M14 10v7M19 10v7M3 17h18v4H3"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 16V3m-5 5 5-5 5 5M4 16v5h16v-5"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/></symbol></svg><div class="preview-bar"><b>پیش‌نمایش طراحی چهارسو</b> · داده‌ها ساختگی‌اند؛ پرداخت و ارسال واقعی انجام نمی‌شود.</div>
<header class="page-header"><div class="header-inner"><a class="brand" href="index.html?v=1791111429240969400"><img src="../../static/images/company-logo.png?v=1791111429240969400" alt="لوله گستر گلپایگان" width="300" height="130"><span class="brand-copy"><b>چهارسو</b><small>مرکز خدمات مشتریان</small></span></a><a class="header-home" href="index.html?v=1791111429240969400">بازگشت به خدمات ←</a></div></header>
<main class="service-main"><nav class="breadcrumb" aria-label="مسیر صفحه"><a href="index.html?v=1791111429240969400">خدمات چهارسو</a><span aria-hidden="true">/</span><span aria-current="page">شماره حساب‌ها</span></nav>
<div class="title-row"><span class="page-icon"><svg class="icon" aria-hidden="true"><use href="#i-bank"/></svg></span><div><h1>شماره حساب‌ها</h1><p class="subtitle">اطلاعات واریزهای اعلام‌شده توسط کارشناس.</p></div></div>
<p class="finance-guidance" role="note">از کارشناس بخواهید شماره حساب‌ها و شناسهٔ واریز مرتبط را برای شما ارسال کند.</p>
<section class="notice-section" aria-labelledby="notices-title"><div class="list-heading"><div><h2 id="notices-title">واریزهای شما</h2></div><span id="notice-count" class="meta" role="status"></span></div>
<label class="finance-search" for="notice-search"><svg class="icon" aria-hidden="true"><use href="#i-search"/></svg><span class="sr-only">جست‌وجو در اعلامیه‌های واریز</span><input id="notice-search" type="search" placeholder="جست‌وجو با فاکتور، بانک یا موضوع…" autocomplete="off"></label>
<div id="payment-notices" class="payment-notices"></div>
<div id="notices-empty" class="empty-notices" hidden><h3>موردی پیدا نشد</h3><p>شمارهٔ فاکتور یا عبارت دیگری را جست‌وجو کنید.</p><button id="clear-search" type="button" class="finance-button secondary">پاک کردن جست‌وجو</button></div>
<!-- EMPTY STATE: اگر اعلامیه‌ای وجود ندارد، عنوان «هنوز اطلاعات واریزی برای شما ارسال نشده است» و توضیح انتظار برای کارشناس نمایش داده شود؛ هیچ حساب عمومی یا حساب جایگزین نمایش داده نشود. -->
</section>
<template id="notice-template"><article class="payment-notice"><h3><button type="button" class="notice-toggle" aria-expanded="false"><span class="notice-summary"><span class="notice-top"><strong class="notice-title"></strong><span class="payment-state"></span></span><span class="notice-amount"></span><span class="notice-meta"></span></span><svg class="icon" aria-hidden="true"><use href="#i-chevron"/></svg></button></h3>
<div class="notice-detail" hidden><div class="notice-bank"><b class="notice-bank-name"></b><span class="notice-holder"></span></div><dl class="payment-fields essential-fields"></dl>
<section class="notice-extra" aria-label="اطلاعات تکمیلی و توضیحات"><h4>اطلاعات تکمیلی و توضیحات</h4><div class="notice-context"><span class="notice-author"></span><span class="notice-date"></span></div><dl class="extra-fields"></dl></section>
<!-- IMPLEMENTATION NOTE: کپی تکی فقط مبلغ، شماره حساب، شبا، شماره کارت و شناسهٔ واریز موجود را شامل می‌شود.
بانک، صاحب حساب، فاکتور و توضیحات دکمهٔ کپی تکی ندارند؛ اطلاعات تکمیلی همیشه در کارت بازشده نمایان‌اند و کنترل جمع‌شونده ندارند.
«کپی کامل اطلاعات» باید همیشه از دادهٔ کامل اعلامیه ساخته شود، نه از متنِ قابل مشاهدهٔ DOM؛ خلاصه شدن نمایش یا حذف دکمه‌های کپی نباید بانک، صاحب حساب، فاکتور، تاریخ، مبلغ، شماره‌ها، شناسه یا توضیحات را از متن کامل حذف کند. -->
<div class="notice-actions"><button type="button" class="finance-button secondary copy-complete"><svg class="icon" aria-hidden="true"><use href="#i-copy"/></svg>کپی کامل اطلاعات</button><a class="finance-button receipt-link"><svg class="icon" aria-hidden="true"><use href="#i-receipt"/></svg>ارسال فیش این واریز</a></div></div></article></template>
<div class="service-footer"><a href="index.html?v=1791111429240969400">→ بازگشت به خدمات چهارسو</a></div>
<!-- در نسخهٔ اصلی امضا از developer_credit در تنظیمات برند تأمین شود. -->
<p class="page-footer">طراحی و توسعه: محمد جواد فصاحت <a href="tel:09202202129" dir="ltr">09202202129</a></p></main>
<div id="copy-feedback" class="copy-feedback" role="status" aria-live="polite" aria-atomic="true"></div>
<dialog id="manual-copy" class="manual-copy" aria-labelledby="manual-copy-title"><h2 id="manual-copy-title">کپی اطلاعات</h2><p>کپی خودکار در این مرورگر ممکن نشد. متن را انتخاب کنید و از گزینهٔ کپی دستگاه استفاده کنید.</p><textarea id="manual-copy-text" readonly aria-label="متن آماده برای کپی" dir="auto"></textarea><div><button type="button" id="select-copy-text" class="finance-button">انتخاب متن</button><button type="button" id="close-copy" class="finance-button secondary">بستن</button></div></dialog>
<noscript><p class="service-notice">برای مشاهدهٔ تعامل‌های این نمونه، جاوااسکریپت را فعال کنید.</p></noscript></body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+491
View File
@@ -0,0 +1,491 @@
<!doctype html>
<html lang="fa" dir="rtl" data-static-version="1791111429240969400">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#08376b">
<title>چهارسو · پیش‌نمایش مرکز خدمات مشتریان</title>
<link rel="icon" href="../../static/images/favicon.ico?v=1791111429240969400">
<link rel="stylesheet" href="../../static/css/tailwind.css?v=1791111429240969400">
<style>
@font-face{font-family:ChaharsuVazirmatn;src:url('../../static/fonts/vazirmatn-arabic-wght-normal.woff2?v=1791111429240969400') format('woff2');font-weight:100 900;font-display:swap}
@font-face{font-family:ChaharsuLatin;src:url('../../static/fonts/vazirmatn-latin-wght-normal.woff2?v=1791111429240969400') format('woff2');font-weight:100 900;font-display:swap}
:root{--ink:var(--color-ink);--sea:var(--color-sea);--muted:#526c86;--line:#dce5ed}
html{scroll-padding-top:110px}
body{font-family:ChaharsuVazirmatn,ChaharsuLatin,sans-serif}
button,a,input{touch-action:manipulation}
button{font-family:inherit}
button:focus-visible,a:focus-visible,input:focus-visible,summary:focus-visible{outline:3px solid var(--sea);outline-offset:4px}
[hidden]{display:none!important}
svg.icon{width:23px;height:23px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
.preview-bar{background:#e5f4ff;color:var(--ink);font-size:11px;text-align:center;padding:7px 14px;line-height:1.8;border-bottom:1px solid #cde6f7}
.preview-bar b{font-weight:800}
.cs-header{position:sticky;top:0;z-index:30;background:#eeeeeeed;border-bottom:1px solid #dce2e8;backdrop-filter:blur(14px)}
.cs-header-inner{max-width:1120px;margin:auto;padding:0 24px;min-height:76px;display:flex;align-items:center;justify-content:space-between;gap:16px}
.cs-brand{display:flex;align-items:center;gap:15px;color:var(--ink)}
.cs-brand img{width:85px;height:auto}
.brand-divider{width:1px;height:32px;background:#cdd7e0}
.cs-brand b{display:block;font-size:20px;font-weight:900;line-height:1.3}
.cs-brand small{display:block;font-size:10px;color:var(--muted);margin-top:4px}
.cs-desktop-nav{display:flex;align-items:center;gap:8px}
.cs-desktop-nav a{padding:11px 18px;border-radius:12px;font-size:13px;font-weight:700;color:var(--muted)}
.cs-desktop-nav a[aria-current]{color:var(--ink);background:white}
.cs-profile{background:white;border:1px solid var(--line);border-radius:14px;color:var(--ink);min-height:44px;box-shadow:none;font-size:12px;padding:8px 12px;gap:8px}
.cs-profile:hover{background:#e5f4ff;color:var(--ink);box-shadow:none}
.avatar{display:grid;place-items:center;border-radius:10px;width:30px;height:30px;background:#e5f4ff;color:var(--ink);font-weight:800}
.cs-main{max-width:1120px;margin:auto;padding:28px 24px 40px}
.welcome{display:flex;align-items:center;justify-content:space-between;margin-bottom:17px;gap:14px}
.welcome p{font-size:13px;color:var(--muted);margin:0}
.welcome b{color:var(--ink);font-weight:800}
.welcome small{font-size:11px;color:var(--muted)}
.cs-hero{position:relative;isolation:isolate;display:flex;align-items:center;justify-content:space-between;gap:30px;overflow:hidden;background:var(--ink);color:white;border-radius:28px;padding:30px 34px;border-bottom:4px solid var(--color-coral)}
.cs-hero:before{content:'';position:absolute;z-index:-1;width:360px;height:360px;border-radius:50%;left:-160px;top:-210px;background:#0693e340}
.hero-copy{max-width:600px}
.hero-eyebrow{display:flex;align-items:center;gap:8px;color:#c0dff4;font-size:11px;font-weight:700;margin:0 0 12px}
.hero-eyebrow span{width:6px;height:6px;border-radius:50%;background:var(--color-coral)}
.cs-hero h1{font-size:30px;line-height:1.6;margin:0;font-weight:900;letter-spacing:0}
.cs-hero p:last-child{font-size:13px;line-height:1.9;color:#cfdfed;margin:8px 0 0}
.crossroads{width:156px;height:156px;position:relative;flex-shrink:0;display:grid;grid-template-columns:1fr 1fr;gap:9px;padding:10px;transform:rotate(-8deg)}
.crossroads span{border:1px solid #ffffff28;border-radius:18px;background:#ffffff0a;display:grid;place-items:center;color:#92d5ff}
.crossroads span:nth-child(2){background:#0693e33b}
.crossroads span:nth-child(3){background:#fa9f0015;color:#ffc451}
.crossroads:after{content:'';position:absolute;width:20px;height:20px;background:#ffc451;border:6px solid var(--ink);border-radius:50%;top:calc(50% - 10px);left:calc(50% - 10px)}
.section-head{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:28px 0 15px}
.section-head h2{font-size:19px;line-height:1.6;margin:0;font-weight:900}
.section-head p{font-size:12px;color:var(--muted);margin:3px 0 0}
.section-head a{display:flex;align-items:center;gap:6px;font-size:12px;color:#1977ce;font-weight:800;min-height:44px;white-space:nowrap}
.section-label{font-size:10px;font-weight:700;color:#1977ce;display:block;margin-bottom:3px}
.service-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
.service-card{position:relative;display:flex;flex-direction:column;align-items:stretch;text-align:right;gap:0;padding:20px;border-radius:22px;border:1px solid var(--line);background:white;color:var(--ink);box-shadow:none;min-height:200px;overflow:hidden}
.service-card:hover{background:white;color:var(--ink);box-shadow:0 15px 35px -25px #08376b70;border-color:#0693e350;transform:translateY(-3px)}
.service-card.featured{border-color:#badff5;background:#e5f4ff}
.service-card.featured:hover{background:#dff1fd}
.service-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;gap:6px}
.service-icon{width:44px;height:44px;border-radius:14px;background:#e5f4ff;display:grid;place-items:center;color:#1977ce}
.featured .service-icon{background:#0693e3;color:white}
.future .service-icon{background:#f0f3f6;color:#526c86}
.soon{color:#805300;background:#fff3da;border:1px solid #f3ddae;border-radius:20px;padding:3px 8px;font-size:10px;font-weight:700;white-space:nowrap}
.service-card strong{font-size:15px;font-weight:900;line-height:1.7}
.service-card p{font-size:11px;font-weight:400;color:var(--muted);line-height:1.9;margin:4px 0 14px}
.service-bottom{display:flex;align-items:center;justify-content:space-between;margin-top:auto;color:#1977ce;font-size:11px;font-weight:800}
.future .service-bottom{color:var(--muted)}
.service-bottom svg{width:17px;height:17px}
.profile-note{display:flex;align-items:center;gap:12px;margin-top:18px;border:1px solid #d6e1ea;background:#ffffff80;border-radius:16px;padding:12px 16px}
.profile-note>.icon{color:#1977ce;width:20px;height:20px}
.profile-note p{margin:0;font-size:11px;color:var(--muted);flex:1;line-height:1.9}
.profile-note b{color:var(--ink);font-weight:800}
.text-button{min-height:40px;padding:5px 8px;background:transparent;color:#1977ce;box-shadow:none;font-size:11px;white-space:nowrap}
.text-button:hover{background:#e5f4ff;color:var(--ink);box-shadow:none;transform:none}
.recent-head{margin-top:32px}
.history-panel{background:white;border:1px solid var(--line);border-radius:24px;overflow:hidden}
.history-toolbar{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;gap:16px;border-bottom:1px solid #e7edf2}
.filter-group{display:flex;gap:5px;padding:4px;border-radius:12px;background:#f2f5f7}
.filter-group button{min-height:36px;padding:6px 12px;border-radius:9px;font-size:11px;background:transparent;box-shadow:none;color:var(--muted);font-weight:700}
.filter-group button[aria-pressed=true]{background:white;color:var(--ink);box-shadow:0 2px 5px #08376b0a}
.filter-group button:hover{transform:none;color:var(--ink);background:white}
.search{display:flex;gap:8px;align-items:center;max-width:250px;background:#f6f8fa;border:1px solid #e1e8ef;border-radius:12px;padding:0 10px}
.search svg{width:18px;height:18px;color:var(--muted)}
.search input[type=search]{min-width:0;background:transparent;border:0;box-shadow:none;border-radius:10px;padding:10px 0;font-size:12px;width:100%;min-height:42px}
.search input:focus{box-shadow:none}
.ticket-row{width:100%;display:grid;grid-template-columns:42px minmax(0,1fr) auto 24px;align-items:center;gap:14px;padding:20px;background:white;color:var(--ink);text-align:right;border-radius:0;box-shadow:none;border-bottom:1px solid #e7edf2;min-height:100px}
.ticket-row:last-child{border:0}
.ticket-row:hover{background:#f7fbfe;color:var(--ink);transform:none;box-shadow:none}
.ticket-symbol{display:grid;place-items:center;width:42px;height:42px;border-radius:14px;background:#f0f5f9;color:#1977ce}
.ticket-content strong{display:block;font-size:14px;font-weight:800;line-height:1.8}
.ticket-content small{display:block;margin-top:6px;font-size:10px;color:var(--muted);font-weight:400}
.ticket-side{text-align:left;display:flex;align-items:flex-end;flex-direction:column;gap:8px}
.status{display:inline-flex;align-items:center;gap:5px;font-size:10px;border-radius:20px;padding:4px 9px;font-weight:700;white-space:nowrap;background:#e5f4ff;color:#1260a3}
.status:before{content:'';width:5px;height:5px;background:currentColor;border-radius:50%}
.status.closed{background:#f0f3f6;color:#526c86}
.status.waiting{background:#fff3da;color:#805300}
.ticket-number{font-family:ChaharsuLatin,monospace;font-size:10px;color:var(--muted);font-weight:400}
.row-arrow{color:#7791a8;width:18px!important}
.history-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid #e7edf2;padding:10px 18px;font-size:10px;color:var(--muted)}
.history-footer button{font-size:11px}
.empty-result{padding:36px 20px;text-align:center;color:var(--muted);font-size:13px}
.empty-result b{display:block;color:var(--ink);font-size:16px;margin:10px 0}
.cs-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;color:var(--muted);font-size:10px;margin-top:25px;line-height:1.8}
.cs-footer span{display:flex;align-items:center;gap:6px}
.cs-footer svg{width:15px;height:15px}
.cs-dock{display:none}
dialog{width:min(520px,calc(100% - 28px));max-height:calc(100dvh - 40px);margin:auto;overflow-y:auto;overscroll-behavior:contain;padding:24px;border:1px solid var(--line);border-radius:24px;background:white;color:var(--ink);box-shadow:0 30px 100px #08376b35}
dialog::backdrop{background:#06284db3;backdrop-filter:blur(5px)}
.dialog-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}
.close-dialog{min-height:40px;width:40px;padding:0;background:#f1f5f8;color:var(--ink);box-shadow:none;border-radius:12px;font-size:22px;font-weight:400}
.close-dialog:hover{background:#e5f4ff;color:var(--ink);transform:none;box-shadow:none}
dialog h2{font-size:22px;line-height:1.7;font-weight:900}
dialog p{font-size:13px;color:var(--muted);line-height:2;margin:10px 0 18px}
.demo-note{background:#fff7e7;color:#805300;font-size:11px;line-height:1.9;padding:12px;border-radius:12px;margin:14px 0}
.dialog-action{display:flex;width:100%;margin-top:16px}
.account-placeholder{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius:12px;padding:14px;margin-top:10px;font-size:12px;gap:12px}
.account-placeholder b{font-weight:700}
.upload-demo{display:block;border:1px dashed #a3c7df;border-radius:16px;padding:22px;background:#f5fbff;text-align:center}
.upload-demo input[type=file]{font-size:12px;margin-top:12px;padding:8px;background:white}
.upload-demo span{font-size:12px;color:var(--muted);font-weight:400;display:block}
#file-status{font-size:12px}
@media(min-width:900px){.cs-main{padding-top:30px}.cs-hero{min-height:220px}.ticket-row{grid-template-columns:42px minmax(0,1fr) 185px 24px}}
@media(max-width:899px){.service-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.cs-desktop-nav{gap:0}.cs-desktop-nav a{padding:10px}.cs-hero h1{font-size:27px}.crossroads{width:130px;height:130px}}
@media(max-width:639px){
html{scroll-padding-top:85px}
.preview-bar{font-size:10px;padding:5px 12px}
.cs-header-inner{min-height:65px;padding:0 18px}
.cs-brand{gap:10px}.cs-brand img{width:68px}.brand-divider{height:28px}.cs-brand b{font-size:18px}.cs-brand small{font-size:9px}
.cs-desktop-nav{display:none}.cs-profile{width:40px;min-height:40px;padding:0;background:transparent;border:0}.cs-profile .profile-label{display:none}
.cs-main{padding:18px 18px 110px}
.welcome{margin-bottom:13px}.welcome p{font-size:12px}.welcome small{font-size:10px}
.cs-hero{padding:22px;border-radius:26px;gap:0;min-height:184px}
.cs-hero h1{font-size:24px;line-height:1.65;max-width:245px}.hero-eyebrow{font-size:10px;margin-bottom:7px}.cs-hero p:last-child{font-size:12px;max-width:270px;margin-top:7px}
.crossroads{position:absolute;left:-15px;top:16px;width:105px;height:105px;opacity:.22;z-index:-1;gap:6px}.crossroads svg{width:18px;height:18px}
.section-head{margin-top:22px;margin-bottom:12px;gap:10px}.section-head h2{font-size:18px}.section-head p{font-size:11px}.section-head a{font-size:11px}.section-label{font-size:10px}
.service-grid{gap:10px}.service-card{padding:15px;border-radius:20px;min-height:174px}.service-top{margin-bottom:13px;gap:4px}.service-icon{width:37px;height:37px;border-radius:12px}.service-icon svg{width:21px;height:21px}.soon{font-size:9px;padding:3px 7px}
.service-card strong{font-size:13px;line-height:1.8}.service-card p{font-size:10px;line-height:1.9;margin:3px 0 10px}.service-bottom{font-size:10px}
.profile-note{gap:8px;padding:11px 12px;margin-top:14px}.profile-note p{font-size:10px}.profile-note p span{display:none}.profile-note .text-button{font-size:10px}
.recent-head{margin-top:24px}
.history-panel{border-radius:22px}.history-toolbar{padding:12px;flex-direction:column;align-items:stretch;gap:10px}.filter-group{align-self:stretch;justify-content:space-between}.filter-group button{flex:1;padding:6px 5px;font-size:10px}.search{max-width:none}.search input[type=search]{font-size:12px;min-height:40px}
.ticket-row{padding:16px 14px;grid-template-columns:minmax(0,1fr) auto;gap:10px;min-height:115px}.ticket-symbol,.row-arrow{display:none!important}.ticket-content strong{font-size:13px}.ticket-content small{font-size:10px;line-height:1.9;max-width:175px}.ticket-number{font-size:9px}.ticket-side{gap:10px}.status{font-size:9px;padding:4px 7px}
.history-footer{padding:10px 12px;font-size:9px}.history-footer button{font-size:10px}.cs-footer{flex-direction:column;align-items:center;font-size:9px;gap:8px}
.cs-dock{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));position:fixed;bottom:max(10px,env(safe-area-inset-bottom));inset-inline:12px;z-index:40;background:#08376bf5;padding:6px;border:1px solid #ffffff75;border-radius:22px;box-shadow:0 15px 35px #08376b35;backdrop-filter:blur(12px)}
.cs-dock a,.cs-dock button{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;min-height:52px;border-radius:16px;color:#c4d7e9;font-size:10px;font-weight:700;background:transparent;padding:5px 2px;box-shadow:none}
.cs-dock [aria-current]{background:var(--sea);color:white}.cs-dock svg{width:20px;height:20px}.cs-dock button:hover{transform:none;box-shadow:none;background:#ffffff14;color:white}
dialog{padding:20px}dialog h2{font-size:20px}
}
@media(max-width:359px){.cs-main{padding-inline:12px}.cs-header-inner{padding-inline:12px}.service-card{padding:12px}.service-card strong{font-size:12px}.service-card p{font-size:10px}.ticket-row{grid-template-columns:1fr}.ticket-side{flex-direction:row;justify-content:space-between;align-items:center}.welcome small{display:none}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{animation:none!important;transition:none!important}}
/* Service-first home: no history feed or profile reminder on this view. */
.services-intro{margin:4px 0 24px;max-width:640px}
.services-intro .section-label{margin-bottom:10px;color:#1977ce;font-size:12px}
.services-intro h1{font-size:30px;line-height:1.7;letter-spacing:0;font-weight:900;margin:0}
.services-intro p{margin:7px 0 0;font-size:13px;line-height:1.9;color:var(--muted)}
.service-grid{gap:16px}
.service-card{min-height:220px;padding:24px}
.service-card strong{font-size:17px}
.service-card p{font-size:12px}
.service-icon{width:48px;height:48px}
.service-bottom{font-size:12px}
.service-card.featured{background:var(--ink);border-color:var(--ink);color:white;border-bottom:3px solid var(--color-coral)}
.service-card.featured:hover{background:#0b4c8c;color:white}
.featured p{color:#cadbea}
.featured .service-bottom{color:white}
.featured .service-icon{background:#0693e3}
.back-to-services{display:inline-flex;align-items:center;min-height:44px;font-size:12px;font-weight:700;color:#1977ce;margin-bottom:8px}
#requests .recent-head{margin-top:8px}
#requests h1{font-size:26px;line-height:1.6;font-weight:900}
.cs-dock{grid-template-columns:repeat(3,minmax(0,1fr))}
@media(max-width:639px){
.cs-main{padding-top:22px}
.services-intro{margin:0 0 22px}
.services-intro .section-label{font-size:10px;margin-bottom:7px}
.services-intro h1{font-size:23px;line-height:1.7}
.services-intro p{font-size:12px;line-height:1.9;margin-top:6px}
.service-grid{gap:12px}
.service-card{min-height:191px;padding:17px 15px}
.service-top{margin-bottom:17px}
.service-icon{width:42px;height:42px}
.service-card strong{font-size:14px}
.service-card p{font-size:11px;margin-bottom:15px}
.service-bottom{font-size:11px}
.cs-footer{margin-top:24px}
#requests h1{font-size:22px}
}
@media(max-width:359px){.services-intro h1{font-size:21px}.service-card{padding:14px 12px;min-height:190px}.service-card strong{font-size:13px}.service-card p{font-size:10px}.service-bottom{font-size:10px}}
.request-service{grid-column:span 2;cursor:default;min-height:154px;padding:20px}
.service-card.request-service:hover{transform:none;box-shadow:none;background:var(--ink);border-color:var(--ink);border-bottom-color:var(--color-coral)}
.request-service-head{display:flex;align-items:center;gap:10px}
.request-service-head .service-icon{flex-shrink:0;width:34px;height:34px;border-radius:11px}
.request-service-head .icon{width:20px;height:20px}
.request-service h2{font-size:17px;line-height:1.6;font-weight:900;margin:0}
.request-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;padding-top:14px;margin-top:auto}
.request-action{display:flex;align-items:center;justify-content:center;gap:8px;min-height:46px;padding:9px 12px;border-radius:12px;color:white;text-align:center;border:1px solid transparent;box-shadow:none}
.request-action>span{min-width:0}
.request-action b{display:block;font-size:13px;line-height:1.7;font-weight:800;white-space:nowrap}
.request-action .icon{width:20px;height:20px}
.request-create{background:var(--sea)}
.request-create:hover{background:#1977ce;color:white;transform:none;box-shadow:none}
.request-history{background:#ffffff0d;border-color:#ffffff40}
.request-history:hover{background:#ffffff1a;color:white;border-color:#ffffff70}
.services-intro{margin:0 0 20px}
.services-intro h1{font-size:28px;line-height:1.6}
.service-card.future{min-height:184px}
@media(max-width:899px){.request-service{min-height:0}}
@media(max-width:639px){
.cs-main{padding:12px 16px 95px}
.services-intro{margin:0 0 12px}
.services-intro h1{font-size:22px;line-height:1.6}
.services-intro p{font-size:12px;margin-top:4px;line-height:1.8}
.service-grid{gap:10px}
.request-service{padding:12px 14px;min-height:0;border-radius:20px}
.request-service h2{font-size:15px}
.request-service-head .service-icon{width:30px;height:30px;border-radius:10px}
.request-service-head .icon{width:18px;height:18px}
.request-actions{gap:8px;padding-top:10px}
.request-action{min-height:44px;padding:9px 8px;gap:6px;border-radius:11px}
.request-action b{font-size:12px}
.request-action .icon{width:18px;height:18px}
.service-card.future{min-height:150px;padding:12px 13px;border-radius:20px}
.future .service-top{margin-bottom:8px}
.future .service-icon{width:31px;height:31px;border-radius:11px}
.future .service-icon .icon{width:20px;height:20px}
.future strong{font-size:13px}
.service-card.future p{font-size:11px;line-height:1.8;margin:4px 0 10px}
.future .service-bottom{font-size:10px}
.cs-footer{margin-top:18px}
}
@media(max-width:359px){
.cs-main{padding-inline:12px}
.request-action b{font-size:11px}
.service-card.future p{font-size:10px}
.service-card.future strong{font-size:12px}
}
.request-service .request-description{font-size:12px;font-weight:400;line-height:1.9;color:#cadbea;margin:6px 0 0;max-width:390px}
.developer-credit{justify-content:center;text-align:center}
.developer-credit p{margin:0;font-size:inherit;line-height:1.9}
.developer-credit a{color:inherit;white-space:nowrap}
.developer-credit a:hover{color:#1977ce}
/* Readable service text at every width; keep the full expert description visible. */
.mobile-copy{display:none}
.request-service h2{font-size:20px}
.request-service .request-description{display:block;font-size:14px;line-height:1.7;max-width:none}
.request-action b{font-size:14px}
.service-card.future strong{font-size:17px}
.service-card.future p{font-size:14px;line-height:1.7}
.future .service-bottom{font-size:13px}
.soon{font-size:12px}
.developer-credit{font-size:12px}
@media(max-width:639px){
.cs-main{padding-top:10px}
.services-intro{margin-bottom:12px}
.services-intro h1{font-size:22px}
.request-service-head{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:0 10px;align-items:center}
.request-service-head>.service-icon{grid-column:2;grid-row:1}
.request-service-head>div{display:contents}
.request-service h2{grid-column:1;grid-row:1;font-size:18px}
.request-service .request-description{grid-column:1/-1;margin-top:6px;font-size:14px;line-height:1.65}
.request-action{gap:5px;padding-inline:7px}
.request-action b{font-size:13px}
.request-action .icon{width:16px;height:16px}
.service-card.future{min-height:120px;padding:11px 12px}
.future .service-top{margin-bottom:7px}
.future .service-icon{width:28px;height:28px}
.service-card.future strong{font-size:14px}
.service-card.future p{font-size:14px;line-height:1.65;margin-bottom:0}
.future .service-bottom{display:none}
.soon{font-size:12px;padding:2px 6px}
.desktop-copy{display:none}
.mobile-copy{display:inline}
}
.cs-brand small{font-size:11px}
.cs-desktop-nav a{font-size:14px}
@media(max-width:639px){
.request-action b{font-size:14px}
.cs-dock a,.cs-dock button{font-size:12px}
}
@media(max-width:359px){.request-action>.icon{display:none}.request-action{padding-inline:6px}}
/* Requests view: apply STYLE_GUIDE typography, spacing and touch targets. */
#requests .back-to-services{font-size:14px;color:var(--ink);min-height:44px}
#requests h1{font-size:28px;line-height:1.5;font-weight:900}
#requests .section-head p{font-size:14px;line-height:1.7;color:var(--muted)}
#requests .section-head{align-items:center;flex-wrap:wrap;gap:12px;margin-bottom:16px}
#requests .text-button{font-size:14px;min-height:44px;color:var(--ink);padding:8px 10px}
#requests .text-button:hover{color:var(--ink);background:#e5f4ff}
#requests .history-panel{border-radius:22px}
#requests .history-toolbar{padding:16px;gap:12px}
#requests .filter-group{gap:4px;border-radius:12px}
#requests .filter-group button{display:inline-flex;align-items:center;justify-content:center;gap:5px;min-height:44px;min-width:44px;padding:8px 12px;font-size:14px;line-height:1.5;color:var(--muted);white-space:nowrap}
#requests .filter-group button[aria-pressed=true]{color:var(--ink);background:white}
#requests .filter-group button span{font-size:12px}
#requests .search{max-width:300px;min-width:0;width:100%;border-color:var(--line)}
#requests .search input[type=search]{font-size:16px;line-height:1.5;min-height:44px;color:var(--ink);padding:10px 0}
#requests .search input::placeholder{color:var(--muted);opacity:1}
#requests .ticket-row{grid-template-columns:42px minmax(0,1fr) 230px 20px;gap:16px;padding:20px;min-height:112px}
#requests .ticket-content{min-width:0}
#requests .ticket-content strong{font-size:16px;line-height:1.7;font-weight:800;overflow-wrap:anywhere}
#requests .ticket-content small{font-size:12px;line-height:1.7;font-weight:400;color:var(--muted);max-width:none}
#requests .ticket-side{min-width:0;gap:8px;text-align:end}
#requests .status{font-size:12px;line-height:1.6;padding:4px 9px}
#requests .ticket-number{font-size:14px;line-height:1.7;overflow-wrap:anywhere;max-width:100%}
#requests .history-footer{padding:10px 16px;font-size:12px;line-height:1.7;flex-wrap:wrap;gap:8px}
#requests .history-footer button{font-size:14px}
#requests .empty-result{font-size:14px;line-height:1.7;padding:24px 16px}
#requests .empty-result b{font-size:18px}
#requests .ticket-symbol{background:#e5f4ff;color:var(--ink)}
#requests .row-arrow{color:var(--muted)}
/* White text on dark blue meets the guide's contrast mapping for this nav item. */
.cs-dock a[href="#requests"][aria-current]{background:var(--color-ink-soft);color:white}
#service-dialog:has(.request-detail-number) p{font-size:14px;line-height:1.7}
#service-dialog:has(.request-detail-number) .demo-note{font-size:14px;line-height:1.7}
#service-dialog:has(.request-detail-number) .close-dialog{min-height:44px;width:44px}
#service-dialog:has(.request-detail-number) h2{font-size:20px}
@media(max-width:899px){
#requests .ticket-row{grid-template-columns:42px minmax(0,1fr) 190px 20px}
#requests .history-toolbar{flex-wrap:wrap}
}
@media(max-width:639px){
#requests h1{font-size:24px}
#requests .section-head{align-items:flex-start;gap:8px}
#requests .history-panel{border-radius:20px}
#requests .history-toolbar{padding:12px;gap:10px}
#requests .filter-group button{padding:8px 5px;gap:4px;flex:1}
#requests .search{max-width:none}
#requests .ticket-row{grid-template-columns:minmax(0,1fr);gap:10px;padding:16px 14px;min-height:0}
#requests .ticket-content strong{font-size:14px;line-height:1.7}
#requests .ticket-content small{margin-top:4px}
#requests .ticket-side{flex-direction:row;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px}
#requests .ticket-number{font-size:14px}
#requests .history-footer{padding:10px 12px}
}
</style>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<symbol id="i-home" viewBox="0 0 24 24"><path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1Z"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-chat" viewBox="0 0 24 24"><path d="M5 4h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-6 3V6a2 2 0 0 1 2-2Z"/><path d="M7 9h10M7 13h6"/></symbol>
<symbol id="i-history" viewBox="0 0 24 24"><path d="M3 11a9 9 0 1 1 2 7M3 5v6h6M12 7v5l3 2"/></symbol>
<symbol id="i-receipt" viewBox="0 0 24 24"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2ZM9 8h6M9 12h6"/></symbol>
<symbol id="i-bank" viewBox="0 0 24 24"><path d="m3 7 9-4 9 4v3H3ZM5 10v7M10 10v7M14 10v7M19 10v7M3 17h18v4H3"/></symbol>
<symbol id="i-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M20 12H4m6-6-6 6 6 6"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6Z"/><path d="m8 12 3 3 5-6"/></symbol>
</svg>
<div class="preview-bar"><b>پیش‌نمایش طراحی چهارسو</b> · اطلاعات نمونه است و هیچ درخواستی ارسال نمی‌شود.</div>
<header class="cs-header">
<div class="cs-header-inner">
<a class="cs-brand" href="#home" aria-label="چهارسو، خانه"><img src="../../static/images/company-logo.png?v=1791111429240969400" alt="لوله گستر گلپایگان" width="300" height="130"><span class="brand-divider" aria-hidden="true"></span><span><b>چهارسو</b><small>مرکز خدمات مشتریان</small></span></a>
<nav class="cs-desktop-nav" aria-label="منوی اصلی"><a href="#home" aria-current="page">خدمات چهارسو</a><a href="#requests">درخواست‌های من</a></nav>
<button class="cs-profile" data-dialog="profile" aria-label="مشاهده پروفایل"><span class="avatar">م</span><span class="profile-label">پروفایل من</span></button>
</div>
</header>
<main class="cs-main">
<section id="home" aria-labelledby="services-title">
<section id="services" aria-labelledby="services-title">
<div class="services-intro"><h1 id="services-title" tabindex="-1">خدمات چهارسو</h1></div>
<div class="service-grid">
<section class="service-card featured request-service" aria-labelledby="request-service-title">
<div class="request-service-head"><span class="service-icon"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><div><h2 id="request-service-title">ارتباط با کارشناسان</h2><p class="request-description">سؤال بپرسید، درخواست ثبت کنید یا برای پیگیری و هماهنگی با کارشناسان در ارتباط باشید.</p></div></div>
<div class="request-actions">
<button class="request-action request-create" data-dialog="new" aria-label="ثبت درخواست جدید"><svg class="icon" aria-hidden="true"><use href="#i-plus"/></svg><span><b>ثبت درخواست</b></span></button>
<a class="request-action request-history" href="#requests"><svg class="icon" aria-hidden="true"><use href="#i-history"/></svg><span><b>درخواست‌های من</b></span></a>
</div>
</section>
<!-- IMPLEMENTATION NOTE: ارسال فیش واریزی و شماره حساب‌ها دو خدمت مستقل‌اند.
در پیاده‌سازی Django برای هرکدام URL نام‌گذاری‌شده، view و template جدا تعریف شود.
این کارت‌ها باید لینک ناوبری به صفحهٔ خدمت باشند؛ آن‌ها را به modal یا data-dialog تبدیل نکنید.
نام فایل‌ها در اینجا فقط مربوط به نمونهٔ مستقل است؛ مسیر نهایی Django هنوز تعیین نشده است. -->
<a class="service-card future" href="payment-receipt.html?v=1791111429240969400">
<span class="service-top"><span class="service-icon"><svg class="icon" aria-hidden="true"><use href="#i-receipt"/></svg></span><span class="soon">به‌زودی</span></span>
<strong>ارسال فیش واریزی</strong><p><span class="desktop-copy">ارسال تصویر فیش برای بررسی پرداخت</span><span class="mobile-copy">ثبت تصویر پرداخت</span></p><span class="service-bottom">صفحهٔ ارسال فیش<svg class="icon" aria-hidden="true"><use href="#i-arrow"/></svg></span>
</a>
<a class="service-card future" href="bank-accounts.html?v=1791111429240969400">
<span class="service-top"><span class="service-icon"><svg class="icon" aria-hidden="true"><use href="#i-bank"/></svg></span><span class="soon">به‌زودی</span></span>
<strong>شماره حساب‌ها</strong><p><span class="desktop-copy">دریافت شماره حساب، شبا و شناسهٔ واریز</span><span class="mobile-copy">حساب، شبا و شناسهٔ واریز</span></p><span class="service-bottom">مشاهده حساب‌ها<svg class="icon" aria-hidden="true"><use href="#i-arrow"/></svg></span>
</a>
</div>
</section>
</section>
<section id="requests" aria-labelledby="recent-title" hidden>
<a class="back-to-services" href="#home">→ بازگشت به خدمات</a><div class="section-head recent-head"><div><h1 id="recent-title" tabindex="-1">درخواست‌های من</h1><p>پاسخ‌ها را ببینید و گفت‌وگوهای قبلی را ادامه دهید.</p></div><button class="text-button" data-dialog="new">+ درخواست جدید</button></div>
<div class="history-panel">
<div class="history-toolbar">
<div class="filter-group" role="group" aria-label="فیلتر درخواست‌ها"><button data-filter="all" aria-pressed="true">همه <span>۵</span></button><button data-filter="open" aria-pressed="false">در جریان <span>۲</span></button><button data-filter="closed" aria-pressed="false">بسته <span>۳</span></button></div>
<label class="search" style="margin:0"><svg class="icon" aria-hidden="true"><use href="#i-search"/></svg><input type="search" id="ticket-search" aria-label="جست‌وجوی درخواست‌ها" placeholder="جست‌وجوی موضوع یا شماره…"></label>
</div>
<div id="ticket-rows">
<button class="ticket-row" data-state="open" data-dialog="ticket" data-title="استعلام قیمت لوله و اتصالات" data-number="TK-2026-95742E53"><span class="ticket-symbol"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><span class="ticket-content"><strong>استعلام قیمت لوله و اتصالات</strong><small>فروش · پیش‌فاکتور و خرید · صف عمومی</small></span><span class="ticket-side"><span class="status">در حال بررسی</span><span class="ticket-number" dir="ltr">TK-2026-95742E53</span></span><svg class="icon row-arrow" aria-hidden="true"><use href="#i-arrow"/></svg></button>
<button class="ticket-row" data-state="open" data-dialog="ticket" data-title="پیگیری زمان تحویل سفارش" data-number="TK-2026-74B21A08"><span class="ticket-symbol"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><span class="ticket-content"><strong>پیگیری زمان تحویل سفارش</strong><small>فروش · پیگیری سفارش · آخرین فعالیت: ۲۴ شهریور</small></span><span class="ticket-side"><span class="status waiting">در انتظار پاسخ شما</span><span class="ticket-number" dir="ltr">TK-2026-74B21A08</span></span><svg class="icon row-arrow" aria-hidden="true"><use href="#i-arrow"/></svg></button>
<button class="ticket-row" data-state="closed" data-dialog="ticket" data-title="آزمایشی: درخواست قیمت" data-number="TK-2026-32C85F10"><span class="ticket-symbol"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><span class="ticket-content"><strong>آزمایشی: درخواست قیمت</strong><small>فروش · پیش‌فاکتور و خرید · ۲۴ شهریور ۱۴۰۵</small></span><span class="ticket-side"><span class="status closed">بسته</span><span class="ticket-number" dir="ltr">TK-2026-32C85F10</span></span><svg class="icon row-arrow" aria-hidden="true"><use href="#i-arrow"/></svg></button>
<button class="ticket-row" data-state="closed" data-dialog="ticket" data-title="مشاوره انتخاب محصول" data-number="TK-2026-AB61D922" data-older hidden><span class="ticket-symbol"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><span class="ticket-content"><strong>مشاوره انتخاب محصول</strong><small>فروش · مشاوره خرید · ۲۰ شهریور ۱۴۰۵</small></span><span class="ticket-side"><span class="status closed">بسته</span><span class="ticket-number" dir="ltr">TK-2026-AB61D922</span></span><svg class="icon row-arrow" aria-hidden="true"><use href="#i-arrow"/></svg></button>
<button class="ticket-row" data-state="closed" data-dialog="ticket" data-title="دریافت پیش‌فاکتور خرید" data-number="TK-2026-4F26C818" data-older hidden><span class="ticket-symbol"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><span class="ticket-content"><strong>دریافت پیش‌فاکتور خرید</strong><small>فروش · پیش‌فاکتور و خرید · ۱۸ شهریور ۱۴۰۵</small></span><span class="ticket-side"><span class="status closed">بسته</span><span class="ticket-number" dir="ltr">TK-2026-4F26C818</span></span><svg class="icon row-arrow" aria-hidden="true"><use href="#i-arrow"/></svg></button>
</div>
<div class="empty-result" id="no-results" role="status" hidden><b>درخواستی پیدا نشد</b>موضوع یا شمارهٔ دیگری جست‌وجو کنید.<br><button class="text-button" id="reset-search">پاک کردن جست‌وجو و فیلتر</button></div>
<div class="history-footer"><span id="result-count" aria-live="polite">نمایش ۳ درخواست از ۵ مورد</span><button class="text-button" id="show-all" aria-expanded="false">مشاهده همهٔ درخواست‌ها ←</button></div>
</div>
</section>
<!-- IMPLEMENTATION NOTE: در نسخهٔ اصلی امضای توسعه‌دهنده از تنظیمات برند (developer_credit) تأمین شود. -->
<footer class="cs-footer developer-credit"><p>طراحی و توسعه: محمد جواد فصاحت <a href="tel:09202202129" dir="ltr">09202202129</a></p></footer>
</main>
<nav class="cs-dock" aria-label="دسترسی سریع مشتری"><a href="#home" aria-current="page"><svg class="icon" aria-hidden="true"><use href="#i-grid"/></svg>خدمات</a><a href="#requests"><svg class="icon" aria-hidden="true"><use href="#i-history"/></svg>درخواست‌ها</a><button data-dialog="profile"><svg class="icon" aria-hidden="true"><use href="#i-user"/></svg>پروفایل</button></nav>
<dialog id="service-dialog" aria-labelledby="dialog-title">
<div class="dialog-top"><span class="service-icon" id="dialog-icon"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><button class="close-dialog" id="close-dialog" aria-label="بستن پنجره">×</button></div>
<h2 id="dialog-title"></h2><div id="dialog-body"></div>
<button class="dialog-action" id="dialog-done">متوجه شدم</button>
</dialog>
<script>
(() => {
const dialog=document.querySelector('#service-dialog');
const title=document.querySelector('#dialog-title');
const body=document.querySelector('#dialog-body');
const rows=[...document.querySelectorAll('.ticket-row')];
let activeFilter='all',expanded=false;
document.addEventListener('click', event=>{
const trigger=event.target.closest('[data-dialog]');
if(trigger){
const kind=trigger.dataset.dialog;
const icon={new:'plus',profile:'user',ticket:'chat'}[kind];
document.querySelector('#dialog-icon use').setAttribute('href','#i-'+icon);
if(kind==='new'){
title.textContent='ثبت درخواست جدید';
body.innerHTML='<p>موضوعتان را انتخاب کنید و گفت‌وگو با کارشناسان را آغاز کنید.</p><div class="demo-note">این نسخه برای بررسی طراحی است. فرم نمونه نیز اطلاعاتی ارسال نمی‌کند.</div><a class="button dialog-action" href="../ticket-new-wizard/index.html?v=1791111429240969400">مشاهده فرم نمونه ←</a>';
}else if(kind==='profile'){
title.textContent='پروفایل شما';
body.innerHTML='<p>نام، نام خانوادگی، استان و شهر به کارشناسان کمک می‌کند دقیق‌تر راهنمایی‌تان کنند.</p><div class="demo-note">تکمیل این اطلاعات اختیاری است و ثبت درخواست را متوقف نمی‌کند. ویرایش پروفایل در این نمونه به حساب واقعی متصل نیست.</div>';
}else{
title.textContent=trigger.dataset.title;
body.replaceChildren();
const number=document.createElement('p');number.className='request-detail-number';number.dir='ltr';number.textContent=trigger.dataset.number;
const note=document.createElement('div');note.className='demo-note';note.textContent='این درخواست نمونه است. در نسخهٔ اصلی، این انتخاب صفحهٔ گفت‌وگو، پیام‌ها و پیوست‌های همین درخواست را باز می‌کند.';
body.append(number,note);
}
dialog.showModal();
}
const filter=event.target.closest('[data-filter]');
if(filter){activeFilter=filter.dataset.filter;document.querySelectorAll('[data-filter]').forEach(b=>b.setAttribute('aria-pressed',String(b===filter)));render();}
});
const showView=(focus=false)=>{
const isHistory=location.hash==='#requests';
document.querySelector('#home').hidden=isHistory;
document.querySelector('#requests').hidden=!isHistory;
document.querySelectorAll('.cs-dock a,.cs-desktop-nav a').forEach(a=>{
a.removeAttribute('aria-current');
if(a.hash===(isHistory?'#requests':'#home'))a.setAttribute('aria-current','page');
});
document.title=isHistory?'درخواست‌های من · چهارسو':'چهارسو · پیش‌نمایش مرکز خدمات مشتریان';
if(focus){window.scrollTo({top:0,behavior:'instant'});document.querySelector(isHistory?'#recent-title':'#services-title').focus({preventScroll:true});}
};
window.addEventListener('hashchange',()=>showView(true));
showView();
const render=()=>{
const query=document.querySelector('#ticket-search').value.trim().toLowerCase().replace(/ي/g,'ی').replace(/ك/g,'ک');
let shown=0,total=0;
rows.forEach(row=>{
const matches=(activeFilter==='all'||row.dataset.state===activeFilter)&&row.textContent.toLowerCase().replace(/ي/g,'ی').replace(/ك/g,'ک').includes(query);
if(matches)total++;
row.hidden=!(matches&&(expanded||activeFilter!=='all'||query||!row.hasAttribute('data-older')));
if(!row.hidden)shown++;
});
document.querySelector('#no-results').hidden=shown!==0;
document.querySelector('#result-count').textContent='نمایش '+shown.toLocaleString('fa-IR')+' درخواست از '+total.toLocaleString('fa-IR')+' مورد';
const all=document.querySelector('#show-all');
all.hidden=expanded||total===shown;
all.setAttribute('aria-expanded',String(expanded));
};
document.querySelector('#ticket-search').addEventListener('input',render);
document.querySelector('#show-all').addEventListener('click',()=>{expanded=true;render();});
document.querySelector('#reset-search').addEventListener('click',()=>{
document.querySelector('#ticket-search').value='';activeFilter='all';
document.querySelectorAll('[data-filter]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.filter==='all')));
render();document.querySelector('#ticket-search').focus();
});
document.querySelector('#close-dialog').addEventListener('click',()=>dialog.close());
document.querySelector('#dialog-done').addEventListener('click',()=>dialog.close());
dialog.addEventListener('click',event=>{if(event.target===dialog){const r=dialog.getBoundingClientRect();if(event.clientX<r.left||event.clientX>r.right||event.clientY<r.top||event.clientY>r.bottom)dialog.close();}});
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -0,0 +1,17 @@
/* DESIGN FIXTURES ONLY. These are invalid bank numbers, never payment destinations.
Production: supply only payment notices addressed to the authenticated customer.
Account, card, IBAN, deposit reference, expected amount and invoice belong to EACH notice.
Do not replace this with a client-side list of all company bank accounts. */
window.chaharsuPaymentNotices = [
{id:'DEMO-101', title:'پرداخت فاکتور خرید لوله', invoice:'DEMO-F-1405-218', issued:'۱۴۰۵/۰۷/۱۲', author:'واحد فروش', amount:'280000000', currency:'ریال', bank:'بانک ملت (نمونه)', holder:'لوله گستر گلپایگان (نمونه)', account:'000000000101', iban:'IR000000000000000000000101', card:'0000000000000101', deposit:'000101218', description:'برای فاکتور اعلام‌شده واریز کنید و شناسهٔ واریز همین مورد را درج کنید. پس از پرداخت، تصویر فیش را از دکمهٔ زیر بفرستید.', receiptReceived:false},
{id:'DEMO-102', title:'پرداخت هزینهٔ حمل سفارش', invoice:'DEMO-F-1405-219', issued:'۱۴۰۵/۰۷/۱۱', author:'واحد مالی', amount:'18500000', currency:'ریال', bank:'بانک تجارت (نمونه)', holder:'لوله گستر گلپایگان (نمونه)', account:'000000000202', iban:'IR000000000000000000000202', card:null, deposit:'000202219', description:'این مورد مربوط به هزینهٔ حمل است و شماره حساب و شناسهٔ آن با فاکتور خرید متفاوت است.', receiptReceived:false},
{id:'DEMO-103', title:'تسویهٔ ماندهٔ فاکتور', invoice:'DEMO-F-1405-203', issued:'۱۴۰۵/۰۷/۰۹', author:'واحد مالی', amount:'96000000', currency:'ریال', bank:'بانک ملی (نمونه)', holder:'لوله گستر گلپایگان (نمونه)', account:'000000000303', iban:'IR000000000000000000000303', card:'0000000000000303', deposit:'000303203', description:'فیش قبلی این مورد دریافت شده و در انتظار بررسی کارشناس است. دریافت فیش به معنی تأیید نهایی پرداخت نیست.', receiptReceived:true}
];
/* DESTINATION FIXTURES ONLY: production must query authorized, active company accounts from the database.
Return opaque ID + display_name only to this dropdown; no account/card/IBAN numbers. */
window.chaharsuCompanyAccounts=[
{id:'DEMO-ACC-101',name:'حساب فروش شرکت — ملت'},
{id:'DEMO-ACC-202',name:'حساب هزینهٔ حمل — تجارت'},
{id:'DEMO-ACC-303',name:'حساب تسویهٔ شرکت — ملی'}
];
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="fa" dir="rtl" data-static-version="1791111429240969400">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#08376b"><title>ارسال فیش واریزی · چهارسو</title>
<link rel="icon" href="../../static/images/favicon.ico?v=1791111429240969400"><link rel="stylesheet" href="../../static/css/tailwind.css?v=1791111429240969400"><link rel="stylesheet" href="service-pages.css?v=1791111429240969400">
<script defer src="payment-demo-data.js?v=1791111429240969400"></script><script defer src="payment-services.js?v=1791111429240969400"></script><script defer src="receipt-date.js?v=1791111429240969400"></script><script defer src="receipt-amount.js?v=1791111429240969400"></script><script defer src="receipt-banks.js?v=1791111429240969400"></script><script defer src="receipt-validation.js?v=1791111429240969400"></script></head>
<body data-finance-page="receipt">
<!-- DESIGN PROTOTYPE ONLY: صفحهٔ مستقل با URL/view/template مستقل در پیاده‌سازی اصلی؛ به modal خانه تبدیل نشود.
پوسته از base.html و دارایی‌ها از static_asset_version استفاده کنند. هیچ اتصال به دیتابیس یا ارسال واقعی وجود ندارد.
اطلاعات پرداخت تنها برای مشتری مخاطب اعلامیه از بک‌اند تأمین شود؛ فهرست عمومی حساب‌ها نمایش داده نشود.
پنهان کردن شماره‌ها در UI کنترل دسترسی نیست: مجوز مشاهده/ثبت فیش و ارتباط اعلامیه با مشتری باید در سرور بررسی شود.
پارامتر notice فقط شناسهٔ اعلامیه است، نه مبلغ یا شماره حساب؛ سرور دادهٔ معتبر را دوباره دریافت کند. -->
<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<symbol id="i-receipt" viewBox="0 0 24 24"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2ZM9 8h6M9 12h6"/></symbol>
<symbol id="i-bank" viewBox="0 0 24 24"><path d="m3 7 9-4 9 4v3H3ZM5 10v7M10 10v7M14 10v7M19 10v7M3 17h18v4H3"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 16V3m-5 5 5-5 5 5M4 16v5h16v-5"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/></symbol></svg><div class="preview-bar"><b>پیش‌نمایش طراحی چهارسو</b> · داده‌ها ساختگی‌اند؛ پرداخت و ارسال واقعی انجام نمی‌شود.</div>
<header class="page-header"><div class="header-inner"><a class="brand" href="index.html?v=1791111429240969400"><img src="../../static/images/company-logo.png?v=1791111429240969400" alt="لوله گستر گلپایگان" width="300" height="130"><span class="brand-copy"><b>چهارسو</b><small>مرکز خدمات مشتریان</small></span></a><a class="header-home" href="index.html?v=1791111429240969400">بازگشت به خدمات ←</a></div></header>
<main class="service-main"><nav class="breadcrumb" aria-label="مسیر صفحه"><a href="index.html?v=1791111429240969400">خدمات چهارسو</a><span aria-hidden="true">/</span><span aria-current="page">ارسال فیش واریزی</span></nav>
<div class="title-row"><span class="page-icon"><svg class="icon" aria-hidden="true"><use href="#i-receipt"/></svg></span><div><h1>ارسال فیش واریزی</h1><p class="subtitle">فیش را مستقل یا برای یکی از واریزهای اعلام‌شده ارسال کنید.</p></div></div>
<form id="receipt-form" enctype="multipart/form-data" novalidate>
<div id="receipt-errors" class="receipt-errors" role="alert" hidden><h2>لطفاً این موارد را اصلاح کنید</h2><ul></ul></div>
<section class="content-card" aria-labelledby="association-title"><h2 id="association-title">این فیش مربوط به کدام واریز است؟</h2>
<fieldset class="receipt-mode"><legend class="sr-only">نوع فیش واریزی</legend>
<label class="recommended-mode"><input type="radio" name="receipt-mode" value="linked" checked aria-describedby="linked-recommendation"><span><b>واریز مرتبط <span class="recommended-badge">پیشنهادی</span></b><small>با اعلامیهٔ واریز کارشناس</small></span></label>
<label><input type="radio" name="receipt-mode" value="independent"><span><b>واریز مستقل</b><small>بدون اعلامیهٔ واریز</small></span></label></fieldset>
<p id="linked-recommendation" class="finance-guidance">برای ثبت دقیق‌تر، از کارشناس بخواهید اعلامیهٔ واریز ایجاد کند؛ این کار توصیه می‌شود و اجباری نیست.</p>
<!-- UX CONTRACT: واریز مرتبط همیشه اولین گزینه و دارای نشان پیشنهادی است. واریز مستقل همچنان قابل انتخاب است، حتی اگر هیچ اعلامیه‌ای وجود نداشته باشد. -->
<div id="linked-payment" hidden><label for="notice-select" class="field-label">انتخاب واریز اعلام‌شده</label><select id="notice-select" name="notice_id" aria-describedby="notice-selection-help notice-error"><option value="">یک مورد را انتخاب کنید</option></select><p id="notice-selection-help" class="field-help">مبلغ و شمارهٔ فاکتور را با واریز انجام‌شده تطبیق دهید.</p><p id="notice-error" class="field-error" hidden></p><p id="notice-invalid" class="inline-error" role="alert" hidden>این اعلامیه در دسترس نیست. مورد دیگری را انتخاب کنید یا نوع فیش را به واریز مستقل تغییر دهید.</p>
<div id="selected-payment" class="selected-payment" hidden><h3 id="selected-title"></h3><dl id="selected-fields" class="payment-fields compact-fields"></dl><a id="selected-details" class="text-action">مشاهدهٔ اطلاعات و کپی شماره‌ها ←</a></div></div>
<p id="independent-help" class="field-help independent-help">اگر اطلاعات واریز را بیرون از چهارسو دریافت کرده‌اید، این گزینه را انتخاب کنید.</p></section>
<section class="content-card" aria-labelledby="receipt-title"><h2 id="receipt-title">اطلاعات فیش پرداخت</h2><p>مبلغ، حساب مقصد، تاریخ کامل و تصویر فیش الزامی‌اند.</p>
<div class="receipt-field-grid"><div><label for="paid-amount" class="field-label">مبلغ پرداخت‌شده <span>(ریال · اجباری)</span></label><input id="paid-amount" required aria-required="true" type="text" inputmode="numeric" autocomplete="off" placeholder="مثلاً ۲۸۰۰۰۰۰۰۰" aria-describedby="amount-words amount-help amount-error"><p id="amount-words" class="amount-words" role="status" aria-live="polite" aria-atomic="true"></p><p id="amount-help" class="field-help">مبلغی که واقعاً پرداخت کرده‌اید؛ ممکن است با مبلغ اعلام‌شده متفاوت باشد.</p><p id="amount-error" class="field-error" hidden></p><input id="paid-amount-value" name="paid_amount" type="hidden" value=""></div><div><label for="receipt-reference" class="field-label">شمارهٔ پیگیری بانکی <span>(اختیاری)</span></label><input id="receipt-reference" name="receipt-reference" type="text" inputmode="numeric" autocomplete="off" placeholder="شمارهٔ درج‌شده روی فیش"></div></div>
<div class="receipt-field-grid bank-fields"><div><label for="origin-bank" class="field-label">بانک مبدأ <span>(اختیاری)</span></label><select id="origin-bank" name="origin_bank"><option value="">انتخاب بانک مبدأ</option></select><div id="origin-other-field" hidden><label for="origin-bank-other" class="field-label">نام بانک دیگر <span>(اختیاری)</span></label><input id="origin-bank-other" name="origin_bank_other" type="text" maxlength="80" disabled autocomplete="off" placeholder="نام بانک را بنویسید"></div></div>
<div><label for="destination-account" class="field-label">به کدام حساب ما واریز کردید؟ <span>(اجباری)</span></label><select id="destination-account" name="destination_account_id" required aria-required="true" aria-describedby="destination-help destination-error"><option value="">انتخاب حساب مقصد</option></select><p id="destination-help" class="field-help">نام حسابی را که وجه به آن واریز کرده‌اید انتخاب کنید.</p><p id="destination-error" class="field-error" hidden></p></div></div>
<!-- BANK CONTRACT: حساب‌های مقصد از دیتابیس شرکت تأمین می‌شوند؛ در این فهرست فقط display_name و ID مجاز ارائه شوند، نه شماره حساب، شبا یا کارت.
انتخاب مقصد اجباری و توسط مشتری است؛ انتخاب اعلامیه یا تغییر نوع فیش نباید حساب مقصد را خودکار عوض کند.
سرور باید destination_account_id را در حساب‌های فعال/مجاز شرکت بررسی کند و نام حساب را از دیتابیس بازیابی کند، نه از متن ارسال‌شده توسط کاربر.
فهرست بانک مبدأ محلی و اختیاری است و گزینهٔ سایر دارد؛ فهرست اصلی باید با نام‌های بانکی معتبر به‌روز نگه داشته شود.
مبلغ نمایشی گروه‌بندی هزارگان دارد، ولی paid_amount ریالی بدون جداکننده است؛ تبدیل به حروف تومان از BigInt و باقی‌ماندهٔ دقیق استفاده می‌کند و گرد نمی‌شود. -->
<fieldset class="receipt-date" aria-describedby="receipt-date-help receipt-date-status date-error"><legend>تاریخ واریز <span>(شمسی · اجباری)</span></legend>
<div class="date-selects"><div><label for="receipt-day">روز</label><select id="receipt-day" required aria-required="true" aria-describedby="date-error"><option value="">روز</option></select></div><div><label for="receipt-month">ماه</label><select id="receipt-month" required aria-required="true" aria-describedby="date-error"><option value="">ماه</option></select></div><div><label for="receipt-year">سال</label><select id="receipt-year" required aria-required="true" aria-describedby="date-error"><option value="">سال</option></select></div></div>
<div class="date-tools"><p id="receipt-date-help">تاریخ درج‌شده روی فیش را انتخاب کنید.</p><button id="receipt-date-today" class="finance-button secondary" type="button">امروز</button></div>
<p id="receipt-date-status" role="status" aria-live="polite">روز پرداخت را انتخاب کنید.</p>
<p id="date-error" class="field-error" hidden></p>
<input id="receipt-date-value" name="paid_date" type="hidden" value="">
<!-- DATE CONTRACT: انتخابگر روز/ماه/سال شمسی با select بومی مرورگر و Intl، بدون CDN یا کتابخانهٔ خارجی.
روز از ابتدا خالی است؛ ماه و سال جاری فقط برای سهولت انتخاب‌اند. امروز بر اساس Asia/Tehran است.
فیلد paid_date تنها مقدار تاریخ قابل ارسال است: تاریخ میلادیِ فقط‌روز با ارقام ASCII و قالب YYYY-MM-DD. selectهای نمایشی name ندارند و تاریخ شمسی به سرور ارسال نمی‌کنند.
سرور باید paid_date را به‌عنوان تاریخ میلادی اعتبارسنجی کند؛ نام فیلد با قرارداد فرم/API نهایی هماهنگ شود. تبدیل در مرورگر از مبدأ دقیق سال شمسی و با کنترل برگشتی انجام می‌شود؛ رشتهٔ شمسی به Date.parse داده نشود.
بازهٔ سال‌ها در نمونه: ده سال قبل تا یک سال بعد؛ بازهٔ اجرایی با نیاز واقعی و اعتبارسنجی سرور هماهنگ شود.
تغییر نوع فیش یا اعلامیه نباید تاریخ پرداخت واردشده را تغییر دهد. تاریخ اعلامیه، تاریخ واقعی پرداخت نیست. -->
</fieldset>
<div class="upload-zone"><svg class="icon" aria-hidden="true"><use href="#i-upload"/></svg><label for="receipt-file" class="file-picker-label">انتخاب تصویر فیش <span>(اجباری)</span></label><input id="receipt-file" class="sr-only" type="file" name="receipt_image" required aria-required="true" accept=".jpg,.jpeg,.png,.gif,.webp" data-max-bytes="10485760" aria-describedby="upload-help file-error"><span id="upload-help">تصویر JPG، PNG، GIF یا WEBP؛ حداکثر ۱۰ مگابایت.</span></div>
<p id="file-error" class="field-error" hidden></p>
<div class="file-selection" id="file-selection" hidden><p id="file-status" role="status" aria-live="polite"></p><button id="clear-file" type="button">حذف انتخاب</button></div>
<label for="receipt-note" class="field-label note-label">توضیحات <span>(اختیاری)</span></label><textarea id="receipt-note" name="receipt-note" rows="3" placeholder="مثلاً شمارهٔ فاکتور یا توضیح دربارهٔ پرداخت"></textarea>
<!-- IMPLEMENTATION CONTRACT: مدل فیش دو حالت دارد: اعلامیهٔ مرتبط (شناسهٔ معتبر متعلق به مشتری) یا مستقل (بدون شناسه).
تغییر حالت باید ارتباط قبلی را حذف کند؛ داده‌های بانکی و مبلغ اعلام‌شده از سرور دریافت شوند، نه از query string یا hidden inputs.
مبلغ پرداخت‌شده با مبلغ مورد انتظار فرق دارد؛ فرم آن را خودکار بازنویسی نکند. مبلغ و واحد ریال جدا و روشن‌اند.
پس از ذخیرهٔ واقعی و اعتبارسنجی فایل، رسید ثبت و وضعیت بررسی نمایش داده شود؛ انتخاب فایل یا کلیک هرگز موفقیت ساختگی نسازد.
دکمه در این نمونه فقط اعتبارسنجی می‌کند و هیچ ارسال واقعی ندارد؛ پس از اعتبارسنجی موفق، پیام «ارسال نشده» نمایش داده شود. برای فیش مرتبط، شمارهٔ اعلامیه/فاکتور نیز همراه رسید ثبت نمایش داده شود. -->
<div class="receipt-summary" id="receipt-summary" role="status">نوع فیش: واریز مستقل</div>
<p id="receipt-validation-status" class="validation-status" role="status" aria-live="polite"></p>
<!-- VALIDATION CONTRACT: مبلغ مثبت صحیح ریال، تاریخ کامل میلادی معتبر و تصویر قابل خواندن الزامی‌اند؛ اعلامیه برای نوع مرتبط الزامی است.
کنترل‌های نمایش مبلغ و شمسی name ندارند؛ پارامترهای مالی FormData: paid_amount عدد ریالی ASCII بدون جداکننده، paid_date میلادی YYYY-MM-DD، receipt_image و destination_account_id اجباری؛ origin_bank اختیاری و notice_id فقط برای حالت مرتبط است.
حد تصویر این نمونه مطابق پیش‌فرض bootstrap پروژه است؛ در نسخهٔ اصلی حد حجم/پسوند/MIME از AttachmentPolicy معتبر سرور دریافت شوند.
اعتبارسنجی کلاینت جای اعتبارسنجی سرور را نمی‌گیرد؛ سرور مقدار، تاریخ، مالکیت اعلامیه و محتوای واقعی فایل را دوباره بررسی کند. -->
<div class="receipt-actions"><button id="submit-receipt" type="submit" disabled>بررسی و ادامه</button><a href="bank-accounts.html?v=1791111429240969400">مشاهدهٔ واریزهای اعلام‌شده ←</a></div></section></form>
<div class="service-footer"><a href="index.html?v=1791111429240969400">→ بازگشت به خدمات چهارسو</a></div>
<!-- در نسخهٔ اصلی امضا از developer_credit در تنظیمات برند تأمین شود. -->
<p class="page-footer">طراحی و توسعه: محمد جواد فصاحت <a href="tel:09202202129" dir="ltr">09202202129</a></p></main>
<div id="copy-feedback" class="copy-feedback" role="status" aria-live="polite" aria-atomic="true"></div>
<dialog id="manual-copy" class="manual-copy" aria-labelledby="manual-copy-title"><h2 id="manual-copy-title">کپی اطلاعات</h2><p>کپی خودکار در این مرورگر ممکن نشد. متن را انتخاب کنید و از گزینهٔ کپی دستگاه استفاده کنید.</p><textarea id="manual-copy-text" readonly aria-label="متن آماده برای کپی" dir="auto"></textarea><div><button type="button" id="select-copy-text" class="finance-button">انتخاب متن</button><button type="button" id="close-copy" class="finance-button secondary">بستن</button></div></dialog>
<noscript><p class="service-notice">برای مشاهدهٔ تعامل‌های این نمونه، جاوااسکریپت را فعال کنید.</p></noscript></body></html>
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff. Show more