fix sms and ticket user frindly

This commit is contained in:
javad committed 2026-09-14 15:48:13 +03:30
1 parent 1ee83374fd
commit b8b3aba855
30 files changed
+2220 -119

No files matched your search

+5 -1
View File
@@ -29,7 +29,7 @@ SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider
SMS0098_USERNAME= SMS0098_USERNAME=
SMS0098_PASSWORD= SMS0098_PASSWORD=
SMS0098_FROM= SMS0098_FROM=
SMS0098_API_URL=http://www.0098sms.com/sendsmslink.aspx SMS0098_WSDL_URL=https://webservice.0098sms.com/service.asmx?WSDL
BEHIN_SMS_USERNAME= BEHIN_SMS_USERNAME=
BEHIN_SMS_PASSWORD= BEHIN_SMS_PASSWORD=
BEHIN_SMS_SPECIAL_NUMBER= BEHIN_SMS_SPECIAL_NUMBER=
@@ -41,6 +41,10 @@ BALE_API_BASE=https://tapi.bale.ai
BALE_BOT_TOKEN= BALE_BOT_TOKEN=
INTEGRATION_HTTP_TIMEOUT_SECONDS=10 INTEGRATION_HTTP_TIMEOUT_SECONDS=10
MAX_NOTIFICATION_ATTEMPTS=5 MAX_NOTIFICATION_ATTEMPTS=5
SMS_MAX_RETRIES=3
SMS_RETRY_DELAY_SECONDS=180
SMS_DELIVERY_CHECK_DELAY_SECONDS=180
SMS_DELIVERY_MAX_CHECKS=15
SECURE_SSL_REDIRECT=true SECURE_SSL_REDIRECT=true
SECURE_HSTS_SECONDS=31536000 SECURE_HSTS_SECONDS=31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS=true SECURE_HSTS_INCLUDE_SUBDOMAINS=true
+12
View File
@@ -7,6 +7,14 @@ class SMSResult:
success: bool success: bool
provider_id: str = "" provider_id: str = ""
error: str = "" error: str = ""
provider_code: str = ""
@dataclass(frozen=True, slots=True)
class SMSDeliveryResult:
status: str
provider_code: str
description: str
is_final: bool = True
def webotp_message(code): def webotp_message(code):
host=settings.WEBOTP_HOST or urlparse(settings.PUBLIC_BASE_URL).hostname host=settings.WEBOTP_HOST or urlparse(settings.PUBLIC_BASE_URL).hostname
if not host: raise ValueError("WEBOTP_HOST یا PUBLIC_BASE_URL معتبر تنظیم نشده است.") if not host: raise ValueError("WEBOTP_HOST یا PUBLIC_BASE_URL معتبر تنظیم نشده است.")
@@ -16,3 +24,7 @@ class SMSProvider(ABC):
def send_otp(self,mobile,code): return self.send_message(mobile,webotp_message(code)) def send_otp(self,mobile,code): return self.send_message(mobile,webotp_message(code))
@abstractmethod @abstractmethod
def send_message(self,mobile,message): raise NotImplementedError def send_message(self,mobile,message): raise NotImplementedError
def get_delivery_status(self, provider_id):
"""Return None when a provider does not support delivery tracking."""
return None
+2 -2
View File
@@ -1,7 +1,7 @@
"""Backward-compatible public facade for the SMS integration.""" """Backward-compatible public facade for the SMS integration."""
from .base import SMSProvider,SMSResult,webotp_message from .base import SMSDeliveryResult,SMSProvider,SMSResult,webotp_message
from .behin import BehinSMSProvider from .behin import BehinSMSProvider
from .console import ConsoleSMSProvider from .console import ConsoleSMSProvider
from .factory import get_sms_provider,reset_sms_provider from .factory import get_sms_provider,reset_sms_provider
from .http import HTTPSMSProvider from .http import HTTPSMSProvider
__all__=["SMSProvider","SMSResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"] __all__=["SMSProvider","SMSResult","SMSDeliveryResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"]
+154 -18
View File
@@ -1,36 +1,172 @@
import logging import logging
import re
import requests import zeep
from django.conf import settings from django.conf import settings
from zeep.transports import Transport
from apps.accounts.models import normalize_mobile from apps.accounts.models import normalize_mobile
from .base import SMSProvider, SMSResult from .base import SMSDeliveryResult, SMSProvider, SMSResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Source: the vendor's "کدهای خطا وب سرویس" table. Keeping the mapping here
# makes worker logs and the staff UI report the same exact reason.
SEND_RESULT_MESSAGES = {
"2": "ارسال با موفقیت انجام شد.",
"-3": "نام کاربری و کلمه عبور با هم تطابق ندارند؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"10": "نام کاربری یا کلمه عبور اشتباه است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"11": "در متن پیامک کاراکتر غیرمجاز وجود دارد.",
"-17": "متن پیامک خالی است.",
"-18": "خطای شارژ رخ داده است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"-19": "شارژ پنل برای ارسال کافی نیست؛ لطفاً پنل را شارژ کنید.",
"-22": "شماره موبایل گیرنده صحیح نیست.",
"66": "نام کاربری و کلمه عبور با هم تطابق ندارند؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"1111": "در متن پیامک کاراکتر غیرمجاز وجود دارد.",
"hang": "حساب کاربری مسدود است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"no doc": "مرحله دوم ثبت‌نام انجام نشده است؛ لطفاً ثبت‌نام را کامل کنید.",
"-23": "شماره پنل مجوز ارسال از لینک را ندارد.",
"-24": "در شماره پنل خطا وجود دارد؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"-25": "اعتبار شماره پنل به پایان رسیده است و باید تمدید شود.",
"-26": "تنظیمات کد OTP انجام نشده است.",
"-27": "فرمت کد OTP صحیح نیست.",
"-28": "تنظیمات کد OTP توسط ادمین تأیید نشده است.",
"wrong timestamp": "مقدار timestamp ارسال‌شده صحیح نیست.",
"wrong time": "زمان ارسال خارج از بازه مجاز است.",
"-29": "فرمت کد OTP صحیح نیست.",
"-31": "شناسه پیامک (smsid) یافت نشد.",
"-32": "شناسه پیامک (smsid) مطابقت ندارد.",
"13": "ارسال از IP غیرمجاز انجام شده است.",
}
DELIVERY_RESULTS = {
"4": ("BLOCKED", "مخاطب دریافت پیامک‌های تبلیغاتی را مسدود کرده است.", True),
"5": ("DELIVERED", "پیامک به گوشی گیرنده رسیده است.", True),
# 0098SMS can initially return 6 or 8 and publish the definitive result
# much later. Keep polling these states instead of freezing a stale result.
"6": ("UNDELIVERED", "هنوز تحویل پیامک به گوشی تأیید نشده است.", False),
"7": ("EXPIRED", "مهلت دریافت پیامک منقضی شده است.", True),
"8": ("UNKNOWN", "وضعیت تحویل هنوز توسط اپراتور مشخص نشده است.", False),
"9": ("ERROR", "اطلاعات مالک شماره پیامک ثبت یا تأیید نشده است.", True),
"12": ("ERROR", "هنوز اجازه ارسال با این شماره پنل داده نشده است.", True),
"13": ("ERROR", "ارسال از IP غیرمجاز انجام شده است.", True),
}
def _scalar(value):
if isinstance(value, (list, tuple)) and value:
value = value[0]
return str(value if value is not None else "").strip()
class ZeroZeroNinetyEightSMSProvider(SMSProvider): class ZeroZeroNinetyEightSMSProvider(SMSProvider):
"""Adapter for the 0098SMS HTTP gateway.""" """SOAP adapter for 0098SMS (0098vip-compatible web service)."""
def __init__(self): def __init__(self):
self.username = settings.SMS0098_USERNAME self.username = settings.SMS0098_USERNAME
self.password = settings.SMS0098_PASSWORD self.password = settings.SMS0098_PASSWORD
self.sender = settings.SMS0098_FROM self.sender = settings.SMS0098_FROM
self.api_url = settings.SMS0098_API_URL self.wsdl_url = settings.SMS0098_WSDL_URL
self._client = None
def _configuration_error(self):
missing = [name for name, value in (
("SMS0098_USERNAME", self.username),
("SMS0098_PASSWORD", self.password),
("SMS0098_FROM", self.sender),
) if not value]
return f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}" if missing else ""
@property
def client(self):
if self._client is None:
timeout = settings.INTEGRATION_HTTP_TIMEOUT_SECONDS
self._client = zeep.Client(
wsdl=self.wsdl_url,
transport=Transport(timeout=timeout, operation_timeout=timeout),
)
return self._client
def _safe_exception(self, exc):
message = str(exc)
for secret in (self.username, self.password):
if secret:
message = message.replace(secret, "***")
return message[:500]
def _call(self, operation, parameters):
method = getattr(self.client.service, operation)
# The live 0098sms.com WSDL exposes separate scalar arguments. This
# deliberately follows the WSDL signature instead of the outdated
# dict-shaped invocation printed in the vendor PDF sample.
return method(**parameters)
def _parse_send_result(self, response):
raw = _scalar(response)
normalized = raw.casefold()
if re.fullmatch(r"\d{9,}", raw):
return SMSResult(True, provider_id=raw, provider_code=raw)
# 2 is the SOAP success code. 0 is retained only for compatibility
# with the vendor's older link endpoint.
if raw in {"0", "2"}:
return SMSResult(True, provider_code=raw)
reason = SEND_RESULT_MESSAGES.get(normalized)
if reason:
return SMSResult(False, error=f"0098SMS [کد {raw}]: {reason}", provider_code=raw)
shown = raw[:120] or "پاسخ خالی"
return SMSResult(False, error=f"0098SMS پاسخ ناشناخته برگرداند: {shown}", provider_code=raw[:50])
def send_message(self, mobile, message): def send_message(self, mobile, message):
missing = [name for name, value in (("SMS0098_USERNAME", self.username), ("SMS0098_PASSWORD", self.password), ("SMS0098_FROM", self.sender)) if not value] error = self._configuration_error()
if missing: if error:
return SMSResult(False, error=f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}") return SMSResult(False, error=error, provider_code="CONFIG")
try: try:
recipient = int(normalize_mobile(str(mobile)).removeprefix("+98")) recipient = normalize_mobile(str(mobile)).removeprefix("+98")
response = requests.get(self.api_url, params={"USERNAME": self.username, "PASSWORD": self.password, "FROM": self.sender, "TO": recipient, "TEXT": str(message)}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS) if not recipient.startswith("0"):
response.raise_for_status() recipient = f"0{recipient}"
# This legacy endpoint may append an HTML page containing the response = self._call("SendSMSWithID", {
# original query string. Persist only its first scalar result so "username": self.username,
# credentials can never leak into notification logs. "password": self.password,
provider_id = response.text.strip().splitlines()[0][:100] "mobileno": recipient,
return SMSResult(True, provider_id=provider_id) "pnlno": str(self.sender),
except (ValueError, requests.RequestException) as exc: "text": str(message),
logger.exception("0098SMS delivery failed") })
return SMSResult(False, error=str(exc)[:1000]) return self._parse_send_result(response)
except Exception as exc:
logger.exception("0098SMS send failed")
return SMSResult(
False,
error=f"خطا در ارتباط با وب‌سرویس 0098SMS: {self._safe_exception(exc)}",
provider_code="CONNECTION",
)
def get_delivery_status(self, provider_id):
error = self._configuration_error()
if error:
return SMSDeliveryResult("ERROR", "CONFIG", error)
if not re.fullmatch(r"\d{9,}", str(provider_id or "")):
return SMSDeliveryResult("ERROR", "INVALID_ID", "شناسه رهگیری 0098SMS معتبر نیست.")
try:
response = self._call("smsdeliveryState", {
"username": self.username,
"password": self.password,
"smsid": str(provider_id),
})
code = _scalar(response)
mapped = DELIVERY_RESULTS.get(code)
if mapped:
status, description, is_final = mapped
return SMSDeliveryResult(status, code, f"0098SMS [کد {code}]: {description}", is_final=is_final)
reason = SEND_RESULT_MESSAGES.get(code.casefold())
if reason:
return SMSDeliveryResult("ERROR", code, f"0098SMS [کد {code}]: {reason}")
return SMSDeliveryResult("UNKNOWN", code[:50], f"0098SMS وضعیت دلیوری ناشناخته برگرداند: {code[:120] or 'پاسخ خالی'}")
except Exception as exc:
logger.exception("0098SMS delivery status lookup failed")
return SMSDeliveryResult(
"CHECK_FAILED",
"CONNECTION",
f"خطا در استعلام دلیوری 0098SMS: {self._safe_exception(exc)}",
is_final=False,
)
+1 -1
View File
@@ -1,7 +1,7 @@
from django.contrib import admin from django.contrib import admin
from .models import NotificationJob,NotificationPolicy,SMSTemplate from .models import NotificationJob,NotificationPolicy,SMSTemplate
@admin.register(NotificationJob) @admin.register(NotificationJob)
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_message_id","created_at"); list_filter=("channel","status"); readonly_fields=("provider_message_id","dedupe_key","created_at","sent_at") class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_status_code","delivery_status","provider_message_id","created_at"); list_filter=("channel","status","delivery_status"); readonly_fields=("provider_message_id","provider_status_code","delivery_status_code","delivery_description","delivery_checked_at","dedupe_key","created_at","sent_at")
@admin.register(SMSTemplate) @admin.register(SMSTemplate)
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")
@@ -6,6 +6,7 @@ import jdatetime
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from apps.accounts.models import TicketMagicLink from apps.accounts.models import TicketMagicLink
@@ -130,6 +131,103 @@ def _prepare_customer_reply_bale(job):
locked.save(update_fields=["status","next_attempt_at","last_error"]); return False locked.save(update_fields=["status","next_attempt_at","last_error"]); return False
return True return True
def _record_failure(job, error, provider_code=""):
"""Persist a provider failure using the channel-specific retry policy."""
job.attempts += 1
job.last_error = str(error)[:1000]
job.provider_status_code = str(provider_code or "")[:50]
job.provider_message_id = ""
if job.channel == NotificationJob.Channel.SMS:
if job.attempts < 1 + settings.SMS_MAX_RETRIES:
job.status = NotificationJob.Status.RETRYING
job.next_attempt_at = timezone.now() + timedelta(seconds=settings.SMS_RETRY_DELAY_SECONDS)
else:
job.status = NotificationJob.Status.FAILED
else:
job.status = NotificationJob.Status.FAILED
job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10))
def _record_success(job, result):
job.attempts += 1
job.status = NotificationJob.Status.SENT
job.sent_at = timezone.now()
job.last_error = ""
job.provider_message_id = getattr(result, "provider_id", "") if result is not None else ""
job.provider_status_code = getattr(result, "provider_code", "") if result is not None else ""
if job.channel == NotificationJob.Channel.SMS and job.provider_message_id:
job.delivery_status = NotificationJob.DeliveryStatus.PENDING
job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS)
def _process_delivery_checks(options):
if options.get("channel") not in (None, NotificationJob.Channel.SMS):
return
now = timezone.now()
with transaction.atomic():
pollable_statuses = [
NotificationJob.DeliveryStatus.PENDING,
NotificationJob.DeliveryStatus.CHECK_FAILED,
NotificationJob.DeliveryStatus.UNKNOWN,
NotificationJob.DeliveryStatus.UNDELIVERED,
]
# Resume provisional results created by older worker versions, but do
# not revive messages beyond the configured 45-minute monitoring span.
monitor_cutoff = now - timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS * settings.SMS_DELIVERY_MAX_CHECKS)
NotificationJob.objects.filter(
channel=NotificationJob.Channel.SMS,
status=NotificationJob.Status.SENT,
delivery_status__in=[NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED],
delivery_next_check_at__isnull=True,
delivery_check_attempts__lt=settings.SMS_DELIVERY_MAX_CHECKS,
sent_at__gte=monitor_cutoff,
).update(delivery_next_check_at=now)
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(
channel=NotificationJob.Channel.SMS,
status=NotificationJob.Status.SENT,
delivery_status__in=pollable_statuses,
delivery_next_check_at__lte=now,
)
if options.get("job_id"):
queryset = queryset.filter(pk=options["job_id"])
elif options.get("min_id"):
queryset = queryset.filter(pk__gte=options["min_id"])
jobs = list(queryset.order_by("delivery_next_check_at")[:20])
# Lease each status check so parallel workers cannot query it twice.
NotificationJob.objects.filter(pk__in=[job.pk for job in jobs]).update(
delivery_next_check_at=now + timedelta(minutes=10),
)
provider = get_sms_provider()
for job in jobs:
result = provider.get_delivery_status(job.provider_message_id)
if result is None:
job.delivery_status = ""
job.delivery_next_check_at = None
job.delivery_description = "این ارائه‌دهنده از استعلام دلیوری پشتیبانی نمی‌کند."
else:
job.delivery_check_attempts += 1
job.delivery_checked_at = timezone.now()
job.delivery_status_code = result.provider_code[:50]
job.delivery_description = result.description[:500]
if result.is_final:
job.delivery_status = result.status
job.delivery_next_check_at = None
elif job.delivery_check_attempts < settings.SMS_DELIVERY_MAX_CHECKS:
job.delivery_status = result.status
job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS)
else:
job.delivery_status = result.status if result.status in NotificationJob.DeliveryStatus.values else NotificationJob.DeliveryStatus.ERROR
job.delivery_next_check_at = None
job.delivery_description = f"{result.description} نتیجه پس از {job.delivery_check_attempts} نوبت استعلام در بازه پایش نهایی شد."[:500]
job.save(update_fields=[
"delivery_status", "delivery_status_code", "delivery_description",
"delivery_checked_at", "delivery_check_attempts", "delivery_next_check_at",
])
if job.ticket_id and job.ticket.group_dispatch_id:
from apps.tickets.services import refresh_group_dispatch_status
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
class Command(BaseCommand): class Command(BaseCommand):
help = "Process notification outbox" help = "Process notification outbox"
def add_arguments(self, parser): def add_arguments(self, parser):
@@ -140,9 +238,15 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
while True: while True:
stale = timezone.now() - timedelta(minutes=10) stale = timezone.now() - timedelta(minutes=10)
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale).update(status="FAILED", next_attempt_at=timezone.now(), last_error="Recovered stale processing job") NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.SMS).update(status=NotificationJob.Status.RETRYING, next_attempt_at=timezone.now(), last_error="بازیابی تلاش ارسال متوقف‌شده")
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale, channel=NotificationJob.Channel.BALE).update(status=NotificationJob.Status.FAILED, next_attempt_at=timezone.now(), last_error="Recovered stale processing job")
with transaction.atomic(): with transaction.atomic():
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(status__in=["PENDING", "FAILED"], attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, next_attempt_at__lte=timezone.now()) sms_limit = 1 + settings.SMS_MAX_RETRIES
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(
Q(channel=NotificationJob.Channel.SMS, attempts__lt=sms_limit, status__in=[NotificationJob.Status.PENDING, NotificationJob.Status.RETRYING])
| Q(channel=NotificationJob.Channel.BALE, attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, status__in=[NotificationJob.Status.PENDING, NotificationJob.Status.FAILED]),
next_attempt_at__lte=timezone.now(),
)
if options.get("channel"): if options.get("channel"):
queryset = queryset.filter(channel=options["channel"]) queryset = queryset.filter(channel=options["channel"])
if options.get("job_id"): if options.get("job_id"):
@@ -160,15 +264,22 @@ class Command(BaseCommand):
continue continue
if job.channel == NotificationJob.Channel.SMS: result, issued_link = _send_sms(job) if job.channel == NotificationJob.Channel.SMS: result, issued_link = _send_sms(job)
else: result = _send_bale(job) else: result = _send_bale(job)
if result is not None and not result.success: raise RuntimeError(result.error or "Provider rejected notification") if result is not None and not result.success:
job.attempts += 1; job.status = "SENT"; job.sent_at = timezone.now(); job.last_error = ""; job.provider_message_id = getattr(result,"provider_id","") if result is not None else "" _record_failure(job, result.error or "ارائه‌دهنده پیام را نپذیرفت.", getattr(result, "provider_code", ""))
if issued_link:
issued_link.revoked_at = timezone.now()
issued_link.save(update_fields=["revoked_at"])
else:
_record_success(job, result)
except Exception as exc: except Exception as exc:
if issued_link: if issued_link:
issued_link.revoked_at = timezone.now(); issued_link.save(update_fields=["revoked_at"]) issued_link.revoked_at = timezone.now(); issued_link.save(update_fields=["revoked_at"])
job.attempts += 1; job.last_error = str(exc)[:1000]; job.provider_message_id = ""; job.status = "FAILED"; job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10)); logger.exception("notification failed id=%s", job.pk) _record_failure(job, exc)
logger.exception("notification failed id=%s", job.pk)
job.save() job.save()
if job.ticket_id and job.ticket.group_dispatch_id: if job.ticket_id and job.ticket.group_dispatch_id:
from apps.tickets.services import refresh_group_dispatch_status from apps.tickets.services import refresh_group_dispatch_status
refresh_group_dispatch_status(job.ticket.group_dispatch_id) refresh_group_dispatch_status(job.ticket.group_dispatch_id)
_process_delivery_checks(options)
if options["once"] or options.get("job_id"): break if options["once"] or options.get("job_id"): break
time.sleep(5) time.sleep(5)
@@ -0,0 +1,32 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("notifications", "0010_use_actual_unit_in_staff_ticket_sms")]
operations = [
migrations.AlterField(
model_name="notificationjob",
name="status",
field=models.CharField(
choices=[
("PENDING", "در صف"),
("PROCESSING", "در حال ارسال"),
("RETRYING", "در انتظار تلاش مجدد"),
("SENT", "ارسال‌شده"),
("FAILED", "ناموفق"),
("CANCELLED", "لغوشده"),
],
db_index=True,
default="PENDING",
max_length=15,
),
),
migrations.AddField(model_name="notificationjob", name="provider_status_code", field=models.CharField(blank=True, max_length=50)),
migrations.AddField(model_name="notificationjob", name="delivery_status", field=models.CharField(blank=True, choices=[("PENDING", "در انتظار استعلام"), ("DELIVERED", "رسیده به گوشی"), ("UNDELIVERED", "نرسیده به گوشی"), ("EXPIRED", "منقضی‌شده"), ("BLOCKED", "مسدود توسط مخاطب"), ("UNKNOWN", "نامشخص"), ("ERROR", "خطای دلیوری"), ("CHECK_FAILED", "خطای موقت استعلام")], db_index=True, max_length=20)),
migrations.AddField(model_name="notificationjob", name="delivery_status_code", field=models.CharField(blank=True, max_length=50)),
migrations.AddField(model_name="notificationjob", name="delivery_description", field=models.CharField(blank=True, max_length=500)),
migrations.AddField(model_name="notificationjob", name="delivery_checked_at", field=models.DateTimeField(blank=True, null=True)),
migrations.AddField(model_name="notificationjob", name="delivery_check_attempts", field=models.PositiveSmallIntegerField(default=0)),
migrations.AddField(model_name="notificationjob", name="delivery_next_check_at", field=models.DateTimeField(blank=True, db_index=True, null=True)),
]
+17 -1
View File
@@ -44,7 +44,23 @@ class NotificationPolicy(models.Model):
class NotificationJob(models.Model): class NotificationJob(models.Model):
class Channel(models.TextChoices): SMS="SMS","پیامک"; BALE="BALE","بله" class Channel(models.TextChoices): SMS="SMS","پیامک"; BALE="BALE","بله"
class Status(models.TextChoices): PENDING="PENDING","در صف"; PROCESSING="PROCESSING","در حال ارسال"; SENT="SENT","ارسال‌شده"; FAILED="FAILED","ناموفق"; CANCELLED="CANCELLED","لغوشده" class Status(models.TextChoices): PENDING="PENDING","در صف"; PROCESSING="PROCESSING","در حال ارسال"; RETRYING="RETRYING","در انتظار تلاش مجدد"; SENT="SENT","ارسال‌شده"; FAILED="FAILED","ناموفق"; CANCELLED="CANCELLED","لغوشده"
class DeliveryStatus(models.TextChoices):
PENDING="PENDING","در انتظار استعلام"
DELIVERED="DELIVERED","رسیده به گوشی"
UNDELIVERED="UNDELIVERED","نرسیده به گوشی"
EXPIRED="EXPIRED","منقضی‌شده"
BLOCKED="BLOCKED","مسدود توسط مخاطب"
UNKNOWN="UNKNOWN","نامشخص"
ERROR="ERROR","خطای دلیوری"
CHECK_FAILED="CHECK_FAILED","خطای موقت استعلام"
channel=models.CharField(max_length=10,choices=Channel.choices); event_type=models.CharField(max_length=50); customer=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="customer_notifications"); staff_user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="staff_notifications"); ticket=models.ForeignKey("tickets.Ticket",on_delete=models.CASCADE,null=True,blank=True); payload=models.JSONField(default=dict); status=models.CharField(max_length=15,choices=Status.choices,default=Status.PENDING,db_index=True); attempts=models.PositiveSmallIntegerField(default=0); next_attempt_at=models.DateTimeField(db_index=True); last_error=models.TextField(blank=True); provider_message_id=models.CharField(max_length=500,blank=True); dedupe_key=models.CharField(max_length=200,unique=True); created_at=models.DateTimeField(auto_now_add=True); sent_at=models.DateTimeField(null=True,blank=True) channel=models.CharField(max_length=10,choices=Channel.choices); event_type=models.CharField(max_length=50); customer=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="customer_notifications"); staff_user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,null=True,blank=True,related_name="staff_notifications"); ticket=models.ForeignKey("tickets.Ticket",on_delete=models.CASCADE,null=True,blank=True); payload=models.JSONField(default=dict); status=models.CharField(max_length=15,choices=Status.choices,default=Status.PENDING,db_index=True); attempts=models.PositiveSmallIntegerField(default=0); next_attempt_at=models.DateTimeField(db_index=True); last_error=models.TextField(blank=True); provider_message_id=models.CharField(max_length=500,blank=True); dedupe_key=models.CharField(max_length=200,unique=True); created_at=models.DateTimeField(auto_now_add=True); sent_at=models.DateTimeField(null=True,blank=True)
provider_status_code=models.CharField(max_length=50,blank=True)
delivery_status=models.CharField(max_length=20,choices=DeliveryStatus.choices,blank=True,db_index=True)
delivery_status_code=models.CharField(max_length=50,blank=True)
delivery_description=models.CharField(max_length=500,blank=True)
delivery_checked_at=models.DateTimeField(null=True,blank=True)
delivery_check_attempts=models.PositiveSmallIntegerField(default=0)
delivery_next_check_at=models.DateTimeField(null=True,blank=True,db_index=True)
class Meta: class Meta:
indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")] indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")]
+6 -5
View File
@@ -1,7 +1,7 @@
from datetime import timedelta from datetime import timedelta
from django.core.exceptions import PermissionDenied,ValidationError from django.core.exceptions import PermissionDenied,ValidationError
from django.db import transaction from django.db import transaction
from django.db.models import Count from django.db.models import Count,Q
from django.utils import timezone from django.utils import timezone
from apps.attachments.services import save_attachments from apps.attachments.services import save_attachments
from apps.core.persian_text import normalize_persian_text from apps.core.persian_text import normalize_persian_text
@@ -100,11 +100,12 @@ 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()
if not dispatch: return None if not dispatch: return None
counts={row["status"]:row["count"] for row in NotificationJob.objects.filter(ticket__group_dispatch_id=dispatch_id,channel=NotificationJob.Channel.SMS,event_type="STAFF_TICKET_CREATED").values("status").annotate(count=Count("id"))} jobs=NotificationJob.objects.filter(ticket__group_dispatch_id=dispatch_id,channel=NotificationJob.Channel.SMS,event_type="STAFF_TICKET_CREATED")
counts={row["status"]:row["count"] for row in jobs.values("status").annotate(count=Count("id"))}
sent=counts.get(NotificationJob.Status.SENT,0) sent=counts.get(NotificationJob.Status.SENT,0)
failed=counts.get(NotificationJob.Status.FAILED,0) failed=jobs.filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=[NotificationJob.DeliveryStatus.UNDELIVERED,NotificationJob.DeliveryStatus.EXPIRED,NotificationJob.DeliveryStatus.BLOCKED,NotificationJob.DeliveryStatus.UNKNOWN,NotificationJob.DeliveryStatus.ERROR],delivery_next_check_at__isnull=True)).count()
if dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT if failed: status=GroupDispatch.Status.PARTIAL
elif failed: status=GroupDispatch.Status.PARTIAL elif dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT
else: status=GroupDispatch.Status.QUEUED else: status=GroupDispatch.Status.QUEUED
if dispatch.status!=status: if dispatch.status!=status:
GroupDispatch.objects.filter(pk=dispatch_id).update(status=status,updated_at=timezone.now()) GroupDispatch.objects.filter(pk=dispatch_id).update(status=status,updated_at=timezone.now())
+62 -8
View File
@@ -395,8 +395,9 @@ def staff_detail(request,public_id):
mark_messages_read(conversation,request.user) mark_messages_read(conversation,request.user)
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}) 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] 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=ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")
latest=conversation[-1] if conversation else None latest=conversation[-1] if conversation else None
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":ShareForm(),"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),"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,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""}) 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":ShareForm(),"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),"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,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
@staff_required @staff_required
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)
@@ -559,19 +560,59 @@ def staff_dispatch_detail(request, 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")) tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id"))
delivery_failures = {
NotificationJob.DeliveryStatus.UNDELIVERED,
NotificationJob.DeliveryStatus.EXPIRED,
NotificationJob.DeliveryStatus.BLOCKED,
NotificationJob.DeliveryStatus.UNKNOWN,
NotificationJob.DeliveryStatus.ERROR,
}
now = timezone.now()
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), 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.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None) ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None)
sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS) links = list(ticket.magic_links.all())
ticket.magic_total_views = sum(link.view_count for link in links)
ticket.magic_link_count = len(links)
ticket.magic_active_count = sum(not link.revoked_at and link.expires_at > now for link in links)
ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None)
job = ticket.sms_job
if not job:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب job پیامک پیدا نشد.", "neutral"
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"
elif job.status == NotificationJob.Status.RETRYING:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "worker پیامک را در موعد بعدی دوباره ارسال می‌کند.", "warning"
elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING):
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info"
elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED:
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:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست و worker استعلام را ادامه می‌دهد.", "warning"
elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger"
elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "مهلت تحویل پایان یافت", "اپراتور پیامک را در بازه مجاز تحویل نداده است.", "danger"
elif job.delivery_status in (NotificationJob.DeliveryStatus.ERROR, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED):
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل تأیید نشد", "بازه پیگیری پایان یافته یا درگاه خطای قطعی برگردانده است.", "danger"
else:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "پذیرفته‌شده توسط درگاه", "شناسه ارسال ثبت شده و در انتظار نتیجه دلیوری است.", "info"
sms_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, channel=NotificationJob.Channel.SMS, event_type=SMSTemplate.Event.STAFF_TICKET_CREATED)
sms_counts = {row["status"]: row["count"] for row in sms_jobs.values("status").annotate(count=Count("id"))} sms_counts = {row["status"]: row["count"] for row in sms_jobs.values("status").annotate(count=Count("id"))}
sent = sms_counts.get(NotificationJob.Status.SENT, 0) sent = sms_counts.get(NotificationJob.Status.SENT, 0)
failed = sms_counts.get(NotificationJob.Status.FAILED, 0) failed = sms_jobs.filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT, delivery_status__in=delivery_failures, delivery_next_check_at__isnull=True)).count()
delivered = sms_jobs.filter(delivery_status=NotificationJob.DeliveryStatus.DELIVERED).count()
retrying = sms_counts.get(NotificationJob.Status.RETRYING, 0)
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
replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count() replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count()
expected = dispatch.recipient_count
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"]) refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"])
metrics = {"sent": sent, "failed": failed, "pending": max(expected - sent - failed, 0), "opened": opened, "replied": replied} pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING))
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "active_staff_section": "dispatches"}) 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}
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"})
@staff_required @staff_required
@@ -579,7 +620,20 @@ def staff_dispatch_detail(request, public_id):
def staff_dispatch_retry(request, public_id): def staff_dispatch_retry(request, public_id):
dispatch=get_object_or_404(GroupDispatch,public_id=public_id) dispatch=get_object_or_404(GroupDispatch,public_id=public_id)
if not DispatchPolicy.can_view(request.user,dispatch) or not DispatchPolicy.can_create(request.user,dispatch.unit,dispatch.operation_type): raise PermissionDenied if not DispatchPolicy.can_view(request.user,dispatch) or not DispatchPolicy.can_create(request.user,dispatch.unit,dispatch.operation_type): raise PermissionDenied
count=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,status=NotificationJob.Status.FAILED).update(status=NotificationJob.Status.PENDING,attempts=0,next_attempt_at=timezone.now(),last_error="") retryable_delivery = [NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.BLOCKED, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.ERROR]
jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=retryable_delivery,delivery_next_check_at__isnull=True))
job_id=request.POST.get("job_id","").strip()
if job_id:
if not job_id.isdigit():
messages.error(request,"شناسه پیامک برای تلاش مجدد معتبر نیست.")
return redirect("staff_tickets:dispatch_detail",public_id=public_id)
jobs=jobs.filter(pk=int(job_id))
count=jobs.update(
status=NotificationJob.Status.PENDING, attempts=0, next_attempt_at=timezone.now(),
last_error="", provider_message_id="", provider_status_code="", sent_at=None,
delivery_status="", delivery_status_code="", delivery_description="",
delivery_checked_at=None, delivery_check_attempts=0, delivery_next_check_at=None,
)
refresh_group_dispatch_status(dispatch.pk) refresh_group_dispatch_status(dispatch.pk)
messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.") messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.")
return redirect("staff_tickets:dispatch_detail",public_id=public_id) return redirect("staff_tickets:dispatch_detail",public_id=public_id)
+5 -1
View File
@@ -96,6 +96,10 @@ 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"))
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_RETRY_DELAY_SECONDS = int(os.getenv("SMS_RETRY_DELAY_SECONDS", "180"))
SMS_DELIVERY_CHECK_DELAY_SECONDS = int(os.getenv("SMS_DELIVERY_CHECK_DELAY_SECONDS", "180"))
SMS_DELIVERY_MAX_CHECKS = int(os.getenv("SMS_DELIVERY_MAX_CHECKS", "15"))
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://127.0.0.1:8000").rstrip("/") PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://127.0.0.1:8000").rstrip("/")
COMPANY_NAME = os.getenv("COMPANY_NAME", "لوله گستر گلپایگان") COMPANY_NAME = os.getenv("COMPANY_NAME", "لوله گستر گلپایگان")
WEBOTP_HOST = os.getenv("WEBOTP_HOST", "") WEBOTP_HOST = os.getenv("WEBOTP_HOST", "")
@@ -110,7 +114,7 @@ BEHIN_SMS_WSDL_URL = os.getenv("BEHIN_SMS_WSDL_URL", "http://panel.behinsms.com/
SMS0098_USERNAME = os.getenv("SMS0098_USERNAME", "") SMS0098_USERNAME = os.getenv("SMS0098_USERNAME", "")
SMS0098_PASSWORD = os.getenv("SMS0098_PASSWORD", "") SMS0098_PASSWORD = os.getenv("SMS0098_PASSWORD", "")
SMS0098_FROM = os.getenv("SMS0098_FROM", "") SMS0098_FROM = os.getenv("SMS0098_FROM", "")
SMS0098_API_URL = os.getenv("SMS0098_API_URL", "http://www.0098sms.com/sendsmslink.aspx") SMS0098_WSDL_URL = os.getenv("SMS0098_WSDL_URL", "https://webservice.0098sms.com/service.asmx?WSDL")
SMS_TEST_ALLOWED_MOBILES = ("+989202202129", "+989384999655") SMS_TEST_ALLOWED_MOBILES = ("+989202202129", "+989384999655")
BALE_API_BASE = os.getenv("BALE_API_BASE", "https://tapi.bale.ai") BALE_API_BASE = os.getenv("BALE_API_BASE", "https://tapi.bale.ai")
BALE_BOT_TOKEN = os.getenv("BALE_BOT_TOKEN", "") BALE_BOT_TOKEN = os.getenv("BALE_BOT_TOKEN", "")
@@ -0,0 +1,148 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>نمونه رابط مرحله‌ای ثبت درخواست</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="prototype-note">
<span>نمونه نمایشی مستقل</span>
<p>این صفحه به Django و اطلاعات واقعی متصل نیست.</p>
</header>
<main class="shell">
<nav class="topbar" aria-label="مسیر صفحه">
<button class="back-button" type="button">→ بازگشت به درخواست‌ها</button>
<div class="brand"><span>لوله‌گستر</span><small>سامانه ارتباط با مشتریان</small></div>
</nav>
<section class="intro">
<div>
<span class="eyebrow">ثبت درخواست جدید</span>
<h1>چطور می‌توانیم کمک کنیم؟</h1>
<p>قدم‌به‌قدم پیش می‌رویم؛ در هر مرحله فقط اطلاعات ضروری همان بخش را می‌بینید.</p>
</div>
<div class="save-state"><i></i> اطلاعات شما در این صفحه حفظ می‌شود</div>
</section>
<ol class="progress" aria-label="مراحل ثبت درخواست">
<li class="active" data-progress="1"><span>۱</span><div><b>موضوع</b><small>مسیر درخواست</small></div></li>
<li data-progress="2"><span>۲</span><div><b>موقعیت</b><small>اختیاری</small></div></li>
<li data-progress="3"><span>۳</span><div><b>کارشناس</b><small>انتخاب مسئول</small></div></li>
<li data-progress="4"><span>۴</span><div><b>جزئیات</b><small>متن و فایل</small></div></li>
<li data-progress="5"><span>۵</span><div><b>بازبینی</b><small>تأیید نهایی</small></div></li>
</ol>
<form class="wizard" id="ticket-wizard" novalidate>
<section class="step active" data-step="1" aria-labelledby="step-1-title">
<header class="step-head"><span class="step-symbol">⌕</span><div><small>مرحله ۱ از ۵</small><h2 id="step-1-title">موضوع درخواستتان چیست؟</h2><p>با چند کلمه شروع کنید تا مسیر مناسب را پیشنهاد دهیم، یا خودتان انتخاب کنید.</p></div></header>
<label class="search-box"><span class="sr-only">جست‌وجوی موضوع درخواست</span><i>⌕</i><input id="intent" type="search" placeholder="مثلاً فیش واریزی، موجودی کالا یا ارسال بار..."><kbd>جست‌وجو</kbd></label>
<div class="suggestions" id="suggestions" aria-live="polite" hidden>
<button type="button" data-suggest-unit="حسابداری" data-suggest-category="فیش واریزی"><b>ارسال فیش یا رسید پرداخت</b><small>حسابداری · فیش واریزی</small><span>انتخاب ←</span></button>
<button type="button" data-suggest-unit="بارگیری انبار" data-suggest-category="هماهنگی بار"><b>هماهنگی ارسال یا بارگیری</b><small>بارگیری انبار · هماهنگی بار</small><span>انتخاب ←</span></button>
<p class="suggestion-empty" id="suggestion-empty" hidden>پیشنهاد دقیقی پیدا نشد؛ از انتخاب دستی استفاده کنید.</p>
</div>
<button class="manual-toggle" type="button" id="manual-toggle" aria-expanded="false">انتخاب دستی واحد و دسته‌بندی <span>⌄</span></button>
<div class="manual-fields" id="manual-fields" hidden>
<label><span>واحد سازمانی <em>*</em></span><select id="unit" required><option value="">انتخاب واحد</option><option>فروش</option><option>حسابداری</option><option>بارگیری انبار</option></select></label>
<label><span>دسته‌بندی <em>*</em></span><select id="category" required disabled><option value="">ابتدا واحد را انتخاب کنید</option></select></label>
</div>
<label class="full-field"><span>موضوع دقیق درخواست <em>*</em></span><input id="subject" maxlength="200" required placeholder="یک عنوان کوتاه و روشن بنویسید"><small>مثال: پیگیری فیش واریزی سفارش ۲۴۸</small></label>
<p class="validation" data-error="1" role="alert"></p>
<footer class="step-actions"><span>حدود یک دقیقه تا پایان</span><button class="primary" type="button" data-next>ادامه <b>←</b></button></footer>
</section>
<section class="step" data-step="2" aria-labelledby="step-2-title" hidden>
<header class="step-head"><span class="step-symbol">⌖</span><div><small>مرحله ۲ از ۵ · اختیاری</small><h2 id="step-2-title">این درخواست مربوط به کجاست؟</h2><p>اگر موقعیت در پاسخ بهتر کارشناسان مؤثر است آن را انتخاب کنید؛ در غیر این صورت عبور کنید.</p></div></header>
<div class="two-columns">
<label><span>استان</span><select id="province"><option value="">انتخاب استان</option><option>تهران</option><option>اصفهان</option><option>مرکزی</option></select></label>
<label><span>شهر</span><select id="city" disabled><option value="">ابتدا استان را انتخاب کنید</option></select></label>
</div>
<div class="soft-tip"><span>i</span><p><b>نیازی به انتخاب ندارید</b>این بخش کاملاً اختیاری است و می‌توانید مستقیماً ادامه دهید.</p></div>
<footer class="step-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary" type="button" data-next>ادامه <b>←</b></button></footer>
</section>
<section class="step" data-step="3" aria-labelledby="step-3-title" hidden>
<header class="step-head"><span class="step-symbol">◎</span><div><small>مرحله ۳ از ۵ · اختیاری</small><h2 id="step-3-title">چه کسی درخواست را بررسی کند؟</h2><p>صف عمومی پیشنهاد ماست؛ درخواست به اولین کارشناس آزاد همان واحد می‌رسد.</p></div></header>
<div class="expert-list">
<label class="expert selected"><input type="radio" name="expert" value="صف عمومی" checked><span class="avatar queue">⌁</span><div><b>صف عمومی واحد</b><small>سریع‌ترین انتخاب · اولین کارشناس آزاد</small></div><i>✓</i></label>
<label class="expert"><input type="radio" name="expert" value="مریم احمدی"><span class="avatar">م</span><div><b>مریم احمدی</b><small>کارشناس فروش · پاسخ‌گویی فعال</small></div><i>✓</i></label>
<label class="expert"><input type="radio" name="expert" value="علی رضایی"><span class="avatar amber">ع</span><div><b>علی رضایی</b><small>کارشناس ارشد · پاسخ‌گویی فعال</small></div><i>✓</i></label>
</div>
<footer class="step-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary" type="button" data-next>ادامه <b>←</b></button></footer>
</section>
<section class="step" data-step="4" aria-labelledby="step-4-title" hidden>
<header class="step-head"><span class="step-symbol">≡</span><div><small>مرحله ۴ از ۵</small><h2 id="step-4-title">جزئیات را برای کارشناس بنویسید</h2><p>هرچه واضح‌تر توضیح دهید، پاسخ دقیق‌تری دریافت می‌کنید. متن یا حداقل یک پیوست لازم است.</p></div></header>
<label class="full-field"><span>شرح درخواست</span><textarea id="body" rows="6" placeholder="شماره سفارش، محصول موردنظر و توضیح مسئله را بنویسید..."></textarea><small><span id="body-count">۰</span> از ۲۰۰۰ نویسه</small></label>
<div class="attachment-zone">
<div class="attachment-head"><span>+</span><div><b>عکس و فایل‌های مرتبط</b><small>حداکثر ۱۰ فایل؛ تصاویر بزرگ قبل از ارسال بهینه می‌شوند.</small></div></div>
<div class="attachment-actions"><label for="gallery">▣ انتخاب عکس از گالری</label><label for="documents">+ انتخاب فایل</label></div>
<input id="gallery" type="file" accept="image/*" multiple hidden><input id="documents" type="file" multiple hidden>
<div class="file-preview" id="file-preview" aria-live="polite"></div>
</div>
<section class="voice-recorder" id="voice-recorder" aria-labelledby="voice-title">
<header class="voice-head">
<span class="voice-icon" aria-hidden="true">◉</span>
<div><b id="voice-title">پیام صوتی</b><small id="voice-status" aria-live="polite">برای توضیح سریع‌تر، صدای خود را ضبط کنید.</small></div>
<span class="voice-timer" id="voice-timer">۰۰:۰۰</span>
</header>
<div class="voice-idle" id="voice-idle">
<button class="voice-start" type="button" id="record"><span>●</span> شروع ضبط صدا</button>
<small>پس از اجازه‌دادن به میکروفن، ضبط آغاز می‌شود.</small>
</div>
<div class="voice-live" id="voice-live" hidden>
<div class="voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
<div class="voice-controls">
<button type="button" id="pause">مکث</button>
<button class="voice-stop" type="button" id="stop">■ توقف و ذخیره</button>
<button class="voice-cancel" type="button" id="cancel-recording">لغو</button>
</div>
</div>
<div class="voice-preview" id="voice-preview" hidden>
<div class="voice-ready"><span>✓</span><p><b>پیام صوتی آماده است</b><small id="voice-meta">قابل پخش و بررسی پیش از ارسال</small></p></div>
<audio id="audio" controls preload="metadata"></audio>
<div class="voice-preview-actions"><button type="button" id="rerecord">ضبط مجدد</button><button type="button" id="delete-voice">حذف پیام صوتی</button></div>
</div>
<p class="voice-note"><span>ⓘ</span> با رفتن به مرحله بعد، ضبط فعال به‌صورت خودکار متوقف و ذخیره می‌شود.</p>
<p class="voice-error" id="voice-error" role="alert" hidden></p>
</section>
<p class="validation" data-error="4" role="alert"></p>
<footer class="step-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary" type="button" data-next>مشاهده پیش‌نمایش <b>←</b></button></footer>
</section>
<section class="step review-step" data-step="5" aria-labelledby="step-5-title" hidden>
<header class="step-head"><span class="step-symbol success">✓</span><div><small>مرحله ۵ از ۵</small><h2 id="step-5-title">همه‌چیز آماده ارسال است</h2><p>اطلاعات را یک بار مرور کنید؛ هر بخش را می‌توانید جداگانه ویرایش کنید.</p></div></header>
<div class="review-callout"><span>✓</span><div><b>یک مرور کوتاه پیش از ارسال</b><small>اگر موردی درست نیست، همان ردیف را ویرایش کنید؛ اطلاعات مراحل دیگر حفظ می‌شود.</small></div></div>
<div class="review-list">
<section class="review-row">
<span class="review-icon">۱</span><div class="review-content"><small>موضوع و مسیر رسیدگی</small><h3 id="review-subject">—</h3><p><span id="review-unit">—</span><i>←</i><span id="review-category">—</span></p></div><button type="button" data-edit="1"><span>✎</span> ویرایش</button>
</section>
<section class="review-row">
<span class="review-icon">۲</span><div class="review-content"><small>موقعیت</small><h3 id="review-location">ثبت نشده</h3><p>اختیاری</p></div><button type="button" data-edit="2"><span>✎</span> ویرایش</button>
</section>
<section class="review-row">
<span class="review-icon">۳</span><div class="review-content"><small>نحوه ارجاع</small><h3 id="review-expert">صف عمومی</h3><p>در همان واحد سازمانی</p></div><button type="button" data-edit="3"><span>✎</span> ویرایش</button>
</section>
<section class="review-row review-details">
<span class="review-icon">۴</span><div class="review-content"><small>شرح و پیوست‌ها</small><p class="review-body" id="review-body">—</p><div class="review-files" id="review-files"></div><audio id="review-audio" class="review-audio" controls hidden></audio></div><button type="button" data-edit="4"><span>✎</span> ویرایش</button>
</section>
</div>
<footer class="step-actions final-actions"><button class="secondary" type="button" data-prev>مرحله قبل</button><button class="primary send" type="submit">ثبت و ارسال نهایی <b>←</b></button></footer>
</section>
<section class="success-page" id="success-page" hidden>
<span>✓</span><h2>درخواست با موفقیت آماده شد</h2><p>این فقط نمونه نمایشی است و اطلاعاتی به سرور ارسال نشد.</p><button type="button" id="restart">مشاهده دوباره طرح</button>
</section>
</form>
</main>
<nav class="mobile-dock" aria-label="دسترسی سریع مشتری">
<button type="button"><span>◎</span>درخواست‌ها</button>
<button class="active" type="button"><span>+</span>درخواست جدید</button>
<button type="button"><span>○</span>پروفایل</button>
</nav>
<script src="prototype.js"></script>
</body>
</html>
+379
View File
@@ -0,0 +1,379 @@
const form = document.querySelector('#ticket-wizard');
const steps = [...document.querySelectorAll('[data-step]')];
const progress = [...document.querySelectorAll('[data-progress]')];
const unit = document.querySelector('#unit');
const category = document.querySelector('#category');
const subject = document.querySelector('#subject');
const body = document.querySelector('#body');
const province = document.querySelector('#province');
const city = document.querySelector('#city');
const files = [];
let current = 1;
const categories = {
'فروش': ['مشاوره خرید', 'موجودی و قیمت', 'پیگیری سفارش'],
'حسابداری': ['فیش واریزی', 'صورتحساب', 'پیگیری پرداخت'],
'بارگیری انبار': ['هماهنگی بار', 'زمان ارسال', 'پیگیری تحویل'],
};
const cities = {
'تهران': ['تهران', 'شهریار', 'اسلامشهر'],
'اصفهان': ['اصفهان', 'کاشان', 'گلپایگان'],
'مرکزی': ['اراک', 'ساوه', 'محلات'],
};
const recorderUi = {
root: document.querySelector('#voice-recorder'), idle: document.querySelector('#voice-idle'),
live: document.querySelector('#voice-live'), preview: document.querySelector('#voice-preview'),
record: document.querySelector('#record'), pause: document.querySelector('#pause'),
stop: document.querySelector('#stop'), cancel: document.querySelector('#cancel-recording'),
rerecord: document.querySelector('#rerecord'), remove: document.querySelector('#delete-voice'),
audio: document.querySelector('#audio'), reviewAudio: document.querySelector('#review-audio'),
timer: document.querySelector('#voice-timer'), status: document.querySelector('#voice-status'),
meta: document.querySelector('#voice-meta'), error: document.querySelector('#voice-error'),
};
let recorder = null;
let microphoneStream = null;
let audioChunks = [];
let voiceBlob = null;
let voiceUrl = '';
let recordingStartedAt = 0;
let pausedStartedAt = 0;
let totalPausedMs = 0;
let timerId = null;
let stopResolver = null;
let discardOnStop = false;
function toPersianDigits(value) {
return String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]);
}
function formatDuration(milliseconds) {
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
return toPersianDigits(`${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`);
}
function elapsedRecordingTime() {
if (!recordingStartedAt) return 0;
const activePause = recorder?.state === 'paused' ? Date.now() - pausedStartedAt : 0;
return Date.now() - recordingStartedAt - totalPausedMs - activePause;
}
function setSelect(select, items, placeholder) {
select.replaceChildren(new Option(placeholder, ''), ...items.map(item => new Option(item, item)));
select.disabled = !items.length;
}
function showStep(number) {
current = number;
steps.forEach(step => {
const active = Number(step.dataset.step) === number;
step.hidden = !active;
step.classList.toggle('active', active);
});
progress.forEach(item => {
const itemNumber = Number(item.dataset.progress);
item.classList.toggle('active', itemNumber === number);
item.classList.toggle('completed', itemNumber < number);
item.querySelector('span').textContent = itemNumber < number ? '✓' : toPersianDigits(itemNumber);
});
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function showValidationError(number, message, invalidFields = []) {
const step = document.querySelector(`[data-step="${number}"]`);
const error = document.querySelector(`[data-error="${number}"]`);
step.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
invalidFields.forEach(field => field?.setAttribute('aria-invalid', 'true'));
error.textContent = message;
error.tabIndex = -1;
error.classList.add('visible');
requestAnimationFrame(() => {
error.focus({ preventScroll: true });
error.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
});
}
function clearValidationError(number) {
const step = document.querySelector(`[data-step="${number}"]`);
const error = document.querySelector(`[data-error="${number}"]`);
step.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
if (error) {
error.textContent = '';
error.classList.remove('visible');
error.removeAttribute('tabindex');
}
}
function validateStep(number) {
const error = document.querySelector(`[data-error="${number}"]`);
if (number === 1) {
const missing = [];
const invalidFields = [];
if (!unit.value) { missing.push('واحد'); invalidFields.push(unit); }
if (!category.value) { missing.push('دسته‌بندی'); invalidFields.push(category); }
if (subject.value.trim().length < 5) { missing.push('موضوع حداقل پنج‌حرفی'); invalidFields.push(subject); }
if (missing.length) {
showValidationError(number, `برای ادامه، ${missing.join('، ')} را تکمیل کنید.`, invalidFields);
return false;
}
}
if (number === 4 && !body.value.trim() && !files.length && !voiceBlob) {
showValidationError(number, 'برای ادامه، توضیح بنویسید یا یک فایل/پیام صوتی اضافه کنید.', [body]);
return false;
}
if (error) clearValidationError(number);
return true;
}
function stopMicrophoneTracks() {
microphoneStream?.getTracks().forEach(track => track.stop());
microphoneStream = null;
}
function clearTimer() { clearInterval(timerId); timerId = null; }
function updateRecorderView(mode, statusText) {
recorderUi.idle.hidden = mode !== 'idle';
recorderUi.live.hidden = mode !== 'live';
recorderUi.preview.hidden = mode !== 'preview';
recorderUi.root.dataset.state = mode;
if (statusText) recorderUi.status.textContent = statusText;
}
function clearVoice() {
clearTimer();
stopMicrophoneTracks();
if (voiceUrl) URL.revokeObjectURL(voiceUrl);
voiceBlob = null;
voiceUrl = '';
recorderUi.audio.removeAttribute('src');
recorderUi.reviewAudio.removeAttribute('src');
recorderUi.reviewAudio.hidden = true;
recorderUi.timer.textContent = '۰۰:۰۰';
recorderUi.pause.textContent = 'مکث';
recorderUi.error.hidden = true;
updateRecorderView('idle', 'برای توضیح سریع‌تر، صدای خود را ضبط کنید.');
}
function completeRecording(blob, duration, automatic) {
if (!blob.size) {
clearVoice();
recorderUi.error.textContent = 'صدایی ثبت نشد؛ لطفاً دوباره تلاش کنید.';
recorderUi.error.hidden = false;
return;
}
if (voiceUrl) URL.revokeObjectURL(voiceUrl);
voiceBlob = blob;
voiceUrl = URL.createObjectURL(blob);
recorderUi.audio.src = voiceUrl;
recorderUi.reviewAudio.src = voiceUrl;
recorderUi.timer.textContent = formatDuration(duration);
recorderUi.meta.textContent = `${formatDuration(duration)} · آماده پخش و ارسال`;
updateRecorderView('preview', automatic ? 'ضبط هنگام ادامه‌دادن متوقف و ذخیره شد.' : 'ضبط با موفقیت ذخیره شد؛ آن را بررسی کنید.');
}
async function startRecording() {
recorderUi.error.hidden = true;
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
recorderUi.error.textContent = 'مرورگر یا اتصال فعلی امکان ضبط امن صدا را ندارد.';
recorderUi.error.hidden = false;
return;
}
try {
if (voiceBlob) clearVoice();
microphoneStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
const mimeType = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/webm']
.find(type => MediaRecorder.isTypeSupported?.(type));
recorder = new MediaRecorder(microphoneStream, mimeType ? { mimeType } : undefined);
audioChunks = [];
totalPausedMs = 0;
discardOnStop = false;
recorder.ondataavailable = event => { if (event.data.size) audioChunks.push(event.data); };
recorder.onerror = () => {
recorderUi.error.textContent = 'ضبط صدا متوقف شد. دوباره تلاش کنید.';
recorderUi.error.hidden = false;
clearVoice();
};
recorder.onstop = () => {
const duration = elapsedRecordingTime();
clearTimer();
stopMicrophoneTracks();
if (discardOnStop) clearVoice();
else completeRecording(new Blob(audioChunks, { type: recorder.mimeType?.split(';')[0] || 'audio/webm' }), duration, recorderUi.root.dataset.autoStop === 'true');
recorderUi.root.dataset.autoStop = 'false';
recorder = null;
stopResolver?.();
stopResolver = null;
};
recorder.start(500);
recordingStartedAt = Date.now();
recorderUi.timer.textContent = '۰۰:۰۰';
updateRecorderView('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.');
timerId = setInterval(() => { recorderUi.timer.textContent = formatDuration(elapsedRecordingTime()); }, 250);
} catch (error) {
stopMicrophoneTracks();
recorderUi.error.textContent = error.name === 'NotAllowedError'
? 'دسترسی میکروفن رد شد؛ آن را از تنظیمات مرورگر فعال کنید.'
: 'میکروفن در دسترس نیست یا برنامه دیگری از آن استفاده می‌کند.';
recorderUi.error.hidden = false;
}
}
function stopRecording({ discard = false, automatic = false } = {}) {
if (!recorder || recorder.state === 'inactive') return Promise.resolve();
discardOnStop = discard;
recorderUi.root.dataset.autoStop = String(automatic);
if (recorder.state === 'paused') {
totalPausedMs += Date.now() - pausedStartedAt;
recorder.resume();
}
return new Promise(resolve => { stopResolver = resolve; recorder.stop(); });
}
function togglePause() {
if (!recorder) return;
if (recorder.state === 'recording') {
recorder.pause();
pausedStartedAt = Date.now();
recorderUi.pause.textContent = 'ادامه ضبط';
recorderUi.status.textContent = 'ضبط موقتاً متوقف شده است.';
recorderUi.root.dataset.state = 'paused';
} else if (recorder.state === 'paused') {
totalPausedMs += Date.now() - pausedStartedAt;
recorder.resume();
recorderUi.pause.textContent = 'مکث';
recorderUi.status.textContent = 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.';
recorderUi.root.dataset.state = 'live';
}
}
function buildReview() {
document.querySelector('#review-subject').textContent = subject.value;
document.querySelector('#review-unit').textContent = unit.value;
document.querySelector('#review-category').textContent = category.value;
document.querySelector('#review-location').textContent = province.value ? [province.value, city.value].filter(Boolean).join('، ') : 'ثبت نشده';
document.querySelector('#review-expert').textContent = document.querySelector('input[name="expert"]:checked').value;
document.querySelector('#review-body').textContent = body.value.trim() || 'شرح متنی ثبت نشده است.';
const list = document.querySelector('#review-files');
list.replaceChildren();
files.forEach(file => {
const chip = document.createElement('span');
chip.textContent = file.name;
list.appendChild(chip);
});
if (!files.length && !voiceBlob) {
const chip = document.createElement('span');
chip.textContent = 'بدون پیوست';
list.appendChild(chip);
}
recorderUi.reviewAudio.hidden = !voiceBlob;
}
document.querySelectorAll('[data-next]').forEach(button => button.addEventListener('click', async () => {
button.disabled = true;
if (current === 4 && recorder && recorder.state !== 'inactive') await stopRecording({ automatic: true });
button.disabled = false;
if (!validateStep(current)) return;
if (current === 4) buildReview();
showStep(Math.min(5, current + 1));
}));
document.querySelectorAll('[data-prev]').forEach(button => button.addEventListener('click', async () => {
if (current === 4 && recorder && recorder.state !== 'inactive') await stopRecording({ automatic: true });
showStep(Math.max(1, current - 1));
}));
document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => showStep(Number(button.dataset.edit))));
document.querySelector('#manual-toggle').addEventListener('click', event => {
const target = document.querySelector('#manual-fields');
target.hidden = !target.hidden;
event.currentTarget.setAttribute('aria-expanded', String(!target.hidden));
event.currentTarget.querySelector('span').textContent = target.hidden ? '⌄' : '⌃';
});
unit.addEventListener('change', () => setSelect(category, categories[unit.value] || [], 'انتخاب دسته‌بندی'));
province.addEventListener('change', () => setSelect(city, cities[province.value] || [], 'انتخاب شهر'));
document.querySelectorAll('[data-suggest-unit]').forEach(button => button.addEventListener('click', () => {
unit.value = button.dataset.suggestUnit;
setSelect(category, categories[unit.value] || [], 'انتخاب دسته‌بندی');
category.value = button.dataset.suggestCategory;
document.querySelector('#manual-fields').hidden = false;
document.querySelector('#manual-toggle').setAttribute('aria-expanded', 'true');
document.querySelectorAll('[data-suggest-unit]').forEach(item => item.classList.toggle('chosen', item === button));
}));
document.querySelector('#intent').addEventListener('input', event => {
const query = event.target.value.trim();
const suggestions = document.querySelector('#suggestions');
const suggestionButtons = [...suggestions.querySelectorAll('[data-suggest-unit]')];
suggestions.hidden = !query;
if (!query) {
suggestionButtons.forEach(button => { button.hidden = false; });
document.querySelector('#suggestion-empty').hidden = true;
return;
}
const normalizedQuery = query.toLocaleLowerCase('fa');
let visibleCount = 0;
suggestionButtons.forEach(button => {
const matches = button.textContent.toLocaleLowerCase('fa').includes(normalizedQuery);
button.hidden = !matches;
if (matches) visibleCount += 1;
});
document.querySelector('#suggestion-empty').hidden = visibleCount > 0;
});
document.querySelectorAll('.expert input').forEach(input => input.addEventListener('change', () => {
document.querySelectorAll('.expert').forEach(card => card.classList.toggle('selected', card.querySelector('input').checked));
}));
body.addEventListener('input', () => { document.querySelector('#body-count').textContent = toPersianDigits(body.value.length); });
function renderFiles() {
const preview = document.querySelector('#file-preview');
preview.replaceChildren();
files.forEach((file, index) => {
const card = document.createElement('div');
card.className = 'file-card';
if (file.type.startsWith('image/')) {
const image = document.createElement('img');
image.src = URL.createObjectURL(file);
image.onload = () => URL.revokeObjectURL(image.src);
image.alt = `پیش‌نمایش ${file.name}`;
card.appendChild(image);
} else {
const icon = document.createElement('div');
icon.className = 'file-icon';
icon.textContent = (file.name.split('.').pop() || 'FILE').toUpperCase();
card.appendChild(icon);
}
const label = document.createElement('small');
label.textContent = file.name;
card.appendChild(label);
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = '×';
remove.setAttribute('aria-label', `حذف ${file.name}`);
remove.addEventListener('click', () => { files.splice(index, 1); renderFiles(); });
card.appendChild(remove);
preview.appendChild(card);
});
}
document.querySelectorAll('#gallery,#documents').forEach(input => input.addEventListener('change', () => {
files.push(...input.files);
input.value = '';
renderFiles();
}));
recorderUi.record.addEventListener('click', startRecording);
recorderUi.pause.addEventListener('click', togglePause);
recorderUi.stop.addEventListener('click', () => stopRecording());
recorderUi.cancel.addEventListener('click', () => stopRecording({ discard: true }));
recorderUi.remove.addEventListener('click', clearVoice);
recorderUi.rerecord.addEventListener('click', startRecording);
window.addEventListener('pagehide', () => { stopMicrophoneTracks(); clearTimer(); });
form.addEventListener('submit', event => {
event.preventDefault();
steps.forEach(step => { step.hidden = true; });
document.querySelector('#success-page').hidden = false;
progress.forEach(item => item.classList.add('completed'));
window.scrollTo({ top: 0, behavior: 'smooth' });
});
document.querySelector('#restart').addEventListener('click', () => {
document.querySelector('#success-page').hidden = true;
showStep(1);
});
@@ -0,0 +1,50 @@
@font-face{font-family:Vazirmatn;src:url("../../static/fonts/vazirmatn-arabic-wght-normal.woff2") format("woff2-variations");font-weight:100 900;font-display:swap}@font-face{font-family:Vazirmatn;src:url("../../static/fonts/vazirmatn-latin-wght-normal.woff2") format("woff2-variations");font-weight:100 900;font-display:swap}
:root{font-family:Vazirmatn,ui-sans-serif,system-ui,sans-serif;color:#08376b;background:#eee;--ink:#08376b;--ink-soft:#0b4c8c;--sea:#0693e3;--sea-dark:#1977ce;--coral:#fa9f00;--cream:#eee;--soft:#eaeaea;--paper:#fff;--mint:#e5f4ff;--line:rgba(8,55,107,.1);--muted:rgba(8,55,107,.58);--shadow-panel:0 18px 55px -35px rgba(8,55,107,.35);--shadow-lift:0 22px 55px -28px rgba(6,147,227,.42)}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-height:100vh;background:var(--cream);color:var(--ink);-webkit-font-smoothing:antialiased}button,input,textarea,select{font:inherit}button,input,textarea,select,a{outline:0}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible{box-shadow:0 0 0 4px rgba(6,147,227,.2)}button{cursor:pointer}button:disabled{pointer-events:none;opacity:.5}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
.prototype-note{display:grid;gap:.05rem;padding:.55rem .8rem;background:#fff3d9;color:#805300;text-align:center;font-size:.68rem}.prototype-note span{font-weight:900}.prototype-note p{margin:0}.shell{width:100%;padding:0 .75rem 6.5rem}.topbar{display:flex;min-height:3.75rem;align-items:center;justify-content:space-between}.back-button,.manual-toggle{border:0;background:transparent;color:var(--sea-dark);font-size:.72rem;font-weight:850}.brand{display:none}.intro{margin:.8rem 0 1.1rem}.eyebrow{color:var(--sea);font-size:.68rem;font-weight:900;letter-spacing:.03em}.intro h1{margin:.3rem 0;color:var(--ink);font-size:1.45rem;line-height:1.45;letter-spacing:-.025em}.intro p{margin:0;color:var(--muted);font-size:.76rem;line-height:1.9}.save-state{display:none}
.progress{position:relative;display:grid;grid-template-columns:repeat(5,1fr);margin:0 0 .75rem;padding:0;list-style:none}.progress:before{position:absolute;right:10%;left:10%;top:1.05rem;height:2px;background:rgba(8,55,107,.1);content:""}.progress li{position:relative;display:grid;justify-items:center;color:rgba(8,55,107,.35)}.progress li>span{z-index:1;display:grid;width:2.1rem;height:2.1rem;place-items:center;border:2px solid #d5dfe4;border-radius:.7rem;background:var(--cream);font-size:.68rem;font-weight:900}.progress li>div{display:none}.progress li.active>span{border-color:var(--sea);background:var(--sea);color:#fff;box-shadow:0 8px 20px -12px var(--sea)}.progress li.completed>span{border-color:#27a47a;background:#e5f7f0;color:#16815e}
.wizard{border:1px solid var(--line);border-radius:1.1rem;background:var(--paper);box-shadow:var(--shadow-panel)}.step{padding:1rem}.step-head{display:flex;align-items:flex-start;gap:.65rem;border-bottom:1px solid rgba(8,55,107,.07);padding-bottom:1rem}.step-symbol{display:grid;width:2.4rem;height:2.4rem;flex:none;place-items:center;border-radius:.72rem;background:var(--mint);color:var(--sea-dark);font-size:1.05rem;font-weight:900}.step-symbol.success{background:#e5f8ef;color:#16815e}.step-head small{color:rgba(8,55,107,.42);font-size:.6rem;font-weight:700}.step-head h2{margin:.1rem 0;color:var(--ink);font-size:1rem;line-height:1.55}.step-head p{margin:0;color:var(--muted);font-size:.7rem;line-height:1.85}
.search-box{display:flex;align-items:center;margin:1rem 0 .6rem;border:1px solid rgba(8,55,107,.14);border-radius:1rem;background:#fff;padding:0 .7rem;box-shadow:0 1px 3px rgba(8,55,107,.06)}.search-box:focus-within{border-color:var(--sea);box-shadow:0 0 0 4px rgba(6,147,227,.1)}.search-box i{color:var(--sea);font-style:normal}.search-box input{width:100%;min-width:0;min-height:3rem;border:0;background:transparent;padding:.65rem;font-size:.76rem;color:var(--ink)}.search-box kbd{display:none}.suggestions{display:grid;gap:.4rem}.suggestions button{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.12rem .5rem;width:100%;border:1px solid rgba(8,55,107,.09);border-radius:.8rem;background:#fff;padding:.7rem;text-align:right;color:var(--ink)}.suggestions button:active,.suggestions button.chosen{border-color:rgba(6,147,227,.45);background:var(--mint)}.suggestions b{font-size:.7rem}.suggestions small{color:var(--muted);font-size:.6rem}.suggestions span{grid-column:2;grid-row:1/3;align-self:center;color:var(--sea-dark);font-size:.6rem;font-weight:900}.manual-toggle{display:flex;width:100%;justify-content:space-between;margin-top:.8rem;border-top:1px dashed rgba(8,55,107,.12);padding:.85rem .1rem .3rem}.manual-fields,.two-columns{display:grid;gap:.7rem;margin-top:.55rem}.manual-fields label,.two-columns label,.full-field{display:grid;gap:.35rem;color:rgba(8,55,107,.78);font-size:.7rem;font-weight:850}.manual-fields em,.full-field em{color:var(--coral);font-style:normal}.manual-fields select,.two-columns select,.full-field input,.full-field textarea{width:100%;min-height:3rem;border:1px solid rgba(8,55,107,.12);border-radius:1rem;background:#fff;padding:.7rem .8rem;color:var(--ink);font-size:.76rem;box-shadow:0 1px 3px rgba(8,55,107,.05)}.manual-fields select:focus,.two-columns select:focus,.full-field input:focus,.full-field textarea:focus{border-color:var(--sea);box-shadow:0 0 0 4px rgba(6,147,227,.1)}.full-field{margin-top:.85rem}.full-field textarea{min-height:9rem;resize:vertical;line-height:1.9}.full-field small{display:flex;justify-content:flex-end;color:rgba(8,55,107,.45);font-size:.6rem;font-weight:500}.validation{min-height:1rem;margin:.45rem 0 0;color:#a32929;font-size:.64rem;font-weight:700}
.step-actions{position:sticky;z-index:10;bottom:4.65rem;display:grid;grid-template-columns:1fr 1.35fr;gap:.55rem;margin:1rem -1rem -1rem;border-top:1px solid rgba(8,55,107,.07);background:rgba(255,255,255,.96);padding:.7rem 1rem;backdrop-filter:blur(12px)}.step-actions>span{display:none}.primary,.secondary,.voice-box button,.success-page button{display:inline-flex;min-height:3rem;align-items:center;justify-content:center;gap:.45rem;border-radius:1rem;padding:.65rem .9rem;font-size:.72rem;font-weight:900}.primary{border:0;background:var(--sea);color:#fff;box-shadow:0 10px 30px -14px var(--sea)}.primary:active{background:var(--sea-dark)}.secondary{border:1px solid var(--line);background:#fff;color:rgba(8,55,107,.7);box-shadow:none}.step-actions .primary:only-of-type{grid-column:2}.soft-tip{display:flex;align-items:center;gap:.65rem;margin:1rem 0;border-radius:.85rem;background:var(--mint);padding:.75rem;color:rgba(8,55,107,.65)}.soft-tip>span{display:grid;width:1.75rem;height:1.75rem;flex:none;place-items:center;border-radius:.55rem;background:#fff;color:var(--sea-dark);font-weight:900}.soft-tip p{margin:0;font-size:.65rem}.soft-tip b{display:block;color:var(--ink)}
.expert-list{display:grid;gap:.55rem;margin:1rem 0}.expert{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.7rem;border:1px solid var(--line);border-radius:1rem;background:#fff;padding:.72rem;text-align:right}.expert.selected{border:2px solid var(--sea);background:var(--mint)}.expert input{position:absolute;opacity:0}.expert>i{display:none;width:1.3rem;height:1.3rem;place-items:center;border-radius:50%;background:var(--sea);color:#fff;font-size:.58rem;font-style:normal}.expert.selected>i{display:grid}.avatar{display:grid;width:2.7rem;height:2.7rem;place-items:center;border-radius:.85rem;background:#e4eef8;color:#285b87;font-weight:950}.avatar.queue{background:#e4f6ef;color:#16815e}.avatar.amber{background:#fff1d7;color:#9a6200}.expert b,.expert small{display:block}.expert b{font-size:.72rem}.expert small{margin-top:.12rem;color:var(--muted);font-size:.59rem}
.attachment-zone{display:grid;gap:.7rem;margin-top:.85rem;border:2px dashed rgba(8,55,107,.14);border-radius:1rem;background:rgba(229,244,255,.45);padding:.85rem}.attachment-head,.voice-box,.voice-box>div{display:flex;align-items:center;gap:.65rem}.attachment-head>span{display:grid;width:2.35rem;height:2.35rem;flex:none;place-items:center;border-radius:.72rem;background:var(--mint);color:var(--sea-dark);font-size:1.05rem;font-weight:900}.attachment-head b,.attachment-head small,.voice-box b,.voice-box small{display:block}.attachment-head b,.voice-box b{font-size:.7rem}.attachment-head small,.voice-box small{margin-top:.1rem;color:var(--muted);font-size:.58rem}.attachment-actions{display:grid;gap:.5rem}.attachment-actions label{display:grid;min-height:3rem;place-items:center;border:1px solid rgba(6,147,227,.38);border-radius:.9rem;background:#fff;color:#0b5f9b;font-size:.7rem;font-weight:900;cursor:pointer}.attachment-actions label:first-child{border-color:var(--sea);background:var(--mint)}.file-preview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.45rem}.file-card{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:.8rem;background:#fff;padding:.4rem}.file-card img,.file-card .file-icon{width:100%;height:76px;border-radius:.6rem}.file-card img{object-fit:cover}.file-card .file-icon{display:grid;place-items:center;background:var(--soft);color:rgba(8,55,107,.62);font-size:.66rem;font-weight:900}.file-card small{display:block;overflow:hidden;margin-top:.3rem;color:rgba(8,55,107,.7);font-size:.54rem;text-overflow:ellipsis;white-space:nowrap}.file-card button{position:absolute;top:.18rem;left:.18rem;display:grid;width:1.6rem;height:1.6rem;place-items:center;border:0;border-radius:.5rem;background:var(--coral);color:var(--ink);font-weight:900}.voice-box{justify-content:space-between;margin-top:.7rem;border:1px solid rgba(6,147,227,.14);border-radius:1rem;background:rgba(229,244,255,.55);padding:.75rem}.voice-box>div>span{color:var(--coral)}.voice-box button{min-height:2.5rem;border:1px solid var(--line);background:#fff;color:var(--ink);font-size:.65rem}
.review-grid{display:grid;gap:.55rem;margin:1rem 0}.review-grid article{border:1px solid var(--line);border-radius:1rem;background:#fff;padding:.8rem;box-shadow:0 8px 24px -24px rgba(8,55,107,.7)}.review-grid header{display:flex;align-items:center;justify-content:space-between}.review-grid header>b{color:var(--muted);font-size:.63rem}.review-grid header button{border:0;background:transparent;color:var(--sea-dark);font-size:.62rem;font-weight:900}.review-grid h3{margin:.45rem 0 .15rem;color:var(--ink);font-size:.8rem}.review-grid p{display:flex;align-items:center;gap:.35rem;margin:0;color:var(--muted);font-size:.62rem;line-height:1.8}.review-grid p i{font-style:normal}.review-body{color:rgba(8,55,107,.75)!important;white-space:pre-wrap}.review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.55rem}.review-files span{border-radius:999px;background:var(--mint);padding:.28rem .5rem;color:var(--sea-dark);font-size:.58rem}.confirm{display:flex;align-items:flex-start;gap:.65rem;border:1px solid rgba(39,164,122,.25);border-radius:1rem;background:#f3fbf8;padding:.75rem}.confirm input{width:1.1rem;height:1.1rem;accent-color:#16815e}.confirm b,.confirm small{display:block}.confirm b{color:#245d4a;font-size:.68rem}.confirm small{margin-top:.12rem;color:#66877b;font-size:.58rem}.success-page{padding:3rem 1rem;text-align:center}.success-page>span{display:grid;width:3.7rem;height:3.7rem;margin:auto;place-items:center;border-radius:1.1rem;background:#e1f7ed;color:#16815e;font-size:1.7rem;font-weight:900}.success-page h2{margin:.9rem 0 .25rem;font-size:1.1rem}.success-page p{color:var(--muted);font-size:.7rem}.success-page button{margin-top:.8rem;border:0;background:var(--ink);color:#fff}.mobile-dock{position:fixed;z-index:50;right:.75rem;bottom:.75rem;left:.75rem;display:grid;grid-template-columns:repeat(3,1fr);border-radius:1.35rem;background:rgba(8,55,107,.97);padding:.35rem;box-shadow:0 20px 50px -18px rgba(8,55,107,.75);backdrop-filter:blur(14px)}.mobile-dock button{display:flex;min-height:3rem;flex-direction:column;align-items:center;justify-content:center;gap:.05rem;border:0;border-radius:1rem;background:transparent;color:rgba(255,255,255,.6);font-size:.58rem;font-weight:800}.mobile-dock button span{font-size:.9rem}.mobile-dock button.active{background:var(--sea);color:#fff}
@media(min-width:640px){.shell{width:min(100% - 2.5rem,72rem);padding-bottom:3rem}.topbar{min-height:4.5rem}.brand{display:grid;text-align:left}.brand span{font-size:.9rem;font-weight:900}.brand small{color:var(--muted);font-size:.58rem}.intro{display:flex;align-items:flex-end;justify-content:space-between;gap:2rem;margin:1.1rem 0 1.35rem}.intro h1{font-size:1.9rem}.intro p{font-size:.8rem}.save-state{display:flex;align-items:center;gap:.45rem;color:var(--muted);font-size:.65rem}.save-state i{width:.48rem;height:.48rem;border-radius:50%;background:#19a774;box-shadow:0 0 0 .23rem rgba(25,167,116,.1)}.progress{margin-bottom:1rem}.progress:before{top:1.12rem}.progress li{display:flex;justify-content:center;gap:.5rem}.progress li>span{width:2.3rem;height:2.3rem}.progress li>div{display:block}.progress li b,.progress li small{display:block}.progress li b{font-size:.68rem}.progress li small{margin-top:.08rem;font-size:.55rem}.wizard{border-radius:1.6rem}.step{padding:1.35rem 1.5rem}.step-head{gap:.8rem}.step-symbol{width:2.7rem;height:2.7rem}.step-head h2{font-size:1.1rem}.step-head p{font-size:.74rem}.search-box{margin-top:1.15rem}.search-box kbd{display:inline;border:1px solid var(--line);border-radius:.4rem;background:var(--cream);padding:.2rem .4rem;color:var(--muted);font-size:.56rem}.manual-fields,.two-columns{grid-template-columns:1fr 1fr}.expert-list{grid-template-columns:repeat(3,1fr)}.expert{display:grid;grid-template-columns:1fr;justify-items:center;padding:.9rem;text-align:center}.expert>i{position:absolute;top:.5rem;left:.5rem}.avatar{width:3.15rem;height:3.15rem}.expert b{margin-top:.5rem}.attachment-actions{grid-template-columns:1fr 1fr}.file-preview{grid-template-columns:repeat(3,minmax(0,1fr))}.review-grid{grid-template-columns:1fr 1fr}.review-grid article.wide{grid-column:1/-1}.step-actions{position:static;display:flex;justify-content:flex-end;margin:1.15rem -1.5rem -1.35rem;padding:.8rem 1.5rem}.step-actions>span{display:block;margin-left:auto;color:rgba(8,55,107,.4);font-size:.6rem}.step-actions .primary:only-of-type{margin-right:auto}.primary,.secondary{min-width:8.5rem}.final-actions{justify-content:space-between}.mobile-dock{display:none}}
@media(min-width:1024px){.shell{width:min(100% - 4rem,65rem)}.intro h1{font-size:2.15rem}.wizard{margin-inline:auto}.step{padding:1.6rem 1.8rem}.step-actions{margin:1.3rem -1.8rem -1.6rem;padding:.85rem 1.8rem}.step-head h2{font-size:1.2rem}.suggestions{grid-template-columns:1fr 1fr}.review-grid{gap:.7rem}.file-preview{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}}
/* Voice recorder: mobile-first states with playback and safe removal. */
.voice-recorder{margin-top:.8rem;overflow:hidden;border:1px solid rgba(6,147,227,.2);border-radius:1.05rem;background:linear-gradient(145deg,#f7fcff,#edf7ff);box-shadow:0 12px 32px -28px rgba(8,55,107,.75)}
.voice-head{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.65rem;padding:.8rem}
.voice-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.75rem;background:#fff;color:var(--sea);font-size:1rem;box-shadow:0 8px 20px -16px rgba(8,55,107,.8)}
.voice-head b,.voice-head small,.voice-ready b,.voice-ready small{display:block}.voice-head b{font-size:.72rem}.voice-head small{margin-top:.12rem;color:var(--muted);font-size:.58rem;line-height:1.7}
.voice-timer{direction:ltr;border:1px solid rgba(6,147,227,.16);border-radius:999px;background:#fff;padding:.28rem .5rem;color:var(--ink);font-size:.64rem;font-weight:900;font-variant-numeric:tabular-nums}
.voice-idle,.voice-live,.voice-preview{border-top:1px solid rgba(8,55,107,.07);padding:.75rem .8rem}
.voice-idle{display:grid;gap:.35rem}.voice-idle>small{color:var(--muted);font-size:.56rem;text-align:center}
.voice-start{display:flex;min-height:3rem;align-items:center;justify-content:center;gap:.45rem;border:0;border-radius:.9rem;background:var(--sea);color:#fff;font-size:.7rem;font-weight:900;box-shadow:0 12px 28px -18px var(--sea)}.voice-start span{color:#fff;font-size:.85rem}
.voice-wave{display:flex;height:2.8rem;align-items:center;justify-content:center;gap:.24rem}.voice-wave i{width:.22rem;height:.75rem;border-radius:999px;background:var(--sea);animation:voice-wave .9s ease-in-out infinite}.voice-wave i:nth-child(2),.voice-wave i:nth-child(6){height:1.35rem;animation-delay:.12s}.voice-wave i:nth-child(3),.voice-wave i:nth-child(5){height:2rem;animation-delay:.24s}.voice-wave i:nth-child(4){height:2.5rem;animation-delay:.36s}.voice-recorder[data-state="paused"] .voice-wave i{animation-play-state:paused;opacity:.4}
@keyframes voice-wave{0%,100%{transform:scaleY(.55)}50%{transform:scaleY(1)}}
.voice-controls{display:grid;grid-template-columns:1fr 1.5fr;gap:.45rem}.voice-controls button,.voice-preview-actions button{min-height:2.65rem;border:1px solid var(--line);border-radius:.8rem;background:#fff;color:var(--ink);font-size:.63rem;font-weight:850}.voice-controls .voice-stop{border-color:var(--sea);background:var(--sea);color:#fff}.voice-controls .voice-cancel{grid-column:1/-1;min-height:2rem;border:0;background:transparent;color:#9c3042}
.voice-preview{display:grid;gap:.65rem;background:#fff}.voice-ready{display:flex;align-items:center;gap:.55rem}.voice-ready>span{display:grid;width:1.8rem;height:1.8rem;flex:none;place-items:center;border-radius:.55rem;background:#e5f8ef;color:#16815e;font-size:.65rem;font-weight:900}.voice-ready b{font-size:.68rem}.voice-ready small{color:var(--muted);font-size:.56rem}.voice-preview audio,.review-audio{width:100%;height:2.6rem;accent-color:var(--sea)}.voice-preview-actions{display:grid;grid-template-columns:1fr 1fr;gap:.45rem}.voice-preview-actions #delete-voice{border-color:rgba(169,39,60,.18);color:#a9273c}
.voice-note{display:flex;align-items:flex-start;gap:.35rem;margin:0;border-top:1px dashed rgba(8,55,107,.1);padding:.6rem .8rem;color:var(--muted);font-size:.55rem;line-height:1.7}.voice-note span{color:var(--sea-dark)}.voice-error{margin:0;padding:.55rem .8rem;background:#fff0f1;color:#9c3042;font-size:.6rem;font-weight:800}
/* Review: one calm scan path instead of a dashboard of competing cards. */
.review-callout{display:flex;align-items:center;gap:.65rem;margin:.85rem 0 .65rem;border-radius:.9rem;background:#f0faf6;padding:.7rem}.review-callout>span{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:#fff;color:#16815e;font-weight:900}.review-callout b,.review-callout small{display:block}.review-callout b{font-size:.68rem}.review-callout small{margin-top:.1rem;color:#66877b;font-size:.56rem;line-height:1.65}
.review-list{overflow:hidden;border:1px solid var(--line);border-radius:1rem;background:#fff}.review-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:.6rem;padding:.75rem}.review-row+.review-row{border-top:1px solid rgba(8,55,107,.07)}.review-icon{display:grid;width:1.8rem;height:1.8rem;place-items:center;border-radius:.55rem;background:var(--mint);color:var(--sea-dark);font-size:.58rem;font-weight:900}.review-content{min-width:0}.review-content>small{color:var(--muted);font-size:.55rem;font-weight:800}.review-content h3{overflow-wrap:anywhere;margin:.16rem 0 .08rem;color:var(--ink);font-size:.73rem;line-height:1.6}.review-content p{display:flex;flex-wrap:wrap;align-items:center;gap:.3rem;margin:0;color:var(--muted);font-size:.58rem;line-height:1.75}.review-content p i{font-style:normal}.review-row>button{display:flex;min-height:2rem;align-items:center;gap:.25rem;border:0;border-radius:.65rem;background:var(--mint);padding:.35rem .5rem;color:var(--sea-dark);font-size:.56rem;font-weight:900}.review-row>button span{font-size:.7rem}.review-details .review-content{grid-column:2/-1}.review-details>button{grid-column:3;grid-row:1}.review-body{display:block!important;margin-top:.2rem!important;color:rgba(8,55,107,.78)!important;white-space:pre-wrap}.review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.45rem}.review-files span{border-radius:999px;background:var(--mint);padding:.28rem .5rem;color:var(--sea-dark);font-size:.56rem}.review-audio{margin-top:.55rem}
@media(min-width:640px){.voice-recorder{margin-top:1rem}.voice-head{padding:.9rem 1rem}.voice-idle,.voice-live,.voice-preview{padding:.85rem 1rem}.voice-idle{grid-template-columns:auto 1fr;align-items:center}.voice-idle>small{text-align:right}.voice-start{min-width:11rem}.voice-live{grid-template-columns:1fr auto;align-items:center}.voice-controls{grid-template-columns:auto auto auto}.voice-controls .voice-cancel{grid-column:auto;min-height:2.65rem}.voice-controls button{min-width:6.5rem}.voice-note{padding:.65rem 1rem}.review-callout{padding:.8rem 1rem}.review-row{gap:.8rem;padding:.9rem 1rem}.review-row>button{padding:.4rem .65rem}.review-content h3{font-size:.78rem}.review-details .review-content{grid-column:2}.review-details>button{grid-column:3}.review-audio{max-width:28rem}}
@media(prefers-reduced-motion:reduce){.voice-wave i{animation:none}}
/* Readability pass: comfortable mobile text, 16px form controls, and clear hierarchy. */
body{font-size:16px;line-height:1.75}.prototype-note{font-size:.76rem;line-height:1.65}.back-button,.manual-toggle{font-size:.82rem}.eyebrow{font-size:.76rem}.intro p{font-size:.86rem;line-height:1.85}.step-head small{font-size:.7rem}.step-head h2{font-size:1.08rem;line-height:1.6}.step-head p{font-size:.82rem;line-height:1.85}.search-box input,.manual-fields select,.two-columns select,.full-field input,.full-field textarea{font-size:1rem}.suggestions b{font-size:.84rem}.suggestions small{font-size:.73rem}.suggestions span{font-size:.72rem}.suggestion-empty{grid-column:1/-1;margin:0;border:1px dashed rgba(8,55,107,.14);border-radius:.8rem;background:#fafcfd;padding:.75rem;color:var(--muted);font-size:.78rem;line-height:1.7;text-align:center}.manual-fields label,.two-columns label,.full-field{font-size:.82rem}.full-field small{font-size:.7rem}.validation{font-size:.74rem;line-height:1.7}.primary,.secondary,.success-page button{font-size:.82rem}.soft-tip p{font-size:.76rem;line-height:1.75}.expert b{font-size:.82rem}.expert small{font-size:.7rem;line-height:1.65}.attachment-head b{font-size:.82rem}.attachment-head small{font-size:.7rem;line-height:1.65}.attachment-actions label{font-size:.8rem}.file-card small{font-size:.68rem}.voice-head b{font-size:.84rem}.voice-head small{font-size:.72rem}.voice-timer{font-size:.75rem}.voice-idle>small{font-size:.69rem}.voice-start{font-size:.82rem}.voice-controls button,.voice-preview-actions button{font-size:.75rem}.voice-ready b{font-size:.8rem}.voice-ready small{font-size:.7rem}.voice-note{font-size:.68rem}.voice-error{font-size:.72rem}.review-callout b{font-size:.82rem}.review-callout small{font-size:.7rem}.review-content>small{font-size:.7rem}.review-content h3{font-size:.9rem;line-height:1.7}.review-content p{font-size:.72rem;line-height:1.8}.review-row>button{font-size:.68rem}.review-files span{font-size:.68rem}.confirm b{font-size:.8rem}.confirm small{font-size:.7rem;line-height:1.7}.success-page p{font-size:.8rem}.mobile-dock button{font-size:.68rem}
#review-subject{font-size:1rem;font-weight:900;line-height:1.8}.review-body{font-size:.88rem!important;font-weight:550;line-height:2!important}
@media(min-width:640px){.brand span{font-size:1rem}.brand small{font-size:.7rem}.intro p{font-size:.9rem}.save-state{font-size:.76rem}.progress li b{font-size:.78rem}.progress li small{font-size:.67rem}.step-head h2{font-size:1.2rem}.step-head p{font-size:.86rem}.review-content h3{font-size:.96rem}#review-subject{font-size:1.08rem}.review-body{font-size:.94rem!important}.review-row>button{font-size:.72rem}}
/* The decisive action stays visible throughout the review step. */
.review-step{padding-bottom:6.75rem}.final-actions{position:fixed;z-index:40;right:.75rem;bottom:4.85rem;left:.75rem;display:grid;grid-template-columns:minmax(0,.78fr) minmax(0,1.4fr);gap:.5rem;margin:0;border:1px solid rgba(8,55,107,.12);border-radius:1.1rem;background:rgba(255,255,255,.96);padding:.55rem;box-shadow:0 20px 50px -20px rgba(8,55,107,.55);backdrop-filter:blur(14px)}.final-actions .primary,.final-actions .secondary{min-width:0}.final-actions .send{box-shadow:0 12px 28px -14px var(--sea)}
@media(min-width:640px){.review-step{padding-bottom:6rem}.final-actions{right:auto;bottom:1rem;left:50%;width:min(calc(100% - 2rem),42rem);transform:translateX(-50%);padding:.6rem}.final-actions .primary,.final-actions .secondary{min-height:3.15rem}}
/* Validation feedback is a visible destination, not a quiet line below the form. */
.validation:empty{display:none}.validation.visible{position:relative;min-height:0;margin:.8rem 0 0;border:1px solid rgba(169,39,60,.2);border-radius:.85rem;background:#fff1f3;padding:.72rem 2.2rem .72rem .75rem;color:#92263a;font-size:.78rem;font-weight:850;line-height:1.8;box-shadow:0 10px 28px -24px rgba(146,38,58,.75)}.validation.visible:before{position:absolute;top:.75rem;right:.75rem;display:grid;width:1.15rem;height:1.15rem;place-items:center;border-radius:50%;background:#a9273c;color:#fff;font-size:.66rem;content:"!"}.validation.visible:focus{box-shadow:0 0 0 4px rgba(169,39,60,.13)}[aria-invalid="true"]{border-color:#b8324a!important;background:#fffafb!important;box-shadow:0 0 0 3px rgba(184,50,74,.1)!important}
@@ -0,0 +1,163 @@
import base64
import pytest
from playwright.sync_api import expect
pytest_plugins = ["tests.e2e.conftest"]
pytestmark = pytest.mark.e2e
def test_validation_message_is_focused_and_scrolled_into_view(browser_page):
with browser_page() as page:
page.set_viewport_size({"width": 390, "height": 620})
page.goto(
"http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",
wait_until="domcontentloaded",
)
page.locator('[data-step="1"] [data-next]').click()
error = page.locator('[data-error="1"]')
expect(error).to_be_visible()
expect(error).to_contain_text("واحد، دسته‌بندی، موضوع")
expect(error).to_be_in_viewport()
expect(page.locator("#unit")).to_have_attribute("aria-invalid", "true")
expect(page.locator("#category")).to_have_attribute("aria-invalid", "true")
expect(page.locator("#subject")).to_have_attribute("aria-invalid", "true")
assert page.evaluate("document.activeElement === document.querySelector('[data-error=\"1\"]')")
def test_ticket_wizard_progressive_flow_preview_edit_and_attachment_removal(browser_page):
tiny_png=base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
with browser_page() as page:
page.set_viewport_size({"width":390,"height":844})
page.goto("http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",wait_until="domcontentloaded")
expect(page.locator('[data-step="1"]')).to_be_visible()
expect(page.locator('[data-step="2"]')).to_be_hidden()
expect(page.locator(".mobile-dock")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
expect(page.locator("#suggestions")).to_be_hidden()
page.locator("#intent").fill("فیش")
expect(page.locator("#suggestions")).to_be_visible()
expect(page.get_by_role("button",name="ارسال فیش یا رسید پرداخت")).to_be_visible()
expect(page.get_by_role("button",name="هماهنگی ارسال یا بارگیری")).to_be_hidden()
assert page.locator("#intent").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 16
page.get_by_role("button",name="ارسال فیش یا رسید پرداخت").click()
expect(page.locator("#unit")).to_have_value("حسابداری")
expect(page.locator("#category")).to_have_value("فیش واریزی")
expect(page.locator("#subject")).to_have_value("")
page.locator("#subject").fill("پیگیری فیش واریزی سفارش")
page.locator("#unit").select_option(label="فروش")
expect(page.locator("#subject")).to_have_value("پیگیری فیش واریزی سفارش")
page.locator("#unit").select_option(label="حسابداری")
page.locator("#category").select_option(label="فیش واریزی")
page.locator('[data-step="1"] [data-next]').click()
expect(page.locator('[data-step="2"]')).to_be_visible()
page.locator('[data-step="2"] [data-next]').click()
expect(page.locator('[data-step="3"]')).to_be_visible()
page.locator('[data-step="3"] [data-next]').click()
expect(page.locator('[data-step="4"]')).to_be_visible()
page.locator("#body").fill("فیش واریزی سفارش را برای بررسی ارسال کرده‌ام.")
page.locator("#gallery").set_input_files({"name":"receipt.png","mimeType":"image/png","buffer":tiny_png})
page.locator("#documents").set_input_files({"name":"invoice.pdf","mimeType":"application/pdf","buffer":b"%PDF-1.4\n%%EOF"})
expect(page.locator("#file-preview .file-card")).to_have_count(2)
page.get_by_role("button",name="حذف receipt.png").click()
expect(page.locator("#file-preview .file-card")).to_have_count(1)
page.get_by_role("button",name="مشاهده پیش‌نمایش").click()
expect(page.locator('[data-step="5"]')).to_be_visible()
expect(page.locator("#review-subject")).to_have_text("پیگیری فیش واریزی سفارش")
assert page.locator("#review-subject").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 16
assert page.locator("#review-body").evaluate("element => parseFloat(getComputedStyle(element).fontSize)") >= 14
expect(page.locator("#review-files")).to_have_text("invoice.pdf")
page.locator('[data-edit="4"]').click()
expect(page.locator('[data-step="4"]')).to_be_visible()
page.get_by_role("button",name="مشاهده پیش‌نمایش").click()
send_button = page.get_by_role("button",name="ثبت و ارسال نهایی")
expect(send_button).to_be_visible()
expect(send_button).to_be_enabled()
send_box = send_button.bounding_box()
assert send_box and send_box["y"] + send_box["height"] <= page.viewport_size["height"]
send_button.click()
expect(page.locator("#success-page")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
page.set_viewport_size({"width":1280,"height":900})
expect(page.locator(".mobile-dock")).to_be_hidden()
expect(page.locator(".progress li > div").first).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
def test_voice_recording_auto_stops_on_next_and_can_be_previewed_and_removed(browser_page):
with browser_page() as page:
page.add_init_script(
"""
window.__microphoneTrackStops = 0;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: async () => ({
getTracks: () => [{ stop: () => { window.__microphoneTrackStops += 1; } }],
}),
},
});
class FakeMediaRecorder {
constructor(stream, options = {}) {
this.stream = stream;
this.mimeType = options.mimeType || 'audio/webm';
this.state = 'inactive';
}
start() { this.state = 'recording'; }
pause() { this.state = 'paused'; }
resume() { this.state = 'recording'; }
stop() {
this.state = 'inactive';
this.ondataavailable?.({ data: new Blob(['recorded voice'], { type: this.mimeType }) });
setTimeout(() => this.onstop?.(), 0);
}
static isTypeSupported() { return true; }
}
Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: FakeMediaRecorder });
"""
)
page.set_viewport_size({"width": 390, "height": 844})
page.goto(
"http://127.0.0.1:4173/design-prototypes/ticket-new-wizard/",
wait_until="domcontentloaded",
)
page.locator("#intent").fill("فیش")
page.get_by_role("button", name="ارسال فیش یا رسید پرداخت").click()
expect(page.locator("#subject")).to_have_value("")
page.locator("#subject").fill("موضوع دستی پیام صوتی")
page.locator('[data-step="1"] [data-next]').click()
page.locator('[data-step="2"] [data-next]').click()
page.locator('[data-step="3"] [data-next]').click()
expect(page.locator('[data-step="4"]')).to_be_visible()
page.get_by_role("button", name="شروع ضبط صدا").click()
expect(page.locator("#voice-live")).to_be_visible()
page.get_by_role("button", name="مکث").click()
expect(page.get_by_role("button", name="ادامه ضبط")).to_be_visible()
page.get_by_role("button", name="ادامه ضبط").click()
page.get_by_role("button", name="مشاهده پیش‌نمایش").click()
expect(page.locator('[data-step="5"]')).to_be_visible()
expect(page.locator("#review-audio")).to_be_visible()
assert page.evaluate("window.__microphoneTrackStops") == 1
page.locator('[data-edit="4"]').click()
expect(page.locator("#voice-preview")).to_be_visible()
assert page.locator("#audio").get_attribute("src").startswith("blob:")
page.get_by_role("button", name="حذف پیام صوتی").click()
expect(page.locator("#voice-idle")).to_be_visible()
expect(page.locator("#voice-preview")).to_be_hidden()
page.get_by_role("button", name="مشاهده پیش‌نمایش").click()
error = page.locator('[data-error="4"]')
expect(error).to_contain_text("توضیح بنویسید")
expect(error).to_be_in_viewport()
expect(page.locator("#body")).to_have_attribute("aria-invalid", "true")
assert page.evaluate("document.activeElement === document.querySelector('[data-error=\"4\"]')")
+5 -1
View File
@@ -33,7 +33,7 @@ x-django-service: &django-service
SMS0098_USERNAME: ${SMS0098_USERNAME:-} SMS0098_USERNAME: ${SMS0098_USERNAME:-}
SMS0098_PASSWORD: ${SMS0098_PASSWORD:-} SMS0098_PASSWORD: ${SMS0098_PASSWORD:-}
SMS0098_FROM: ${SMS0098_FROM:-} SMS0098_FROM: ${SMS0098_FROM:-}
SMS0098_API_URL: ${SMS0098_API_URL:-http://www.0098sms.com/sendsmslink.aspx} SMS0098_WSDL_URL: ${SMS0098_WSDL_URL:-https://webservice.0098sms.com/service.asmx?WSDL}
BEHIN_SMS_USERNAME: ${BEHIN_SMS_USERNAME:-} BEHIN_SMS_USERNAME: ${BEHIN_SMS_USERNAME:-}
BEHIN_SMS_PASSWORD: ${BEHIN_SMS_PASSWORD:-} BEHIN_SMS_PASSWORD: ${BEHIN_SMS_PASSWORD:-}
BEHIN_SMS_SPECIAL_NUMBER: ${BEHIN_SMS_SPECIAL_NUMBER:-} BEHIN_SMS_SPECIAL_NUMBER: ${BEHIN_SMS_SPECIAL_NUMBER:-}
@@ -45,6 +45,10 @@ x-django-service: &django-service
BALE_BOT_TOKEN: ${BALE_BOT_TOKEN:-} BALE_BOT_TOKEN: ${BALE_BOT_TOKEN:-}
INTEGRATION_HTTP_TIMEOUT_SECONDS: ${INTEGRATION_HTTP_TIMEOUT_SECONDS:-10} INTEGRATION_HTTP_TIMEOUT_SECONDS: ${INTEGRATION_HTTP_TIMEOUT_SECONDS:-10}
MAX_NOTIFICATION_ATTEMPTS: ${MAX_NOTIFICATION_ATTEMPTS:-5} MAX_NOTIFICATION_ATTEMPTS: ${MAX_NOTIFICATION_ATTEMPTS:-5}
SMS_MAX_RETRIES: ${SMS_MAX_RETRIES:-3}
SMS_RETRY_DELAY_SECONDS: ${SMS_RETRY_DELAY_SECONDS:-180}
SMS_DELIVERY_CHECK_DELAY_SECONDS: ${SMS_DELIVERY_CHECK_DELAY_SECONDS:-180}
SMS_DELIVERY_MAX_CHECKS: ${SMS_DELIVERY_MAX_CHECKS:-15}
SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-true} SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-true}
SECURE_HSTS_SECONDS: ${SECURE_HSTS_SECONDS:-31536000} SECURE_HSTS_SECONDS: ${SECURE_HSTS_SECONDS:-31536000}
SECURE_HSTS_INCLUDE_SUBDOMAINS: ${SECURE_HSTS_INCLUDE_SUBDOMAINS:-true} SECURE_HSTS_INCLUDE_SUBDOMAINS: ${SECURE_HSTS_INCLUDE_SUBDOMAINS:-true}
+6 -2
View File
@@ -80,7 +80,7 @@ SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider
SMS0098_USERNAME=<username> SMS0098_USERNAME=<username>
SMS0098_PASSWORD=<password> SMS0098_PASSWORD=<password>
SMS0098_FROM=<sender-number> SMS0098_FROM=<sender-number>
SMS0098_API_URL=http://www.0098sms.com/sendsmslink.aspx SMS0098_WSDL_URL=https://webservice.0098sms.com/service.asmx?WSDL
``` ```
### Behin SMS ### Behin SMS
@@ -109,11 +109,15 @@ BALE_API_BASE=https://tapi.bale.ai
BALE_BOT_TOKEN=<bot-token> BALE_BOT_TOKEN=<bot-token>
INTEGRATION_HTTP_TIMEOUT_SECONDS=10 INTEGRATION_HTTP_TIMEOUT_SECONDS=10
MAX_NOTIFICATION_ATTEMPTS=5 MAX_NOTIFICATION_ATTEMPTS=5
SMS_MAX_RETRIES=3
SMS_RETRY_DELAY_SECONDS=180
SMS_DELIVERY_CHECK_DELAY_SECONDS=180
SMS_DELIVERY_MAX_CHECKS=15
``` ```
در production نبودن `BALE_BOT_TOKEN` خطای قابل‌مشاهده ایجاد می‌کند و job را بی‌صدا SENT علامت نمی‌زند. شناسه گروه دیگر یک env سراسری نیست: برای هر واحد از مسیر `x5admin → واحدهای سازمانی`، فیلد «شناسه گروه بله» را جداگانه وارد کنید. خالی بودن این فیلد یعنی اعلان‌های گروهی تیکت جدید و پاسخ مشتری برای همان واحد ارسال نمی‌شود؛ سامانه هرگز به گروه واحد دیگر fallback نمی‌کند. سه شماره اختیاری سرپرست نیز در همین صفحه تنظیم می‌شوند و فقط هنگام ایجاد تیکت جدید، پیامک کوتاه دریافت می‌کنند. در production نبودن `BALE_BOT_TOKEN` خطای قابل‌مشاهده ایجاد می‌کند و job را بی‌صدا SENT علامت نمی‌زند. شناسه گروه دیگر یک env سراسری نیست: برای هر واحد از مسیر `x5admin → واحدهای سازمانی`، فیلد «شناسه گروه بله» را جداگانه وارد کنید. خالی بودن این فیلد یعنی اعلان‌های گروهی تیکت جدید و پاسخ مشتری برای همان واحد ارسال نمی‌شود؛ سامانه هرگز به گروه واحد دیگر fallback نمی‌کند. سه شماره اختیاری سرپرست نیز در همین صفحه تنظیم می‌شوند و فقط هنگام ایجاد تیکت جدید، پیامک کوتاه دریافت می‌کنند.
worker هر پنج ثانیه outbox دیتابیس را بررسی می‌کند، با قفل `skip_locked` از پردازش هم‌زمان جلوگیری می‌کند و خطاها را با backoff تا سقف `MAX_NOTIFICATION_ATTEMPTS` دوباره امتحان می‌کند. worker هر پنج ثانیه outbox دیتابیس را بررسی می‌کند و با قفل `skip_locked` از پردازش هم‌زمان جلوگیری می‌کند. هر پیامک پس از شکست اولیه، دقیقاً `SMS_MAX_RETRIES=3` بار با فاصله ثابت `SMS_RETRY_DELAY_SECONDS=180` ثانیه دوباره امتحان می‌شود. `MAX_NOTIFICATION_ATTEMPTS` همچنان سقف تلاش کانال‌های دیگر مانند بله است. پیامک دارای شناسه رهگیری پس از `SMS_DELIVERY_CHECK_DELAY_SECONDS` استعلام می‌شود؛ پاسخ‌های موقت 0098SMS (کدهای 6 و 8) هر سه دقیقه تا 15 نوبت، یعنی 45 دقیقه، دوباره بررسی می‌شوند.
سیاست تأخیر پیامک پاسخ کارشناس و اعلان پاسخ مشتری در Bale از مدل `NotificationPolicy` در پنل مدیریت دیتابیس کنترل می‌شود؛ متغیر قدیمی `STAFF_REPLY_SMS_THROTTLE_SECONDS` مرجع اصلی این رفتار نیست. سیاست تأخیر پیامک پاسخ کارشناس و اعلان پاسخ مشتری در Bale از مدل `NotificationPolicy` در پنل مدیریت دیتابیس کنترل می‌شود؛ متغیر قدیمی `STAFF_REPLY_SMS_THROTTLE_SECONDS` مرجع اصلی این رفتار نیست.
+16
View File
@@ -0,0 +1,16 @@
.ticket-wizard-page{width:min(100%,65rem);margin-inline:auto;padding:0 0 6.5rem;color:#08376b}
.ticket-wizard-topbar{display:flex;min-height:3rem;align-items:center;justify-content:space-between;gap:1rem}.ticket-wizard-back{color:#1977ce;font-size:.82rem;font-weight:850}.ticket-wizard-save{display:none;align-items:center;gap:.45rem;color:rgba(8,55,107,.62);font-size:.76rem}.ticket-wizard-save i{width:.48rem;height:.48rem;border-radius:50%;background:#19a774;box-shadow:0 0 0 .23rem rgba(25,167,116,.1)}
.ticket-wizard-intro{margin:.65rem 0 1.1rem}.ticket-wizard-intro .page-kicker{margin:0;color:#0693e3;font-size:.76rem;font-weight:900}.ticket-wizard-intro h1{margin:.25rem 0;font-size:1.5rem;line-height:1.5}.ticket-wizard-intro>p:last-child{margin:0;color:rgba(8,55,107,.62);font-size:.86rem;line-height:1.9}
.ticket-wizard-progress{position:relative;display:grid;grid-template-columns:repeat(5,1fr);margin:0 0 .8rem;padding:0;list-style:none}.ticket-wizard-progress:before{position:absolute;top:1.05rem;right:10%;left:10%;height:2px;background:rgba(8,55,107,.1);content:""}.ticket-wizard-progress li{position:relative;display:grid;justify-items:center;color:rgba(8,55,107,.35)}.ticket-wizard-progress li>span{z-index:1;display:grid;width:2.1rem;height:2.1rem;place-items:center;border:2px solid #d5dfe4;border-radius:.7rem;background:#eee;font-size:.72rem;font-weight:900}.ticket-wizard-progress li>div{display:none}.ticket-wizard-progress li.is-active>span{border-color:#0693e3;background:#0693e3;color:#fff;box-shadow:0 8px 20px -12px #0693e3}.ticket-wizard-progress li.is-complete>span{border-color:#27a47a;background:#e5f7f0;color:#16815e}
.ticket-wizard-card{overflow:visible;border:1px solid rgba(8,55,107,.1);border-radius:1.15rem;background:#fff;box-shadow:0 18px 55px -35px rgba(8,55,107,.35)}.ticket-wizard-step{padding:1rem}.ticket-wizard-card[data-wizard-enhanced] .ticket-wizard-step:not(.is-active){display:none}.ticket-wizard-step-head{display:flex;align-items:flex-start;gap:.7rem;border-bottom:1px solid rgba(8,55,107,.07);padding-bottom:1rem}.ticket-wizard-symbol{display:grid;width:2.45rem;height:2.45rem;flex:none;place-items:center;border-radius:.75rem;background:#e5f4ff;color:#1977ce;font-size:1.05rem;font-weight:900}.ticket-wizard-symbol.is-success{background:#e5f8ef;color:#16815e}.ticket-wizard-step-head small{color:rgba(8,55,107,.48);font-size:.7rem;font-weight:750}.ticket-wizard-step-head h2{margin:.08rem 0;color:#08376b;font-size:1.08rem;line-height:1.6}.ticket-wizard-step-head p{margin:0;color:rgba(8,55,107,.62);font-size:.82rem;line-height:1.85}
.ticket-intent-search{display:flex;align-items:center;margin:1rem 0 .65rem;border:1px solid rgba(8,55,107,.14);border-radius:1rem;background:#fff;padding:0 .75rem;box-shadow:0 1px 3px rgba(8,55,107,.06)}.ticket-intent-search:focus-within{border-color:#0693e3;box-shadow:0 0 0 4px rgba(6,147,227,.1)}.ticket-intent-search i{color:#0693e3;font-style:normal}.ticket-intent-search input{min-height:3.2rem;border:0!important;background:transparent!important;padding:.7rem!important;font-size:1rem!important;box-shadow:none!important}.ticket-intent-search kbd{display:none}.ticket-intent-results:empty{display:none}.ticket-intent-results{margin-bottom:.65rem}.ticket-intent-results .intent-result{font-size:.84rem}.ticket-intent-results .intent-result span{font-size:.75rem}.ticket-intent-results .intent-result small,.ticket-intent-results .intent-message{font-size:.73rem;line-height:1.7}
.ticket-manual-routing{margin:.75rem 0!important;border-color:rgba(8,55,107,.1)!important;border-radius:1rem!important}.ticket-manual-routing summary{background:#fafcfd!important;padding:.8rem!important;font-size:.8rem;font-weight:850}.ticket-manual-routing summary small{font-size:.7rem!important}.ticket-manual-body{padding:.8rem!important}.ticket-routing-grid,.ticket-two-columns{display:grid;gap:.7rem}.ticket-wizard-page .field{margin-bottom:.8rem}.ticket-wizard-page label{font-size:.82rem}.ticket-wizard-page input:not([type=checkbox]):not([type=radio]),.ticket-wizard-page textarea,.ticket-wizard-page select{min-height:3.1rem;font-size:1rem}.ticket-subject-field{margin-top:.85rem!important}.ticket-subject-field label em{color:#fa9f00;font-style:normal}.ticket-subject-field>small,.ticket-body-field>small{display:flex;justify-content:flex-end;margin-top:.35rem;color:rgba(8,55,107,.5);font-size:.7rem;font-weight:500}.ticket-body-field textarea{min-height:9rem;line-height:1.9}.ticket-wizard-page .errorlist{margin:.35rem 0 0;padding:.5rem .65rem;border-radius:.7rem;background:#fff1f3;color:#92263a;font-size:.74rem;font-weight:750;list-style:none}
.ticket-location-fields{margin-top:1rem}.ticket-location-fields .location-hint{margin:.2rem 0;color:rgba(8,55,107,.55);font-size:.72rem}.ticket-soft-tip{display:flex;align-items:center;gap:.65rem;margin:.8rem 0;border-radius:.85rem;background:#e5f4ff;padding:.75rem;color:rgba(8,55,107,.68)}.ticket-soft-tip>span{display:grid;width:1.75rem;height:1.75rem;flex:none;place-items:center;border-radius:.55rem;background:#fff;color:#1977ce;font-weight:900}.ticket-soft-tip p{margin:0;font-size:.76rem;line-height:1.75}.ticket-soft-tip b{color:#08376b}
.ticket-expert-picker{display:grid!important;grid-template-columns:1fr!important;gap:.55rem!important;margin:1rem 0!important}.ticket-expert-picker .expert-card{display:grid!important;min-height:0!important;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.7rem;border-radius:1rem!important;padding:.72rem!important;text-align:right!important}.ticket-expert-picker .expert-card:has(input:checked){border-color:#0693e3;background:#e5f4ff}.ticket-expert-picker .expert-card img,.ticket-expert-picker .expert-avatar{width:2.8rem!important;height:2.8rem!important;margin:0!important;border-radius:.85rem!important}.ticket-expert-picker .expert-card strong,.ticket-expert-picker .expert-card small{display:block}.ticket-expert-picker .expert-card strong{font-size:.82rem}.ticket-expert-picker .expert-card small{margin-top:.1rem;font-size:.7rem;line-height:1.6}.ticket-expert-picker .expert-card:after{display:none;place-items:center;width:1.3rem;height:1.3rem;border-radius:50%;background:#0693e3;color:#fff;font-size:.6rem;font-weight:900;content:"✓"}.ticket-expert-picker .expert-card:has(input:checked):after{display:grid}
.ticket-attachment-zone{display:grid;gap:.7rem;margin-top:.8rem!important;border-color:rgba(8,55,107,.14)!important;border-radius:1rem!important;background:rgba(229,244,255,.45)!important;padding:.85rem!important;text-align:right!important}.ticket-attachment-head{display:flex;align-items:center;gap:.65rem}.ticket-attachment-head>span{display:grid;width:2.35rem;height:2.35rem;flex:none;place-items:center;border-radius:.72rem;background:#fff;color:#1977ce;font-size:1.05rem;font-weight:900}.ticket-attachment-head b,.ticket-attachment-head small{display:block}.ticket-attachment-head b{font-size:.82rem}.ticket-attachment-head small{margin-top:.1rem;color:rgba(8,55,107,.58);font-size:.7rem;line-height:1.65}.ticket-attachment-actions{display:grid!important;gap:.5rem!important}.ticket-attachment-actions label{display:grid;min-height:3rem;place-items:center;border:1px solid rgba(6,147,227,.38);border-radius:.9rem;background:#fff;color:#0b5f9b;font-size:.8rem;font-weight:900}.ticket-attachment-actions .gallery-button{background:#e5f4ff}.ticket-attachment-zone .compression-note{font-size:.7rem;line-height:1.65}.ticket-attachment-zone .file-chip{font-size:.72rem}.ticket-attachment-zone .file-chip button{min-width:1.7rem;min-height:1.7rem}.ticket-attachment-zone .canonical-files{display:none}
.ticket-voice-recorder{margin-top:.8rem;overflow:hidden;border:1px solid rgba(6,147,227,.2);border-radius:1.05rem;background:linear-gradient(145deg,#f7fcff,#edf7ff);box-shadow:0 12px 32px -28px rgba(8,55,107,.75)}.ticket-voice-head{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.65rem;padding:.8rem}.ticket-voice-icon{display:grid;width:2.35rem;height:2.35rem;place-items:center;border-radius:.75rem;background:#fff;color:#0693e3;font-size:1rem}.ticket-voice-head b,.ticket-voice-head small,.ticket-voice-ready b,.ticket-voice-ready small{display:block}.ticket-voice-head b{font-size:.84rem}.ticket-voice-head small{margin-top:.12rem;color:rgba(8,55,107,.58);font-size:.72rem;line-height:1.7}.ticket-voice-timer{direction:ltr;border:1px solid rgba(6,147,227,.16);border-radius:999px;background:#fff;padding:.28rem .5rem;font-size:.75rem;font-weight:900;font-variant-numeric:tabular-nums}.ticket-voice-idle,.ticket-voice-live,.ticket-voice-preview{border-top:1px solid rgba(8,55,107,.07);padding:.75rem .8rem}.ticket-voice-idle{display:grid;gap:.35rem}.ticket-voice-idle>small{color:rgba(8,55,107,.58);font-size:.69rem;text-align:center}.ticket-voice-start{display:flex;min-height:3rem;align-items:center;justify-content:center;gap:.45rem;border:0;border-radius:.9rem;background:#0693e3;color:#fff;font-size:.82rem;font-weight:900}.ticket-voice-wave{display:flex;height:2.8rem;align-items:center;justify-content:center;gap:.24rem}.ticket-voice-wave i{width:.22rem;height:.75rem;border-radius:999px;background:#0693e3;animation:ticket-voice-wave .9s ease-in-out infinite}.ticket-voice-wave i:nth-child(2),.ticket-voice-wave i:nth-child(6){height:1.35rem;animation-delay:.12s}.ticket-voice-wave i:nth-child(3),.ticket-voice-wave i:nth-child(5){height:2rem;animation-delay:.24s}.ticket-voice-wave i:nth-child(4){height:2.5rem;animation-delay:.36s}.ticket-voice-recorder[data-state="paused"] .ticket-voice-wave i{animation-play-state:paused;opacity:.4}@keyframes ticket-voice-wave{0%,100%{transform:scaleY(.55)}50%{transform:scaleY(1)}}.ticket-voice-controls,.ticket-voice-preview-actions{display:grid;grid-template-columns:1fr 1.5fr;gap:.45rem}.ticket-voice-controls button,.ticket-voice-preview-actions button{min-height:2.65rem;border:1px solid rgba(8,55,107,.1);border-radius:.8rem;background:#fff;color:#08376b;font-size:.75rem;font-weight:850}.ticket-voice-controls .ticket-voice-stop{border-color:#0693e3;background:#0693e3;color:#fff}.ticket-voice-controls .ticket-voice-cancel{grid-column:1/-1;min-height:2rem;border:0;background:transparent;color:#9c3042}.ticket-voice-preview{display:grid;gap:.65rem;background:#fff}.ticket-voice-ready{display:flex;align-items:center;gap:.55rem}.ticket-voice-ready>span{display:grid;width:1.8rem;height:1.8rem;flex:none;place-items:center;border-radius:.55rem;background:#e5f8ef;color:#16815e;font-size:.65rem;font-weight:900}.ticket-voice-ready b{font-size:.8rem}.ticket-voice-ready small{color:rgba(8,55,107,.58);font-size:.7rem}.ticket-voice-preview audio,.ticket-review-audio{width:100%;height:2.6rem;accent-color:#0693e3}.ticket-voice-preview-actions{grid-template-columns:1fr 1fr}.ticket-voice-preview-actions [data-voice-delete]{border-color:rgba(169,39,60,.18);color:#a9273c}.ticket-voice-note{display:flex;gap:.35rem;margin:0;border-top:1px dashed rgba(8,55,107,.1);padding:.6rem .8rem;color:rgba(8,55,107,.58);font-size:.68rem;line-height:1.7}.ticket-voice-note span{color:#1977ce}.ticket-voice-error{margin:0;padding:.55rem .8rem;background:#fff0f1;color:#9c3042;font-size:.72rem;font-weight:800}
.ticket-server-errors:empty{display:none}.ticket-wizard-validation:empty{display:none}.ticket-wizard-validation.is-visible{position:relative;margin:.8rem 0 0;border:1px solid rgba(169,39,60,.2);border-radius:.85rem;background:#fff1f3;padding:.72rem 2.2rem .72rem .75rem;color:#92263a;font-size:.78rem;font-weight:850;line-height:1.8}.ticket-wizard-validation.is-visible:before{position:absolute;top:.75rem;right:.75rem;display:grid;width:1.15rem;height:1.15rem;place-items:center;border-radius:50%;background:#a9273c;color:#fff;font-size:.66rem;content:"!"}.ticket-wizard-validation.is-visible:focus{box-shadow:0 0 0 4px rgba(169,39,60,.13)}.ticket-wizard-page [aria-invalid="true"]{border-color:#b8324a!important;background:#fffafb!important;box-shadow:0 0 0 3px rgba(184,50,74,.1)!important}
.ticket-wizard-actions{position:sticky;z-index:10;bottom:4.8rem;display:flex;justify-content:flex-end;gap:.55rem;margin:1rem -1rem -1rem;border-top:1px solid rgba(8,55,107,.07);background:rgba(255,255,255,.97);padding:.7rem 1rem;backdrop-filter:blur(12px)}.ticket-wizard-actions>span{display:none}.ticket-wizard-actions button{min-height:3rem;border-radius:1rem;padding:.65rem .9rem;font-size:.82rem;font-weight:900}.ticket-wizard-actions .primary{border:0;background:#0693e3;color:#fff}.ticket-wizard-actions .secondary{border:1px solid rgba(8,55,107,.1);background:#fff;color:rgba(8,55,107,.72)}.ticket-wizard-card:not([data-wizard-enhanced]) [data-wizard-next],.ticket-wizard-card:not([data-wizard-enhanced]) [data-wizard-prev]{display:none}
.ticket-review-callout{display:flex;align-items:center;gap:.65rem;margin:.85rem 0 .65rem;border-radius:.9rem;background:#f0faf6;padding:.7rem}.ticket-review-callout>span{display:grid;width:2rem;height:2rem;flex:none;place-items:center;border-radius:.65rem;background:#fff;color:#16815e;font-weight:900}.ticket-review-callout b,.ticket-review-callout small{display:block}.ticket-review-callout b{font-size:.82rem}.ticket-review-callout small{margin-top:.1rem;color:#66877b;font-size:.7rem;line-height:1.65}.ticket-review-list{overflow:hidden;border:1px solid rgba(8,55,107,.1);border-radius:1rem;background:#fff}.ticket-review-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:.6rem;padding:.75rem}.ticket-review-row+.ticket-review-row{border-top:1px solid rgba(8,55,107,.07)}.ticket-review-icon{display:grid;width:1.8rem;height:1.8rem;place-items:center;border-radius:.55rem;background:#e5f4ff;color:#1977ce;font-size:.65rem;font-weight:900}.ticket-review-content{min-width:0}.ticket-review-content>small{color:rgba(8,55,107,.58);font-size:.7rem;font-weight:800}.ticket-review-content h3{overflow-wrap:anywhere;margin:.16rem 0 .08rem;color:#08376b;font-size:.9rem;line-height:1.7}.ticket-review-content [data-review-subject]{font-size:1rem;font-weight:900;line-height:1.8}.ticket-review-content p{display:flex;flex-wrap:wrap;align-items:center;gap:.3rem;margin:0;color:rgba(8,55,107,.62);font-size:.72rem;line-height:1.8}.ticket-review-content p i{font-style:normal}.ticket-review-row>button{display:flex;min-height:2rem;align-items:center;gap:.25rem;border:0;border-radius:.65rem;background:#e5f4ff;padding:.35rem .5rem;color:#1977ce;font-size:.68rem;font-weight:900}.ticket-review-details .ticket-review-content{grid-column:2/-1}.ticket-review-details>button{grid-column:3;grid-row:1}.ticket-review-body{display:block!important;margin-top:.2rem!important;color:rgba(8,55,107,.82)!important;font-size:.88rem!important;font-weight:550;line-height:2!important;white-space:pre-wrap}.ticket-review-files{display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.45rem}.ticket-review-files span{border-radius:999px;background:#e5f4ff;padding:.28rem .5rem;color:#1977ce;font-size:.68rem}.ticket-review-audio{margin-top:.55rem}.ticket-review-step{padding-bottom:6.75rem}.ticket-wizard-card[data-wizard-enhanced] .ticket-final-actions{position:fixed;z-index:45;right:.75rem;bottom:5rem;left:.75rem;display:grid;grid-template-columns:minmax(0,.78fr) minmax(0,1.4fr);gap:.5rem;margin:0;border:1px solid rgba(8,55,107,.12);border-radius:1.1rem;background:rgba(255,255,255,.96);padding:.55rem;box-shadow:0 20px 50px -20px rgba(8,55,107,.55);backdrop-filter:blur(14px)}
@media(min-width:640px){.ticket-wizard-page{padding-bottom:3rem}.ticket-wizard-topbar{min-height:3.8rem}.ticket-wizard-save{display:flex}.ticket-wizard-intro{margin:.8rem 0 1.35rem}.ticket-wizard-intro h1{font-size:2rem}.ticket-wizard-intro>p:last-child{font-size:.9rem}.ticket-wizard-progress{margin-bottom:1rem}.ticket-wizard-progress:before{top:1.12rem}.ticket-wizard-progress li{display:flex;justify-content:center;gap:.5rem}.ticket-wizard-progress li>span{width:2.3rem;height:2.3rem}.ticket-wizard-progress li>div{display:block}.ticket-wizard-progress li b,.ticket-wizard-progress li small{display:block}.ticket-wizard-progress li b{font-size:.78rem}.ticket-wizard-progress li small{font-size:.67rem}.ticket-wizard-card{border-radius:1.6rem}.ticket-wizard-step{padding:1.35rem 1.5rem}.ticket-wizard-step-head h2{font-size:1.2rem}.ticket-intent-search kbd{display:inline;border:1px solid rgba(8,55,107,.1);border-radius:.4rem;background:#eee;padding:.2rem .4rem;color:rgba(8,55,107,.58);font-size:.68rem}.ticket-routing-grid,.ticket-two-columns{grid-template-columns:1fr 1fr}.ticket-manual-body{padding:1rem!important}.ticket-expert-picker{grid-template-columns:repeat(3,1fr)!important}.ticket-expert-picker .expert-card{display:grid!important;grid-template-columns:1fr;justify-items:center;padding:.9rem!important;text-align:center!important}.ticket-expert-picker .expert-card img,.ticket-expert-picker .expert-avatar{width:3.3rem!important;height:3.3rem!important}.ticket-expert-picker .expert-card:after{position:absolute;top:.5rem;left:.5rem}.ticket-attachment-actions{grid-template-columns:1fr 1fr}.ticket-voice-idle{grid-template-columns:auto 1fr;align-items:center}.ticket-voice-idle>small{text-align:right}.ticket-voice-start{min-width:11rem}.ticket-voice-live{display:grid;grid-template-columns:1fr auto;align-items:center}.ticket-voice-controls{grid-template-columns:auto auto auto}.ticket-voice-controls .ticket-voice-cancel{grid-column:auto;min-height:2.65rem}.ticket-wizard-actions{position:static;margin:1.15rem -1.5rem -1.35rem;padding:.8rem 1.5rem}.ticket-wizard-actions>span{display:block;margin-left:auto;color:rgba(8,55,107,.45);font-size:.72rem}.ticket-wizard-actions button{min-width:8.5rem}.ticket-review-row{gap:.8rem;padding:.9rem 1rem}.ticket-review-details .ticket-review-content{grid-column:2}.ticket-review-content h3{font-size:.96rem}.ticket-review-content [data-review-subject]{font-size:1.08rem}.ticket-review-body{font-size:.94rem!important}.ticket-review-step{padding-bottom:6rem}.ticket-wizard-card[data-wizard-enhanced] .ticket-final-actions{position:fixed;right:auto;bottom:1rem;left:50%;width:min(calc(100% - 2rem),42rem);transform:translateX(-50%);padding:.6rem}}
@media(prefers-reduced-motion:reduce){.ticket-voice-wave i{animation:none}}
+17
View File
@@ -1,2 +1,19 @@
.dispatch-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.7rem}.dispatch-metrics article{display:grid;gap:.25rem;border:1px solid #dce7ef;border-radius:1rem;background:#fff;padding:.9rem 1rem;box-shadow:0 12px 30px -28px #08376b}.dispatch-metrics small{color:#718698;font-size:.62rem}.dispatch-metrics b{color:#123e62;font-size:1.25rem}.dispatch-filter{display:grid;gap:.65rem;border-bottom:1px solid #e3ebf1;background:#f8fbfd;padding:.8rem}.dispatch-filter>div{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}.dispatch-filter label{display:grid;gap:.25rem;color:#536d81;font-size:.58rem;font-weight:900}.dispatch-filter input,.dispatch-filter select{width:100%;min-height:2.35rem;border:1px solid #cbd9e5;border-radius:.6rem;background:#fff;padding:.45rem .55rem;color:#173f60;font-size:.62rem}.dispatch-filter footer{display:flex;align-items:center;gap:.5rem}.dispatch-filter button{min-height:2.35rem;flex:1;border:0;border-radius:.6rem;background:#0875aa;color:#fff;font-size:.62rem;font-weight:900}.dispatch-filter footer a{color:#647b8d;font-size:.58rem;font-weight:900}.dispatch-results{display:grid;max-height:34rem;overflow:auto}.operations-pagination{display:flex;align-items:center;justify-content:space-between;border-top:1px solid #e5edf3;padding:.7rem .8rem;color:#667e91;font-size:.6rem}.operations-pagination a{border-radius:.55rem;background:#e8f4fb;padding:.4rem .65rem;color:#0873a9;font-weight:900} .dispatch-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.7rem}.dispatch-metrics article{display:grid;gap:.25rem;border:1px solid #dce7ef;border-radius:1rem;background:#fff;padding:.9rem 1rem;box-shadow:0 12px 30px -28px #08376b}.dispatch-metrics small{color:#718698;font-size:.62rem}.dispatch-metrics b{color:#123e62;font-size:1.25rem}.dispatch-filter{display:grid;gap:.65rem;border-bottom:1px solid #e3ebf1;background:#f8fbfd;padding:.8rem}.dispatch-filter>div{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}.dispatch-filter label{display:grid;gap:.25rem;color:#536d81;font-size:.58rem;font-weight:900}.dispatch-filter input,.dispatch-filter select{width:100%;min-height:2.35rem;border:1px solid #cbd9e5;border-radius:.6rem;background:#fff;padding:.45rem .55rem;color:#173f60;font-size:.62rem}.dispatch-filter footer{display:flex;align-items:center;gap:.5rem}.dispatch-filter button{min-height:2.35rem;flex:1;border:0;border-radius:.6rem;background:#0875aa;color:#fff;font-size:.62rem;font-weight:900}.dispatch-filter footer a{color:#647b8d;font-size:.58rem;font-weight:900}.dispatch-results{display:grid;max-height:34rem;overflow:auto}.operations-pagination{display:flex;align-items:center;justify-content:space-between;border-top:1px solid #e5edf3;padding:.7rem .8rem;color:#667e91;font-size:.6rem}.operations-pagination a{border-radius:.55rem;background:#e8f4fb;padding:.4rem .65rem;color:#0873a9;font-weight:900}
@media(max-width:700px){.dispatch-metrics{display:flex;overflow-x:auto}.dispatch-metrics article{min-width:8.5rem}} @media(max-width:700px){.dispatch-metrics{display:flex;overflow-x:auto}.dispatch-metrics article{min-width:8.5rem}}
.dispatch-detail-head{align-items:flex-start}.dispatch-head-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.55rem}.dispatch-head-actions form{margin:0}.button-danger-soft{background:#fff1f2!important;color:#a51d35!important;border:1px solid #fecdd3!important}.dispatch-original-message{display:grid;gap:1rem;border:1px solid #d8e6ef;border-radius:1rem;background:linear-gradient(145deg,#fff,#f5fbff);padding:1.1rem 1.2rem;box-shadow:0 16px 40px -36px #164f75}.dispatch-original-message header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.dispatch-original-message h2{margin:.18rem 0 0;color:#173f60;font-size:1rem}.dispatch-original-message header>span{color:#73889a;font-size:.62rem;direction:ltr}.dispatch-original-message>p{margin:0;border-right:3px solid #1490bb;padding:.7rem .9rem;color:#294c66;line-height:2;white-space:normal}.dispatch-detail-metrics{grid-template-columns:repeat(6,minmax(0,1fr))}.dispatch-detail-metrics small{display:block;margin-top:.25rem;color:#788c9c;font-size:.55rem}.dispatch-sms-state{min-width:15rem}.delivery-badge{display:inline-flex;border-radius:99rem;background:#e8f0f5;padding:.3rem .58rem;color:#435f73;font-size:.58rem}.delivery-delivered{background:#dcfce7;color:#166534}.delivery-undelivered,.delivery-expired,.delivery-blocked,.delivery-error,.delivery-failed{background:#fee2e2;color:#991b1b}.delivery-pending,.delivery-processing,.delivery-retrying{background:#fef3c7;color:#92400e}.delivery-unknown,.delivery-check_failed{background:#f1f5f9;color:#475569}.delivery-detail{max-width:24rem;margin:.45rem 0 0;color:#4b6477;font-size:.58rem;line-height:1.7}.sms-error-detail{max-width:24rem;margin-top:.45rem;border:1px solid #fecaca;border-radius:.55rem;background:#fff7f7;padding:.4rem .5rem;color:#8d1e2d;font-size:.57rem}.sms-error-detail summary{cursor:pointer;font-weight:900}.sms-error-detail p{margin:.35rem 0 0;line-height:1.7}.inline-retry-form{margin-top:.5rem}.inline-retry-form button{border:1px solid #f1b7bf;border-radius:.5rem;background:#fff;color:#a51d35;padding:.38rem .55rem;font:inherit;font-size:.56rem;font-weight:900;cursor:pointer}
@media(max-width:1100px){.dispatch-detail-metrics{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:700px){.dispatch-detail-head,.dispatch-original-message header{display:grid}.dispatch-head-actions{width:100%}.dispatch-head-actions>*{flex:1}.dispatch-detail-metrics{display:flex;overflow-x:auto}.dispatch-detail-metrics article{min-width:9rem}}
/* Dispatch report: human-readable status first, technical gateway data second. */
.dispatch-report-hero{display:flex;align-items:flex-start;justify-content:space-between;gap:1.25rem;border:1px solid #d8e6ef;border-radius:1.2rem;background:linear-gradient(135deg,#f8fcff 0%,#fff 55%,#eff9fc 100%);padding:1.35rem;box-shadow:0 20px 50px -42px #0c4269}.dispatch-report-title{display:grid;gap:.7rem}.dispatch-title-line{display:flex;align-items:center;gap:.55rem}.dispatch-report-title h1{margin:0;color:#123b5b;font-size:1.45rem}.report-status{display:inline-flex;border-radius:99rem;background:#e7f0f5;padding:.34rem .7rem;color:#426177;font-size:.58rem;font-weight:900}.report-status.status-sent{background:#dcfce7;color:#166534}.report-status.status-partial{background:#fff0e1;color:#9a4f06}.dispatch-meta-list{display:flex;flex-wrap:wrap;gap:.5rem}.dispatch-meta-list span{display:flex;align-items:center;gap:.35rem;border:1px solid #deebf2;border-radius:.65rem;background:#fff;padding:.42rem .6rem;color:#557084;font-size:.59rem}.dispatch-meta-list b{color:#204c6d}.dispatch-original-message blockquote{margin:0;border:0;border-right:3px solid #1490bb;border-radius:.35rem;background:rgba(255,255,255,.72);padding:.8rem 1rem;color:#294c66;line-height:2}.dispatch-original-message footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:.5rem;border-top:1px dashed #d9e7ef;padding-top:.75rem;color:#6d8292;font-size:.57rem}.delivery-overview{display:grid;grid-template-columns:auto minmax(12rem,1fr) minmax(14rem,.7fr);align-items:center;gap:1rem;border:1px solid #d8e7ee;border-radius:1rem;background:#fff;padding:1rem 1.15rem}.delivery-progress-copy{min-width:12rem}.delivery-progress-copy strong{display:block;margin:.1rem 0;color:#0878a8;font-size:1.8rem}.delivery-progress-copy p{margin:0;color:#688092;font-size:.58rem}.delivery-progress-track{height:.75rem;overflow:hidden;border-radius:99rem;background:#e9f1f5;box-shadow:inset 0 1px 2px #cfdde6}.delivery-progress-track span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#13a36f,#42c895);transition:width .3s ease}.delivery-overview-note{display:grid;gap:.2rem;border-right:1px solid #e1eaf0;padding-right:1rem}.delivery-overview-note b{color:#294f69;font-size:.66rem}.delivery-overview-note span{color:#728797;font-size:.56rem;line-height:1.7}.dispatch-kpi-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:.7rem}.dispatch-kpi{display:flex;align-items:center;gap:.7rem;border:1px solid #dce8ef;border-radius:.9rem;background:#fff;padding:.85rem;box-shadow:0 14px 34px -32px #123f61}.kpi-icon{display:grid;width:2rem;height:2rem;flex:0 0 auto;place-items:center;border-radius:.65rem;background:#eaf2f7;color:#44657a;font-weight:900}.dispatch-kpi>div{min-width:0}.dispatch-kpi small,.dispatch-kpi p{display:block;margin:0;color:#778b99;font-size:.53rem}.dispatch-kpi strong{display:block;margin:.08rem 0;color:#183f5c;font-size:1.22rem}.kpi-success .kpi-icon{background:#dcfce7;color:#16734c}.kpi-info .kpi-icon{background:#e0f2fe;color:#0878a8}.kpi-danger .kpi-icon{background:#fee2e2;color:#a51d35}.kpi-view .kpi-icon{background:#ede9fe;color:#6d3fc0}.kpi-reply .kpi-icon{background:#fef3c7;color:#96600d}.dispatch-recipient-report{overflow:hidden}.dispatch-detail-table{border-collapse:separate;border-spacing:0;width:100%}.dispatch-detail-table thead th{position:sticky;top:0;z-index:2;background:#f5f9fc;color:#526c80;font-size:.56rem}.dispatch-detail-table tbody tr{transition:background .15s ease}.dispatch-detail-table tbody tr:hover{background:#f9fcfe}.dispatch-detail-table td{vertical-align:top;padding:.9rem .7rem;border-bottom:1px solid #e8eff3}.recipient-cell{display:flex;align-items:center;gap:.55rem;min-width:9rem}.recipient-avatar{display:grid;width:2rem;height:2rem;flex:0 0 auto;place-items:center;border-radius:.7rem;background:linear-gradient(145deg,#dff4fb,#bfe8f5);color:#0875a3;font-weight:900}.recipient-cell div{display:grid;gap:.17rem}.recipient-cell strong{color:#1e4866;font-size:.63rem}.recipient-cell small{color:#738896;font-size:.55rem}.ticket-reference{display:grid;gap:.22rem;text-decoration:none}.ticket-reference strong{color:#176993;font-size:.6rem}.ticket-reference span{color:#7b8d99;font-size:.52rem}.dispatch-sms-state{min-width:18rem}.sms-state-card{display:flex;align-items:flex-start;gap:.55rem;border:1px solid #dfe9ef;border-radius:.75rem;background:#f8fbfd;padding:.65rem}.sms-state-icon{display:grid;width:1.65rem;height:1.65rem;flex:0 0 auto;place-items:center;border-radius:.55rem;background:#e5eef4;color:#3f6075;font-weight:900}.sms-state-card>div{display:grid;gap:.18rem}.sms-state-card strong{color:#254c68;font-size:.61rem}.sms-state-card p,.sms-state-card small{margin:0;color:#718694;font-size:.53rem;line-height:1.65}.tone-success{border-color:#bcebd3;background:#f2fcf7}.tone-success .sms-state-icon{background:#d6f7e6;color:#17734d}.tone-warning{border-color:#f2d7a0;background:#fffaf0}.tone-warning .sms-state-icon{background:#feefc9;color:#93600c}.tone-danger{border-color:#f3c7cd;backgroundLine truncated
@media(max-width:1100px){.dispatch-kpi-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.delivery-overview{grid-template-columns:auto 1fr}.delivery-overview-note{grid-column:1/-1;border-top:1px solid #e5edf2;border-right:0;padding: .7rem 0 0}}
@media(max-width:760px){.dispatch-report-hero{display:grid}.dispatch-report-title h1{font-size:1.15rem}.dispatch-head-actions{display:grid}.dispatch-kpi-grid{display:flex;overflow-x:auto;padding-bottom:.25rem}.dispatch-kpi{min-width:10.5rem}.delivery-overview{grid-template-columns:1fr}.delivery-progress-copy{min-width:0}.dispatch-detail-table thead{display:none}.dispatch-detail-table,.dispatch-detail-table tbody,.dispatch-detail-table tr,.dispatch-detail-table td{display:block;width:100%}.dispatch-detail-table tr{border-bottom:8px solid #edf3f6}.dispatch-detail-table td{display:grid;grid-template-columns:6.5rem 1fr;gap:.6rem;border-bottom:1px solid #edf2f5}.dispatch-detail-table td:before{content:attr(data-label);color:#8799a5;font-size:.52rem;font-weight:900}.dispatch-sms-state{min-width:0}.dispatch-detail-table td>*{min-width:0}}
/* Readable report typography and a compact delivery summary. */
.operations-dispatch-detail{font-size:1rem}.operations-dispatch-detail .eyebrow,.operations-dispatch-detail .panel-index{font-size:.72rem}.operations-dispatch-detail .report-status{font-size:.72rem}.operations-dispatch-detail .dispatch-meta-list span{gap:.45rem;font-size:.76rem}.operations-dispatch-detail .dispatch-original-message header>span,.operations-dispatch-detail .dispatch-original-message footer{font-size:.72rem}.operations-dispatch-detail .dispatch-original-message blockquote{font-size:.86rem}.operations-dispatch-detail .delivery-overview{display:grid;grid-template-columns:minmax(0,1fr) minmax(12rem,.42fr);gap:.7rem 1rem;padding:.75rem 1rem}.operations-dispatch-detail .delivery-progress-copy{display:flex;align-items:center;justify-content:space-between;gap:1rem;min-width:0}.operations-dispatch-detail .delivery-progress-copy>div{display:flex;align-items:center;gap:.55rem}.operations-dispatch-detail .delivery-progress-copy strong{display:inline;margin:0;font-size:1rem}.operations-dispatch-detail .delivery-progress-copy p{font-size:.74rem}.operations-dispatch-detail .delivery-progress-track{grid-column:1;height:.42rem}.operations-dispatch-detail .delivery-overview-note{grid-column:2;grid-row:1/3;align-content:center;border-right:1px solid #e1eaf0;padding-right:1rem}.operations-dispatch-detail .delivery-overview-note b{font-size:.78rem}.operations-dispatch-detail .delivery-overview-note span{font-size:.7rem}.operations-dispatch-detail .dispatch-kpi small,.operations-dispatch-detail .dispatch-kpi p{font-size:.7rem;line-height:1.65}.operations-dispatch-detail .dispatch-detail-table thead th{font-size:.72rem}.operations-dispatch-detail .recipient-cell strong,.operations-dispatch-detail .ticket-reference strong,.operations-dispatch-detail .sms-state-card strong{font-size:.78rem}.operations-dispatch-detail .recipient-cell small,.operations-dispatch-detail .ticket-reference span,.operations-dispatch-detail .sms-state-card p,.operations-dispatch-detail .sms-state-card small{font-size:.7rem}.operations-dispatch-detail .sms-technical-details,.operations-dispatch-detail .sms-error-panel,.operations-dispatch-detail .ticket-state-pill,.operations-dispatch-detail .engagement-state strong,.operations-dispatch-detail .recipient-reply p{font-size:.72rem}.operations-dispatch-detail .engagement-state small,.operations-dispatch-detail .recipient-reply small,.operations-dispatch-detail .no-reply-state{font-size:.7rem}
@media(max-width:1100px){.operations-dispatch-detail .delivery-overview{grid-template-columns:1fr}.operations-dispatch-detail .delivery-progress-track{grid-column:1}.operations-dispatch-detail .delivery-overview-note{grid-column:1;grid-row:auto;border-top:1px solid #e5edf2;border-right:0;padding:.65rem 0 0}}
@media(max-width:760px){.operations-dispatch-detail .delivery-progress-copy{align-items:flex-start;flex-direction:column;gap:.35rem}.operations-dispatch-detail .dispatch-detail-table td:before{font-size:.66rem}}
/* Shared attachments for isolated group dispatches. */
.dispatch-attachment-picker{display:grid;gap:.8rem;margin:0 1rem 1rem;border:1px dashed #8fb7d1;border-radius:.9rem;background:linear-gradient(145deg,#f8fcff,#f2f9fd);padding:1rem}.dispatch-attachment-picker .attachment-picker-copy{display:flex;align-items:center;gap:.75rem}.dispatch-attachment-picker .attachment-icon{display:grid;width:2.45rem;height:2.45rem;flex:none;place-items:center;border-radius:.72rem;background:#dff2ff;color:#1977ce;font-size:1.2rem;font-weight:900}.dispatch-attachment-picker b,.dispatch-attachment-picker small{display:block}.dispatch-attachment-picker b{color:#173f60;font-size:.78rem}.dispatch-attachment-picker .attachment-picker-copy small{margin-top:.15rem;color:#647d91;font-size:.65rem;line-height:1.7}.dispatch-attachment-picker .media-picker-actions{margin:0}.dispatch-attachment-picker .file-preview{margin-top:0}.dispatch-attachment-picker .file-preview:empty{display:none}.dispatch-attachment-picker .file-chip{box-shadow:0 10px 25px -22px #08376b}.dispatch-attachment-picker .compression-note{color:#688095;font-size:.62rem;line-height:1.7}
@media(max-width:700px){.dispatch-attachment-picker{margin:.8rem;padding:.8rem}.dispatch-attachment-picker .media-picker-actions{display:grid;grid-template-columns:1fr}.dispatch-attachment-picker .media-picker-button{justify-content:center}}
+320
View File
@@ -0,0 +1,320 @@
(() => {
const form = document.querySelector('[data-ticket-wizard]');
if (!form) return;
const steps = [...form.querySelectorAll('[data-wizard-step]')];
const progress = [...document.querySelectorAll('[data-wizard-progress-item]')];
const unit = form.querySelector('[data-ticket-unit]');
const category = form.querySelector('[data-ticket-category]');
const subject = form.querySelector('[data-ticket-subject]');
const province = form.querySelector('[data-province-input]');
const city = form.querySelector('[data-city-input]');
const body = form.querySelector('textarea[name="body"]');
const canonicalFiles = form.querySelector('input[name="files"]');
const voiceRoot = form.querySelector('[data-ticket-voice-recorder]');
let currentStep = 1;
const toPersianDigits = value => String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]);
function updateProgress(number) {
progress.forEach(item => {
const itemNumber = Number(item.dataset.wizardProgressItem);
item.classList.toggle('is-active', itemNumber === number);
item.classList.toggle('is-complete', itemNumber < number);
item.querySelector('span').textContent = itemNumber < number ? '✓' : toPersianDigits(itemNumber);
});
}
function showStep(number, { scroll = true } = {}) {
currentStep = number;
steps.forEach(step => {
const active = Number(step.dataset.wizardStep) === number;
step.hidden = !active;
step.classList.toggle('is-active', active);
});
updateProgress(number);
if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
}
function clearValidation(number) {
const step = form.querySelector(`[data-wizard-step="${number}"]`);
const error = form.querySelector(`[data-wizard-error="${number}"]`);
step?.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
if (error) {
error.textContent = '';
error.classList.remove('is-visible');
error.removeAttribute('tabindex');
}
}
function showValidation(number, problem) {
const step = form.querySelector(`[data-wizard-step="${number}"]`);
const error = form.querySelector(`[data-wizard-error="${number}"]`);
if (number === 1 && problem.fields.some(field => field === unit || field === category)) {
const manualRouting = form.querySelector('.ticket-manual-routing');
if (manualRouting) manualRouting.open = true;
}
step?.querySelectorAll('[aria-invalid="true"]').forEach(field => field.removeAttribute('aria-invalid'));
problem.fields.forEach(field => field?.setAttribute('aria-invalid', 'true'));
error.textContent = problem.message;
error.tabIndex = -1;
error.classList.add('is-visible');
requestAnimationFrame(() => {
error.focus({ preventScroll: true });
error.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
});
}
function problemForStep(number) {
if (number === 1) {
const missing = [];
const fields = [];
if (!unit?.value) { missing.push('واحد سازمانی'); fields.push(unit); }
if (!category?.value) { missing.push('دسته‌بندی'); fields.push(category); }
if ((subject?.value.trim().length || 0) < 5) { missing.push('موضوع دقیق حداقل پنج‌حرفی'); fields.push(subject); }
if (missing.length) return { message: `برای ادامه، ${missing.join('، ')} را تکمیل کنید.`, fields };
}
if (number === 2) {
if (city?.value && !province?.value) return { message: 'برای شهر انتخاب‌شده، استان را نیز مشخص کنید.', fields: [province, city] };
}
if (number === 4 && !body?.value.trim() && !(canonicalFiles?.files.length)) {
return { message: 'برای ادامه، شرح درخواست بنویسید یا یک فایل/پیام صوتی اضافه کنید.', fields: [body] };
}
return null;
}
function validateStep(number) {
const problem = problemForStep(number);
if (problem) {
showValidation(number, problem);
return false;
}
clearValidation(number);
return true;
}
function selectedText(select, fallback) {
return select?.selectedOptions?.[0]?.textContent?.trim() || fallback;
}
function buildReview() {
form.querySelector('[data-review-subject]').textContent = subject.value.trim();
form.querySelector('[data-review-unit]').textContent = selectedText(unit, '—');
form.querySelector('[data-review-category]').textContent = selectedText(category, '—');
form.querySelector('[data-review-location]').textContent = province?.value
? [province.value, city?.value].filter(Boolean).join('، ')
: 'ثبت نشده';
const selectedExpert = form.querySelector('input[name="selected_expert"]:checked');
form.querySelector('[data-review-expert]').textContent = selectedExpert?.closest('label')?.querySelector('strong')?.textContent?.trim() || 'صف عمومی واحد';
form.querySelector('[data-review-body]').textContent = body.value.trim() || 'شرح متنی ثبت نشده است.';
const fileList = form.querySelector('[data-review-files]');
fileList.replaceChildren();
const visibleFiles = [...(canonicalFiles?.files || [])].filter(file => !file.type.startsWith('audio/'));
visibleFiles.forEach(file => {
const chip = document.createElement('span');
chip.textContent = file.name;
fileList.append(chip);
});
const reviewAudio = form.querySelector('[data-review-audio]');
const voiceApi = voiceRoot?.ticketVoiceRecorder;
reviewAudio.hidden = !voiceApi?.hasVoice();
if (voiceApi?.url()) reviewAudio.src = voiceApi.url();
if (!visibleFiles.length && !voiceApi?.hasVoice()) {
const chip = document.createElement('span');
chip.textContent = 'بدون پیوست';
fileList.append(chip);
}
}
async function leaveCurrentStep(targetStep) {
const button = form.querySelector(`[data-wizard-step="${currentStep}"] [data-wizard-next]`);
if (button) button.disabled = true;
if (currentStep === 4) await voiceRoot?.ticketVoiceRecorder?.stopAndSave({ automatic: true });
if (button) button.disabled = false;
if (!validateStep(currentStep)) return;
if (currentStep === 4) buildReview();
showStep(targetStep);
}
form.querySelectorAll('[data-wizard-next]').forEach(button => button.addEventListener('click', () => leaveCurrentStep(Math.min(5, currentStep + 1))));
form.querySelectorAll('[data-wizard-prev]').forEach(button => button.addEventListener('click', async () => {
if (currentStep === 4) await voiceRoot?.ticketVoiceRecorder?.stopAndSave({ automatic: true });
showStep(Math.max(1, currentStep - 1));
}));
form.querySelectorAll('[data-wizard-edit]').forEach(button => button.addEventListener('click', () => showStep(Number(button.dataset.wizardEdit))));
form.addEventListener('input', event => {
event.target.removeAttribute?.('aria-invalid');
if (event.target === body) form.querySelector('[data-body-count]').textContent = toPersianDigits(body.value.length);
});
form.addEventListener('change', event => event.target.removeAttribute?.('aria-invalid'));
form.addEventListener('submit', event => {
const invalidStep = [1, 2, 3, 4].find(number => problemForStep(number));
if (!invalidStep) return;
event.preventDefault();
form.querySelectorAll('button[type="submit"]').forEach(button => { button.disabled = false; });
showStep(invalidStep);
requestAnimationFrame(() => validateStep(invalidStep));
});
function setupVoiceRecorder(root) {
if (!root) return;
const ui = {
idle: root.querySelector('[data-voice-idle]'), live: root.querySelector('[data-voice-live]'),
preview: root.querySelector('[data-voice-preview]'), record: root.querySelector('[data-voice-record]'),
pause: root.querySelector('[data-voice-pause]'), stop: root.querySelector('[data-voice-stop]'),
cancel: root.querySelector('[data-voice-cancel]'), rerecord: root.querySelector('[data-voice-rerecord]'),
remove: root.querySelector('[data-voice-delete]'), audio: root.querySelector('[data-voice-audio]'),
timer: root.querySelector('[data-voice-timer]'), status: root.querySelector('[data-voice-status]'),
meta: root.querySelector('[data-voice-meta]'), error: root.querySelector('[data-voice-error]'),
};
let recorder = null;
let stream = null;
let chunks = [];
let voiceFile = null;
let voiceUrl = '';
let startedAt = 0;
let pausedAt = 0;
let pausedMs = 0;
let timer = null;
let stopResolver = null;
let discardOnStop = false;
let automaticStop = false;
const elapsed = () => Math.max(0, Date.now() - startedAt - pausedMs - (recorder?.state === 'paused' ? Date.now() - pausedAt : 0));
const formatDuration = milliseconds => {
const seconds = Math.floor(milliseconds / 1000);
return toPersianDigits(`${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`);
};
const stopTracks = () => { stream?.getTracks().forEach(track => track.stop()); stream = null; };
const clearTimer = () => { clearInterval(timer); timer = null; };
const showState = (state, status) => {
ui.idle.hidden = state !== 'idle';
ui.live.hidden = state !== 'live' && state !== 'paused';
ui.preview.hidden = state !== 'preview';
root.dataset.state = state;
if (status) ui.status.textContent = status;
};
const removeVoice = ({ notify = true } = {}) => {
if (notify && voiceFile) form.dispatchEvent(new CustomEvent('voice-removed', { detail: voiceFile }));
voiceFile = null;
if (voiceUrl) URL.revokeObjectURL(voiceUrl);
voiceUrl = '';
ui.audio.removeAttribute('src');
ui.timer.textContent = '۰۰:۰۰';
ui.pause.textContent = 'مکث';
ui.error.hidden = true;
showState('idle', 'برای توضیح سریع‌تر، صدای خود را ضبط کنید.');
};
const complete = (blob, duration) => {
if (!blob.size) {
removeVoice();
ui.error.textContent = 'صدایی ثبت نشد؛ لطفاً دوباره تلاش کنید.';
ui.error.hidden = false;
return;
}
if (voiceUrl) URL.revokeObjectURL(voiceUrl);
voiceUrl = URL.createObjectURL(blob);
ui.audio.src = voiceUrl;
const type = blob.type || 'audio/webm';
const extension = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : type.includes('wav') ? 'wav' : 'webm';
voiceFile = new File([blob], `voice-${Date.now()}.${extension}`, { type });
form.dispatchEvent(new CustomEvent('voice-added', { detail: voiceFile }));
ui.timer.textContent = formatDuration(duration);
ui.meta.textContent = `${formatDuration(duration)} · آماده پخش و ارسال`;
showState('preview', automaticStop ? 'ضبط هنگام ادامه‌دادن متوقف و ذخیره شد.' : 'ضبط ذخیره شد؛ پیش از ارسال آن را بررسی کنید.');
};
const start = async () => {
ui.error.hidden = true;
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
ui.error.textContent = 'مرورگر یا اتصال فعلی امکان ضبط امن صدا را ندارد.';
ui.error.hidden = false;
return;
}
try {
if (voiceFile) removeVoice();
stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
const mimeType = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/webm', 'audio/wav'].find(type => MediaRecorder.isTypeSupported?.(type));
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
chunks = [];
pausedMs = 0;
discardOnStop = false;
automaticStop = false;
recorder.ondataavailable = event => { if (event.data.size) chunks.push(event.data); };
recorder.onerror = () => {
stopTracks();
clearTimer();
ui.error.textContent = 'ضبط صدا متوقف شد. دوباره تلاش کنید.';
ui.error.hidden = false;
showState('idle');
};
recorder.onstop = () => {
const duration = elapsed();
clearTimer();
stopTracks();
if (discardOnStop) removeVoice();
else complete(new Blob(chunks, { type: recorder.mimeType?.split(';')[0] || 'audio/webm' }), duration);
recorder = null;
stopResolver?.();
stopResolver = null;
};
recorder.start(500);
startedAt = Date.now();
ui.timer.textContent = '۰۰:۰۰';
showState('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.');
timer = setInterval(() => { ui.timer.textContent = formatDuration(elapsed()); }, 250);
} catch (error) {
stopTracks();
ui.error.textContent = error.name === 'NotAllowedError'
? 'دسترسی میکروفن رد شد؛ آن را از تنظیمات مرورگر فعال کنید.'
: 'میکروفن در دسترس نیست یا برنامه دیگری از آن استفاده می‌کند.';
ui.error.hidden = false;
}
};
const stopAndSave = ({ discard = false, automatic = false } = {}) => {
if (!recorder || recorder.state === 'inactive') return Promise.resolve();
discardOnStop = discard;
automaticStop = automatic;
if (recorder.state === 'paused') {
pausedMs += Date.now() - pausedAt;
recorder.resume();
}
return new Promise(resolve => { stopResolver = resolve; recorder.stop(); });
};
const togglePause = () => {
if (recorder?.state === 'recording') {
recorder.pause();
pausedAt = Date.now();
ui.pause.textContent = 'ادامه ضبط';
showState('paused', 'ضبط موقتاً متوقف شده است.');
} else if (recorder?.state === 'paused') {
pausedMs += Date.now() - pausedAt;
recorder.resume();
ui.pause.textContent = 'مکث';
showState('live', 'در حال ضبط؛ واضح و نزدیک به میکروفن صحبت کنید.');
}
};
ui.record.addEventListener('click', start);
ui.pause.addEventListener('click', togglePause);
ui.stop.addEventListener('click', () => stopAndSave());
ui.cancel.addEventListener('click', () => stopAndSave({ discard: true }));
ui.remove.addEventListener('click', removeVoice);
ui.rerecord.addEventListener('click', start);
root.ticketVoiceRecorder = { stopAndSave, hasVoice: () => Boolean(voiceFile), url: () => voiceUrl };
window.addEventListener('pagehide', () => { stopTracks(); clearTimer(); });
}
setupVoiceRecorder(voiceRoot);
form.dataset.wizardEnhanced = '';
const serverErrorStep = steps.find(step => step.querySelector('.errorlist'));
const initialStep = serverErrorStep ? Number(serverErrorStep.dataset.wizardStep) : 1;
showStep(initialStep, { scroll: false });
if (serverErrorStep) {
const messages = [...serverErrorStep.querySelectorAll('.errorlist li')].map(item => item.textContent.trim()).filter(Boolean);
const number = Number(serverErrorStep.dataset.wizardStep);
requestAnimationFrame(() => showValidation(number, { message: messages.join(' '), fields: [] }));
}
})();
+138 -23
View File
@@ -3,29 +3,144 @@
{% block content %} {% block content %}
<link rel="stylesheet" href="{% static 'css/ticket-composer.css' %}?v={{ static_asset_version }}"> <link rel="stylesheet" href="{% static 'css/ticket-composer.css' %}?v={{ static_asset_version }}">
<link rel="stylesheet" href="{% static 'css/ticket-composer-v2.css' %}?v={{ static_asset_version }}"> <link rel="stylesheet" href="{% static 'css/ticket-composer-v2.css' %}?v={{ static_asset_version }}">
<div class="page-head"><div><a class="back-link" href="{% url 'tickets:list' %}">→ بازگشت به درخواست‌ها</a><p class="page-kicker">ثبت درخواست جدید</p><h1>چه کمکی از ما برمی‌آید؟</h1><p>درخواست خود را کامل بنویسید تا همان بار اول پاسخ دقیق‌تری دریافت کنید.</p></div></div> <link rel="stylesheet" href="{% static 'css/customer-ticket-wizard.css' %}?v={{ static_asset_version }}">
<div class="request-layout">
<section class="request-form card"><form method="post" enctype="multipart/form-data" data-ticket-composer data-unit-options-url="{% url 'tickets:new_options' %}" data-ticket-search-url="{% url 'tickets:new_search' %}" data-chat-coach data-chat-coach-key="customer-new-{{ user.public_id }}">{% csrf_token %}{{ form.non_field_errors }} <div class="ticket-wizard-page">
<section class="intent-finder" aria-labelledby="intent-finder-title"><span class="intent-icon">⌕</span><div class="intent-copy"><h2 id="intent-finder-title">موضوع درخواستتان چیست؟</h2><p>چند کلمه بنویسید؛ واحد و دسته‌بندی مناسب را پیشنهاد می‌دهیم.</p><label class="intent-search"><span class="sr-only">جست‌وجوی موضوع درخواست</span><input type="search" data-ticket-search autocomplete="off" placeholder="مثلاً فیش واریزی، موجودی کالا یا ارسال بار..."><kbd>جست‌وجو</kbd></label><div class="intent-results" data-ticket-search-results aria-live="polite"></div></div></section> <nav class="ticket-wizard-topbar" aria-label="مسیر صفحه">
<details class="manual-routing" open><summary><span>انتخاب دستی واحد و دسته‌بندی</span><small>در صورت تمایل خودتان مسیر درخواست را مشخص کنید</small></summary><div class="manual-routing-body"> <a class="ticket-wizard-back" href="{% url 'tickets:list' %}">→ بازگشت به درخواست‌ها</a>
<div class="step-title"><span class="step-number">۱</span><div><h3>موضوع درخواست</h3><p class="muted">دسته‌بندی و یک عنوان کوتاه انتخاب کنید.</p></div></div> <span class="ticket-wizard-save"><i></i> اطلاعات تا زمان خروج از صفحه حفظ می‌شود</span>
<div class="routing-selectors" data-routing-selectors><div class="field">{{ form.unit.label_tag }}{{ form.unit }}{{ form.unit.errors }}</div><div class="field">{{ form.category.label_tag }}{{ form.category }}{{ form.category.errors }}</div></div> </nav>
<div class="field routing-subject">{{ form.subject.label_tag }}{{ form.subject }}{{ form.subject.errors }}<small>موضوع را به‌صورت دستی و دقیق وارد کنید.</small></div>
</div></details> <header class="ticket-wizard-intro">
<div class="step-title"><span class="step-number">۲</span><div><h3>موقعیت این درخواست</h3><p class="muted">اختیاری است؛ موقعیت ذخیره‌شده پروفایل از قبل انتخاب شده و فقط در صورت نیاز آن را تغییر دهید.</p></div></div> <p class="page-kicker">ثبت درخواست جدید</p>
<section class="location-fields" data-location-picker data-locations-url="{% static 'data/iran-locations.json' %}"> <h1>چطور می‌توانیم کمک کنیم؟</h1>
<div class="grid gap-4 sm:grid-cols-2"><div class="field">{{ form.province.label_tag }}{{ form.province }}{{ form.province.errors }}</div><div class="field">{{ form.city.label_tag }}{{ form.city }}{{ form.city.errors }}</div></div> <p>قدم‌به‌قدم پیش می‌رویم؛ در هر مرحله فقط اطلاعات ضروری همان بخش را می‌بینید.</p>
<p class="location-hint" data-location-hint>نام استان را جست‌وجو و انتخاب کنید.</p> </header>
<ol class="ticket-wizard-progress" data-wizard-progress aria-label="مراحل ثبت درخواست">
<li class="is-active" data-wizard-progress-item="1"><span>۱</span><div><b>موضوع</b><small>مسیر درخواست</small></div></li>
<li data-wizard-progress-item="2"><span>۲</span><div><b>موقعیت</b><small>اختیاری</small></div></li>
<li data-wizard-progress-item="3"><span>۳</span><div><b>کارشناس</b><small>انتخاب مسئول</small></div></li>
<li data-wizard-progress-item="4"><span>۴</span><div><b>جزئیات</b><small>متن و فایل</small></div></li>
<li data-wizard-progress-item="5"><span>۵</span><div><b>بازبینی</b><small>تأیید نهایی</small></div></li>
</ol>
<form class="ticket-wizard-card" method="post" enctype="multipart/form-data" novalidate
data-ticket-composer data-ticket-wizard
data-unit-options-url="{% url 'tickets:new_options' %}"
data-ticket-search-url="{% url 'tickets:new_search' %}"
data-chat-coach data-chat-coach-key="customer-new-{{ user.public_id }}">
{% csrf_token %}
<section class="ticket-wizard-step is-active" data-wizard-step="1" aria-labelledby="wizard-step-1-title">
<header class="ticket-wizard-step-head">
<span class="ticket-wizard-symbol" aria-hidden="true">⌕</span>
<div><small>مرحله ۱ از ۵</small><h2 id="wizard-step-1-title">موضوع درخواستتان چیست؟</h2><p>چند کلمه بنویسید تا مسیر مناسب پیشنهاد شود، یا واحد و دسته‌بندی را دستی انتخاب کنید.</p></div>
</header>
<label class="ticket-intent-search">
<span class="sr-only">جست‌وجوی موضوع درخواست</span><i aria-hidden="true">⌕</i>
<input type="search" data-ticket-search autocomplete="off" placeholder="مثلاً فیش واریزی، موجودی کالا یا ارسال بار...">
<kbd>جست‌وجو</kbd>
</label>
<div class="intent-results ticket-intent-results" data-ticket-search-results aria-live="polite"></div>
<details class="ticket-manual-routing manual-routing" {% if form.is_bound and form.errors %}open{% endif %}>
<summary><span>انتخاب دستی واحد و دسته‌بندی</span><small>در صورت تمایل مسیر درخواست را خودتان مشخص کنید</small></summary>
<div class="ticket-manual-body manual-routing-body">
<div class="ticket-routing-grid" data-routing-selectors>
<div class="field">{{ form.unit.label_tag }}{{ form.unit }}{{ form.unit.errors }}</div>
<div class="field">{{ form.category.label_tag }}{{ form.category }}{{ form.category.errors }}</div>
</div>
</div>
</details>
<div class="field ticket-subject-field">
<label for="{{ form.subject.id_for_label }}">موضوع دقیق درخواست <em>*</em></label>
{{ form.subject }}{{ form.subject.errors }}
<small>این عنوان فقط توسط شما نوشته می‌شود و با تغییر واحد یا دسته‌بندی عوض نخواهد شد.</small>
</div>
<p class="ticket-wizard-validation" data-wizard-error="1" role="alert"></p>
<footer class="ticket-wizard-actions"><span>حدود یک دقیقه تا پایان</span><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
</section> </section>
<div class="step-title"><span class="step-number">۳</span><div><h3>انتخاب کارشناس</h3><p class="muted">اختیاری است؛ صف عمومی معمولاً سریع‌ترین انتخاب است.</p></div></div>
<fieldset class="expert-picker" data-expert-picker><legend class="sr-only">کارشناس دلخواه</legend><label class="expert-card" data-general-queue data-unit-id="{{ selected_unit.pk|default:'' }}"><input type="radio" name="selected_expert" value="" {% if not form.selected_expert.value %}checked{% endif %}><span class="expert-avatar">⌁</span><strong>صف عمومی {{ selected_unit.name|default:'واحد' }}</strong><small>اولین کارشناس آزاد همین واحد</small></label>{% for expert in form.fields.selected_expert.queryset %}<label class="expert-card"><input type="radio" name="selected_expert" value="{{ expert.pk }}" {% if form.selected_expert.value|stringformat:'s' == expert.pk|stringformat:'s' %}checked{% endif %}><img src="{% url 'tickets:expert_avatar' expert.user.public_id %}?v={{ expert.updated_at|date:'U' }}" alt="تصویر {{ expert.display_name }}" width="56" height="56" loading="lazy"><strong>{{ expert.display_name }}</strong><small>{{ expert.job_title|default:'کارشناس واحد' }}</small></label>{% empty %}<p class="muted expert-empty">کارشناس قابل انتخابی ثبت نشده؛ درخواست به صف عمومی می‌رود.</p>{% endfor %}</fieldset>{{ form.selected_expert.errors }} <section class="ticket-wizard-step" data-wizard-step="2" aria-labelledby="wizard-step-2-title">
<div class="step-title"><span class="step-number">۴</span><div><h3>شرح و مستندات</h3><p class="muted">متن، فایل یا پیام صوتی خود را اضافه کنید.</p></div></div> <header class="ticket-wizard-step-head">
<div class="field">{{ form.body.label_tag }}{{ form.body }}{{ form.body.errors }}</div> <span class="ticket-wizard-symbol" aria-hidden="true">⌖</span>
<div class="upload-zone media-picker"><div class="media-picker-actions"><label class="media-picker-button gallery-button" for="ticket-gallery-input">▣ انتخاب عکس از گالری</label><label class="media-picker-button" for="ticket-file-input">+ انتخاب فایل</label></div><input id="ticket-gallery-input" type="file" accept="image/*" multiple data-gallery-input><input id="ticket-file-input" type="file" multiple data-document-input><span class="canonical-files">{{ form.files }}</span>{{ form.files.errors }}<small class="compression-note" data-compression-status>تصاویر بزرگ پیش از ارسال، روی گوشی شما بهینه و کم‌حجم می‌شوند.</small><div class="file-preview" data-file-preview></div></div> <div><small>مرحله ۲ از ۵ · اختیاری</small><h2 id="wizard-step-2-title">این درخواست مربوط به کجاست؟</h2><p>اگر موقعیت در پاسخ بهتر کارشناسان مؤثر است آن را انتخاب کنید؛ در غیر این صورت عبور کنید.</p></div>
<div class="voice-recorder" data-voice-recorder><div class="voice-head"><b>پیام صوتی</b><span class="badge" data-timer>00:00</span></div><div class="voice-actions"><button type="button" data-record>● شروع ضبط</button><button type="button" data-pause disabled>مکث</button><button type="button" data-stop disabled>توقف</button><button type="button" class="ghost" data-delete hidden>حذف</button></div><audio class="mt-3 w-full" controls hidden></audio></div> </header>
<button class="submit-ticket" type="submit">ثبت و ارسال درخواست <span>←</span></button> <section class="ticket-location-fields" data-location-picker data-locations-url="{% static 'data/iran-locations.json' %}">
</form></section> <div class="ticket-two-columns"><div class="field">{{ form.province.label_tag }}{{ form.province }}{{ form.province.errors }}</div><div class="field">{{ form.city.label_tag }}{{ form.city }}{{ form.city.errors }}</div></div>
<aside class="request-aside"><div class="help-card"><h3>برای پاسخ سریع‌تر</h3><div class="help-list"><div class="help-item"><b>۱</b><span>نام محصول یا خدمت موردنظر را دقیق بنویسید.</span></div><div class="help-item"><b>۲</b><span>تعداد، شهر مقصد و زمان موردنظر را ذکر کنید.</span></div><div class="help-item"><b>۳</b><span>تصویر یا فایل مرتبط را همین‌جا ضمیمه کنید.</span></div></div></div></aside> <p class="location-hint" data-location-hint>نام استان را جست‌وجو و انتخاب کنید.</p>
</section>
<div class="ticket-soft-tip"><span>i</span><p><b>نیازی به انتخاب ندارید</b> این بخش کاملاً اختیاری است و می‌توانید مستقیماً ادامه دهید.</p></div>
<p class="ticket-wizard-validation" data-wizard-error="2" role="alert"></p>
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
</section>
<section class="ticket-wizard-step" data-wizard-step="3" aria-labelledby="wizard-step-3-title">
<header class="ticket-wizard-step-head">
<span class="ticket-wizard-symbol" aria-hidden="true">◎</span>
<div><small>مرحله ۳ از ۵ · اختیاری</small><h2 id="wizard-step-3-title">چه کسی درخواست را بررسی کند؟</h2><p>صف عمومی پیشنهاد ماست؛ درخواست به اولین کارشناس آزاد همان واحد می‌رسد.</p></div>
</header>
<fieldset class="expert-picker ticket-expert-picker" data-expert-picker>
<legend class="sr-only">کارشناس دلخواه</legend>
<label class="expert-card" data-general-queue data-unit-id="{{ selected_unit.pk|default:'' }}"><input type="radio" name="selected_expert" value="" {% if not form.selected_expert.value %}checked{% endif %}><span class="expert-avatar">⌁</span><strong>صف عمومی {{ selected_unit.name|default:'واحد' }}</strong><small>سریع‌ترین انتخاب · اولین کارشناس آزاد</small></label>
{% for expert in form.fields.selected_expert.queryset %}<label class="expert-card"><input type="radio" name="selected_expert" value="{{ expert.pk }}" {% if form.selected_expert.value|stringformat:'s' == expert.pk|stringformat:'s' %}checked{% endif %}><img src="{% url 'tickets:expert_avatar' expert.user.public_id %}?v={{ expert.updated_at|date:'U' }}" alt="تصویر {{ expert.display_name }}" width="56" height="56" loading="lazy"><strong>{{ expert.display_name }}</strong><small>{{ expert.job_title|default:'کارشناس واحد' }}</small></label>{% empty %}<p class="muted expert-empty">کارشناس قابل انتخابی ثبت نشده؛ درخواست به صف عمومی می‌رود.</p>{% endfor %}
</fieldset>
{{ form.selected_expert.errors }}
<p class="ticket-wizard-validation" data-wizard-error="3" role="alert"></p>
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>ادامه <b>←</b></button></footer>
</section>
<section class="ticket-wizard-step" data-wizard-step="4" aria-labelledby="wizard-step-4-title">
<header class="ticket-wizard-step-head">
<span class="ticket-wizard-symbol" aria-hidden="true">≡</span>
<div><small>مرحله ۴ از ۵</small><h2 id="wizard-step-4-title">جزئیات را برای کارشناس بنویسید</h2><p>شرح متنی، فایل یا پیام صوتی اضافه کنید تا کارشناس مسئله را دقیق‌تر بررسی کند.</p></div>
</header>
<div class="field ticket-body-field">{{ form.body.label_tag }}{{ form.body }}{{ form.body.errors }}<small><span data-body-count>۰</span> نویسه</small></div>
<div class="upload-zone media-picker ticket-attachment-zone">
<header class="ticket-attachment-head"><span>+</span><div><b>عکس و فایل‌های مرتبط</b><small>تصاویر بزرگ پیش از ارسال روی دستگاه شما بهینه می‌شوند.</small></div></header>
<div class="media-picker-actions ticket-attachment-actions"><label class="media-picker-button gallery-button" for="ticket-gallery-input">▣ انتخاب عکس از گالری</label><label class="media-picker-button" for="ticket-file-input">+ انتخاب فایل</label></div>
<input id="ticket-gallery-input" type="file" accept="image/*" multiple data-gallery-input><input id="ticket-file-input" type="file" multiple data-document-input>
<span class="canonical-files">{{ form.files }}</span>{{ form.files.errors }}
<small class="compression-note" data-compression-status>فایل‌های انتخاب‌شده پیش از ثبت قابل مشاهده و حذف هستند.</small>
<div class="file-preview" data-file-preview aria-live="polite"></div>
</div>
<section class="ticket-voice-recorder" data-ticket-voice-recorder aria-labelledby="ticket-voice-title">
<header class="ticket-voice-head"><span class="ticket-voice-icon" aria-hidden="true">◉</span><div><b id="ticket-voice-title">پیام صوتی</b><small data-voice-status aria-live="polite">برای توضیح سریع‌تر، صدای خود را ضبط کنید.</small></div><span class="ticket-voice-timer" data-voice-timer>۰۰:۰۰</span></header>
<div class="ticket-voice-idle" data-voice-idle><button class="ticket-voice-start" type="button" data-voice-record><span>●</span> شروع ضبط صدا</button><small>پس از اجازه‌دادن به میکروفن، ضبط آغاز می‌شود.</small></div>
<div class="ticket-voice-live" data-voice-live hidden><div class="ticket-voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div><div class="ticket-voice-controls"><button type="button" data-voice-pause>مکث</button><button class="ticket-voice-stop" type="button" data-voice-stop>■ توقف و ذخیره</button><button class="ticket-voice-cancel" type="button" data-voice-cancel>لغو</button></div></div>
<div class="ticket-voice-preview" data-voice-preview hidden><div class="ticket-voice-ready"><span>✓</span><p><b>پیام صوتی آماده است</b><small data-voice-meta>قابل پخش و بررسی پیش از ارسال</small></p></div><audio controls preload="metadata" data-voice-audio></audio><div class="ticket-voice-preview-actions"><button type="button" data-voice-rerecord>ضبط مجدد</button><button type="button" data-voice-delete>حذف پیام صوتی</button></div></div>
<p class="ticket-voice-note"><span>ⓘ</span> با رفتن به مرحله بعد، ضبط فعال به‌صورت خودکار متوقف و ذخیره می‌شود.</p>
<p class="ticket-voice-error" data-voice-error role="alert" hidden></p>
</section>
<div class="ticket-server-errors" data-step-server-errors>{% if form.non_field_errors %}{{ form.non_field_errors }}{% endif %}</div>
<p class="ticket-wizard-validation" data-wizard-error="4" role="alert"></p>
<footer class="ticket-wizard-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="button" data-wizard-next>مشاهده پیش‌نمایش <b>←</b></button></footer>
</section>
<section class="ticket-wizard-step ticket-review-step" data-wizard-step="5" aria-labelledby="wizard-step-5-title">
<header class="ticket-wizard-step-head"><span class="ticket-wizard-symbol is-success" aria-hidden="true">✓</span><div><small>مرحله ۵ از ۵</small><h2 id="wizard-step-5-title">همه‌چیز آماده ارسال است</h2><p>اطلاعات را یک‌بار مرور کنید؛ هر بخش را می‌توانید جداگانه ویرایش کنید.</p></div></header>
<div class="ticket-review-callout"><span>✓</span><div><b>یک مرور کوتاه پیش از ارسال</b><small>اگر موردی درست نیست، همان ردیف را ویرایش کنید؛ اطلاعات مراحل دیگر حفظ می‌شود.</small></div></div>
<div class="ticket-review-list">
<section class="ticket-review-row"><span class="ticket-review-icon">۱</span><div class="ticket-review-content"><small>موضوع و مسیر رسیدگی</small><h3 data-review-subject>—</h3><p><span data-review-unit>—</span><i>←</i><span data-review-category>—</span></p></div><button type="button" data-wizard-edit="1"><span>✎</span> ویرایش</button></section>
<section class="ticket-review-row"><span class="ticket-review-icon">۲</span><div class="ticket-review-content"><small>موقعیت</small><h3 data-review-location>ثبت نشده</h3><p>اختیاری</p></div><button type="button" data-wizard-edit="2"><span>✎</span> ویرایش</button></section>
<section class="ticket-review-row"><span class="ticket-review-icon">۳</span><div class="ticket-review-content"><small>نحوه ارجاع</small><h3 data-review-expert>صف عمومی</h3><p>در همان واحد سازمانی</p></div><button type="button" data-wizard-edit="3"><span>✎</span> ویرایش</button></section>
<section class="ticket-review-row ticket-review-details"><span class="ticket-review-icon">۴</span><div class="ticket-review-content"><small>شرح و پیوست‌ها</small><p class="ticket-review-body" data-review-body>—</p><div class="ticket-review-files" data-review-files></div><audio class="ticket-review-audio" controls hidden data-review-audio></audio></div><button type="button" data-wizard-edit="4"><span>✎</span> ویرایش</button></section>
</div>
<p class="ticket-wizard-validation" data-wizard-error="5" role="alert"></p>
<footer class="ticket-wizard-actions ticket-final-actions"><button class="secondary" type="button" data-wizard-prev>مرحله قبل</button><button class="primary" type="submit" data-final-submit>ثبت و ارسال نهایی <b>←</b></button></footer>
</section>
</form>
</div> </div>
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/location-picker.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/ticket-unit-options.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/ticket-intent-search.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'js/location-picker.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'js/ticket-unit-options.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'js/ticket-intent-search.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'js/customer-ticket-wizard.js' %}?v={{ static_asset_version }}"></script>
{% endblock %} {% endblock %}
+1
View File
@@ -10,6 +10,7 @@
<aside class="workspace-side actions-side" id="ticket-actions" data-workspace-panel> <aside class="workspace-side actions-side" id="ticket-actions" data-workspace-panel>
<button type="button" class="panel-close" data-close-panel>×</button> <button type="button" class="panel-close" data-close-panel>×</button>
<section class="info-card"><p class="panel-index">وضعیت تیکت</p><div class="info-list"><div class="info-row"><span>وضعیت</span><b>{{ ticket.get_status_display }}</b></div><div class="info-row"><span>اولویت</span><b>{{ ticket.get_priority_display }}</b></div><div class="info-row"><span>دسته</span><b>{{ ticket.category }}</b></div><div class="info-row"><span>مالک</span><b>{{ ticket.assigned_expert_name }}</b></div>{% if ticket.first_response_due_at %}<div class="info-row"><span>مهلت پاسخ</span><b>{{ ticket.first_response_due_at|jalali_datetime }}</b></div>{% endif %}</div></section> <section class="info-card"><p class="panel-index">وضعیت تیکت</p><div class="info-list"><div class="info-row"><span>وضعیت</span><b>{{ ticket.get_status_display }}</b></div><div class="info-row"><span>اولویت</span><b>{{ ticket.get_priority_display }}</b></div><div class="info-row"><span>دسته</span><b>{{ ticket.category }}</b></div><div class="info-row"><span>مالک</span><b>{{ ticket.assigned_expert_name }}</b></div>{% if ticket.first_response_due_at %}<div class="info-row"><span>مهلت پاسخ</span><b>{{ ticket.first_response_due_at|jalali_datetime }}</b></div>{% endif %}</div></section>
{% if sms_jobs %}<section class="info-card"><h3>وضعیت پیامک‌های مشتری</h3><div class="info-list">{% for sms_job in sms_jobs %}<div class="info-row"><span>{% if sms_job.event_type == 'STAFF_TICKET_CREATED' %}ثبت تیکت توسط کارشناس{% elif sms_job.event_type == 'CUSTOMER_TICKET_CREATED' %}ثبت تیکت مشتری{% elif sms_job.event_type == 'STAFF_REPLY' %}اعلان پاسخ کارشناس{% else %}پیامک سیستمی{% endif %}</span><b>{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}</b></div>{% if sms_job.delivery_description %}<p class="muted">{{ sms_job.delivery_description }}</p>{% endif %}{% if sms_job.last_error %}<div class="create-form-errors">علت دقیق: {{ sms_job.last_error }}</div>{% endif %}{% endfor %}</div></section>{% endif %}
<section class="info-card ticket-operations"><h3>عملیات تیکت</h3>{% if perms.tickets.change_ticket %}<form method="post" action="{% url 'staff_tickets:status' ticket.public_id %}">{% csrf_token %}<label>وضعیت<select name="status">{% for value,label in statuses %}<option value="{{ value }}" {% if ticket.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button>ثبت وضعیت</button></form>{% endif %}{% if perms.tickets.change_ticket_priority %}<form method="post" action="{% url 'staff_tickets:priority' ticket.public_id %}">{% csrf_token %}<label>اولویت<select name="priority">{% for value,label in priorities %}<option value="{{ value }}" {% if ticket.priority == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="secondary">ثبت اولویت</button></form>{% endif %}{% if can_transfer %}<details><summary>انتقال تیکت</summary><form method="post" action="{% url 'staff_tickets:transfer' ticket.public_id %}">{% csrf_token %}{{ transfer_form.as_p }}<button>انتقال</button></form></details>{% endif %}{% if can_share %}<details><summary>اشتراک با کارشناس</summary><form method="post" action="{% url 'staff_tickets:share' ticket.public_id %}">{% csrf_token %}{{ share_form.as_p }}<button>ایجاد دسترسی</button></form>{% for grant in grants %}<div class="grant"><span>{{ grant.user.staff_profile.display_name }}</span><form method="post" action="{% url 'staff_tickets:revoke' ticket.public_id grant.user.public_id %}">{% csrf_token %}<button class="danger-button">لغو</button></form></div>{% endfor %}</details>{% endif %}</section> <section class="info-card ticket-operations"><h3>عملیات تیکت</h3>{% if perms.tickets.change_ticket %}<form method="post" action="{% url 'staff_tickets:status' ticket.public_id %}">{% csrf_token %}<label>وضعیت<select name="status">{% for value,label in statuses %}<option value="{{ value }}" {% if ticket.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button>ثبت وضعیت</button></form>{% endif %}{% if perms.tickets.change_ticket_priority %}<form method="post" action="{% url 'staff_tickets:priority' ticket.public_id %}">{% csrf_token %}<label>اولویت<select name="priority">{% for value,label in priorities %}<option value="{{ value }}" {% if ticket.priority == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="secondary">ثبت اولویت</button></form>{% endif %}{% if can_transfer %}<details><summary>انتقال تیکت</summary><form method="post" action="{% url 'staff_tickets:transfer' ticket.public_id %}">{% csrf_token %}{{ transfer_form.as_p }}<button>انتقال</button></form></details>{% endif %}{% if can_share %}<details><summary>اشتراک با کارشناس</summary><form method="post" action="{% url 'staff_tickets:share' ticket.public_id %}">{% csrf_token %}{{ share_form.as_p }}<button>ایجاد دسترسی</button></form>{% for grant in grants %}<div class="grant"><span>{{ grant.user.staff_profile.display_name }}</span><form method="post" action="{% url 'staff_tickets:revoke' ticket.public_id grant.user.public_id %}">{% csrf_token %}<button class="danger-button">لغو</button></form></div>{% endfor %}</details>{% endif %}</section>
{% if can_delete %}<section class="info-card danger-zone"><h3>حذف کامل تیکت</h3><p class="muted">تمام پیام‌ها، فایل‌ها و سوابق تیکت برای همیشه حذف می‌شوند.</p><form method="post" action="{% url 'staff_tickets:delete' ticket.public_id %}" onsubmit="return confirm('آیا از حذف کامل و غیرقابل‌بازگشت این تیکت مطمئن هستید؟')">{% csrf_token %}<button type="submit" class="danger-button">حذف کامل تیکت</button></form></section>{% endif %} {% if can_delete %}<section class="info-card danger-zone"><h3>حذف کامل تیکت</h3><p class="muted">تمام پیام‌ها، فایل‌ها و سوابق تیکت برای همیشه حذف می‌شوند.</p><form method="post" action="{% url 'staff_tickets:delete' ticket.public_id %}" onsubmit="return confirm('آیا از حذف کامل و غیرقابل‌بازگشت این تیکت مطمئن هستید؟')">{% csrf_token %}<button type="submit" class="danger-button">حذف کامل تیکت</button></form></section>{% endif %}
</aside> </aside>
+4 -3
View File
@@ -1,12 +1,12 @@
{% extends 'base.html' %} {% extends 'base.html' %}{% load static %}
{% block title %}{{ page_title }}{% endblock %} {% block title %}{{ page_title }}{% endblock %}
{% block content %}<div class="operations-page operations-dispatch"> {% block content %}<div class="operations-page operations-dispatch">
<section class="operations-hero"><div><p class="eyebrow">ارسال انبوه امن</p><h1>ارسال گروهی ایزوله</h1><p>برای هر مخاطب یک تیکت و Magic Link مستقل ایجاد می‌شود؛ هیچ دریافت‌کننده‌ای پاسخ دیگران را نمی‌بیند.</p></div><div class="security-callout"><span>⌾</span><div><b>حریم خصوصی فعال</b><small>تیکت مستقل برای هر گیرنده</small></div></div></section> <section class="operations-hero"><div><p class="eyebrow">ارسال انبوه امن</p><h1>ارسال گروهی ایزوله</h1><p>برای هر مخاطب یک تیکت و Magic Link مستقل ایجاد می‌شود؛ هیچ دریافت‌کننده‌ای پاسخ دیگران را نمی‌بیند.</p></div><div class="security-callout"><span>⌾</span><div><b>حریم خصوصی فعال</b><small>تیکت مستقل برای هر گیرنده</small></div></div></section>
<section class="operations-metrics dispatch-metrics" aria-label="آمار ارسال‌های گروهی"><article><small>کل عملیات</small><b>{{ metrics.total }}</b></article><article><small>کل گیرندگان</small><b>{{ metrics.recipients }}</b></article><article><small>ارسال کامل</small><b>{{ metrics.sent }}</b></article><article><small>نیازمند پیگیری</small><b>{{ metrics.attention }}</b></article></section> <section class="operations-metrics dispatch-metrics" aria-label="آمار ارسال‌های گروهی"><article><small>کل عملیات</small><b>{{ metrics.total }}</b></article><article><small>کل گیرندگان</small><b>{{ metrics.recipients }}</b></article><article><small>ارسال کامل</small><b>{{ metrics.sent }}</b></article><article><small>نیازمند پیگیری</small><b>{{ metrics.attention }}</b></article></section>
<div class="dispatch-layout"><form method="post" enctype="multipart/form-data" class="dispatch-composer" data-disable-on-submit>{% csrf_token %}{% if form.non_field_errors %}<div class="create-form-errors" role="alert">{{ form.non_field_errors }}</div>{% endif %} <div class="dispatch-layout"><form method="post" enctype="multipart/form-data" class="dispatch-composer" data-disable-on-submit data-ticket-composer>{% csrf_token %}{% if form.non_field_errors %}<div class="create-form-errors" role="alert">{{ form.non_field_errors }}</div>{% endif %}
<section><header><span>۱</span><div><h2>نوع عملیات، مخاطبان و مالکیت</h2><p>قابلیت‌های مجاز و گروه هدف را مشخص کنید.</p></div></header><div class="editor-grid"><label><span>{{ form.operation_type.label }}</span>{{ form.operation_type }}{{ form.operation_type.errors }}</label><label><span>{{ form.unit.label }}</span>{{ form.unit }}{{ form.unit.errors }}</label><label><span>{{ form.contact_group.label }}</span>{{ form.contact_group }}{{ form.contact_group.errors }}<small>فقط گروه‌های واحد انتخاب‌شده معتبر هستند.</small></label></div></section> <section><header><span>۱</span><div><h2>نوع عملیات، مخاطبان و مالکیت</h2><p>قابلیت‌های مجاز و گروه هدف را مشخص کنید.</p></div></header><div class="editor-grid"><label><span>{{ form.operation_type.label }}</span>{{ form.operation_type }}{{ form.operation_type.errors }}</label><label><span>{{ form.unit.label }}</span>{{ form.unit }}{{ form.unit.errors }}</label><label><span>{{ form.contact_group.label }}</span>{{ form.contact_group }}{{ form.contact_group.errors }}<small>فقط گروه‌های واحد انتخاب‌شده معتبر هستند.</small></label></div></section>
<section><header><span>۲</span><div><h2>موضوع و دسته‌بندی</h2><p>عنوان کوتاه و برچسب‌های مناسب، گزارش‌گیری را دقیق‌تر می‌کند.</p></div></header><div class="editor-grid"><label><span>{{ form.category.label }}</span>{{ form.category }}{{ form.category.errors }}</label><label><span>{{ form.subject.label }}</span>{{ form.subject }}{{ form.subject.errors }}</label><label class="editor-wide"><span>{{ form.tags.label }}</span>{{ form.tags }}{{ form.tags.errors }}</label></div></section> <section><header><span>۲</span><div><h2>موضوع و دسته‌بندی</h2><p>عنوان کوتاه و برچسب‌های مناسب، گزارش‌گیری را دقیق‌تر می‌کند.</p></div></header><div class="editor-grid"><label><span>{{ form.category.label }}</span>{{ form.category }}{{ form.category.errors }}</label><label><span>{{ form.subject.label }}</span>{{ form.subject }}{{ form.subject.errors }}</label><label class="editor-wide"><span>{{ form.tags.label }}</span>{{ form.tags }}{{ form.tags.errors }}</label></div></section>
<section><header><span>۳</span><div><h2>متن و اسناد پیام</h2><p>درخواست را شفاف بنویسید و اسناد لازم را برای همه تیکت‌های خصوصی ضمیمه کنید.</p></div></header><label class="message-editor"><span>{{ form.body.label }}</span>{{ form.body }}{{ form.body.errors }}</label><label class="attachment-field"><span class="attachment-icon">+</span><div><b>{{ form.files.label }}</b><small>{{ form.files.help_text }}</small></div>{{ form.files }}{{ form.files.errors }}</label></section> <section><header><span>۳</span><div><h2>متن و اسناد پیام</h2><p>درخواست را شفاف بنویسید و اسناد لازم را برای همه تیکت‌های خصوصی ضمیمه کنید.</p></div></header><label class="message-editor"><span>{{ form.body.label }}</span>{{ form.body }}{{ form.body.errors }}</label><div class="dispatch-attachment-picker media-picker"><div class="attachment-picker-copy"><span class="attachment-icon" aria-hidden="true">+</span><div><b>{{ form.files.label }}</b><small>{{ form.files.help_text }}</small></div></div><div class="media-picker-actions"><label class="media-picker-button gallery-button" for="dispatch-gallery-input">▣ افزودن عکس از گالری</label><label class="media-picker-button" for="dispatch-file-input">+ افزودن فایل</label></div><input id="dispatch-gallery-input" type="file" accept="image/*" multiple data-gallery-input><input id="dispatch-file-input" type="file" multiple data-document-input><span class="canonical-files">{{ form.files }}</span>{{ form.files.errors }}<small class="compression-note" data-compression-status>تصاویر بزرگ پیش از ارسال روی دستگاه شما بهینه می‌شوند؛ هر پیوست را می‌توانید پیش از ثبت حذف کنید.</small><div class="file-preview" data-file-preview aria-live="polite" aria-label="پیش‌نمایش پیوست‌های انتخاب‌شده"></div></div></section>
<footer><div><b>قبل از ارسال بررسی کنید</b><small>این عملیات برای همه اعضای گروه تیکت مستقل ایجاد می‌کند.</small></div><button type="submit">ایجاد تیکت‌ها و قرار دادن پیامک در صف ←</button></footer> <footer><div><b>قبل از ارسال بررسی کنید</b><small>این عملیات برای همه اعضای گروه تیکت مستقل ایجاد می‌کند.</small></div><button type="submit">ایجاد تیکت‌ها و قرار دادن پیامک در صف ←</button></footer>
</form><aside class="dispatch-history"><header><div><small>ردیابی عملیات</small><h2>ارسال‌های اخیر</h2></div><span>{{ page.paginator.count }}</span></header> </form><aside class="dispatch-history"><header><div><small>ردیابی عملیات</small><h2>ارسال‌های اخیر</h2></div><span>{{ page.paginator.count }}</span></header>
<form method="get" class="dispatch-filter" aria-label="فیلتر ارسال‌ها"><label class="dispatch-search"><span>جستجو</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="موضوع، گروه یا کارشناس"></label><div><label><span>وضعیت</span><select name="status"><option value="">همه وضعیت‌ها</option>{% for value,label in dispatch_statuses %}<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><label><span>واحد</span><select name="unit"><option value="">همه واحدها</option>{% for item in units %}<option value="{{ item.pk }}" {% if request.GET.unit == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label></div><label><span>گروه مخاطب</span><select name="group"><option value="">همه گروه‌ها</option>{% for item in groups %}<option value="{{ item.pk }}" {% if request.GET.group == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.unit.name }} — {{ item.name }}</option>{% endfor %}</select></label><div><label><span>از تاریخ</span><input type="date" name="created_from" value="{{ request.GET.created_from }}"></label><label><span>تا تاریخ</span><input type="date" name="created_to" value="{{ request.GET.created_to }}"></label></div><footer><button type="submit">اعمال فیلتر</button><a href="{% url 'staff_tickets:dispatch_create' %}">پاک کردن</a></footer></form> <form method="get" class="dispatch-filter" aria-label="فیلتر ارسال‌ها"><label class="dispatch-search"><span>جستجو</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="موضوع، گروه یا کارشناس"></label><div><label><span>وضعیت</span><select name="status"><option value="">همه وضعیت‌ها</option>{% for value,label in dispatch_statuses %}<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><label><span>واحد</span><select name="unit"><option value="">همه واحدها</option>{% for item in units %}<option value="{{ item.pk }}" {% if request.GET.unit == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label></div><label><span>گروه مخاطب</span><select name="group"><option value="">همه گروه‌ها</option>{% for item in groups %}<option value="{{ item.pk }}" {% if request.GET.group == item.pk|stringformat:'s' %}selected{% endif %}>{{ item.unit.name }} — {{ item.name }}</option>{% endfor %}</select></label><div><label><span>از تاریخ</span><input type="date" name="created_from" value="{{ request.GET.created_from }}"></label><label><span>تا تاریخ</span><input type="date" name="created_to" value="{{ request.GET.created_to }}"></label></div><footer><button type="submit">اعمال فیلتر</button><a href="{% url 'staff_tickets:dispatch_create' %}">پاک کردن</a></footer></form>
@@ -14,3 +14,4 @@
{% if page.paginator.num_pages > 1 %}<nav class="operations-pagination" aria-label="صفحه‌بندی ارسال‌ها">{% if page.has_previous %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.previous_page_number }}">قبلی</a>{% endif %}<span>صفحه {{ page.number }} از {{ page.paginator.num_pages }}</span>{% if page.has_next %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.next_page_number }}">بعدی</a>{% endif %}</nav>{% endif %} {% if page.paginator.num_pages > 1 %}<nav class="operations-pagination" aria-label="صفحه‌بندی ارسال‌ها">{% if page.has_previous %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.previous_page_number }}">قبلی</a>{% endif %}<span>صفحه {{ page.number }} از {{ page.paginator.num_pages }}</span>{% if page.has_next %}<a href="?q={{ request.GET.q|urlencode }}&status={{ request.GET.status|urlencode }}&unit={{ request.GET.unit|urlencode }}&group={{ request.GET.group|urlencode }}&created_from={{ request.GET.created_from|urlencode }}&created_to={{ request.GET.created_to|urlencode }}&page={{ page.next_page_number }}">بعدی</a>{% endif %}</nav>{% endif %}
</aside></div> </aside></div>
</div>{% endblock %} </div>{% endblock %}
{% block extra_scripts %}<script defer src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
+79 -23
View File
@@ -1,24 +1,80 @@
{% extends 'base.html' %} {% extends 'base.html' %}{% load jalali %}
{% block title %}{{ page_title }}{% endblock %} {% block title %}{{ dispatch.subject }}{% endblock %}
{% block content %}<div class="operations-page operations-dispatch-detail"> {% block content %}
<section class="staff-page-head"> <div class="operations-page operations-dispatch-detail">
<div><p class="eyebrow">شناسه ارسال {{ dispatch.public_id }}</p><h1>{{ dispatch.subject }}</h1><p>{{ dispatch.unit }} · گروه {{ dispatch.contact_group.name }} · {{ dispatch.recipient_count }} مخاطب</p></div> <section class="dispatch-report-hero">
<div><a class="button" href="{% url 'staff_tickets:dispatch_create' %}">ارسال گروهی جدید</a>{% if metrics.failed %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}" style="margin-top:.75rem">{% csrf_token %}<button class="button" type="submit">تلاش مجدد برای {{ metrics.failed }} ارسال ناموفق</button></form>{% endif %}</div> <div class="dispatch-report-title">
</section> <div class="dispatch-title-line"><span class="report-status status-{{ dispatch.status|lower }}">{{ dispatch.get_status_display }}</span><span class="eyebrow">گزارش ارسال گروهی</span></div>
<section class="stats-grid operations-metrics" aria-label="وضعیت ارسال گروهی"><article class="stat-card"><div class="stat-label">پیامک موفق</div><div class="stat-value">{{ metrics.sent }}</div></article><article class="stat-card"><div class="stat-label">در صف</div><div class="stat-value">{{ metrics.pending }}</div></article><article class="stat-card"><div class="stat-label">لینک مشاهده‌شده</div><div class="stat-value">{{ metrics.opened }}</div></article><article class="stat-card"><div class="stat-label">پاسخ ثبت‌شده</div><div class="stat-value">{{ metrics.replied }}</div></article><article class="stat-card alert"><div class="stat-label">ارسال ناموفق</div><div class="stat-value">{{ metrics.failed }}</div></article></section> <h1>{{ dispatch.subject }}</h1>
<section class="ticket-stream"> <div class="dispatch-meta-list">
<header class="stream-head"><div><span class="panel-index">نمای مقایسه‌ای</span><h2>وضعیت دریافت و پاسخ مخاطبان</h2></div><span>{{ dispatch.get_status_display }}</span></header> <span><b>واحد</b>{{ dispatch.unit }}</span>
<div class="table-scroll"><table><thead><tr><th>مخاطب</th><th>شماره</th><th>تیکت</th><th>وضعیت پیامک</th><th>وضعیت تیکت</th><th>بازدید لینک</th><th>پاسخ پیشنهادی</th></tr></thead><tbody> <span><b>گروه مخاطبان</b>{{ dispatch.contact_group.name }}</span>
{% for ticket in tickets %} <span><b>تعداد گیرندگان</b>{{ dispatch.recipient_count }} نفر</span>
<tr> <span><b>زمان ایجاد</b><time datetime="{{ dispatch.created_at|date:'c' }}">{{ dispatch.created_at|jalali_datetime }}</time></span>
<td>{{ ticket.customer.customer_profile|default:ticket.customer.username }}</td> </div>
<td dir="ltr">{{ ticket.customer.local_mobile }}</td> </div>
<td><a href="{% url 'staff_tickets:detail' ticket.public_id %}">{{ ticket.ticket_number }}</a></td> <div class="dispatch-head-actions">
<td>{% if ticket.sms_job %}{% if ticket.sms_job.status == 'SENT' %}پذیرفته‌شده توسط درگاه{% else %}{{ ticket.sms_job.get_status_display }}{% endif %}{% if ticket.sms_job.provider_message_id %}<small class="table-subtitle" dir="ltr">مرجع: {{ ticket.sms_job.provider_message_id|truncatechars:45 }}</small>{% endif %}{% if ticket.sms_job.last_error %}<small class="table-subtitle">{{ ticket.sms_job.last_error|truncatechars:60 }}</small>{% endif %}{% else %}ثبت نشده{% endif %}</td><td>{{ ticket.get_status_display }}</td> <a class="button" href="{% url 'staff_tickets:dispatch_create' %}">+ ارسال گروهی جدید</a>
<td>{% for link in ticket.magic_links.all %}{{ link.view_count }}{% if not forloop.last %} + {% endif %}{% empty %}ارسال نشده{% endfor %}</td> {% if metrics.failed %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}">{% csrf_token %}<button class="button button-danger-soft" type="submit">↻ تلاش مجدد {{ metrics.failed }} پیامک</button></form>{% endif %}
<td>{% if ticket.latest_customer_response %}{{ ticket.latest_customer_response.body|truncatechars:120 }}<small class="table-subtitle">{{ ticket.latest_customer_response.created_at|date:'Y/m/d H:i' }}</small>{% else %}هنوز پاسخی ثبت نشده{% endif %}</td> </div>
</tr> </section>
{% empty %}<tr><td colspan="7">برای این ارسال تیکتی ثبت نشده است.</td></tr>{% endfor %}
</tbody></table></div> <section class="dispatch-original-message" aria-labelledby="dispatch-message-title">
</section></div> <header><div><span class="panel-index">متن اولیه کارشناس</span><h2 id="dispatch-message-title">پیام اصلی این ارسال</h2></div><span>شناسه عملیات: <b dir="ltr">{{ dispatch.public_id }}</b></span></header>
<blockquote>{{ dispatch.body|linebreaksbr }}</blockquote>
<footer><span>ایجادکننده: <b>{{ dispatch.created_by.staff_profile.display_name|default:dispatch.created_by.username }}</b></span><span>این متن در یک تیکت مستقل برای هر مخاطب ثبت شده است.</span></footer>
</section>
<section class="delivery-overview" aria-label="خلاصه دلیوری">
<div class="delivery-progress-copy"><div><span class="panel-index">پیشرفت تحویل</span><strong>{{ metrics.delivery_percent }}٪</strong></div><p>{{ metrics.delivered }} از {{ dispatch.recipient_count }} پیامک تحویل قطعی شده است.</p></div>
<div class="delivery-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ metrics.delivery_percent }}"><span style="width:{{ metrics.delivery_percent }}%"></span></div>
<div class="delivery-overview-note">{% if metrics.tracking %}<b>{{ metrics.tracking }} پیامک در حال پیگیری است</b><span>نتیجه‌های موقت هر ۳ دقیقه تا ۴۵ دقیقه دوباره استعلام می‌شوند.</span>{% elif metrics.failed %}<b>موارد ناموفق نیاز به بررسی دارند</b><span>علت دقیق و دکمه تلاش مجدد در ردیف مخاطب قرار دارد.</span>{% else %}<b>پردازش صف پایان یافته است</b><span>برای جزئیات هر مخاطب، گزارش پایین صفحه را ببینید.</span>{% endif %}</div>
</section>
<section class="dispatch-kpi-grid" aria-label="شاخص‌های ارسال گروهی">
<article class="dispatch-kpi kpi-success"><span class="kpi-icon">✓</span><div><small>تحویل قطعی</small><strong>{{ metrics.delivered }}</strong><p>رسیده به گوشی</p></div></article>
<article class="dispatch-kpi kpi-info"><span class="kpi-icon">◌</span><div><small>پیگیری فعال</small><strong>{{ metrics.tracking }}</strong><p>{{ metrics.sent }} مورد پذیرفته‌شده توسط درگاه</p></div></article>
<article class="dispatch-kpi kpi-danger"><span class="kpi-icon">!</span><div><small>نیازمند اقدام</small><strong>{{ metrics.failed }}</strong><p>ناموفق یا تحویل‌نشده قطعی</p></div></article>
<article class="dispatch-kpi kpi-view"><span class="kpi-icon">◉</span><div><small>تعامل با لینک</small><strong>{{ metrics.opened }}</strong><p>{{ metrics.total_views }} بازدید · {{ metrics.not_opened }} مخاطب بدون بازدید</p></div></article>
<article class="dispatch-kpi kpi-reply"><span class="kpi-icon">↵</span><div><small>پاسخ دریافتی</small><strong>{{ metrics.replied }}</strong><p>پاسخ ثبت‌شده در تیکت‌ها</p></div></article>
</section>
<section class="ticket-stream dispatch-recipient-report">
<header class="stream-head"><div><span class="panel-index">جزئیات هر گیرنده</span><h2>وضعیت پیامک، مشاهده و پاسخ</h2></div><span>{{ dispatch.recipient_count }} مخاطب</span></header>
<div class="table-scroll"><table class="dispatch-detail-table"><thead><tr><th>مخاطب</th><th>تیکت</th><th>وضعیت پیامک</th><th>وضعیت تیکت</th><th>مشاهده لینک</th><th>آخرین پاسخ</th></tr></thead><tbody>
{% for ticket in tickets %}
<tr>
<td data-label="مخاطب"><div class="recipient-cell"><span class="recipient-avatar">{{ ticket.customer.customer_profile.first_name|first|default:'•' }}</span><div><strong>{{ ticket.customer.customer_profile|default:ticket.customer.username }}</strong><small dir="ltr">{{ ticket.customer.local_mobile }}</small></div></div></td>
<td data-label="تیکت"><a class="ticket-reference" href="{% url 'staff_tickets:detail' ticket.public_id %}"><strong dir="ltr">{{ ticket.ticket_number }}</strong><span>مشاهده تیکت ←</span></a></td>
<td data-label="وضعیت پیامک" class="dispatch-sms-state">
<div class="sms-state-card tone-{{ ticket.sms_state_tone }}">
<span class="sms-state-icon">{% if ticket.sms_state_tone == 'success' %}✓{% elif ticket.sms_state_tone == 'danger' %}!{% elif ticket.sms_state_tone == 'warning' %}◌{% else %}↑{% endif %}</span>
<div><strong>{{ ticket.sms_state_title }}</strong><p>{{ ticket.sms_state_hint }}</p>{% if ticket.sms_job.delivery_next_check_at %}<small>استعلام بعدی: {{ ticket.sms_job.delivery_next_check_at|timeuntil }} دیگر</small>{% endif %}</div>
</div>
{% if ticket.sms_job %}
<details class="sms-technical-details">
<summary>جزئیات فنی و رهگیری</summary>
<dl>
<div><dt>ارسال</dt><dd>{% if ticket.sms_job.attempts == 1 %}موفق در اولین تلاش{% else %}{{ ticket.sms_job.attempts }} تلاش از {{ sms_max_attempts }}{% endif %}</dd></div>
{% if ticket.sms_job.provider_message_id %}<div><dt>شناسه رهگیری</dt><dd dir="ltr">{{ ticket.sms_job.provider_message_id }}</dd></div>{% endif %}
{% if ticket.sms_job.delivery_status_code %}<div><dt>کد دلیوری</dt><dd dir="ltr">{{ ticket.sms_job.delivery_status_code }}</dd></div>{% endif %}
{% if ticket.sms_job.delivery_check_attempts %}<div><dt>تعداد استعلام</dt><dd>{{ ticket.sms_job.delivery_check_attempts }} نوبت</dd></div>{% endif %}
{% if ticket.sms_job.delivery_checked_at %}<div><dt>آخرین استعلام</dt><dd>{{ ticket.sms_job.delivery_checked_at|jalali_datetime }}</dd></div>{% endif %}
</dl>
{% if ticket.sms_job.delivery_description %}<p class="gateway-technical-message">{{ ticket.sms_job.delivery_description }}</p>{% endif %}
</details>
{% if ticket.sms_job.last_error %}<div class="sms-error-panel"><b>علت دقیق عدم ارسال</b><p>{{ ticket.sms_job.last_error }}</p></div>{% endif %}
{% if ticket.sms_retryable %}<form method="post" action="{% url 'staff_tickets:dispatch_retry' dispatch.public_id %}" class="inline-retry-form">{% csrf_token %}<input type="hidden" name="job_id" value="{{ ticket.sms_job.pk }}"><button type="submit">↻ تلاش مجدد همین پیامک</button></form>{% endif %}
{% endif %}
</td>
<td data-label="وضعیت تیکت"><span class="ticket-state-pill state-{{ ticket.status|lower }}">{{ ticket.get_status_display }}</span></td>
<td data-label="مشاهده لینک">
<div class="engagement-state {% if ticket.magic_total_views %}has-engagement{% endif %}"><span>{% if ticket.magic_total_views %}◉{% else %}○{% endif %}</span><div>{% if ticket.magic_total_views %}<strong>{{ ticket.magic_total_views }} بازدید</strong>{% if ticket.magic_last_used_at %}<small>آخرین بازدید {{ ticket.magic_last_used_at|jalali_datetime }}</small>{% endif %}{% elif ticket.magic_link_count %}<strong>مشاهده نشده</strong><small>{{ ticket.magic_active_count }} لینک فعال</small>{% else %}<strong>لینک صادر نشده</strong>{% endif %}</div></div>
</td>
<td data-label="آخرین پاسخ">{% if ticket.latest_customer_response %}<div class="recipient-reply"><p>{{ ticket.latest_customer_response.body|truncatechars:140 }}</p><small>{{ ticket.latest_customer_response.created_at|jalali_datetime }}</small></div>{% else %}<span class="no-reply-state">هنوز پاسخی ثبت نشده</span>{% endif %}</td>
</tr>
{% empty %}<tr><td colspan="6">برای این ارسال تیکتی ثبت نشده است.</td></tr>{% endfor %}
</tbody></table></div>
</section>
</div>
{% endblock %} {% endblock %}
+27 -7
View File
@@ -37,7 +37,8 @@ class TicketConversationPage(BasePage):
class PublicTicketCreatePage(BasePage): class PublicTicketCreatePage(BasePage):
def open(self): def open(self):
self.goto("/tickets/new/") self.goto("/tickets/new/")
expect(self.unit).to_be_visible() expect(self.page.locator('[data-wizard-step="1"]')).to_be_visible()
expect(self.page.locator('[data-ticket-subject]')).to_be_visible()
@property @property
def unit(self): def unit(self):
@@ -66,7 +67,10 @@ class PublicTicketCreatePage(BasePage):
and f"unit={unit_id}" in response.url and f"unit={unit_id}" in response.url
and response.status == 200 and response.status == 200
) as response_info: ) as response_info:
self.unit.select_option(unit_id) self.unit.evaluate(
"(node, value) => { node.value = value; node.dispatchEvent(new Event('change', {bubbles: true})); }",
unit_id,
)
payload = response_info.value.json() payload = response_info.value.json()
expect(self.page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", unit_id) expect(self.page.locator("[data-general-queue]")).to_have_attribute("data-unit-id", unit_id)
expected_ids = {str(item["id"]) for item in payload["experts"]} expected_ids = {str(item["id"]) for item in payload["experts"]}
@@ -84,17 +88,24 @@ class PublicTicketCreatePage(BasePage):
card = self.expert_picker.locator("label.expert-card").filter(has_text=display_name) card = self.expert_picker.locator("label.expert-card").filter(has_text=display_name)
expect(card).to_have_count(1) expect(card).to_have_count(1)
radio = card.locator('input[name="selected_expert"]') radio = card.locator('input[name="selected_expert"]')
radio.check() radio.evaluate(
"node => { node.checked = true; node.dispatchEvent(new Event('change', {bubbles: true})); }"
)
expect(radio).to_be_checked() expect(radio).to_be_checked()
return radio.get_attribute("value") return radio.get_attribute("value")
def select_general_queue(self): def select_general_queue(self):
radio = self.queue.locator('input[name="selected_expert"]') radio = self.queue.locator('input[name="selected_expert"]')
radio.check() radio.evaluate(
"node => { node.checked = true; node.dispatchEvent(new Event('change', {bubbles: true})); }"
)
expect(radio).to_be_checked() expect(radio).to_be_checked()
def select_category(self, category_id): def select_category(self, category_id):
self.category.select_option(str(category_id)) self.category.evaluate(
"(node, value) => { node.value = value; node.dispatchEvent(new Event('change', {bubbles: true})); }",
str(category_id),
)
expect(self.category).to_have_value(str(category_id)) expect(self.category).to_have_value(str(category_id))
def upload_gallery(self, files, *, total_count=None): def upload_gallery(self, files, *, total_count=None):
@@ -108,13 +119,22 @@ class PublicTicketCreatePage(BasePage):
def remove_attachment(self, filename): def remove_attachment(self, filename):
chip = self.file_preview.locator(".file-chip").filter(has_text=filename) chip = self.file_preview.locator(".file-chip").filter(has_text=filename)
expect(chip).to_have_count(1) expect(chip).to_have_count(1)
chip.get_by_role("button", name="حذف فایل").click() chip.locator("button").evaluate("node => node.click()")
expect(self.file_preview.locator(".file-chip").filter(has_text=filename)).to_have_count(0) expect(self.file_preview.locator(".file-chip").filter(has_text=filename)).to_have_count(0)
def submit(self, *, subject, body): def submit(self, *, subject, body):
self.page.locator('input[name="subject"]').fill(subject) self.page.locator('input[name="subject"]').fill(subject)
self.page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
expect(self.page.locator('[data-wizard-step="2"]')).to_be_visible()
self.page.locator('[data-wizard-step="2"] [data-wizard-next]').click()
expect(self.page.locator('[data-wizard-step="3"]')).to_be_visible()
self.page.locator('[data-wizard-step="3"] [data-wizard-next]').click()
expect(self.page.locator('[data-wizard-step="4"]')).to_be_visible()
self.page.locator('textarea[name="body"]').fill(body) self.page.locator('textarea[name="body"]').fill(body)
self.page.locator('[data-wizard-step="4"] [data-wizard-next]').click()
expect(self.page.locator('[data-wizard-step="5"]')).to_be_visible()
expect(self.page.locator('[data-review-subject]')).to_have_text(subject)
with self.page.expect_navigation(wait_until="domcontentloaded"): with self.page.expect_navigation(wait_until="domcontentloaded"):
self.page.get_by_role("button", name="ثبت و ارسال درخواست").click() self.page.get_by_role("button", name="ثبت و ارسال نهایی").click()
expect(self.page).to_have_url(re.compile(r"/tickets/(?!new/)[^/]+/$")) expect(self.page).to_have_url(re.compile(r"/tickets/(?!new/)[^/]+/$"))
expect(self.page.locator("h1")).to_have_text(subject) expect(self.page.locator("h1")).to_have_text(subject)
@@ -1,4 +1,5 @@
import io import io
import re
import pytest import pytest
from django.core.management import call_command from django.core.management import call_command
@@ -133,6 +134,8 @@ def test_e2e_subject_stays_empty_for_manual_and_search_routing_until_user_types(
with browser_page() as page: with browser_page() as page:
form = open_form(page, live_server, customer) form = open_form(page, live_server, customer)
subject_input = page.locator("[data-ticket-subject]") subject_input = page.locator("[data-ticket-subject]")
page.locator(".ticket-manual-routing summary").click()
expect(form.unit).to_be_visible()
unit_box = form.unit.bounding_box() unit_box = form.unit.bounding_box()
category_box = form.category.bounding_box() category_box = form.category.bounding_box()
assert unit_box and category_box assert unit_box and category_box
@@ -159,6 +162,115 @@ def test_e2e_subject_stays_empty_for_manual_and_search_routing_until_user_types(
) )
def test_e2e_customer_wizard_validation_preview_edit_and_floating_submit(
live_server, browser_page, public_workflow
):
customer, units, categories = public_workflow
subject = "بازبینی مرحله‌ای و ویرایش E2E"
with browser_page() as page:
page.set_viewport_size({"width": 390, "height": 640})
form = open_form(page, live_server, customer)
expect(page.locator('[data-wizard-step="1"]')).to_be_visible()
expect(page.locator('[data-wizard-step="2"]')).to_be_hidden()
expect(page.locator('[data-ticket-search-results]')).to_be_hidden()
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
error = page.locator('[data-wizard-error="1"]')
expect(error).to_be_visible()
expect(error).to_be_in_viewport()
expect(page.locator('[data-ticket-subject]')).to_have_attribute("aria-invalid", "true")
expect(form.category).to_be_visible()
assert page.evaluate("document.activeElement === document.querySelector('[data-wizard-error=\"1\"]')")
form.select_search_result("عمومی فروش", "عمومی فروش")
expect(page.locator('[data-ticket-subject]')).to_have_value("")
page.locator('[data-ticket-subject]').fill(subject)
expect(page.locator('[data-ticket-subject]')).to_have_value(subject)
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
page.locator('[data-wizard-step="2"] [data-wizard-next]').click()
page.locator('[data-wizard-step="3"] [data-wizard-next]').click()
page.locator('textarea[name="body"]').fill("شرح خوانا برای پیش‌نمایش مرحله پنجم")
page.locator('[data-wizard-step="4"] [data-wizard-next]').click()
expect(page.locator('[data-wizard-step="5"]')).to_be_visible()
expect(page.locator('[data-review-subject]')).to_have_text(subject)
expect(page.locator('[data-review-body]')).to_contain_text("شرح خوانا")
final_submit = page.get_by_role("button", name="ثبت و ارسال نهایی")
expect(final_submit).to_be_visible()
submit_box = final_submit.bounding_box()
assert submit_box and submit_box["y"] + submit_box["height"] <= page.viewport_size["height"]
assert page.locator('[data-review-subject]').evaluate("node => parseFloat(getComputedStyle(node).fontSize)") >= 16
assert page.locator('[data-review-body]').evaluate("node => parseFloat(getComputedStyle(node).fontSize)") >= 14
page.locator('[data-wizard-edit="1"]').click()
expect(page.locator('[data-ticket-subject]')).to_have_value(subject)
expect(page.locator('[data-ticket-unit]')).to_have_value(str(units["sales"].pk))
expect(page.locator('[data-ticket-category]')).to_have_value(str(categories["sales_general"].pk))
def test_e2e_customer_wizard_auto_stops_voice_and_submits_audio_only_ticket(
live_server, browser_page, public_workflow
):
customer, units, categories = public_workflow
subject = "ثبت تیکت فقط با پیام صوتی E2E"
with browser_page() as page:
page.add_init_script(
"""
window.__microphoneTrackStops = 0;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {getUserMedia: async () => ({getTracks: () => [{stop: () => {window.__microphoneTrackStops += 1;}}]})},
});
class FakeMediaRecorder {
constructor(stream, options = {}) {this.stream = stream; this.mimeType = options.mimeType || 'audio/wav'; this.state = 'inactive';}
start() {this.state = 'recording';}
pause() {this.state = 'paused';}
resume() {this.state = 'recording';}
stop() {
this.state = 'inactive';
const wav = new Uint8Array([
82,73,70,70,40,0,0,0,87,65,86,69,102,109,116,32,
16,0,0,0,1,0,1,0,68,172,0,0,136,88,1,0,
2,0,16,0,100,97,116,97,4,0,0,0,0,0,0,0,
]);
this.ondataavailable?.({data: new Blob([wav], {type: this.mimeType})});
setTimeout(() => this.onstop?.(), 0);
}
static isTypeSupported(type) {return type === 'audio/wav';}
}
Object.defineProperty(window, 'MediaRecorder', {configurable: true, value: FakeMediaRecorder});
"""
)
form = open_form(page, live_server, customer)
form.select_unit(units["sales"].pk)
form.select_category(categories["sales_general"].pk)
form.select_general_queue()
page.locator('[data-ticket-subject]').fill(subject)
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
page.locator('[data-wizard-step="2"] [data-wizard-next]').click()
page.locator('[data-wizard-step="3"] [data-wizard-next]').click()
expect(page.locator('[data-wizard-step="4"]')).to_be_visible()
page.get_by_role("button", name="شروع ضبط صدا").click()
expect(page.locator('[data-voice-live]')).to_be_visible()
page.get_by_role("button", name="مشاهده پیش‌نمایش").click()
expect(page.locator('[data-wizard-step="5"]')).to_be_visible()
expect(page.locator('[data-review-audio]')).to_be_visible()
assert page.evaluate("window.__microphoneTrackStops") == 1
with page.expect_navigation(wait_until="domcontentloaded"):
page.get_by_role("button", name="ثبت و ارسال نهایی").click()
expect(page).to_have_url(re.compile(r"/tickets/(?!new/)[^/]+/$"))
ticket = Ticket.objects.get(subject=subject)
assert ticket.owning_unit == units["sales"]
assert ticket.category == categories["sales_general"]
attachments = list(ticket.ticket_messages.get().attachments.all())
assert len(attachments) == 1
assert attachments[0].mime_type.startswith("audio/")
assert attachments[0].original_name.startswith("voice-")
def test_e2e_experts_follow_configured_numeric_order(live_server, browser_page, public_workflow): def test_e2e_experts_follow_configured_numeric_order(live_server, browser_page, public_workflow):
customer, units, _ = public_workflow customer, units, _ = public_workflow
configured = { configured = {
+66 -3
View File
@@ -3,15 +3,16 @@ import base64
import pytest import pytest
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from playwright.sync_api import expect from playwright.sync_api import expect
from apps.accounts.models import TicketMagicLink, User from apps.accounts.models import TicketMagicLink, User
from apps.customers.models import ContactGroup, CustomerProfile from apps.customers.models import ContactGroup, CustomerProfile
from apps.core.models import PageTitle, SiteBranding from apps.core.models import PageTitle, SiteBranding
from apps.notifications.models import SMSTemplate from apps.notifications.models import NotificationJob, SMSTemplate
from apps.staff.models import OrganizationalUnit, StaffProfile from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import TicketCategory from apps.tickets.models import TicketCategory
from apps.tickets.services import create_ticket from apps.tickets.services import create_ticket, dispatch_group_tickets
from tests.e2e.pages.auth import AuthenticationPage from tests.e2e.pages.auth import AuthenticationPage
@@ -79,6 +80,20 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
expect(page.locator(".staff-developer-credit")).to_contain_text("امضای مستقل sidebar E2E") expect(page.locator(".staff-developer-credit")).to_contain_text("امضای مستقل sidebar E2E")
expect(page.get_by_role("searchbox", name="جستجو")).to_be_visible() expect(page.get_by_role("searchbox", name="جستجو")).to_be_visible()
expect(page.locator('link[href*="dispatches.css"]')).to_have_count(1) expect(page.locator('link[href*="dispatches.css"]')).to_have_count(1)
expect(page.get_by_text("افزودن عکس از گالری")).to_be_visible()
expect(page.locator('label[for="dispatch-file-input"]')).to_be_visible()
tiny_png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
page.locator("#dispatch-gallery-input").set_input_files([
{"name":"dispatch-first.png","mimeType":"image/png","buffer":tiny_png},
{"name":"dispatch-second.png","mimeType":"image/png","buffer":tiny_png},
])
expect(page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip")).to_have_count(2)
expect(page.locator(".dispatch-attachment-picker [data-file-preview] img")).to_have_count(2)
page.locator("#dispatch-file-input").set_input_files({"name":"dispatch-document.pdf","mimeType":"application/pdf","buffer":b"%PDF-1.4\n%%EOF"})
expect(page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip")).to_have_count(3)
page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip button").first.click()
expect(page.locator(".dispatch-attachment-picker [data-file-preview] .file-chip")).to_have_count(2)
assert page.locator('.dispatch-attachment-picker input[name="files"]').evaluate("input => input.files.length") == 2
page.goto(f"{live_server.url}{reverse('staff_tickets:sms_templates')}", wait_until="domcontentloaded") page.goto(f"{live_server.url}{reverse('staff_tickets:sms_templates')}", wait_until="domcontentloaded")
expect(page.get_by_text("الگوی عمومی سیستم").first).to_be_visible() expect(page.get_by_text("الگوی عمومی سیستم").first).to_be_visible()
@@ -105,7 +120,6 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
expect(page.locator(f'input[name="groups"][value="{secondary_group.pk}"]')).to_be_checked() expect(page.locator(f'input[name="groups"][value="{secondary_group.pk}"]')).to_be_checked()
page.goto(f"{live_server.url}{reverse('staff_tickets:create')}", wait_until="domcontentloaded") page.goto(f"{live_server.url}{reverse('staff_tickets:create')}", wait_until="domcontentloaded")
tiny_png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
page.locator("#staff-ticket-gallery-input").set_input_files([ page.locator("#staff-ticket-gallery-input").set_input_files([
{"name": "first.png", "mimeType": "image/png", "buffer": tiny_png}, {"name": "first.png", "mimeType": "image/png", "buffer": tiny_png},
{"name": "second.png", "mimeType": "image/png", "buffer": tiny_png}, {"name": "second.png", "mimeType": "image/png", "buffer": tiny_png},
@@ -115,3 +129,52 @@ def test_staff_operations_pages_are_authorized_and_visually_styled(live_server,
page.locator("[data-file-preview] .file-chip button").first.click() page.locator("[data-file-preview] .file-chip button").first.click()
expect(page.locator("[data-file-preview] .file-chip")).to_have_count(1) expect(page.locator("[data-file-preview] .file-chip")).to_have_count(1)
assert page.locator('input[name="files"]').evaluate("input => input.files.length") == 1 assert page.locator('input[name="files"]').evaluate("input => input.files.length") == 1
def test_dispatch_report_presents_delivery_and_engagement_professionally(live_server, browser_page):
from apps.core.templatetags.jalali import jalali_datetime
unit=OrganizationalUnit.objects.create(name="فروش گزارش E2E",slug="dispatch-report-e2e")
unit.permissions.add(*Permission.objects.filter(content_type__app_label="tickets",codename__in=("view_ticket","dispatch_group_tickets")))
staff=User.objects.create_user(username="dispatch-report-agent",password=PASSWORD,account_type=User.AccountType.STAFF)
StaffProfile.objects.create(user=staff,display_name="کارشناس گزارش",primary_unit=unit)
customer=User.objects.create_user(username="dispatch-report-customer",mobile="09120009131",account_type=User.AccountType.CUSTOMER)
profile=CustomerProfile.objects.create(user=customer,first_name="مخاطب",last_name="آزمایشی")
group=ContactGroup.objects.create(unit=unit,name="گروه گزارش حرفه‌ای")
profile.groups.add(group)
category=TicketCategory.objects.create(unit=unit,title="استعلام گزارش",slug="dispatch-report-category-e2e")
dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="استعلام حرفه‌ای",body="این متن اولیه کارشناس باید بالای گزارش دیده شود.")
ticket=tickets[0]
job=NotificationJob.objects.get(ticket=ticket,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED)
NotificationJob.objects.filter(pk=job.pk).update(
status=NotificationJob.Status.SENT,attempts=1,sent_at=timezone.now(),
provider_message_id="1111182358",provider_status_code="1111182358",
delivery_status=NotificationJob.DeliveryStatus.DELIVERED,delivery_status_code="5",
delivery_description="0098SMS [کد 5]: پیامک به گوشی گیرنده رسیده است.",
delivery_check_attempts=2,delivery_checked_at=timezone.now(),delivery_next_check_at=None,
)
links=[TicketMagicLink.issue_with_record(ticket,created_by=staff)[1] for _ in range(4)]
links[0].view_count=2; links[0].used_at=timezone.now(); links[0].save(update_fields=["view_count","used_at"])
with browser_page() as page:
AuthenticationPage(page,live_server.url).login_staff(staff,PASSWORD)
page.goto(f"{live_server.url}{reverse('staff_tickets:dispatch_detail',args=[dispatch.public_id])}",wait_until="domcontentloaded")
expect(page.get_by_role("heading",name="استعلام حرفه‌ای",level=1)).to_be_visible()
expect(page.get_by_text("این متن اولیه کارشناس باید بالای گزارش دیده شود.")).to_be_visible()
expect(page.locator(".sms-state-card")).to_contain_text("تحویل به گوشی تأیید شد")
expect(page.locator(".engagement-state")).to_contain_text("2 بازدید")
expect(page.locator('[role="progressbar"]')).to_have_attribute("aria-valuenow","100")
expect(page.locator(".delivery-progress-copy")).to_contain_text("100٪")
assert float(page.locator(".sms-state-card strong").evaluate("node => parseFloat(getComputedStyle(node).fontSize)")) >= 12
assert float(page.locator(".delivery-progress-track").evaluate("node => parseFloat(getComputedStyle(node).height)")) <= 8
created_text=page.locator(".dispatch-meta-list time").inner_text()
assert created_text == jalali_datetime(dispatch.created_at)
assert "0 + 0" not in page.locator("body").inner_text()
page.get_by_text("جزئیات فنی و رهگیری").click()
expect(page.get_by_text("1111182358")).to_be_visible()
expect(page.get_by_text("موفق در اولین تلاش")).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
page.set_viewport_size({"width":390,"height":844})
expect(page.locator(".dispatch-detail-table tbody tr").first).to_be_visible()
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1")
+143 -10
View File
@@ -421,6 +421,105 @@ def test_due_unread_staff_reply_is_sent_once(category,customer,monkeypatch):
call_command("process_notifications",once=True,job_id=job.pk) call_command("process_notifications",once=True,job_id=job.pk)
assert len(sent)==1 assert len(sent)==1
@pytest.mark.django_db
def test_every_failed_sms_gets_exactly_three_retries_three_minutes_apart(category,customer,monkeypatch,settings):
from django.core.management import call_command
from apps.integrations.sms.base import SMSResult
from apps.notifications.management.commands import process_notifications
settings.SMS_MAX_RETRIES=3
settings.SMS_RETRY_DELAY_SECONDS=180
ticket=create_ticket(customer=customer,category=category,subject="آزمون retry پیامک",body="متن")
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
calls=[]
class Provider:
def send_message(self,mobile,message):
calls.append((mobile,message))
return SMSResult(False,error="0098SMS [کد -19]: شارژ پنل برای ارسال کافی نیست.",provider_code="-19")
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:Provider())
for attempt in range(1,5):
before=timezone.now()
call_command("process_notifications",once=True,job_id=job.pk)
job.refresh_from_db()
assert job.attempts==attempt
assert job.provider_status_code=="-19"
assert "کد -19" in job.last_error
if attempt<4:
assert job.status==NotificationJob.Status.RETRYING
assert timedelta(seconds=179) <= job.next_attempt_at-before <= timedelta(seconds=181)
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now()-timedelta(seconds=1))
else:
assert job.status==NotificationJob.Status.FAILED
call_command("process_notifications",once=True,job_id=job.pk)
assert len(calls)==4
@pytest.mark.django_db
def test_worker_persists_0098_delivery_result(category,customer,monkeypatch,settings):
from django.core.management import call_command
from apps.integrations.sms.base import SMSDeliveryResult
from apps.notifications.management.commands import process_notifications
ticket=create_ticket(customer=customer,category=category,subject="استعلام دلیوری",body="متن")
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
NotificationJob.objects.filter(pk=job.pk).update(
status=NotificationJob.Status.SENT,
provider_message_id="123456789",
delivery_status=NotificationJob.DeliveryStatus.PENDING,
delivery_next_check_at=timezone.now()-timedelta(seconds=1),
)
class Provider:
def get_delivery_status(self,provider_id):
assert provider_id=="123456789"
return SMSDeliveryResult("DELIVERED","5","0098SMS [کد 5]: پیامک به گوشی گیرنده رسیده است.")
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:Provider())
call_command("process_notifications",once=True,job_id=job.pk)
job.refresh_from_db()
assert job.delivery_status==NotificationJob.DeliveryStatus.DELIVERED
assert job.delivery_status_code=="5"
assert job.delivery_next_check_at is None
@pytest.mark.django_db
def test_worker_rechecks_provisional_delivery_until_definitive_result(category,customer,monkeypatch,settings):
from django.core.management import call_command
from apps.integrations.sms.base import SMSDeliveryResult
from apps.notifications.management.commands import process_notifications
settings.SMS_DELIVERY_CHECK_DELAY_SECONDS=180
settings.SMS_DELIVERY_MAX_CHECKS=15
ticket=create_ticket(customer=customer,category=category,subject="پیگیری طولانی دلیوری",body="متن")
job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
NotificationJob.objects.filter(pk=job.pk).update(
status=NotificationJob.Status.SENT, sent_at=timezone.now(), provider_message_id="123456789",
delivery_status=NotificationJob.DeliveryStatus.PENDING,
delivery_next_check_at=timezone.now()-timedelta(seconds=1),
)
results=[
SMSDeliveryResult("UNKNOWN","8","وضعیت هنوز مشخص نشده است.",is_final=False),
SMSDeliveryResult("UNDELIVERED","6","تحویل هنوز تأیید نشده است.",is_final=False),
SMSDeliveryResult("DELIVERED","5","پیامک به گوشی رسیده است."),
]
class Provider:
def get_delivery_status(self,provider_id): return results.pop(0)
provider=Provider()
monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:provider)
for expected_status,expected_attempts in ((NotificationJob.DeliveryStatus.UNKNOWN,1),(NotificationJob.DeliveryStatus.UNDELIVERED,2)):
before=timezone.now()
call_command("process_notifications",once=True,job_id=job.pk)
job.refresh_from_db()
assert job.delivery_status==expected_status
assert job.delivery_check_attempts==expected_attempts
assert timedelta(seconds=179) <= job.delivery_next_check_at-before <= timedelta(seconds=181)
NotificationJob.objects.filter(pk=job.pk).update(delivery_next_check_at=timezone.now()-timedelta(seconds=1))
call_command("process_notifications",once=True,job_id=job.pk)
job.refresh_from_db()
assert job.delivery_status==NotificationJob.DeliveryStatus.DELIVERED
assert job.delivery_check_attempts==3 and job.delivery_next_check_at is None
@pytest.mark.django_db @pytest.mark.django_db
def test_customer_ticket_created_sms_has_seven_day_magic_link(category,customer,settings,monkeypatch): def test_customer_ticket_created_sms_has_seven_day_magic_link(category,customer,settings,monkeypatch):
from datetime import timedelta from datetime import timedelta
@@ -512,16 +611,50 @@ def test_behin_provider_maps_send_array_contract(settings):
assert calls["CheckingMessageID"]=={"long":[0]} assert calls["CheckingMessageID"]=={"long":[0]}
assert calls["SpecialNumber"]=="3000" and calls["IsFlashMessage"] is False assert calls["SpecialNumber"]=="3000" and calls["IsFlashMessage"] is False
def test_0098_provider_maps_http_contract(settings,monkeypatch): def test_0098_provider_maps_soap_contract(settings):
from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider
settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000" settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000"
calls={} calls={}
class Response: class Service:
text="42" def SendSMSWithID(self,**parameters): calls.update(parameters); return "123456789"
def raise_for_status(self): return None class Client: service=Service()
def fake_get(url,**kwargs): calls.update(url=url,**kwargs); return Response() provider=ZeroZeroNinetyEightSMSProvider(); provider._client=Client()
monkeypatch.setattr("apps.integrations.sms.sms0098.requests.get",fake_get) result=provider.send_message("09202202129","پیام آزمایشی")
result=ZeroZeroNinetyEightSMSProvider().send_message("09202202129","پیام آزمایشی") assert result.success and result.provider_id=="123456789"
assert result.success and result.provider_id=="42" assert calls["mobileno"]=="09202202129"
assert calls["params"]["TO"]==9202202129 assert calls["text"]=="پیام آزمایشی"
assert calls["params"]["TEXT"]=="پیام آزمایشی" assert calls["pnlno"]=="3000"
assert set(calls)=={"username","password","text","mobileno","pnlno"}
@pytest.mark.parametrize("code,expected", [
("-3", "تطابق ندارند"), ("10", "اشتباه است"), ("11", "کاراکتر غیرمجاز"),
("-17", "خالی است"), ("-18", "خطای شارژ"), ("-19", "شارژ پنل"),
("-22", "شماره موبایل"), ("66", "تطابق ندارند"), ("1111", "کاراکتر غیرمجاز"),
("Hang", "مسدود است"), ("No Doc", "مرحله دوم"), ("-23", "مجوز ارسال"),
("-24", "شماره پنل"), ("-25", "اعتبار شماره پنل"), ("-26", "تنظیمات کد OTP"),
("-27", "فرمت کد OTP"), ("-28", "تأیید نشده"), ("Wrong timestamp", "timestamp"),
("Wrong time", "خارج از بازه"), ("-29", "فرمت کد OTP"), ("-31", "یافت نشد"),
("-32", "مطابقت ندارد"), ("13", "IP غیرمجاز"),
])
def test_0098_provider_maps_every_documented_send_error(settings,code,expected):
from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider
settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000"
provider=ZeroZeroNinetyEightSMSProvider()
result=provider._parse_send_result(code)
assert not result.success
assert result.provider_code==code
assert expected in result.error
@pytest.mark.parametrize("code,status,is_final", [("4","BLOCKED",True),("5","DELIVERED",True),("6","UNDELIVERED",False),("7","EXPIRED",True),("8","UNKNOWN",False),("9","ERROR",True),("12","ERROR",True),("13","ERROR",True)])
def test_0098_provider_maps_documented_delivery_states(settings,code,status,is_final):
from apps.integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider
settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000"
class Service:
def smsdeliveryState(self,**parameters): return code
class Client: service=Service()
provider=ZeroZeroNinetyEightSMSProvider(); provider._client=Client()
result=provider.get_delivery_status("123456789")
assert result.status==status and result.provider_code==code and result.is_final is is_final
assert f"کد {code}" in result.description
+100 -3
View File
@@ -1,7 +1,11 @@
import base64
from datetime import timedelta
import pytest import pytest
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import MagicLinkAccessLog, TicketMagicLink, User from apps.accounts.models import MagicLinkAccessLog, TicketMagicLink, User
from apps.attachments.models import AttachmentPolicy from apps.attachments.models import AttachmentPolicy
@@ -157,6 +161,41 @@ def test_group_dispatch_copies_shared_document_into_each_private_ticket():
assert len(set(paths))==2 assert len(set(paths))==2
@pytest.mark.django_db
def test_dispatch_page_supports_professional_multi_attachment_picker_and_submission(client):
unit=OrganizationalUnit.objects.create(name="واحد پیوست گروهی",slug="dispatch-attachment-unit")
unit.permissions.add(Permission.objects.get(codename="dispatch_group_tickets",content_type__app_label="tickets"))
staff=make_staff("dispatch-attachment-staff",unit)
customer=make_contact("dispatch-attachment-customer","09120000313")
group=ContactGroup.objects.create(name="گروه پیوست",unit=unit)
customer.customer_profile.groups.add(group)
category=TicketCategory.objects.create(unit=unit,title="اسناد گروهی",slug="dispatch-attachments-category")
AttachmentPolicy.objects.update_or_create(attachment_type="IMAGE",defaults={"max_file_size":1024*1024,"max_files_per_message":10,"allowed_extensions":[".png"],"allowed_mime_types":["image/png"]})
AttachmentPolicy.objects.update_or_create(attachment_type="PDF",defaults={"max_file_size":1024*1024,"max_files_per_message":10,"allowed_extensions":[".pdf"],"allowed_mime_types":["application/pdf"]})
client.force_login(staff)
url=reverse("staff_tickets:dispatch_create")
page=client.get(url)
content=page.content.decode()
assert page.status_code==200
for marker in ("data-ticket-composer","data-gallery-input","data-document-input","data-file-preview","attachment-uploader.js"):
assert marker in content
assert "افزودن عکس از گالری" in content and "افزودن فایل" in content
response=client.post(url,{
"operation_type":GroupDispatch.OperationType.BULK,"unit":unit.pk,"contact_group":group.pk,
"category":category.pk,"subject":"ارسال چند پیوست گروهی","body":"تصویر و سند را بررسی کنید.",
"files":[
SimpleUploadedFile("preview.png",base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="),content_type="image/png"),
SimpleUploadedFile("document.pdf",b"%PDF-1.4\n%%EOF",content_type="application/pdf"),
],
})
assert response.status_code==302, response.context["form"].errors.as_json() if response.context else response.content.decode()
ticket=Ticket.objects.get(subject="ارسال چند پیوست گروهی")
assert set(ticket.ticket_messages.get().attachments.values_list("original_name",flat=True))=={"preview.png","document.pdf"}
@pytest.mark.django_db @pytest.mark.django_db
def test_magic_link_records_every_open_with_ip_and_user_agent(client): def test_magic_link_records_every_open_with_ip_and_user_agent(client):
unit = OrganizationalUnit.objects.create(name="حسابداری ممیزی", slug="audit-accounting") unit = OrganizationalUnit.objects.create(name="حسابداری ممیزی", slug="audit-accounting")
@@ -367,29 +406,87 @@ def test_unit_scoped_add_ticket_preserves_contact_ownership_and_stakeholder(clie
@pytest.mark.django_db @pytest.mark.django_db
def test_dispatch_retry_is_unit_scoped_and_refreshes_persistent_status(client): def test_dispatch_retry_is_unit_scoped_and_refreshes_persistent_status(client):
from apps.core.templatetags.jalali import jalali_datetime
unit=OrganizationalUnit.objects.create(name="لوجستیک retry",slug="logistics-retry") unit=OrganizationalUnit.objects.create(name="لوجستیک retry",slug="logistics-retry")
other=OrganizationalUnit.objects.create(name="فروش retry",slug="sales-retry") other=OrganizationalUnit.objects.create(name="فروش retry",slug="sales-retry")
permission=Permission.objects.get(content_type__app_label="tickets",codename="dispatch_group_tickets") permission=Permission.objects.get(content_type__app_label="tickets",codename="dispatch_group_tickets")
unit.permissions.add(permission) unit.permissions.add(permission,Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
staff=make_staff("retry-dispatcher",unit); outsider=make_staff("retry-outsider",other) staff=make_staff("retry-dispatcher",unit); outsider=make_staff("retry-outsider",other)
contact=make_contact("retry-carrier","09120000302") contact=make_contact("retry-carrier","09120000302")
group=ContactGroup.objects.create(name="باربری retry",unit=unit); contact.customer_profile.groups.add(group) group=ContactGroup.objects.create(name="باربری retry",unit=unit); contact.customer_profile.groups.add(group)
category=TicketCategory.objects.create(title="استعلام retry",slug="quote-retry") category=TicketCategory.objects.create(title="استعلام retry",slug="quote-retry")
dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="استعلام هزینه جدید",body="قیمت را ثبت کنید.") dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="استعلام هزینه جدید",body="قیمت را ثبت کنید.")
job=NotificationJob.objects.get(ticket=tickets[0],channel="SMS") job=NotificationJob.objects.get(ticket=tickets[0],channel="SMS")
job.status=NotificationJob.Status.FAILED; job.attempts=3; job.last_error="provider"; job.save() job.status=NotificationJob.Status.FAILED; job.attempts=4; job.last_error="0098SMS [کد -19]: شارژ پنل برای ارسال کافی نیست."; job.provider_status_code="-19"; job.save()
for _ in range(4):
TicketMagicLink.issue_with_record(tickets[0],reason="staff_created_ticket",created_by=staff)
# Supervisor alerts are separate from the recipient message and must not
# alter dispatch metrics or become the row's displayed/retryable SMS.
NotificationJob.objects.create(
channel=NotificationJob.Channel.SMS,event_type="UNIT_SUPERVISOR_NEW_TICKET",
ticket=tickets[0],customer=tickets[0].customer,payload={"recipient_mobile":"09120000999"},
status=NotificationJob.Status.SENT,attempts=1,next_attempt_at=timezone.now(),
dedupe_key=f"test-supervisor-{tickets[0].pk}",
)
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db() refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db()
assert dispatch.status==GroupDispatch.Status.PARTIAL assert dispatch.status==GroupDispatch.Status.PARTIAL
client.force_login(staff)
detail=client.get(reverse("staff_tickets:dispatch_detail",args=[dispatch.public_id]))
content=detail.content.decode()
assert detail.status_code==200
assert "قیمت را ثبت کنید." in content
assert "متن اولیه کارشناس" in content
assert "کد -19" in content and "شارژ پنل" in content
assert "جزئیات فنی و رهگیری" in content
assert jalali_datetime(dispatch.created_at) in content
assert dispatch.created_at.strftime("%Y/%m/%d %H:%M") not in content
assert "0 + 0" not in content
assert "مشاهده نشده" in content
assert detail.context["tickets"][0].sms_job.pk==job.pk
assert detail.context["metrics"]["failed"]==1
ticket_detail=client.get(reverse("staff_tickets:detail",args=[tickets[0].public_id]))
assert ticket_detail.status_code==200
assert "علت دقیق" in ticket_detail.content.decode() and "کد -19" in ticket_detail.content.decode()
client.force_login(outsider) client.force_login(outsider)
assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id])).status_code==403 assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id])).status_code==403
client.force_login(staff) client.force_login(staff)
assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id])).status_code==302 assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id]),{"job_id":job.pk}).status_code==302
job.refresh_from_db(); dispatch.refresh_from_db() job.refresh_from_db(); dispatch.refresh_from_db()
assert (job.status,job.attempts,job.last_error)==(NotificationJob.Status.PENDING,0,"") assert (job.status,job.attempts,job.last_error)==(NotificationJob.Status.PENDING,0,"")
assert job.provider_status_code=="" and job.delivery_status==""
assert dispatch.status==GroupDispatch.Status.QUEUED assert dispatch.status==GroupDispatch.Status.QUEUED
@pytest.mark.django_db
def test_provisional_delivery_is_tracking_not_failure_or_manual_retry(client):
unit=OrganizationalUnit.objects.create(name="پیگیری دلیوری",slug="delivery-tracking-unit")
unit.permissions.add(*Permission.objects.filter(content_type__app_label="tickets",codename__in=("view_ticket","dispatch_group_tickets")))
staff=make_staff("delivery-tracking-agent",unit)
contact=make_contact("delivery-tracking-contact","09120000322")
group=ContactGroup.objects.create(name="گروه پیگیری",unit=unit); contact.customer_profile.groups.add(group)
category=TicketCategory.objects.create(unit=unit,title="پیگیری",slug="delivery-tracking-category")
dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="پیگیری تحویل",body="متن")
job=NotificationJob.objects.get(ticket=tickets[0],event_type="STAFF_TICKET_CREATED")
NotificationJob.objects.filter(pk=job.pk).update(
status=NotificationJob.Status.SENT,attempts=1,sent_at=timezone.now(),provider_message_id="123456789",
delivery_status=NotificationJob.DeliveryStatus.UNKNOWN,delivery_status_code="8",
delivery_next_check_at=timezone.now()+timedelta(minutes=3),delivery_check_attempts=1,
)
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db()
assert dispatch.status==GroupDispatch.Status.SENT
client.force_login(staff)
detail=client.get(reverse("staff_tickets:dispatch_detail",args=[dispatch.public_id]))
assert detail.context["metrics"]["tracking"]==1 and detail.context["metrics"]["failed"]==0
assert detail.context["tickets"][0].sms_retryable is False
client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id]),{"job_id":job.pk})
job.refresh_from_db()
assert job.status==NotificationJob.Status.SENT and job.delivery_status==NotificationJob.DeliveryStatus.UNKNOWN
@pytest.mark.django_db @pytest.mark.django_db
def test_magic_link_can_be_revoked_only_with_unit_permission(client): def test_magic_link_can_be_revoked_only_with_unit_permission(client):
unit=OrganizationalUnit.objects.create(name="حسابرسی",slug="audit-unit") unit=OrganizationalUnit.objects.create(name="حسابرسی",slug="audit-unit")
+34 -2
View File
@@ -6,7 +6,7 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from apps.accounts.models import User from apps.accounts.models import User
from apps.staff.models import OrganizationalUnit, StaffProfile from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import TicketCategory from apps.tickets.models import Ticket, TicketCategory
from apps.tickets.services import create_ticket, claim_ticket, reply_ticket from apps.tickets.services import create_ticket, claim_ticket, reply_ticket
from apps.messaging.models import Message,MessageReadReceipt from apps.messaging.models import Message,MessageReadReceipt
from apps.customers.models import CustomerProfile from apps.customers.models import CustomerProfile
@@ -112,10 +112,42 @@ def test_new_ticket_page_has_expert_cards_voice_and_file_preview(client):
client.force_login(customer) client.force_login(customer)
content = client.get(reverse("tickets:new"),{"unit":unit.pk}).content.decode() content = client.get(reverse("tickets:new"),{"unit":unit.pk}).content.decode()
assert "کارشناس نمونه" in content assert "کارشناس نمونه" in content
assert "data-voice-recorder" in content assert "data-ticket-voice-recorder" in content
assert "data-file-preview" in content assert "data-file-preview" in content
assert "data-ticket-wizard" in content
assert "customer-ticket-wizard.js" in content
assert reverse("tickets:expert_avatar", args=[expert.public_id]) in content assert reverse("tickets:expert_avatar", args=[expert.public_id]) in content
@pytest.mark.django_db
def test_customer_ticket_wizard_keeps_server_validation_and_submitted_values(client):
unit = OrganizationalUnit.objects.create(name="واحد اعتبارسنجی مرحله‌ای", slug="wizard-validation-unit")
category = TicketCategory.objects.create(unit=unit, title="دسته مرحله‌ای", slug="wizard-validation-category")
customer = User.objects.create_user(
username="wizard-validation-customer",
mobile="09126666001",
account_type="CUSTOMER",
)
client.force_login(customer)
response = client.post(
reverse("tickets:new"),
{
"unit": unit.pk,
"category": category.pk,
"subject": "موضوع حفظ‌شده پس از خطای سرور",
"province": "",
"city": "",
"selected_expert": "",
"body": "",
},
)
assert response.status_code == 200
assert "متن یا پیوست الزامی است." in response.context["form"].non_field_errors()
content = response.content.decode()
assert "data-step-server-errors" in content
assert "موضوع حفظ‌شده پس از خطای سرور" in content
assert not Ticket.objects.filter(subject="موضوع حفظ‌شده پس از خطای سرور").exists()
@pytest.mark.django_db @pytest.mark.django_db
def test_profile_location_prefills_ticket_but_can_be_overridden_per_ticket(client): def test_profile_location_prefills_ticket_but_can_be_overridden_per_ticket(client):
unit=OrganizationalUnit.objects.create(name="فروش تست موقعیت",slug="location-sales") unit=OrganizationalUnit.objects.create(name="فروش تست موقعیت",slug="location-sales")