From b8b3aba855309663cb68622e2e676f7331a02325 Mon Sep 17 00:00:00 2001 From: javad Date: Mon, 14 Sep 2026 15:48:13 +0330 Subject: [PATCH] fix sms and ticket user frindly --- .env.example | 6 +- apps/integrations/sms/base.py | 12 + apps/integrations/sms/providers.py | 4 +- apps/integrations/sms/sms0098.py | 172 +++++++- apps/notifications/admin.py | 2 +- .../commands/process_notifications.py | 121 +++++- .../0011_notification_delivery_tracking.py | 32 ++ apps/notifications/models.py | 18 +- apps/tickets/services.py | 11 +- apps/tickets/views.py | 70 +++- config/settings/base.py | 6 +- .../ticket-new-wizard/index.html | 148 +++++++ .../ticket-new-wizard/prototype.js | 379 ++++++++++++++++++ .../ticket-new-wizard/styles.css | 50 +++ .../ticket-new-wizard/test_prototype_e2e.py | 163 ++++++++ docker-compose.yml | 6 +- docs/DEPLOYMENT_COOLIFY.md | 8 +- static/css/customer-ticket-wizard.css | 16 + static/css/dispatches.css | 17 + static/js/customer-ticket-wizard.js | 320 +++++++++++++++ templates/tickets/customer_new.html | 161 ++++++-- templates/tickets/staff_detail.html | 1 + templates/tickets/staff_dispatch_create.html | 7 +- templates/tickets/staff_dispatch_detail.html | 102 +++-- tests/e2e/pages/tickets.py | 34 +- tests/e2e/test_public_ticket_workflows_e2e.py | 112 ++++++ tests/e2e/test_staff_operations_ui.py | 69 +++- tests/test_domain.py | 153 ++++++- tests/test_organizational_ticketing.py | 103 ++++- tests/test_views.py | 36 +- 30 files changed, 2220 insertions(+), 119 deletions(-) create mode 100644 apps/notifications/migrations/0011_notification_delivery_tracking.py create mode 100644 design-prototypes/ticket-new-wizard/index.html create mode 100644 design-prototypes/ticket-new-wizard/prototype.js create mode 100644 design-prototypes/ticket-new-wizard/styles.css create mode 100644 design-prototypes/ticket-new-wizard/test_prototype_e2e.py create mode 100644 static/css/customer-ticket-wizard.css create mode 100644 static/js/customer-ticket-wizard.js diff --git a/.env.example b/.env.example index 288d95f..92b9090 100644 --- a/.env.example +++ b/.env.example @@ -29,7 +29,7 @@ SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider SMS0098_USERNAME= SMS0098_PASSWORD= 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_PASSWORD= BEHIN_SMS_SPECIAL_NUMBER= @@ -41,6 +41,10 @@ BALE_API_BASE=https://tapi.bale.ai BALE_BOT_TOKEN= INTEGRATION_HTTP_TIMEOUT_SECONDS=10 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_HSTS_SECONDS=31536000 SECURE_HSTS_INCLUDE_SUBDOMAINS=true diff --git a/apps/integrations/sms/base.py b/apps/integrations/sms/base.py index f1de2cc..ffcb239 100644 --- a/apps/integrations/sms/base.py +++ b/apps/integrations/sms/base.py @@ -7,6 +7,14 @@ class SMSResult: success: bool provider_id: 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): host=settings.WEBOTP_HOST or urlparse(settings.PUBLIC_BASE_URL).hostname 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)) @abstractmethod 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 diff --git a/apps/integrations/sms/providers.py b/apps/integrations/sms/providers.py index 1faa4c0..b15592e 100644 --- a/apps/integrations/sms/providers.py +++ b/apps/integrations/sms/providers.py @@ -1,7 +1,7 @@ """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 .console import ConsoleSMSProvider from .factory import get_sms_provider,reset_sms_provider 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"] diff --git a/apps/integrations/sms/sms0098.py b/apps/integrations/sms/sms0098.py index 5a99400..eb88979 100644 --- a/apps/integrations/sms/sms0098.py +++ b/apps/integrations/sms/sms0098.py @@ -1,36 +1,172 @@ import logging +import re -import requests +import zeep from django.conf import settings +from zeep.transports import Transport from apps.accounts.models import normalize_mobile -from .base import SMSProvider, SMSResult +from .base import SMSDeliveryResult, SMSProvider, SMSResult 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): - """Adapter for the 0098SMS HTTP gateway.""" + """SOAP adapter for 0098SMS (0098vip-compatible web service).""" def __init__(self): self.username = settings.SMS0098_USERNAME self.password = settings.SMS0098_PASSWORD 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): - missing = [name for name, value in (("SMS0098_USERNAME", self.username), ("SMS0098_PASSWORD", self.password), ("SMS0098_FROM", self.sender)) if not value] - if missing: - return SMSResult(False, error=f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}") + error = self._configuration_error() + if error: + return SMSResult(False, error=error, provider_code="CONFIG") try: - recipient = int(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) - response.raise_for_status() - # This legacy endpoint may append an HTML page containing the - # original query string. Persist only its first scalar result so - # credentials can never leak into notification logs. - provider_id = response.text.strip().splitlines()[0][:100] - return SMSResult(True, provider_id=provider_id) - except (ValueError, requests.RequestException) as exc: - logger.exception("0098SMS delivery failed") - return SMSResult(False, error=str(exc)[:1000]) + recipient = normalize_mobile(str(mobile)).removeprefix("+98") + if not recipient.startswith("0"): + recipient = f"0{recipient}" + response = self._call("SendSMSWithID", { + "username": self.username, + "password": self.password, + "mobileno": recipient, + "pnlno": str(self.sender), + "text": str(message), + }) + 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, + ) diff --git a/apps/notifications/admin.py b/apps/notifications/admin.py index f1dabaa..eb7bbc5 100644 --- a/apps/notifications/admin.py +++ b/apps/notifications/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from .models import NotificationJob,NotificationPolicy,SMSTemplate @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) class SMSTemplateAdmin(admin.ModelAdmin): list_display=("event_type","unit","title","is_active","updated_at") diff --git a/apps/notifications/management/commands/process_notifications.py b/apps/notifications/management/commands/process_notifications.py index b6b56c4..e8f5229 100644 --- a/apps/notifications/management/commands/process_notifications.py +++ b/apps/notifications/management/commands/process_notifications.py @@ -6,6 +6,7 @@ import jdatetime from django.conf import settings from django.core.management.base import BaseCommand from django.db import transaction +from django.db.models import Q from django.utils import timezone 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 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): help = "Process notification outbox" def add_arguments(self, parser): @@ -140,9 +238,15 @@ class Command(BaseCommand): def handle(self, *args, **options): while True: 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(): - 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"): queryset = queryset.filter(channel=options["channel"]) if options.get("job_id"): @@ -160,15 +264,22 @@ class Command(BaseCommand): continue if job.channel == NotificationJob.Channel.SMS: result, issued_link = _send_sms(job) else: result = _send_bale(job) - if result is not None and not result.success: raise RuntimeError(result.error or "Provider rejected notification") - 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 "" + if result is not None and not result.success: + _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: if issued_link: 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() 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) + _process_delivery_checks(options) if options["once"] or options.get("job_id"): break time.sleep(5) diff --git a/apps/notifications/migrations/0011_notification_delivery_tracking.py b/apps/notifications/migrations/0011_notification_delivery_tracking.py new file mode 100644 index 0000000..4711eb2 --- /dev/null +++ b/apps/notifications/migrations/0011_notification_delivery_tracking.py @@ -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)), + ] diff --git a/apps/notifications/models.py b/apps/notifications/models.py index 742e65c..e9a49bc 100644 --- a/apps/notifications/models.py +++ b/apps/notifications/models.py @@ -44,7 +44,23 @@ class NotificationPolicy(models.Model): class NotificationJob(models.Model): 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) + 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: indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")] diff --git a/apps/tickets/services.py b/apps/tickets/services.py index 1dfb4db..d9f1bd6 100644 --- a/apps/tickets/services.py +++ b/apps/tickets/services.py @@ -1,7 +1,7 @@ from datetime import timedelta from django.core.exceptions import PermissionDenied,ValidationError from django.db import transaction -from django.db.models import Count +from django.db.models import Count,Q from django.utils import timezone from apps.attachments.services import save_attachments 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.""" dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first() 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) - failed=counts.get(NotificationJob.Status.FAILED,0) - if dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT - elif failed: status=GroupDispatch.Status.PARTIAL + 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 failed: status=GroupDispatch.Status.PARTIAL + elif dispatch.recipient_count and sent>=dispatch.recipient_count: status=GroupDispatch.Status.SENT else: status=GroupDispatch.Status.QUEUED if dispatch.status!=status: GroupDispatch.objects.filter(pk=dispatch_id).update(status=status,updated_at=timezone.now()) diff --git a/apps/tickets/views.py b/apps/tickets/views.py index c3e21dc..8f41c20 100644 --- a/apps/tickets/views.py +++ b/apps/tickets/views.py @@ -395,8 +395,9 @@ def staff_detail(request,public_id): 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}) 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 - 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 def staff_messages(request,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): raise PermissionDenied 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: - 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) - 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"))} 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() + 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() - expected = dispatch.recipient_count 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} - return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "active_staff_section": "dispatches"}) + pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING)) + delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0 + 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 @@ -579,7 +620,20 @@ def staff_dispatch_detail(request, public_id): def staff_dispatch_retry(request, 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 - 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) messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.") return redirect("staff_tickets:dispatch_detail",public_id=public_id) diff --git a/config/settings/base.py b/config/settings/base.py index d40bccd..72afe50 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -96,6 +96,10 @@ DEV_OTP_MOBILE = "" DEV_OTP_CODE = "" MAGIC_LINK_TTL_DAYS = int(os.getenv("MAGIC_LINK_TTL_DAYS", "7")) 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("/") COMPANY_NAME = os.getenv("COMPANY_NAME", "لوله گستر گلپایگان") 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_PASSWORD = os.getenv("SMS0098_PASSWORD", "") 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") BALE_API_BASE = os.getenv("BALE_API_BASE", "https://tapi.bale.ai") BALE_BOT_TOKEN = os.getenv("BALE_BOT_TOKEN", "") diff --git a/design-prototypes/ticket-new-wizard/index.html b/design-prototypes/ticket-new-wizard/index.html new file mode 100644 index 0000000..19af141 --- /dev/null +++ b/design-prototypes/ticket-new-wizard/index.html @@ -0,0 +1,148 @@ + + + + + + نمونه رابط مرحله‌ای ثبت درخواست + + + +
+ نمونه نمایشی مستقل +

این صفحه به Django و اطلاعات واقعی متصل نیست.

+
+ +
+ + +
+
+ ثبت درخواست جدید +

چطور می‌توانیم کمک کنیم؟

+

قدم‌به‌قدم پیش می‌رویم؛ در هر مرحله فقط اطلاعات ضروری همان بخش را می‌بینید.

+
+
اطلاعات شما در این صفحه حفظ می‌شود
+
+ +
    +
  1. ۱
    موضوعمسیر درخواست
  2. +
  3. ۲
    موقعیتاختیاری
  4. +
  5. ۳
    کارشناسانتخاب مسئول
  6. +
  7. ۴
    جزئیاتمتن و فایل
  8. +
  9. ۵
    بازبینیتأیید نهایی
  10. +
+ +
+
+
⌕
مرحله ۱ از ۵

موضوع درخواستتان چیست؟

با چند کلمه شروع کنید تا مسیر مناسب را پیشنهاد دهیم، یا خودتان انتخاب کنید.

+ + + + + + +
حدود یک دقیقه تا پایان
+
+ + + + + + + + + + +
+
+ + + + diff --git a/design-prototypes/ticket-new-wizard/prototype.js b/design-prototypes/ticket-new-wizard/prototype.js new file mode 100644 index 0000000..519d5cc --- /dev/null +++ b/design-prototypes/ticket-new-wizard/prototype.js @@ -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); +}); diff --git a/design-prototypes/ticket-new-wizard/styles.css b/design-prototypes/ticket-new-wizard/styles.css new file mode 100644 index 0000000..4306261 --- /dev/null +++ b/design-prototypes/ticket-new-wizard/styles.css @@ -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} diff --git a/design-prototypes/ticket-new-wizard/test_prototype_e2e.py b/design-prototypes/ticket-new-wizard/test_prototype_e2e.py new file mode 100644 index 0000000..1c94d27 --- /dev/null +++ b/design-prototypes/ticket-new-wizard/test_prototype_e2e.py @@ -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\"]')") diff --git a/docker-compose.yml b/docker-compose.yml index 3cdf406..259dce9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ x-django-service: &django-service SMS0098_USERNAME: ${SMS0098_USERNAME:-} SMS0098_PASSWORD: ${SMS0098_PASSWORD:-} 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_PASSWORD: ${BEHIN_SMS_PASSWORD:-} BEHIN_SMS_SPECIAL_NUMBER: ${BEHIN_SMS_SPECIAL_NUMBER:-} @@ -45,6 +45,10 @@ x-django-service: &django-service BALE_BOT_TOKEN: ${BALE_BOT_TOKEN:-} INTEGRATION_HTTP_TIMEOUT_SECONDS: ${INTEGRATION_HTTP_TIMEOUT_SECONDS:-10} 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_HSTS_SECONDS: ${SECURE_HSTS_SECONDS:-31536000} SECURE_HSTS_INCLUDE_SUBDOMAINS: ${SECURE_HSTS_INCLUDE_SUBDOMAINS:-true} diff --git a/docs/DEPLOYMENT_COOLIFY.md b/docs/DEPLOYMENT_COOLIFY.md index 38d6cf4..9c90692 100644 --- a/docs/DEPLOYMENT_COOLIFY.md +++ b/docs/DEPLOYMENT_COOLIFY.md @@ -80,7 +80,7 @@ SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider SMS0098_USERNAME= SMS0098_PASSWORD= SMS0098_FROM= -SMS0098_API_URL=http://www.0098sms.com/sendsmslink.aspx +SMS0098_WSDL_URL=https://webservice.0098sms.com/service.asmx?WSDL ``` ### Behin SMS @@ -109,11 +109,15 @@ BALE_API_BASE=https://tapi.bale.ai BALE_BOT_TOKEN= INTEGRATION_HTTP_TIMEOUT_SECONDS=10 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 نمی‌کند. سه شماره اختیاری سرپرست نیز در همین صفحه تنظیم می‌شوند و فقط هنگام ایجاد تیکت جدید، پیامک کوتاه دریافت می‌کنند. -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` مرجع اصلی این رفتار نیست. diff --git a/static/css/customer-ticket-wizard.css b/static/css/customer-ticket-wizard.css new file mode 100644 index 0000000..6e23a30 --- /dev/null +++ b/static/css/customer-ticket-wizard.css @@ -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}} diff --git a/static/css/dispatches.css b/static/css/dispatches.css index c3af99c..3b9ecba 100644 --- a/static/css/dispatches.css +++ b/static/css/dispatches.css @@ -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} @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;background:#fff7f8}.tone-danger .sms-state-icon{background:#fee2e2;color:#a51d35}.tone-info{border-color:#bee3f3;background:#f4fbfe}.tone-info .sms-state-icon{background:#dff4fc;color:#0879aa}.sms-technical-details{margin-top:.45rem;border:1px solid #e0e8ed;border-radius:.65rem;background:#fff;color:#607788;font-size:.54rem}.sms-technical-details summary{cursor:pointer;padding:.45rem .55rem;color:#416177;font-weight:900}.sms-technical-details dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.35rem;margin:0;border-top:1px solid #e8eef2;padding:.55rem}.sms-technical-details dl div{display:grid;gap:.1rem}.sms-technical-details dt{color:#8a9aa5}.sms-technical-details dd{margin:0;color:#294e68;font-weight:800;overflow-wrap:anywhere}.gateway-technical-message{margin:0;border-top:1px dashed #e1e9ed;padding:.5rem .55rem;line-height:1.65}.sms-error-panel{margin-top:.45rem;border:1px solid #f3c3ca;border-radius:.65rem;background:#fff5f6;padding:.55rem;color:#912235;font-size:.55rem}.sms-error-panel p{margin:.2rem 0 0;line-height:1.7}.ticket-state-pill{display:inline-flex;white-space:nowrap;border-radius:99rem;background:#e8f1f6;padding:.34rem .6rem;color:#456578;font-size:.55rem;font-weight:900}.engagement-state{display:flex;align-items:flex-start;gap:.45rem;min-width:7.5rem;color:#718493}.engagement-state>span{display:grid;width:1.6rem;height:1.6rem;place-items:center;border-radius:.5rem;background:#eef3f6}.engagement-state div{display:grid;gap:.16rem}.engagement-state strong{color:#536b7b;font-size:.58rem}.engagement-state small{color:#8797a2;font-size:.51rem}.engagement-state.has-engagement>span{background:#e8e3fb;color:#6b3eb9}.engagement-state.has-engagement strong{color:#5c369d}.recipient-reply{display:grid;min-width:10rem;gap:.3rem}.recipient-reply p{margin:0;color:#395a70;font-size:.57rem;line-height:1.75}.recipient-reply small,.no-reply-state{color:#8a9aa5;font-size:.52rem}.inline-retry-form button{width:100%} +@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}} diff --git a/static/js/customer-ticket-wizard.js b/static/js/customer-ticket-wizard.js new file mode 100644 index 0000000..05999eb --- /dev/null +++ b/static/js/customer-ticket-wizard.js @@ -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: [] })); + } +})(); diff --git a/templates/tickets/customer_new.html b/templates/tickets/customer_new.html index 0f02c1a..d5a80e3 100644 --- a/templates/tickets/customer_new.html +++ b/templates/tickets/customer_new.html @@ -3,29 +3,144 @@ {% block content %} -
→ بازگشت به درخواست‌ها

ثبت درخواست جدید

چه کمکی از ما برمی‌آید؟

درخواست خود را کامل بنویسید تا همان بار اول پاسخ دقیق‌تری دریافت کنید.

-
-
{% csrf_token %}{{ form.non_field_errors }} -
⌕

موضوع درخواستتان چیست؟

چند کلمه بنویسید؛ واحد و دسته‌بندی مناسب را پیشنهاد می‌دهیم.

-
انتخاب دستی واحد و دسته‌بندیدر صورت تمایل خودتان مسیر درخواست را مشخص کنید
-
۱

موضوع درخواست

دسته‌بندی و یک عنوان کوتاه انتخاب کنید.

-
{{ form.unit.label_tag }}{{ form.unit }}{{ form.unit.errors }}
{{ form.category.label_tag }}{{ form.category }}{{ form.category.errors }}
-
{{ form.subject.label_tag }}{{ form.subject }}{{ form.subject.errors }}موضوع را به‌صورت دستی و دقیق وارد کنید.
-
-
۲

موقعیت این درخواست

اختیاری است؛ موقعیت ذخیره‌شده پروفایل از قبل انتخاب شده و فقط در صورت نیاز آن را تغییر دهید.

-
-
{{ form.province.label_tag }}{{ form.province }}{{ form.province.errors }}
{{ form.city.label_tag }}{{ form.city }}{{ form.city.errors }}
-

نام استان را جست‌وجو و انتخاب کنید.

+ + +
+ + +
+

ثبت درخواست جدید

+

چطور می‌توانیم کمک کنیم؟

+

قدم‌به‌قدم پیش می‌رویم؛ در هر مرحله فقط اطلاعات ضروری همان بخش را می‌بینید.

+
+ +
    +
  1. ۱
    موضوعمسیر درخواست
  2. +
  3. ۲
    موقعیتاختیاری
  4. +
  5. ۳
    کارشناسانتخاب مسئول
  6. +
  7. ۴
    جزئیاتمتن و فایل
  8. +
  9. ۵
    بازبینیتأیید نهایی
  10. +
+ + + {% csrf_token %} + +
+
+ +
مرحله ۱ از ۵

موضوع درخواستتان چیست؟

چند کلمه بنویسید تا مسیر مناسب پیشنهاد شود، یا واحد و دسته‌بندی را دستی انتخاب کنید.

+
+ + +
+ +
+ انتخاب دستی واحد و دسته‌بندیدر صورت تمایل مسیر درخواست را خودتان مشخص کنید +
+
+
{{ form.unit.label_tag }}{{ form.unit }}{{ form.unit.errors }}
+
{{ form.category.label_tag }}{{ form.category }}{{ form.category.errors }}
+
+
+
+ +
+ + {{ form.subject }}{{ form.subject.errors }} + این عنوان فقط توسط شما نوشته می‌شود و با تغییر واحد یا دسته‌بندی عوض نخواهد شد. +
+ +
حدود یک دقیقه تا پایان
-
۳

انتخاب کارشناس

اختیاری است؛ صف عمومی معمولاً سریع‌ترین انتخاب است.

-
کارشناس دلخواه{% for expert in form.fields.selected_expert.queryset %}{% empty %}

کارشناس قابل انتخابی ثبت نشده؛ درخواست به صف عمومی می‌رود.

{% endfor %}
{{ form.selected_expert.errors }} -
۴

شرح و مستندات

متن، فایل یا پیام صوتی خود را اضافه کنید.

-
{{ form.body.label_tag }}{{ form.body }}{{ form.body.errors }}
-
{{ form.files }}{{ form.files.errors }}تصاویر بزرگ پیش از ارسال، روی گوشی شما بهینه و کم‌حجم می‌شوند.
-
پیام صوتی00:00
- -
- + +
+
+ +
مرحله ۲ از ۵ · اختیاری

این درخواست مربوط به کجاست؟

اگر موقعیت در پاسخ بهتر کارشناسان مؤثر است آن را انتخاب کنید؛ در غیر این صورت عبور کنید.

+
+
+
{{ form.province.label_tag }}{{ form.province }}{{ form.province.errors }}
{{ form.city.label_tag }}{{ form.city }}{{ form.city.errors }}
+

نام استان را جست‌وجو و انتخاب کنید.

+
+
i

نیازی به انتخاب ندارید این بخش کاملاً اختیاری است و می‌توانید مستقیماً ادامه دهید.

+ +
+
+ +
+
+ +
مرحله ۳ از ۵ · اختیاری

چه کسی درخواست را بررسی کند؟

صف عمومی پیشنهاد ماست؛ درخواست به اولین کارشناس آزاد همان واحد می‌رسد.

+
+
+ کارشناس دلخواه + + {% for expert in form.fields.selected_expert.queryset %}{% empty %}

کارشناس قابل انتخابی ثبت نشده؛ درخواست به صف عمومی می‌رود.

{% endfor %} +
+ {{ form.selected_expert.errors }} + +
+
+ +
+
+ +
مرحله ۴ از ۵

جزئیات را برای کارشناس بنویسید

شرح متنی، فایل یا پیام صوتی اضافه کنید تا کارشناس مسئله را دقیق‌تر بررسی کند.

+
+
{{ form.body.label_tag }}{{ form.body }}{{ form.body.errors }}۰ نویسه
+
+
+
عکس و فایل‌های مرتبطتصاویر بزرگ پیش از ارسال روی دستگاه شما بهینه می‌شوند.
+
+ + {{ form.files }}{{ form.files.errors }} + فایل‌های انتخاب‌شده پیش از ثبت قابل مشاهده و حذف هستند. +
+
+ +
+
پیام صوتیبرای توضیح سریع‌تر، صدای خود را ضبط کنید.
۰۰:۰۰
+
پس از اجازه‌دادن به میکروفن، ضبط آغاز می‌شود.
+ + +

ⓘ با رفتن به مرحله بعد، ضبط فعال به‌صورت خودکار متوقف و ذخیره می‌شود.

+ +
+ +
{% if form.non_field_errors %}{{ form.non_field_errors }}{% endif %}
+ +
+
+ +
+
مرحله ۵ از ۵

همه‌چیز آماده ارسال است

اطلاعات را یک‌بار مرور کنید؛ هر بخش را می‌توانید جداگانه ویرایش کنید.

+
✓
یک مرور کوتاه پیش از ارسالاگر موردی درست نیست، همان ردیف را ویرایش کنید؛ اطلاعات مراحل دیگر حفظ می‌شود.
+
+
۱
موضوع و مسیر رسیدگی

—

—←—

+
۲
موقعیت

ثبت نشده

اختیاری

+
۳
نحوه ارجاع

صف عمومی

در همان واحد سازمانی

+
۴
شرح و پیوست‌ها

—

+
+ +
+
+
- + + + + + + + {% endblock %} diff --git a/templates/tickets/staff_detail.html b/templates/tickets/staff_detail.html index be55e66..66d5d87 100644 --- a/templates/tickets/staff_detail.html +++ b/templates/tickets/staff_detail.html @@ -10,6 +10,7 @@ diff --git a/templates/tickets/staff_dispatch_create.html b/templates/tickets/staff_dispatch_create.html index d409044..e2d9342 100644 --- a/templates/tickets/staff_dispatch_create.html +++ b/templates/tickets/staff_dispatch_create.html @@ -1,12 +1,12 @@ -{% extends 'base.html' %} +{% extends 'base.html' %}{% load static %} {% block title %}{{ page_title }}{% endblock %} {% block content %}

ارسال انبوه امن

ارسال گروهی ایزوله

برای هر مخاطب یک تیکت و Magic Link مستقل ایجاد می‌شود؛ هیچ دریافت‌کننده‌ای پاسخ دیگران را نمی‌بیند.

⌾
حریم خصوصی فعالتیکت مستقل برای هر گیرنده
کل عملیات{{ metrics.total }}
کل گیرندگان{{ metrics.recipients }}
ارسال کامل{{ metrics.sent }}
نیازمند پیگیری{{ metrics.attention }}
-
{% csrf_token %}{% if form.non_field_errors %}{% endif %} +
{% csrf_token %}{% if form.non_field_errors %}{% endif %}
۱

نوع عملیات، مخاطبان و مالکیت

قابلیت‌های مجاز و گروه هدف را مشخص کنید.

۲

موضوع و دسته‌بندی

عنوان کوتاه و برچسب‌های مناسب، گزارش‌گیری را دقیق‌تر می‌کند.

-
۳

متن و اسناد پیام

درخواست را شفاف بنویسید و اسناد لازم را برای همه تیکت‌های خصوصی ضمیمه کنید.

+
۳

متن و اسناد پیام

درخواست را شفاف بنویسید و اسناد لازم را برای همه تیکت‌های خصوصی ضمیمه کنید.

{{ form.files.label }}{{ form.files.help_text }}
{{ form.files }}{{ form.files.errors }}تصاویر بزرگ پیش از ارسال روی دستگاه شما بهینه می‌شوند؛ هر پیوست را می‌توانید پیش از ثبت حذف کنید.
قبل از ارسال بررسی کنیداین عملیات برای همه اعضای گروه تیکت مستقل ایجاد می‌کند.
{% endblock %} +{% block extra_scripts %}{% endblock %} diff --git a/templates/tickets/staff_dispatch_detail.html b/templates/tickets/staff_dispatch_detail.html index d4a82c8..b702efa 100644 --- a/templates/tickets/staff_dispatch_detail.html +++ b/templates/tickets/staff_dispatch_detail.html @@ -1,24 +1,80 @@ -{% extends 'base.html' %} -{% block title %}{{ page_title }}{% endblock %} -{% block content %}
-
-

شناسه ارسال {{ dispatch.public_id }}

{{ dispatch.subject }}

{{ dispatch.unit }} · گروه {{ dispatch.contact_group.name }} · {{ dispatch.recipient_count }} مخاطب

-
ارسال گروهی جدید{% if metrics.failed %}
{% csrf_token %}
{% endif %}
-
-
پیامک موفق
{{ metrics.sent }}
در صف
{{ metrics.pending }}
لینک مشاهده‌شده
{{ metrics.opened }}
پاسخ ثبت‌شده
{{ metrics.replied }}
ارسال ناموفق
{{ metrics.failed }}
-
-
نمای مقایسه‌ای

وضعیت دریافت و پاسخ مخاطبان

{{ dispatch.get_status_display }}
-
- {% for ticket in tickets %} - - - - - - - - - {% empty %}{% endfor %} -
مخاطبشمارهتیکتوضعیت پیامکوضعیت تیکتبازدید لینکپاسخ پیشنهادی
{{ ticket.customer.customer_profile|default:ticket.customer.username }}{{ ticket.customer.local_mobile }}{{ ticket.ticket_number }}{% 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 %}مرجع: {{ ticket.sms_job.provider_message_id|truncatechars:45 }}{% endif %}{% if ticket.sms_job.last_error %}{{ ticket.sms_job.last_error|truncatechars:60 }}{% endif %}{% else %}ثبت نشده{% endif %}{{ ticket.get_status_display }}{% for link in ticket.magic_links.all %}{{ link.view_count }}{% if not forloop.last %} + {% endif %}{% empty %}ارسال نشده{% endfor %}{% if ticket.latest_customer_response %}{{ ticket.latest_customer_response.body|truncatechars:120 }}{{ ticket.latest_customer_response.created_at|date:'Y/m/d H:i' }}{% else %}هنوز پاسخی ثبت نشده{% endif %}
برای این ارسال تیکتی ثبت نشده است.
-
+{% extends 'base.html' %}{% load jalali %} +{% block title %}{{ dispatch.subject }}{% endblock %} +{% block content %} +
+
+
+
{{ dispatch.get_status_display }}گزارش ارسال گروهی
+

{{ dispatch.subject }}

+
+ واحد{{ dispatch.unit }} + گروه مخاطبان{{ dispatch.contact_group.name }} + تعداد گیرندگان{{ dispatch.recipient_count }} نفر + زمان ایجاد +
+
+
+ + ارسال گروهی جدید + {% if metrics.failed %}
{% csrf_token %}
{% endif %} +
+
+ +
+
متن اولیه کارشناس

پیام اصلی این ارسال

شناسه عملیات: {{ dispatch.public_id }}
+
{{ dispatch.body|linebreaksbr }}
+
ایجادکننده: {{ dispatch.created_by.staff_profile.display_name|default:dispatch.created_by.username }}این متن در یک تیکت مستقل برای هر مخاطب ثبت شده است.
+
+ +
+
پیشرفت تحویل{{ metrics.delivery_percent }}٪

{{ metrics.delivered }} از {{ dispatch.recipient_count }} پیامک تحویل قطعی شده است.

+
+
{% if metrics.tracking %}{{ metrics.tracking }} پیامک در حال پیگیری استنتیجه‌های موقت هر ۳ دقیقه تا ۴۵ دقیقه دوباره استعلام می‌شوند.{% elif metrics.failed %}موارد ناموفق نیاز به بررسی دارندعلت دقیق و دکمه تلاش مجدد در ردیف مخاطب قرار دارد.{% else %}پردازش صف پایان یافته استبرای جزئیات هر مخاطب، گزارش پایین صفحه را ببینید.{% endif %}
+
+ +
+
✓
تحویل قطعی{{ metrics.delivered }}

رسیده به گوشی

+
◌
پیگیری فعال{{ metrics.tracking }}

{{ metrics.sent }} مورد پذیرفته‌شده توسط درگاه

+
!
نیازمند اقدام{{ metrics.failed }}

ناموفق یا تحویل‌نشده قطعی

+
◉
تعامل با لینک{{ metrics.opened }}

{{ metrics.total_views }} بازدید · {{ metrics.not_opened }} مخاطب بدون بازدید

+
↵
پاسخ دریافتی{{ metrics.replied }}

پاسخ ثبت‌شده در تیکت‌ها

+
+ +
+
جزئیات هر گیرنده

وضعیت پیامک، مشاهده و پاسخ

{{ dispatch.recipient_count }} مخاطب
+
+ {% for ticket in tickets %} + + + + + + + + + {% empty %}{% endfor %} +
مخاطبتیکتوضعیت پیامکوضعیت تیکتمشاهده لینکآخرین پاسخ
{{ ticket.customer.customer_profile.first_name|first|default:'•' }}
{{ ticket.customer.customer_profile|default:ticket.customer.username }}{{ ticket.customer.local_mobile }}
{{ ticket.ticket_number }}مشاهده تیکت ← +
+ {% if ticket.sms_state_tone == 'success' %}✓{% elif ticket.sms_state_tone == 'danger' %}!{% elif ticket.sms_state_tone == 'warning' %}◌{% else %}↑{% endif %} +
{{ ticket.sms_state_title }}

{{ ticket.sms_state_hint }}

{% if ticket.sms_job.delivery_next_check_at %}استعلام بعدی: {{ ticket.sms_job.delivery_next_check_at|timeuntil }} دیگر{% endif %}
+
+ {% if ticket.sms_job %} +
+ جزئیات فنی و رهگیری +
+
ارسال
{% if ticket.sms_job.attempts == 1 %}موفق در اولین تلاش{% else %}{{ ticket.sms_job.attempts }} تلاش از {{ sms_max_attempts }}{% endif %}
+ {% if ticket.sms_job.provider_message_id %}
شناسه رهگیری
{{ ticket.sms_job.provider_message_id }}
{% endif %} + {% if ticket.sms_job.delivery_status_code %}
کد دلیوری
{{ ticket.sms_job.delivery_status_code }}
{% endif %} + {% if ticket.sms_job.delivery_check_attempts %}
تعداد استعلام
{{ ticket.sms_job.delivery_check_attempts }} نوبت
{% endif %} + {% if ticket.sms_job.delivery_checked_at %}
آخرین استعلام
{{ ticket.sms_job.delivery_checked_at|jalali_datetime }}
{% endif %} +
+ {% if ticket.sms_job.delivery_description %}

{{ ticket.sms_job.delivery_description }}

{% endif %} +
+ {% if ticket.sms_job.last_error %}
علت دقیق عدم ارسال

{{ ticket.sms_job.last_error }}

{% endif %} + {% if ticket.sms_retryable %}
{% csrf_token %}
{% endif %} + {% endif %} +
{{ ticket.get_status_display }} + + {% if ticket.latest_customer_response %}

{{ ticket.latest_customer_response.body|truncatechars:140 }}

{{ ticket.latest_customer_response.created_at|jalali_datetime }}
{% else %}هنوز پاسخی ثبت نشده{% endif %}
برای این ارسال تیکتی ثبت نشده است.
+
+
{% endblock %} diff --git a/tests/e2e/pages/tickets.py b/tests/e2e/pages/tickets.py index d442353..2295033 100644 --- a/tests/e2e/pages/tickets.py +++ b/tests/e2e/pages/tickets.py @@ -37,7 +37,8 @@ class TicketConversationPage(BasePage): class PublicTicketCreatePage(BasePage): def open(self): 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 def unit(self): @@ -66,7 +67,10 @@ class PublicTicketCreatePage(BasePage): and f"unit={unit_id}" in response.url and response.status == 200 ) 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() 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"]} @@ -84,17 +88,24 @@ class PublicTicketCreatePage(BasePage): card = self.expert_picker.locator("label.expert-card").filter(has_text=display_name) expect(card).to_have_count(1) 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() return radio.get_attribute("value") def select_general_queue(self): 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() 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)) def upload_gallery(self, files, *, total_count=None): @@ -108,13 +119,22 @@ class PublicTicketCreatePage(BasePage): def remove_attachment(self, filename): chip = self.file_preview.locator(".file-chip").filter(has_text=filename) 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) def submit(self, *, subject, body): 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('[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"): - 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.locator("h1")).to_have_text(subject) diff --git a/tests/e2e/test_public_ticket_workflows_e2e.py b/tests/e2e/test_public_ticket_workflows_e2e.py index 32c0c0b..9035c91 100644 --- a/tests/e2e/test_public_ticket_workflows_e2e.py +++ b/tests/e2e/test_public_ticket_workflows_e2e.py @@ -1,4 +1,5 @@ import io +import re import pytest 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: form = open_form(page, live_server, customer) 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() category_box = form.category.bounding_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): customer, units, _ = public_workflow configured = { diff --git a/tests/e2e/test_staff_operations_ui.py b/tests/e2e/test_staff_operations_ui.py index b8b3955..60d6cfe 100644 --- a/tests/e2e/test_staff_operations_ui.py +++ b/tests/e2e/test_staff_operations_ui.py @@ -3,15 +3,16 @@ import base64 import pytest from django.contrib.auth.models import Permission from django.urls import reverse +from django.utils import timezone from playwright.sync_api import expect from apps.accounts.models import TicketMagicLink, User from apps.customers.models import ContactGroup, CustomerProfile 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.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 @@ -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.get_by_role("searchbox", name="جستجو")).to_be_visible() 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") 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() 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([ {"name": "first.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() expect(page.locator("[data-file-preview] .file-chip")).to_have_count(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") diff --git a/tests/test_domain.py b/tests/test_domain.py index 4c5699d..e337143 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -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) 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 def test_customer_ticket_created_sms_has_seven_day_magic_link(category,customer,settings,monkeypatch): from datetime import timedelta @@ -512,16 +611,50 @@ def test_behin_provider_maps_send_array_contract(settings): assert calls["CheckingMessageID"]=={"long":[0]} 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 settings.SMS0098_USERNAME="user"; settings.SMS0098_PASSWORD="pass"; settings.SMS0098_FROM="3000" calls={} - class Response: - text="42" - def raise_for_status(self): return None - def fake_get(url,**kwargs): calls.update(url=url,**kwargs); return Response() - monkeypatch.setattr("apps.integrations.sms.sms0098.requests.get",fake_get) - result=ZeroZeroNinetyEightSMSProvider().send_message("09202202129","پیام آزمایشی") - assert result.success and result.provider_id=="42" - assert calls["params"]["TO"]==9202202129 - assert calls["params"]["TEXT"]=="پیام آزمایشی" + class Service: + def SendSMSWithID(self,**parameters): calls.update(parameters); return "123456789" + class Client: service=Service() + provider=ZeroZeroNinetyEightSMSProvider(); provider._client=Client() + result=provider.send_message("09202202129","پیام آزمایشی") + assert result.success and result.provider_id=="123456789" + assert calls["mobileno"]=="09202202129" + assert calls["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 diff --git a/tests/test_organizational_ticketing.py b/tests/test_organizational_ticketing.py index bd6ccbb..07b0a7a 100644 --- a/tests/test_organizational_ticketing.py +++ b/tests/test_organizational_ticketing.py @@ -1,7 +1,11 @@ +import base64 +from datetime import timedelta + import pytest from django.contrib.auth.models import Permission from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse +from django.utils import timezone from apps.accounts.models import MagicLinkAccessLog, TicketMagicLink, User 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 +@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 def test_magic_link_records_every_open_with_ip_and_user_agent(client): 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 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") other=OrganizationalUnit.objects.create(name="فروش retry",slug="sales-retry") 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) contact=make_contact("retry-carrier","09120000302") group=ContactGroup.objects.create(name="باربری retry",unit=unit); contact.customer_profile.groups.add(group) 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="قیمت را ثبت کنید.") 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() 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) assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id])).status_code==403 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() 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 +@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 def test_magic_link_can_be_revoked_only_with_unit_permission(client): unit=OrganizationalUnit.objects.create(name="حسابرسی",slug="audit-unit") diff --git a/tests/test_views.py b/tests/test_views.py index b5bb05e..18b30b2 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -6,7 +6,7 @@ from django.urls import reverse from django.utils import timezone from apps.accounts.models import User 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.messaging.models import Message,MessageReadReceipt 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) content = client.get(reverse("tickets:new"),{"unit":unit.pk}).content.decode() assert "کارشناس نمونه" in content - assert "data-voice-recorder" in content + assert "data-ticket-voice-recorder" 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 + +@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 def test_profile_location_prefills_ticket_but_can_be_overridden_per_ticket(client): unit=OrganizationalUnit.objects.create(name="فروش تست موقعیت",slug="location-sales")