diff --git a/.env.example b/.env.example index e1bb8c4..288d95f 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,6 @@ SMS_API_TOKEN=replace-me SMS_SENDER= BALE_API_BASE=https://tapi.bale.ai BALE_BOT_TOKEN= -BALE_GROUP_CHAT_ID=5924558401 INTEGRATION_HTTP_TIMEOUT_SECONDS=10 MAX_NOTIFICATION_ATTEMPTS=5 SECURE_SSL_REDIRECT=true diff --git a/.env.local.example b/.env.local.example index 1ce9572..d6f9267 100644 --- a/.env.local.example +++ b/.env.local.example @@ -8,5 +8,4 @@ DEV_OTP_CODE=12345 SMS_PROVIDER_CLASS=apps.integrations.sms.console.ConsoleSMSProvider BALE_API_BASE=https://tapi.bale.ai BALE_BOT_TOKEN= -BALE_GROUP_CHAT_ID=5924558401 INTEGRATION_HTTP_TIMEOUT_SECONDS=10 diff --git a/apps/notifications/management/commands/process_notifications.py b/apps/notifications/management/commands/process_notifications.py index 8d060a2..b6b56c4 100644 --- a/apps/notifications/management/commands/process_notifications.py +++ b/apps/notifications/management/commands/process_notifications.py @@ -60,6 +60,15 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1, def _send_sms(job): issued = None + if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET": + recipient = job.payload.get("recipient_mobile", "") + if not recipient: + raise ValueError("Unit supervisor mobile is not configured") + subject = " ".join(job.ticket.subject.split())[:80] or "بدون موضوع" + category = " ".join(job.ticket.category.title.split())[:50] + customer_mobile = job.ticket.customer.local_mobile or "ثبت نشده" + message = f"تیکت جدید {job.ticket.ticket_number}\nتلفن: {customer_mobile}\nموضوع: {subject}\nدسته: {category}" + return get_sms_provider().send_message(recipient, message), issued if job.event_type == "CUSTOMER_TICKET_CREATED": issued, url = _magic_link(job, "customer_created_ticket") message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit) @@ -72,12 +81,13 @@ def _send_sms(job): return get_sms_provider().send_message(job.customer.mobile, message), issued def _send_bale(job): + unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip() if job.event_type == "NEW_CUSTOMER_TICKET": - return get_bale_provider().send_message(settings.BALE_GROUP_CHAT_ID, _bale_ticket_text(job.ticket)) + return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]: message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first() if not message: raise ValueError("Customer reply message no longer exists") - return get_bale_provider().send_message(settings.BALE_GROUP_CHAT_ID, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED")) + return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED")) if unit_chat_id else None if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured") profile = job.staff_user.staff_profile if not profile.bale_notifications_enabled: return None diff --git a/apps/notifications/services.py b/apps/notifications/services.py index 751ebf1..9cd448f 100644 --- a/apps/notifications/services.py +++ b/apps/notifications/services.py @@ -5,6 +5,22 @@ from .models import NotificationJob,NotificationPolicy def enqueue(channel,event_type,ticket,dedupe_key,payload=None,customer=None,staff_user=None): return NotificationJob.objects.get_or_create(dedupe_key=dedupe_key,defaults={"channel":channel,"event_type":event_type,"ticket":ticket,"payload":payload or {},"customer":customer,"staff_user":staff_user,"next_attempt_at":timezone.now()})[0] +def enqueue_unit_supervisor_ticket_sms(ticket): + """Queue one concise new-ticket SMS for each configured unit supervisor.""" + unit = ticket.owning_unit + if not unit: + return [] + return [ + enqueue( + NotificationJob.Channel.SMS, + "UNIT_SUPERVISOR_NEW_TICKET", + ticket, + f"ticket:{ticket.pk}:unit-supervisor:{mobile}", + payload={"recipient_mobile": mobile}, + ) + for mobile in unit.supervisor_mobiles + ] + def staff_reply_policy(): return NotificationPolicy.objects.get_or_create(key=NotificationPolicy.Key.STAFF_REPLY_SMS,defaults={"quiet_period_seconds":300})[0] @@ -40,6 +56,8 @@ def cancel_staff_reply_sms_if_read(ticket,customer): def schedule_customer_reply_bale(ticket,message): """Debounce consecutive customer chat messages into one Bale group alert.""" + if not ticket.owning_unit_id or not ticket.owning_unit.bale_chat_id: + return None,False policy=customer_reply_bale_policy() if not policy.is_active: return None,False due=timezone.now()+timedelta(seconds=policy.quiet_period_seconds) diff --git a/apps/staff/admin.py b/apps/staff/admin.py index ddff25a..7f1ad13 100644 --- a/apps/staff/admin.py +++ b/apps/staff/admin.py @@ -13,12 +13,17 @@ class UnitMembershipInline(admin.TabularInline): @admin.register(OrganizationalUnit) class OrganizationalUnitAdmin(admin.ModelAdmin): - list_display = ("name", "slug", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at") + list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at") list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active") - search_fields = ("name", "slug") + search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3") list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active") filter_horizontal = ("permissions",) inlines = (UnitMembershipInline,) + fieldsets = ( + ("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}), + ("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3")}), + ("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}), + ) @admin.register(UnitMembership) diff --git a/apps/staff/migrations/0010_organizationalunit_notification_destinations.py b/apps/staff/migrations/0010_organizationalunit_notification_destinations.py new file mode 100644 index 0000000..f838f5d --- /dev/null +++ b/apps/staff/migrations/0010_organizationalunit_notification_destinations.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.1 on 2026-09-13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("staff", "0009_alter_staffprofile_sort_order"), + ] + + operations = [ + migrations.AddField( + model_name="organizationalunit", + name="bale_chat_id", + field=models.CharField(blank=True, help_text="شناسه chat_id گروه بله این واحد؛ در صورت خالی بودن، اعلان گروهی بله برای این واحد ارسال نمی‌شود.", max_length=100, verbose_name="شناسه گروه بله"), + ), + migrations.AddField( + model_name="organizationalunit", + name="supervisor_mobile_1", + field=models.CharField(blank=True, max_length=16, verbose_name="شماره همراه سرپرست اول"), + ), + migrations.AddField( + model_name="organizationalunit", + name="supervisor_mobile_2", + field=models.CharField(blank=True, max_length=16, verbose_name="شماره همراه سرپرست دوم"), + ), + migrations.AddField( + model_name="organizationalunit", + name="supervisor_mobile_3", + field=models.CharField(blank=True, max_length=16, verbose_name="شماره همراه سرپرست سوم"), + ), + ] diff --git a/apps/staff/models.py b/apps/staff/models.py index 9ed9751..cabe735 100644 --- a/apps/staff/models.py +++ b/apps/staff/models.py @@ -1,5 +1,6 @@ from django.conf import settings from django.contrib.auth.models import Permission +from django.core.exceptions import ValidationError from django.db import models from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel @@ -26,6 +27,15 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel): help_text="اگر غیرفعال باشد، مشتریان نمی‌توانند مستقیماً برای این واحد تیکت ثبت کنند.", ) show_in_customer_form = models.BooleanField(default=True, verbose_name="نمایش در فرم مشتری", help_text="در صورت خاموش بودن، واحد در فرم ثبت تیکت و جستجوی مشتری نمایش داده نمی‌شود.") + bale_chat_id = models.CharField( + max_length=100, + blank=True, + verbose_name="شناسه گروه بله", + help_text="شناسه chat_id گروه بله این واحد؛ در صورت خالی بودن، اعلان گروهی بله برای این واحد ارسال نمی‌شود.", + ) + supervisor_mobile_1 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست اول") + supervisor_mobile_2 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست دوم") + supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم") class Meta: ordering = ["sort_order", "name", "pk"] @@ -35,6 +45,47 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel): def __str__(self): return self.name + @property + def supervisor_mobiles(self): + """Return configured supervisor mobiles once, in their configured order.""" + return tuple(dict.fromkeys(filter(None, ( + self.supervisor_mobile_1, + self.supervisor_mobile_2, + self.supervisor_mobile_3, + )))) + + def clean(self): + super().clean() + from apps.accounts.models import normalize_mobile + + normalized = [] + errors = {} + for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3"): + value = getattr(self, field_name, "").strip() + if not value: + setattr(self, field_name, "") + continue + try: + value = normalize_mobile(value) + except ValueError as exc: + errors[field_name] = str(exc) + continue + setattr(self, field_name, value) + if value in normalized: + errors[field_name] = "این شماره همراه قبلاً برای همین واحد وارد شده است." + normalized.append(value) + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + from apps.accounts.models import normalize_mobile + + for field_name in ("supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3"): + value = getattr(self, field_name, "").strip() + setattr(self, field_name, normalize_mobile(value) if value else "") + self.bale_chat_id = self.bale_chat_id.strip() + super().save(*args, **kwargs) + class UnitMembership(TimeStampedModel): """Dynamic per-user unit membership with optional extra permissions.""" diff --git a/apps/tickets/services.py b/apps/tickets/services.py index c07d6fa..1dfb4db 100644 --- a/apps/tickets/services.py +++ b/apps/tickets/services.py @@ -7,7 +7,7 @@ from apps.attachments.services import save_attachments from apps.core.persian_text import normalize_persian_text from apps.messaging.models import Message,MessageMention from apps.notifications.models import NotificationJob -from apps.notifications.services import cancel_staff_reply_sms,enqueue,schedule_customer_reply_bale,schedule_staff_reply_sms +from apps.notifications.services import cancel_staff_reply_sms,enqueue,enqueue_unit_supervisor_ticket_sms,schedule_customer_reply_bale,schedule_staff_reply_sms from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketEvent from .policies import DispatchPolicy, TicketPolicy @@ -39,7 +39,9 @@ def create_ticket(*,customer,category,subject,selected_expert=None,body="",files enqueue(NotificationJob.Channel.SMS,"STAFF_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:staff-created",customer=customer) else: enqueue(NotificationJob.Channel.SMS,"CUSTOMER_TICKET_CREATED",ticket,f"ticket:{ticket.pk}:customer-created",customer=customer) - enqueue(NotificationJob.Channel.BALE,"NEW_CUSTOMER_TICKET",ticket,f"ticket:{ticket.pk}:bale-group",payload={"ticket_number":ticket.ticket_number}) + if owning_unit and owning_unit.bale_chat_id: + enqueue(NotificationJob.Channel.BALE,"NEW_CUSTOMER_TICKET",ticket,f"ticket:{ticket.pk}:bale-group",payload={"ticket_number":ticket.ticket_number}) + enqueue_unit_supervisor_ticket_sms(ticket) return ticket @@ -163,7 +165,8 @@ def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_user if reopened: ticket.status=Ticket.Status.REOPENED; ticket.reopened_at=now; TicketEvent.objects.create(ticket=ticket,event_type=TicketEvent.Type.REOPENED,actor=user) elif ticket.status != Ticket.Status.NEW: ticket.status=Ticket.Status.WAITING_STAFF if reopened: - enqueue(NotificationJob.Channel.BALE,"CUSTOMER_TICKET_REOPENED",ticket,f"message:{msg.pk}:bale-reopened",customer=ticket.customer,payload={"message_id":msg.pk,"message_count":1}) + if ticket.owning_unit_id and ticket.owning_unit.bale_chat_id: + enqueue(NotificationJob.Channel.BALE,"CUSTOMER_TICKET_REOPENED",ticket,f"message:{msg.pk}:bale-reopened",customer=ticket.customer,payload={"message_id":msg.pk,"message_count":1}) else: schedule_customer_reply_bale(ticket,msg) else: diff --git a/config/settings/base.py b/config/settings/base.py index 19a8290..d40bccd 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -114,7 +114,6 @@ SMS0098_API_URL = os.getenv("SMS0098_API_URL", "http://www.0098sms.com/sendsmsli 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", "") -BALE_GROUP_CHAT_ID = os.getenv("BALE_GROUP_CHAT_ID", "") INTEGRATION_HTTP_TIMEOUT_SECONDS = int(os.getenv("INTEGRATION_HTTP_TIMEOUT_SECONDS", "10")) STAFF_REPLY_SMS_THROTTLE_SECONDS = int(os.getenv("STAFF_REPLY_SMS_THROTTLE_SECONDS", "180")) X_FRAME_OPTIONS = "DENY" diff --git a/docker-compose.yml b/docker-compose.yml index bc3dd00..3cdf406 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,6 @@ x-django-service: &django-service SMS_SENDER: ${SMS_SENDER:-} BALE_API_BASE: ${BALE_API_BASE:-https://tapi.bale.ai} BALE_BOT_TOKEN: ${BALE_BOT_TOKEN:-} - BALE_GROUP_CHAT_ID: ${BALE_GROUP_CHAT_ID:-} INTEGRATION_HTTP_TIMEOUT_SECONDS: ${INTEGRATION_HTTP_TIMEOUT_SECONDS:-10} MAX_NOTIFICATION_ATTEMPTS: ${MAX_NOTIFICATION_ATTEMPTS:-5} SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-true} diff --git a/docs/DEPLOYMENT_COOLIFY.md b/docs/DEPLOYMENT_COOLIFY.md index a1e144f..38d6cf4 100644 --- a/docs/DEPLOYMENT_COOLIFY.md +++ b/docs/DEPLOYMENT_COOLIFY.md @@ -107,12 +107,13 @@ SMS_SENDER= ```dotenv BALE_API_BASE=https://tapi.bale.ai BALE_BOT_TOKEN= -BALE_GROUP_CHAT_ID= INTEGRATION_HTTP_TIMEOUT_SECONDS=10 MAX_NOTIFICATION_ATTEMPTS=5 ``` -در production نبودن `BALE_BOT_TOKEN` خطای قابل‌مشاهده ایجاد می‌کند و job را بی‌صدا SENT علامت نمی‌زند. worker هر پنج ثانیه outbox دیتابیس را بررسی می‌کند، با قفل `skip_locked` از پردازش هم‌زمان جلوگیری می‌کند و خطاها را با backoff تا سقف `MAX_NOTIFICATION_ATTEMPTS` دوباره امتحان می‌کند. +در production نبودن `BALE_BOT_TOKEN` خطای قابل‌مشاهده ایجاد می‌کند و job را بی‌صدا SENT علامت نمی‌زند. شناسه گروه دیگر یک env سراسری نیست: برای هر واحد از مسیر `x5admin → واحدهای سازمانی`، فیلد «شناسه گروه بله» را جداگانه وارد کنید. خالی بودن این فیلد یعنی اعلان‌های گروهی تیکت جدید و پاسخ مشتری برای همان واحد ارسال نمی‌شود؛ سامانه هرگز به گروه واحد دیگر fallback نمی‌کند. سه شماره اختیاری سرپرست نیز در همین صفحه تنظیم می‌شوند و فقط هنگام ایجاد تیکت جدید، پیامک کوتاه دریافت می‌کنند. + +worker هر پنج ثانیه outbox دیتابیس را بررسی می‌کند، با قفل `skip_locked` از پردازش هم‌زمان جلوگیری می‌کند و خطاها را با backoff تا سقف `MAX_NOTIFICATION_ATTEMPTS` دوباره امتحان می‌کند. سیاست تأخیر پیامک پاسخ کارشناس و اعلان پاسخ مشتری در Bale از مدل `NotificationPolicy` در پنل مدیریت دیتابیس کنترل می‌شود؛ متغیر قدیمی `STAFF_REPLY_SMS_THROTTLE_SECONDS` مرجع اصلی این رفتار نیست. diff --git a/tests/e2e/test_organization_permissions_e2e.py b/tests/e2e/test_organization_permissions_e2e.py index 30939c6..79fb4fe 100644 --- a/tests/e2e/test_organization_permissions_e2e.py +++ b/tests/e2e/test_organization_permissions_e2e.py @@ -88,7 +88,7 @@ def test_e2e_02_contact_group_picker_excludes_other_units(live_server, browser_p with browser_page() as page: login_staff(page, live_server, users["kimiya_ghaffari"]) page.goto(f"{live_server.url}{reverse('staff_tickets:contact_create')}", wait_until="domcontentloaded") - options = page.locator('select[name="groups"] option') + options = page.locator('[data-contact-group-selector] label') expect(options.filter(has_text="مخاطبان فروش")).to_have_count(1) expect(options.filter(has_text="بارگیری انبار")).to_have_count(0) expect(options.filter(has_text="مالی / حسابداری")).to_have_count(0) @@ -96,7 +96,7 @@ def test_e2e_02_contact_group_picker_excludes_other_units(live_server, browser_p page.locator('input[name="first_name"]').fill("مخاطب") page.locator('input[name="last_name"]').fill("فروش") page.locator('select[name="stakeholder_type"]').select_option(CustomerProfile.StakeholderType.CUSTOMER) - page.locator('select[name="groups"]').select_option(str(sales_group.pk)) + page.locator(f'input[name="groups"][value="{sales_group.pk}"]').check() with page.expect_navigation(wait_until="domcontentloaded"): page.get_by_role("button", name="ذخیره مخاطب").click() expect(page.locator("body")).to_contain_text("مخاطب فروش") diff --git a/tests/test_domain.py b/tests/test_domain.py index c375364..4c5699d 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -2,7 +2,7 @@ import pytest import base64 from datetime import timedelta from django.contrib.auth.models import Permission -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, ValidationError from django.test import override_settings from django.utils import timezone from django.core.files.uploadedfile import SimpleUploadedFile @@ -16,7 +16,7 @@ from apps.tickets.services import claim_ticket, create_ticket, delete_ticket, re @pytest.fixture def category(db): - unit = OrganizationalUnit.objects.create(name="واحد تست دامنه", slug="domain-test-unit") + unit = OrganizationalUnit.objects.create(name="واحد تست دامنه", slug="domain-test-unit", bale_chat_id="domain-bale-group") unit.permissions.add( Permission.objects.get(codename="view_ticket", content_type__app_label="tickets"), Permission.objects.get(codename="add_message", content_type__app_label="messaging"), @@ -50,9 +50,8 @@ def test_customer_reply_enqueues_bale_group_notification(category, customer): assert job.next_attempt_at>=timezone.now()+timedelta(seconds=595) @pytest.mark.django_db -def test_customer_ticket_and_reply_bale_events_always_target_group(category, customer, settings, monkeypatch): +def test_customer_ticket_and_reply_bale_events_target_owning_unit_group(category, customer, monkeypatch): from apps.notifications.management.commands.process_notifications import _send_bale - settings.BALE_GROUP_CHAT_ID = "group-123" sent = [] class Provider: def send_message(self, chat_id, text): @@ -68,7 +67,7 @@ def test_customer_ticket_and_reply_bale_events_always_target_group(category, cus reply = reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ مشتری") reply_job = NotificationJob.objects.get(ticket=ticket, event_type="CUSTOMER_TICKET_REPLY") _send_bale(reply_job) - assert [chat_id for chat_id, _ in sent] == ["group-123", "group-123"] + assert [chat_id for chat_id, _ in sent] == ["domain-bale-group", "domain-bale-group"] assert "تیکت جدید مشتری" in sent[0][1] assert "پاسخ جدید مشتری" in sent[1][1] assert reply.body in sent[1][1] @@ -79,6 +78,132 @@ def test_customer_ticket_and_reply_bale_events_always_target_group(category, cus assert "🎫" in text and "📌 اعلان بله" in text and "📝" in text assert "📍 تهران" in sent[0][1] + +@pytest.mark.django_db +def test_bale_group_notifications_are_isolated_between_units(category, customer, monkeypatch): + from apps.notifications.management.commands.process_notifications import _send_bale + + second_unit = OrganizationalUnit.objects.create( + name="واحد دوم اعلان بله", + slug="second-bale-unit", + bale_chat_id="second-bale-group", + ) + second_category = TicketCategory.objects.create(unit=second_unit, title="حسابداری", slug="second-bale-category") + sent = [] + + class Provider: + def send_message(self, chat_id, text): + sent.append((chat_id, text)) + return type("Result", (), {"success": True})() + + monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_bale_provider", lambda: Provider()) + first_ticket = create_ticket(customer=customer, category=category, subject="درخواست فروش", body="پیام فروش") + second_ticket = create_ticket(customer=customer, category=second_category, subject="درخواست مالی", body="پیام مالی") + first_reply = reply_ticket(ticket_id=first_ticket.pk, user=customer, body="پاسخ فروش") + second_reply = reply_ticket(ticket_id=second_ticket.pk, user=customer, body="پاسخ مالی") + + for ticket, event_type in ( + (first_ticket, "NEW_CUSTOMER_TICKET"), + (second_ticket, "NEW_CUSTOMER_TICKET"), + (first_ticket, "CUSTOMER_TICKET_REPLY"), + (second_ticket, "CUSTOMER_TICKET_REPLY"), + ): + _send_bale(NotificationJob.objects.get(ticket=ticket, event_type=event_type)) + + assert [chat_id for chat_id, _ in sent] == [ + "domain-bale-group", + "second-bale-group", + "domain-bale-group", + "second-bale-group", + ] + assert first_reply.body in sent[2][1] + assert second_reply.body in sent[3][1] + assert "درخواست مالی" not in sent[0][1] and "درخواست فروش" not in sent[1][1] + + +@pytest.mark.django_db +def test_blank_unit_bale_chat_never_falls_back_to_global_group(category, customer, settings): + settings.BALE_GROUP_CHAT_ID = "legacy-global-group" + category.unit.bale_chat_id = "" + category.unit.save(update_fields=["bale_chat_id", "updated_at"]) + + ticket = create_ticket(customer=customer, category=category, subject="بدون گروه", body="پیام") + reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ") + + assert not NotificationJob.objects.filter(ticket=ticket, channel=NotificationJob.Channel.BALE).exists() + + +@pytest.mark.django_db +def test_unit_notification_destinations_normalize_and_reject_duplicate_supervisors(): + unit = OrganizationalUnit( + name="واحد مقصد اعلان", + slug="notification-destinations", + supervisor_mobile_1="0912 111 2233", + supervisor_mobile_2="0935 222 3344", + ) + unit.full_clean() + unit.save() + assert unit.supervisor_mobiles == ("+989121112233", "+989352223344") + + unit.supervisor_mobile_3 = "09121112233" + with pytest.raises(ValidationError, match="قبلاً"): + unit.full_clean() + + +@pytest.mark.django_db +def test_new_ticket_sms_is_queued_for_each_unit_supervisor_only_on_creation(category, customer, monkeypatch): + from apps.notifications.management.commands.process_notifications import _send_sms + + unit = category.unit + unit.supervisor_mobile_1 = "09121112233" + unit.supervisor_mobile_2 = "09352223344" + unit.supervisor_mobile_3 = "09903334455" + unit.save() + ticket = create_ticket(customer=customer, category=category, subject=" پیگیری بارگیری ", body="متن") + jobs = list(NotificationJob.objects.filter(ticket=ticket, event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("id")) + assert [job.payload["recipient_mobile"] for job in jobs] == list(unit.supervisor_mobiles) + + sent = [] + + class Provider: + def send_message(self, mobile, message): + sent.append((mobile, message)) + return type("Result", (), {"success": True})() + + monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: Provider()) + for job in jobs: + result, issued_link = _send_sms(job) + assert result.success and issued_link is None + + assert [mobile for mobile, _ in sent] == list(unit.supervisor_mobiles) + for _, message in sent: + assert customer.local_mobile in message + assert "موضوع: پیگیری بارگیری" in message + assert f"دسته: {category.title}" in message + + supervisor_job_count = NotificationJob.objects.filter(event_type="UNIT_SUPERVISOR_NEW_TICKET").count() + reply_ticket(ticket_id=ticket.pk, user=customer, body="پاسخ جدید") + assert NotificationJob.objects.filter(event_type="UNIT_SUPERVISOR_NEW_TICKET").count() == supervisor_job_count + + +@pytest.mark.django_db +def test_staff_created_ticket_also_notifies_owning_unit_supervisors(category, customer): + category.unit.supervisor_mobile_1 = "09124445566" + category.unit.save() + staff = make_staff("a") + + ticket = create_ticket( + customer=customer, + created_by=staff, + unit=category.unit, + category=category, + subject="ثبت توسط کارشناس", + body="متن", + ) + + job = NotificationJob.objects.get(ticket=ticket, event_type="UNIT_SUPERVISOR_NEW_TICKET") + assert job.payload == {"recipient_mobile": "+989124445566"} + @pytest.mark.django_db def test_customer_bale_replies_are_debounced_by_database_policy(category,customer): from apps.notifications.models import NotificationPolicy