forked from hesabix/arc
1305 lines
53 KiB
Python
Executable file
1305 lines
53 KiB
Python
Executable file
"""
|
|
سرویس جامع مدیریت و ارسال نوتیفیکیشن برای کسبوکارها
|
|
|
|
این سرویس شامل:
|
|
- رندر قالبها با جایگزینی متغیرها (Jinja2 Template Engine)
|
|
- بررسی محدودیتهای ارسال (Rate Limiting)
|
|
- ارسال SMS/Email با استفاده از قالبها
|
|
- ثبت لاگ کامل
|
|
- بهروزرسانی آمار
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import json
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, date
|
|
from typing import Any, Dict, List, Optional
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import and_
|
|
from sqlalchemy.orm import Session
|
|
from jinja2.sandbox import SandboxedEnvironment
|
|
from jinja2 import BaseLoader, TemplateSyntaxError, UndefinedError
|
|
|
|
from adapters.db.models.business_notification import (
|
|
NotificationEventType,
|
|
BusinessNotificationTemplate,
|
|
NotificationSendLog
|
|
)
|
|
from adapters.db.models.person import Person
|
|
from adapters.db.models.business import Business
|
|
from adapters.db.repositories.business_notification_repo import (
|
|
NotificationEventTypeRepository,
|
|
BusinessNotificationTemplateRepository,
|
|
NotificationSendLogRepository,
|
|
NotificationDailyStatRepository
|
|
)
|
|
from app.services.providers.sms_provider import SmsProvider
|
|
from app.services.providers.email_provider import EmailProvider
|
|
from app.services.email_service import EmailService
|
|
from app.services.system_settings_service import (
|
|
get_effective_notifications_settings,
|
|
get_notification_sms_pricing,
|
|
get_wallet_settings
|
|
)
|
|
from app.services.wallet_service import (
|
|
charge_wallet_for_notification,
|
|
_get_wallet_account_for_update
|
|
)
|
|
from app.services.notification_service import NotificationService
|
|
from app.utils.phone_utils import normalize_phone_number
|
|
from app.core.responses import ApiError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ResolvedNotificationTemplate:
|
|
"""قالب حلشده برای ارسال — از کسبوکار یا پیشفرض سیستم."""
|
|
|
|
body: str
|
|
subject: Optional[str]
|
|
source: str # business | system_default
|
|
template_id: Optional[int]
|
|
template_name: str
|
|
|
|
|
|
def calculate_sms_count(text: str) -> int:
|
|
"""
|
|
محاسبه تعداد پیامک بر اساس طول متن
|
|
|
|
استاندارد SMS:
|
|
- 1-70 کاراکتر = 1 پیامک
|
|
- 71-134 کاراکتر = 2 پیامک
|
|
- 135-201 کاراکتر = 3 پیامک
|
|
- و الی آخر (هر 67 کاراکتر بعدی = 1 پیامک اضافی)
|
|
|
|
Args:
|
|
text: متن پیامک
|
|
|
|
Returns:
|
|
تعداد پیامک مورد نیاز
|
|
"""
|
|
length = len(text)
|
|
if length <= 70:
|
|
return 1
|
|
# هر 67 کاراکتر بعدی = 1 پیامک اضافی
|
|
return 1 + ((length - 70 + 66) // 67)
|
|
|
|
|
|
class TemplateRenderService:
|
|
"""
|
|
سرویس رندر قالبها با Jinja2
|
|
|
|
قابلیتها:
|
|
- جایگزینی متغیرها با مقادیر واقعی
|
|
- فیلترهای سفارشی (مثلاً فرمت تاریخ، فرمت عدد)
|
|
- Sandbox محیط برای امنیت
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.env = SandboxedEnvironment(
|
|
loader=BaseLoader(),
|
|
autoescape=True,
|
|
enable_async=False
|
|
)
|
|
|
|
# اضافه کردن فیلترهای سفارشی
|
|
self.env.filters['format_number'] = self._format_number
|
|
self.env.filters['format_date'] = self._format_date
|
|
self.env.filters['format_currency'] = self._format_currency
|
|
|
|
def _format_number(self, value: Any) -> str:
|
|
"""فرمت کردن عدد با جداکننده هزارگان"""
|
|
try:
|
|
return f"{float(value):,.0f}".replace(',', '،')
|
|
except (ValueError, TypeError):
|
|
return str(value)
|
|
|
|
def _format_date(self, value: Any, format: str = '%Y/%m/%d') -> str:
|
|
"""فرمت کردن تاریخ"""
|
|
try:
|
|
if isinstance(value, str):
|
|
# تلاش برای parse کردن
|
|
dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
elif isinstance(value, datetime):
|
|
dt = value
|
|
elif isinstance(value, date):
|
|
dt = datetime.combine(value, datetime.min.time())
|
|
else:
|
|
return str(value)
|
|
|
|
return dt.strftime(format)
|
|
except (ValueError, AttributeError):
|
|
return str(value)
|
|
|
|
def _format_currency(self, value: Any, currency: str = 'تومان') -> str:
|
|
"""فرمت کردن مبلغ با واحد ارز"""
|
|
formatted_number = self._format_number(value)
|
|
return f"{formatted_number} {currency}"
|
|
|
|
def render(self, template_text: str, context: Dict[str, Any]) -> str:
|
|
"""
|
|
رندر یک قالب با context داده شده
|
|
|
|
Args:
|
|
template_text: متن قالب (مثلاً "سلام {{ customer_name }}")
|
|
context: دیکشنری متغیرها
|
|
|
|
Returns:
|
|
متن رندر شده
|
|
|
|
Raises:
|
|
TemplateSyntaxError: خطای Syntax در قالب
|
|
UndefinedError: متغیر استفاده شده تعریف نشده
|
|
"""
|
|
if not template_text:
|
|
return ""
|
|
|
|
try:
|
|
template = self.env.from_string(template_text)
|
|
return template.render(**context)
|
|
except TemplateSyntaxError as e:
|
|
logger.error(f"Template syntax error: {e}")
|
|
raise ApiError(
|
|
"TEMPLATE_SYNTAX_ERROR",
|
|
f"خطای Syntax در قالب: {str(e)}",
|
|
http_status=400
|
|
)
|
|
except UndefinedError as e:
|
|
logger.error(f"Undefined variable in template: {e}")
|
|
raise ApiError(
|
|
"TEMPLATE_UNDEFINED_VARIABLE",
|
|
f"متغیر تعریف نشده در قالب: {str(e)}",
|
|
http_status=400
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error rendering template: {e}", exc_info=True)
|
|
# در صورت خطای غیرمنتظره، متن خام برمیگردانیم
|
|
return template_text
|
|
|
|
def validate_template(self, template_text: str, required_vars: List[str]) -> Dict[str, Any]:
|
|
"""
|
|
اعتبارسنجی یک قالب
|
|
|
|
بررسی:
|
|
- Syntax قالب صحیح باشد
|
|
- متغیرهای لازم استفاده شده باشند
|
|
|
|
Returns:
|
|
دیکشنری با نتیجه validation
|
|
"""
|
|
result = {
|
|
"is_valid": True,
|
|
"errors": [],
|
|
"warnings": [],
|
|
"used_variables": []
|
|
}
|
|
|
|
try:
|
|
# بررسی Syntax
|
|
template = self.env.from_string(template_text)
|
|
|
|
# استخراج متغیرهای استفاده شده
|
|
from jinja2 import meta
|
|
ast = self.env.parse(template_text)
|
|
used_vars = meta.find_undeclared_variables(ast)
|
|
result["used_variables"] = list(used_vars)
|
|
|
|
# بررسی متغیرهای لازم
|
|
for var in required_vars:
|
|
if var not in used_vars:
|
|
result["warnings"].append(f"متغیر '{var}' استفاده نشده است")
|
|
|
|
except TemplateSyntaxError as e:
|
|
result["is_valid"] = False
|
|
result["errors"].append(f"خطای Syntax: {str(e)}")
|
|
except Exception as e:
|
|
result["is_valid"] = False
|
|
result["errors"].append(f"خطای غیرمنتظره: {str(e)}")
|
|
|
|
return result
|
|
|
|
|
|
class BusinessNotificationService:
|
|
"""
|
|
سرویس اصلی مدیریت و ارسال نوتیفیکیشن کسبوکارها
|
|
"""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
self.template_renderer = TemplateRenderService()
|
|
|
|
# Repositories
|
|
self.event_type_repo = NotificationEventTypeRepository(db)
|
|
self.template_repo = BusinessNotificationTemplateRepository(db)
|
|
self.log_repo = NotificationSendLogRepository(db)
|
|
self.stat_repo = NotificationDailyStatRepository(db)
|
|
|
|
# Providers
|
|
notify_cfg = get_effective_notifications_settings(db)
|
|
self.sms_provider = SmsProvider(
|
|
provider_name=notify_cfg.get("sms_provider_name"),
|
|
api_key=notify_cfg.get("sms_api_key"),
|
|
sender=notify_cfg.get("sms_sender"),
|
|
username=notify_cfg.get("sms_provider_username"),
|
|
password=notify_cfg.get("sms_provider_password"),
|
|
is_flash=notify_cfg.get("sms_is_flash", False),
|
|
)
|
|
self.email_provider = EmailProvider(db)
|
|
self.email_service = EmailService(db) # برای ارسال مستقیم به Person
|
|
|
|
def _calculate_sms_cost(
|
|
self,
|
|
sms_count: int,
|
|
event_type: str
|
|
) -> Decimal:
|
|
"""
|
|
محاسبه هزینه ارسال پیامک
|
|
|
|
Args:
|
|
sms_count: تعداد پیامک
|
|
event_type: نوع رویداد
|
|
|
|
Returns:
|
|
هزینه کل (بر اساس ارز کیف پول)
|
|
|
|
Raises:
|
|
ApiError: در صورت خطا در محاسبه هزینه
|
|
"""
|
|
try:
|
|
if sms_count <= 0:
|
|
raise ApiError(
|
|
"INVALID_SMS_COUNT",
|
|
"تعداد پیامک باید بزرگتر از صفر باشد",
|
|
http_status=400
|
|
)
|
|
|
|
pricing = get_notification_sms_pricing(self.db)
|
|
|
|
# بررسی قیمت خاص برای event_type
|
|
event_prices = pricing.get("event_type_prices", {})
|
|
price_per_sms = event_prices.get(event_type)
|
|
|
|
# اگر قیمت خاصی تعریف نشده، از قیمت پیشفرض استفاده میکنیم
|
|
if price_per_sms is None:
|
|
price_per_sms = pricing.get("price_per_sms", 500.0)
|
|
|
|
# اعتبارسنجی نهایی قیمت
|
|
try:
|
|
price_per_sms = float(price_per_sms)
|
|
if price_per_sms <= 0:
|
|
logger.warning(
|
|
f"قیمت پیامک نامعتبر است ({price_per_sms}). استفاده از مقدار پیشفرض 500"
|
|
)
|
|
price_per_sms = 500.0
|
|
except (ValueError, TypeError):
|
|
logger.warning(
|
|
f"خطا در تبدیل قیمت پیامک. استفاده از مقدار پیشفرض 500"
|
|
)
|
|
price_per_sms = 500.0
|
|
|
|
total_cost = Decimal(str(price_per_sms)) * Decimal(str(sms_count))
|
|
|
|
if total_cost <= 0:
|
|
raise ApiError(
|
|
"INVALID_COST",
|
|
"هزینه محاسبه شده نامعتبر است",
|
|
http_status=500
|
|
)
|
|
|
|
return total_cost
|
|
|
|
except ApiError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"خطا در محاسبه هزینه پیامک: {e}", exc_info=True)
|
|
raise ApiError(
|
|
"COST_CALCULATION_ERROR",
|
|
f"خطا در محاسبه هزینه: {str(e)}",
|
|
http_status=500
|
|
)
|
|
|
|
def estimate_sms_template_cost(
|
|
self,
|
|
business_id: int,
|
|
template_id: int,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
برآورد هزینه ارسال بر اساس طول متن خام قالب و قیمتگذاری اعلامی در تنظیمات ادمین.
|
|
پس از رندر قالب متن نهایی ممکن است متفاوت باشد؛ هزینه واقعی همان لحظه ارسال محاسبه میشود.
|
|
"""
|
|
template = self.template_repo.get_by_id(template_id, business_id)
|
|
if not template:
|
|
raise ApiError("TEMPLATE_NOT_FOUND", "قالب یافت نشد", http_status=404)
|
|
if template.channel != "sms":
|
|
raise ApiError(
|
|
"TEMPLATE_NOT_SMS",
|
|
"قالب انتخابشده برای پیامک نیست",
|
|
http_status=400,
|
|
)
|
|
|
|
body = template.body or ""
|
|
sms_count = calculate_sms_count(body)
|
|
total_cost = self._calculate_sms_cost(sms_count, template.event_type)
|
|
unit_cost = self._calculate_sms_cost(1, template.event_type)
|
|
price_per_sms = float(unit_cost)
|
|
|
|
return {
|
|
"template_id": template.id,
|
|
"template_name": template.name,
|
|
"event_type": template.event_type,
|
|
"body_char_count": len(body),
|
|
"sms_segments": sms_count,
|
|
"price_per_sms": price_per_sms,
|
|
"estimated_total": float(total_cost),
|
|
"disclaimer": "estimate_based_on_raw_template",
|
|
}
|
|
|
|
def send_sms_by_template(
|
|
self,
|
|
business_id: int,
|
|
template_id: int,
|
|
context: Dict[str, Any],
|
|
person_id: int,
|
|
recipient_mobile_override: Optional[str] = None,
|
|
triggered_by_user_id: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
ارسال پیامک با یک قالب مشخص (برای ورکفلو و اتوماسیون).
|
|
|
|
قالب باید متعلق به همان business، کانال sms، وضعیت approved و فعال باشد.
|
|
هزینه و کیف پول مطابق تنظیمات قیمتگذاری پیامک و الگوی send_to_person.
|
|
"""
|
|
template = self.template_repo.get_by_id(template_id, business_id)
|
|
if not template:
|
|
raise ApiError("TEMPLATE_NOT_FOUND", "قالب یافت نشد", http_status=404)
|
|
if template.channel != "sms":
|
|
raise ApiError(
|
|
"TEMPLATE_NOT_SMS",
|
|
"قالب انتخابشده برای پیامک نیست",
|
|
http_status=400,
|
|
)
|
|
if template.status != "approved" or not template.is_active:
|
|
raise ApiError(
|
|
"TEMPLATE_NOT_APPROVED",
|
|
"قالب تایید نشده یا غیرفعال است",
|
|
http_status=400,
|
|
)
|
|
|
|
person = (
|
|
self.db.query(Person)
|
|
.filter(and_(Person.id == person_id, Person.business_id == business_id))
|
|
.first()
|
|
)
|
|
if not person:
|
|
raise ApiError("PERSON_NOT_FOUND", "شخص یافت نشد", http_status=404)
|
|
|
|
sms_destination: Optional[str] = None
|
|
if recipient_mobile_override and str(recipient_mobile_override).strip():
|
|
try:
|
|
sms_destination = normalize_phone_number(str(recipient_mobile_override).strip())
|
|
except Exception as e:
|
|
raise ApiError(
|
|
"INVALID_RECIPIENT_MOBILE",
|
|
f"شماره موبایل مقصد معتبر نیست: {e}",
|
|
http_status=400,
|
|
)
|
|
elif person.mobile:
|
|
try:
|
|
sms_destination = normalize_phone_number(person.mobile)
|
|
except Exception:
|
|
sms_destination = None
|
|
|
|
if not sms_destination:
|
|
raise ApiError(
|
|
"NO_SMS_RECIPIENT",
|
|
"شماره موبایل در پرونده شخص ثبت نشده و شماره مقصد هم ارسال نشده است.",
|
|
http_status=400,
|
|
)
|
|
|
|
business = self.db.query(Business).filter(Business.id == business_id).first()
|
|
if business:
|
|
context.setdefault("business_name", business.name)
|
|
context.setdefault("business_phone", business.phone or "")
|
|
|
|
event_type = template.event_type
|
|
ch = "sms"
|
|
|
|
try:
|
|
if not self._check_daily_limit(business_id, template.id, ch):
|
|
result = {
|
|
"success": False,
|
|
"error": "DAILY_LIMIT",
|
|
"error_message": "محدودیت ارسال روزانه",
|
|
}
|
|
self.db.commit()
|
|
return result
|
|
|
|
rendered_body = self.template_renderer.render(template.body, context)
|
|
|
|
wallet_charge_result = None
|
|
total_cost: Decimal = Decimal("0")
|
|
|
|
try:
|
|
sms_count = calculate_sms_count(rendered_body)
|
|
if sms_count <= 0:
|
|
self.db.commit()
|
|
return {
|
|
"success": False,
|
|
"error": "INVALID_SMS_COUNT",
|
|
"error_message": "تعداد پیامک محاسبه شده نامعتبر است",
|
|
}
|
|
|
|
total_cost = self._calculate_sms_cost(sms_count, event_type)
|
|
except ApiError as cost_error:
|
|
logger.error(f"خطا در محاسبه هزینه: {cost_error}")
|
|
self.db.commit()
|
|
return {
|
|
"success": False,
|
|
"error": cost_error.code or "COST_ERROR",
|
|
"error_message": cost_error.message or "خطا در محاسبه هزینه پیامک",
|
|
}
|
|
|
|
try:
|
|
account = _get_wallet_account_for_update(self.db, business_id)
|
|
available_balance = Decimal(str(account.available_balance or 0))
|
|
|
|
if available_balance < total_cost:
|
|
ins = self._handle_insufficient_funds(
|
|
business_id=business_id,
|
|
user_id=triggered_by_user_id,
|
|
required_amount=total_cost,
|
|
available_amount=available_balance,
|
|
template_name=template.name,
|
|
event_type=event_type,
|
|
template_id=template.id,
|
|
person_id=person_id,
|
|
person=person,
|
|
channel=ch,
|
|
rendered_body=rendered_body,
|
|
context=context,
|
|
)
|
|
self.db.commit()
|
|
return ins
|
|
|
|
wallet_charge_result = charge_wallet_for_notification(
|
|
db=self.db,
|
|
business_id=business_id,
|
|
user_id=triggered_by_user_id or 0,
|
|
amount=total_cost,
|
|
sms_count=sms_count,
|
|
event_type=event_type,
|
|
template_id=template.id,
|
|
template_name=template.name,
|
|
)
|
|
except ApiError as e:
|
|
if e.code == "INSUFFICIENT_FUNDS":
|
|
ins = self._handle_insufficient_funds(
|
|
business_id=business_id,
|
|
user_id=triggered_by_user_id,
|
|
required_amount=total_cost,
|
|
available_amount=Decimal("0"),
|
|
template_name=template.name,
|
|
event_type=event_type,
|
|
template_id=template.id,
|
|
person_id=person_id,
|
|
person=person,
|
|
channel=ch,
|
|
rendered_body=rendered_body,
|
|
context=context,
|
|
)
|
|
self.db.commit()
|
|
return ins
|
|
raise
|
|
|
|
to_phone = sms_destination
|
|
send_result = self._send_message(
|
|
channel=ch,
|
|
recipient=person,
|
|
subject=None,
|
|
body=rendered_body,
|
|
to_phone_override=to_phone,
|
|
)
|
|
|
|
if wallet_charge_result:
|
|
try:
|
|
total_cost = self._calculate_sms_cost(
|
|
calculate_sms_count(rendered_body),
|
|
event_type,
|
|
)
|
|
send_result["cost"] = total_cost
|
|
except Exception as cost_error:
|
|
logger.warning(f"خطا در محاسبه مجدد هزینه: {cost_error}")
|
|
|
|
if not send_result["success"] and wallet_charge_result:
|
|
try:
|
|
account = _get_wallet_account_for_update(self.db, business_id)
|
|
account.available_balance += total_cost
|
|
self.db.flush()
|
|
|
|
from adapters.db.models.wallet import WalletTransaction
|
|
|
|
reversal_tx = WalletTransaction(
|
|
business_id=int(business_id),
|
|
type="notification_sms_reversal",
|
|
status="succeeded",
|
|
amount=total_cost,
|
|
fee_amount=Decimal("0"),
|
|
description=f"بازگشت مبلغ - خطا در ارسال پیامک: {send_result.get('error', 'خطای نامشخص')}",
|
|
document_id=wallet_charge_result.get("document_id"),
|
|
extra_info=json.dumps(
|
|
{
|
|
"source": "business_notification",
|
|
"original_transaction_id": wallet_charge_result.get(
|
|
"wallet_transaction_id"
|
|
),
|
|
"reason": "send_failed",
|
|
"error": send_result.get("error"),
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
)
|
|
self.db.add(reversal_tx)
|
|
self.db.flush()
|
|
except Exception as reversal_error:
|
|
logger.error(
|
|
f"خطا در بازگشت مبلغ به کیف پول: {reversal_error}",
|
|
exc_info=True,
|
|
)
|
|
|
|
recipient_identifier = sms_destination or person.mobile
|
|
log = self._create_log(
|
|
business_id=business_id,
|
|
template_id=template.id,
|
|
recipient_type="person",
|
|
recipient_id=person_id,
|
|
recipient_identifier=recipient_identifier,
|
|
channel=ch,
|
|
subject=None,
|
|
body=rendered_body,
|
|
context_data=context,
|
|
event_type=event_type,
|
|
triggered_by_user_id=triggered_by_user_id,
|
|
send_result=send_result,
|
|
)
|
|
|
|
if send_result["success"]:
|
|
self.stat_repo.increment_sent(
|
|
business_id=business_id,
|
|
template_id=template.id,
|
|
target_date=date.today(),
|
|
channel=ch,
|
|
cost=send_result.get("cost", Decimal("0")),
|
|
)
|
|
else:
|
|
self.stat_repo.increment_failed(
|
|
business_id=business_id,
|
|
template_id=template.id,
|
|
target_date=date.today(),
|
|
channel=ch,
|
|
)
|
|
|
|
self.db.commit()
|
|
|
|
display_name = ""
|
|
if person.first_name or person.last_name:
|
|
display_name = " ".join(
|
|
filter(None, [person.first_name, person.last_name])
|
|
).strip()
|
|
if not display_name:
|
|
display_name = person.alias_name or ""
|
|
|
|
return {
|
|
"success": send_result["success"],
|
|
"template_id": template.id,
|
|
"event_type": event_type,
|
|
"log_id": log.id,
|
|
"message": (
|
|
"ارسال با موفقیت انجام شد"
|
|
if send_result["success"]
|
|
else (send_result.get("error") or "خطا در ارسال")
|
|
),
|
|
"error": None if send_result["success"] else (send_result.get("error") or "SEND_FAILED"),
|
|
"recipient": {
|
|
"type": "person",
|
|
"id": person_id,
|
|
"name": display_name,
|
|
},
|
|
"cost": float(total_cost) if wallet_charge_result else 0.0,
|
|
}
|
|
|
|
except ApiError:
|
|
self.db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"send_sms_by_template failed: {e}", exc_info=True)
|
|
self.db.rollback()
|
|
raise
|
|
|
|
def _resolve_template(
|
|
self,
|
|
business_id: int,
|
|
event_type: str,
|
|
channel: str,
|
|
) -> Optional[ResolvedNotificationTemplate]:
|
|
"""
|
|
زنجیره اولویت: قالب تأییدشده کسبوکار → قالب پیشفرض سیستم (notification_event_types).
|
|
"""
|
|
business_template = self.template_repo.find_active_template(
|
|
business_id=business_id,
|
|
event_type=event_type,
|
|
channel=channel,
|
|
)
|
|
if business_template:
|
|
return ResolvedNotificationTemplate(
|
|
body=business_template.body,
|
|
subject=business_template.subject,
|
|
source="business",
|
|
template_id=business_template.id,
|
|
template_name=business_template.name,
|
|
)
|
|
|
|
event_type_row = self.event_type_repo.get_by_code(event_type)
|
|
if not event_type_row or not event_type_row.is_active:
|
|
return None
|
|
|
|
if channel == "sms":
|
|
body = (event_type_row.default_sms_template or "").strip()
|
|
if not body:
|
|
return None
|
|
return ResolvedNotificationTemplate(
|
|
body=body,
|
|
subject=None,
|
|
source="system_default",
|
|
template_id=None,
|
|
template_name=f"پیشفرض سیستم — {event_type_row.name}",
|
|
)
|
|
|
|
if channel == "email":
|
|
body = (event_type_row.default_email_template or "").strip()
|
|
if not body:
|
|
return None
|
|
subject = (event_type_row.default_email_subject or "پیام از {{ business_name }}").strip()
|
|
return ResolvedNotificationTemplate(
|
|
body=body,
|
|
subject=subject,
|
|
source="system_default",
|
|
template_id=None,
|
|
template_name=f"پیشفرض سیستم — {event_type_row.name}",
|
|
)
|
|
|
|
return None
|
|
|
|
def send_to_person(
|
|
self,
|
|
business_id: int,
|
|
person_id: int,
|
|
event_type: str,
|
|
context: Dict[str, Any],
|
|
channel: Optional[str] = None,
|
|
recipient_mobile_override: Optional[str] = None,
|
|
triggered_by_user_id: Optional[int] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
ارسال نوتیفیکیشن به یک Person (مشتری، تامینکننده، و...)
|
|
|
|
Args:
|
|
business_id: شناسه کسبوکار
|
|
person_id: شناسه Person
|
|
event_type: نوع رویداد (مثلاً "invoice.created")
|
|
context: دادههای متغیرها
|
|
channel: کانال ارسال (None = همه کانالهای فعال)
|
|
recipient_mobile_override: شماره موبایل مقصد (اختیاری؛ برای SMS به این شماره ارسال میشود)
|
|
triggered_by_user_id: کاربری که این ارسال را trigger کرده
|
|
|
|
Returns:
|
|
دیکشنری نتیجه ارسال
|
|
"""
|
|
# دریافت Person
|
|
person = self.db.query(Person).filter(Person.id == person_id).first()
|
|
if not person:
|
|
raise ApiError("PERSON_NOT_FOUND", "شخص یافت نشد", http_status=404)
|
|
|
|
# نرمالسازی شماره override در صورت ارسال
|
|
sms_destination = None
|
|
if recipient_mobile_override and recipient_mobile_override.strip():
|
|
try:
|
|
sms_destination = normalize_phone_number(recipient_mobile_override.strip())
|
|
except Exception as e:
|
|
raise ApiError(
|
|
"INVALID_RECIPIENT_MOBILE",
|
|
f"شماره موبایل مقصد معتبر نیست: {e}",
|
|
http_status=400
|
|
)
|
|
elif person.mobile:
|
|
try:
|
|
sms_destination = normalize_phone_number(person.mobile)
|
|
except Exception:
|
|
sms_destination = None
|
|
|
|
# دریافت کسبوکار برای اضافه کردن به context
|
|
business = self.db.query(Business).filter(Business.id == business_id).first()
|
|
if business:
|
|
context.setdefault("business_name", business.name)
|
|
context.setdefault("business_phone", business.phone or "")
|
|
|
|
# تعیین کانالهای ارسال
|
|
channels_to_send = []
|
|
if channel:
|
|
if channel == "sms" and not sms_destination:
|
|
raise ApiError(
|
|
"NO_SMS_RECIPIENT",
|
|
"شماره موبایل در پرونده شخص ثبت نشده و شماره مقصد هم ارسال نشده است.",
|
|
http_status=400
|
|
)
|
|
channels_to_send = [channel]
|
|
else:
|
|
# ارسال به همه کانالهایی که قالب فعال دارند
|
|
if sms_destination:
|
|
channels_to_send.append('sms')
|
|
if person.email:
|
|
channels_to_send.append('email')
|
|
|
|
results = {}
|
|
|
|
for ch in channels_to_send:
|
|
try:
|
|
resolved = self._resolve_template(
|
|
business_id=business_id,
|
|
event_type=event_type,
|
|
channel=ch,
|
|
)
|
|
|
|
if not resolved:
|
|
logger.warning(
|
|
f"No template found for business={business_id}, "
|
|
f"event={event_type}, channel={ch} (business or system default)"
|
|
)
|
|
results[ch] = {
|
|
"success": False,
|
|
"error": "قالب فعالی یافت نشد و قالب پیشفرض سیستم برای این رویداد تعریف نشده است",
|
|
}
|
|
continue
|
|
|
|
if resolved.template_id is not None and not self._check_daily_limit(
|
|
business_id, resolved.template_id, ch
|
|
):
|
|
results[ch] = {
|
|
"success": False,
|
|
"error": "محدودیت ارسال روزانه",
|
|
}
|
|
continue
|
|
|
|
rendered_body = self.template_renderer.render(resolved.body, context)
|
|
rendered_subject = None
|
|
if resolved.subject and ch == "email":
|
|
rendered_subject = self.template_renderer.render(resolved.subject, context)
|
|
|
|
wallet_charge_result = None
|
|
if ch == "sms":
|
|
try:
|
|
# محاسبه تعداد پیامک
|
|
sms_count = calculate_sms_count(rendered_body)
|
|
|
|
if sms_count <= 0:
|
|
results[ch] = {
|
|
"success": False,
|
|
"error": "تعداد پیامک محاسبه شده نامعتبر است"
|
|
}
|
|
continue
|
|
|
|
# محاسبه هزینه
|
|
total_cost = self._calculate_sms_cost(sms_count, event_type)
|
|
except ApiError as cost_error:
|
|
logger.error(f"خطا در محاسبه هزینه: {cost_error}")
|
|
results[ch] = {
|
|
"success": False,
|
|
"error": cost_error.message or "خطا در محاسبه هزینه پیامک"
|
|
}
|
|
continue
|
|
except Exception as cost_error:
|
|
logger.error(f"خطای غیرمنتظره در محاسبه هزینه: {cost_error}", exc_info=True)
|
|
results[ch] = {
|
|
"success": False,
|
|
"error": "خطای غیرمنتظره در محاسبه هزینه پیامک"
|
|
}
|
|
continue
|
|
|
|
# بررسی موجودی کیف پول
|
|
try:
|
|
from app.services.wallet_service import _get_wallet_account_for_update
|
|
account = _get_wallet_account_for_update(self.db, business_id)
|
|
available_balance = Decimal(str(account.available_balance or 0))
|
|
|
|
if available_balance < total_cost:
|
|
# موجودی کافی نیست - ارسال نشود و هشدار بده
|
|
results[ch] = self._handle_insufficient_funds(
|
|
business_id=business_id,
|
|
user_id=triggered_by_user_id,
|
|
required_amount=total_cost,
|
|
available_amount=available_balance,
|
|
template_name=resolved.template_name,
|
|
event_type=event_type,
|
|
template_id=resolved.template_id,
|
|
person_id=person_id,
|
|
person=person,
|
|
channel=ch,
|
|
rendered_body=rendered_body,
|
|
context=context,
|
|
template_source=resolved.source,
|
|
)
|
|
continue
|
|
|
|
# کسر از کیف پول قبل از ارسال
|
|
wallet_charge_result = charge_wallet_for_notification(
|
|
db=self.db,
|
|
business_id=business_id,
|
|
user_id=triggered_by_user_id or 0,
|
|
amount=total_cost,
|
|
sms_count=sms_count,
|
|
event_type=event_type,
|
|
template_id=resolved.template_id,
|
|
template_name=resolved.template_name,
|
|
)
|
|
except ApiError as e:
|
|
if e.code == "INSUFFICIENT_FUNDS":
|
|
results[ch] = self._handle_insufficient_funds(
|
|
business_id=business_id,
|
|
user_id=triggered_by_user_id,
|
|
required_amount=total_cost,
|
|
available_amount=Decimal('0'),
|
|
template_name=resolved.template_name,
|
|
event_type=event_type,
|
|
template_id=resolved.template_id,
|
|
person_id=person_id,
|
|
person=person,
|
|
channel=ch,
|
|
rendered_body=rendered_body,
|
|
context=context,
|
|
template_source=resolved.source,
|
|
)
|
|
continue
|
|
raise
|
|
|
|
# ارسال
|
|
to_phone = sms_destination if ch == 'sms' else None
|
|
send_result = self._send_message(
|
|
channel=ch,
|
|
recipient=person,
|
|
subject=rendered_subject,
|
|
body=rendered_body,
|
|
to_phone_override=to_phone
|
|
)
|
|
|
|
# اگر SMS بود و هزینه محاسبه شده، cost را تنظیم کن
|
|
if ch == 'sms' and wallet_charge_result:
|
|
try:
|
|
total_cost = self._calculate_sms_cost(
|
|
calculate_sms_count(rendered_body),
|
|
event_type
|
|
)
|
|
send_result['cost'] = total_cost
|
|
except Exception as cost_error:
|
|
# اگر خطا در محاسبه مجدد هزینه رخ داد، از مقدار قبلی استفاده میکنیم
|
|
logger.warning(f"خطا در محاسبه مجدد هزینه: {cost_error}")
|
|
# cost قبلاً در wallet_charge_result ذخیره شده است
|
|
|
|
# در صورت خطای ارسال و کسر شده بودن، مبلغ را برگردان
|
|
if not send_result['success'] and wallet_charge_result and ch == 'sms':
|
|
try:
|
|
# بازگشت مبلغ به کیف پول
|
|
account = _get_wallet_account_for_update(self.db, business_id)
|
|
account.available_balance += total_cost
|
|
self.db.flush()
|
|
|
|
# ثبت تراکنش reversal
|
|
from adapters.db.models.wallet import WalletTransaction
|
|
reversal_tx = WalletTransaction(
|
|
business_id=int(business_id),
|
|
type="notification_sms_reversal",
|
|
status="succeeded",
|
|
amount=total_cost,
|
|
fee_amount=Decimal("0"),
|
|
description=f"بازگشت مبلغ - خطا در ارسال پیامک: {send_result.get('error', 'خطای نامشخص')}",
|
|
document_id=wallet_charge_result.get('document_id'),
|
|
extra_info=json.dumps({
|
|
"source": "business_notification",
|
|
"original_transaction_id": wallet_charge_result.get('wallet_transaction_id'),
|
|
"reason": "send_failed",
|
|
"error": send_result.get('error')
|
|
}, ensure_ascii=False)
|
|
)
|
|
self.db.add(reversal_tx)
|
|
self.db.flush()
|
|
|
|
logger.warning(
|
|
f"مبلغ {total_cost} به کیف پول کسبوکار {business_id} برگردانده شد "
|
|
f"به دلیل خطا در ارسال پیامک"
|
|
)
|
|
except Exception as reversal_error:
|
|
logger.error(
|
|
f"خطا در بازگشت مبلغ به کیف پول: {reversal_error}",
|
|
exc_info=True
|
|
)
|
|
|
|
# ثبت لاگ (شماره/ایمیل واقعی که به آن ارسال شد)
|
|
recipient_identifier = (sms_destination if ch == 'sms' else None) or (person.mobile if ch == 'sms' else person.email)
|
|
log = self._create_log(
|
|
business_id=business_id,
|
|
template_id=resolved.template_id,
|
|
recipient_type='person',
|
|
recipient_id=person_id,
|
|
recipient_identifier=recipient_identifier,
|
|
channel=ch,
|
|
subject=rendered_subject,
|
|
body=rendered_body,
|
|
context_data=context,
|
|
event_type=event_type,
|
|
triggered_by_user_id=triggered_by_user_id,
|
|
send_result=send_result,
|
|
template_source=resolved.source,
|
|
)
|
|
|
|
if send_result['success']:
|
|
self.stat_repo.increment_sent(
|
|
business_id=business_id,
|
|
template_id=resolved.template_id,
|
|
target_date=date.today(),
|
|
channel=ch,
|
|
cost=send_result.get('cost', Decimal('0'))
|
|
)
|
|
else:
|
|
self.stat_repo.increment_failed(
|
|
business_id=business_id,
|
|
template_id=resolved.template_id,
|
|
target_date=date.today(),
|
|
channel=ch
|
|
)
|
|
|
|
results[ch] = {
|
|
"success": send_result['success'],
|
|
"log_id": log.id,
|
|
"template_source": resolved.source,
|
|
"used_system_default": resolved.source == "system_default",
|
|
"message": "ارسال با موفقیت انجام شد" if send_result['success'] else send_result.get('error')
|
|
}
|
|
|
|
except ApiError as api_error:
|
|
# خطاهای API را با پیام مناسب برمیگردانیم
|
|
logger.error(f"API Error sending notification via {ch}: {api_error}")
|
|
results[ch] = {
|
|
"success": False,
|
|
"error": api_error.message or str(api_error),
|
|
"error_code": api_error.code
|
|
}
|
|
except Exception as e:
|
|
# خطاهای غیرمنتظره را لاگ میکنیم و پیام عمومی نمایش میدهیم
|
|
logger.error(f"Error sending notification via {ch}: {e}", exc_info=True)
|
|
results[ch] = {
|
|
"success": False,
|
|
"error": "خطا در ارسال ناتیفیکیشن. لطفاً با پشتیبانی تماس بگیرید."
|
|
}
|
|
|
|
self.db.commit()
|
|
|
|
# نام نمایشی شخص (first_name + last_name یا alias_name)
|
|
display_name = ""
|
|
if person.first_name or person.last_name:
|
|
display_name = " ".join(filter(None, [person.first_name, person.last_name])).strip()
|
|
if not display_name:
|
|
display_name = person.alias_name or ""
|
|
|
|
return {
|
|
"event_type": event_type,
|
|
"recipient": {
|
|
"type": "person",
|
|
"id": person_id,
|
|
"name": display_name
|
|
},
|
|
"results": results
|
|
}
|
|
|
|
def _check_daily_limit(
|
|
self,
|
|
business_id: int,
|
|
template_id: int,
|
|
channel: str
|
|
) -> bool:
|
|
"""بررسی محدودیت ارسال روزانه"""
|
|
template = self.template_repo.get_by_id(template_id, business_id)
|
|
if not template:
|
|
return False
|
|
|
|
today_count = self.log_repo.get_daily_count(
|
|
business_id=business_id,
|
|
template_id=template_id,
|
|
channel=channel,
|
|
target_date=date.today()
|
|
)
|
|
|
|
return today_count < template.daily_limit
|
|
|
|
def _send_message(
|
|
self,
|
|
channel: str,
|
|
recipient: Person,
|
|
subject: Optional[str],
|
|
body: str,
|
|
to_phone_override: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""ارسال پیام از طریق کانال مشخص. برای SMS در صورت ارسال to_phone_override از آن استفاده میشود."""
|
|
result = {
|
|
"success": False,
|
|
"error": None,
|
|
"provider_message_id": None,
|
|
"cost": Decimal('0')
|
|
}
|
|
|
|
try:
|
|
if channel == 'sms':
|
|
mobile = to_phone_override
|
|
if not mobile and recipient.mobile:
|
|
mobile = normalize_phone_number(recipient.mobile)
|
|
if not mobile:
|
|
result["error"] = "شماره موبایل یافت نشد"
|
|
return result
|
|
|
|
success, message_id, error_msg = self.sms_provider.send_text_with_details(
|
|
to_phone=mobile,
|
|
text=body
|
|
)
|
|
|
|
result["success"] = success
|
|
result["error"] = error_msg
|
|
result["provider_message_id"] = message_id
|
|
# TODO: دریافت cost از provider (نیاز به API provider دارد)
|
|
|
|
elif channel == 'email':
|
|
if not recipient.email:
|
|
result["error"] = "ایمیل یافت نشد"
|
|
return result
|
|
|
|
# ارسال ایمیل به Person با استفاده از EmailService
|
|
try:
|
|
success = self.email_service.send_email(
|
|
to=recipient.email,
|
|
subject=subject or "نوتیفیکیشن",
|
|
body=body,
|
|
html_body=None # میتوان در آینده HTML body اضافه کرد
|
|
)
|
|
|
|
if success:
|
|
result["success"] = True
|
|
# TODO: دریافت message_id از email service (اگر در آینده اضافه شود)
|
|
else:
|
|
result["error"] = "خطا در ارسال ایمیل"
|
|
logger.warning(f"Failed to send email to {recipient.email}")
|
|
except Exception as email_error:
|
|
logger.error(f"Error sending email to Person {recipient.id}: {email_error}", exc_info=True)
|
|
result["error"] = f"خطا در ارسال ایمیل: {str(email_error)}"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in _send_message: {e}", exc_info=True)
|
|
result["error"] = str(e)
|
|
|
|
return result
|
|
|
|
def _create_log(
|
|
self,
|
|
business_id: int,
|
|
template_id: Optional[int],
|
|
recipient_type: str,
|
|
recipient_id: int,
|
|
recipient_identifier: Optional[str],
|
|
channel: str,
|
|
subject: Optional[str],
|
|
body: str,
|
|
context_data: Dict[str, Any],
|
|
event_type: str,
|
|
triggered_by_user_id: Optional[int],
|
|
send_result: Dict[str, Any],
|
|
template_source: str = "business",
|
|
) -> NotificationSendLog:
|
|
"""ثبت لاگ ارسال"""
|
|
enriched_context = dict(context_data)
|
|
enriched_context["template_source"] = template_source
|
|
log_data = {
|
|
"business_id": business_id,
|
|
"template_id": template_id,
|
|
"recipient_type": recipient_type,
|
|
"recipient_id": recipient_id,
|
|
"recipient_identifier": recipient_identifier,
|
|
"channel": channel,
|
|
"subject": subject,
|
|
"body": body,
|
|
"context_data": enriched_context,
|
|
"event_type": event_type,
|
|
"triggered_by_user_id": triggered_by_user_id,
|
|
"status": "sent" if send_result['success'] else "failed",
|
|
"provider_name": "kavenegar" if channel == 'sms' else "smtp",
|
|
"provider_message_id": send_result.get('provider_message_id'),
|
|
"cost": send_result.get('cost', Decimal('0'))
|
|
}
|
|
|
|
if send_result['success']:
|
|
log_data["sent_at"] = datetime.utcnow()
|
|
else:
|
|
log_data["failed_at"] = datetime.utcnow()
|
|
log_data["failure_reason"] = send_result.get('error')
|
|
|
|
return self.log_repo.create(log_data)
|
|
|
|
def preview_template(
|
|
self,
|
|
business_id: int,
|
|
template_id: int,
|
|
sample_context: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
پیشنمایش یک قالب با context نمونه
|
|
|
|
برای نمایش به کاربر قبل از ارسال واقعی
|
|
"""
|
|
template = self.template_repo.get_by_id(template_id, business_id)
|
|
if not template:
|
|
raise ApiError("TEMPLATE_NOT_FOUND", "قالب یافت نشد", http_status=404)
|
|
|
|
try:
|
|
rendered_body = self.template_renderer.render(template.body, sample_context)
|
|
rendered_subject = None
|
|
if template.subject:
|
|
rendered_subject = self.template_renderer.render(template.subject, sample_context)
|
|
|
|
return {
|
|
"template_id": template.id,
|
|
"template_name": template.name,
|
|
"channel": template.channel,
|
|
"original": {
|
|
"subject": template.subject,
|
|
"body": template.body
|
|
},
|
|
"rendered": {
|
|
"subject": rendered_subject,
|
|
"body": rendered_body
|
|
},
|
|
"context_used": sample_context
|
|
}
|
|
except ApiError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error previewing template: {e}", exc_info=True)
|
|
raise ApiError(
|
|
"PREVIEW_ERROR",
|
|
f"خطا در پیشنمایش قالب: {str(e)}",
|
|
http_status=500
|
|
)
|
|
|
|
def _handle_insufficient_funds(
|
|
self,
|
|
business_id: int,
|
|
user_id: Optional[int],
|
|
required_amount: Decimal,
|
|
available_amount: Decimal,
|
|
template_name: str,
|
|
event_type: str,
|
|
template_id: Optional[int],
|
|
person_id: int,
|
|
person: Person,
|
|
channel: str,
|
|
rendered_body: str,
|
|
context: Dict[str, Any],
|
|
template_source: str = "business",
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
مدیریت موجودی ناکافی:
|
|
1. ارسال هشدار به کاربر
|
|
2. ثبت لاگ
|
|
3. برگرداندن خطا
|
|
"""
|
|
if not user_id:
|
|
user_id = 0
|
|
|
|
# دریافت اطلاعات ارز کیف پول
|
|
wallet_settings = get_wallet_settings(self.db)
|
|
currency_code = wallet_settings.get("wallet_base_currency_code", "IRR")
|
|
currency_symbol = wallet_settings.get("wallet_base_currency_symbol", currency_code)
|
|
|
|
# محاسبه کسری
|
|
shortfall = float(required_amount - available_amount)
|
|
|
|
# آمادهسازی پیام هشدار
|
|
warning_title = "موجودی کیف پول کافی نیست"
|
|
warning_message = (
|
|
f"برای ارسال ناتیفیکیشن '{template_name}' موجودی کیف پول کافی نیست.\n\n"
|
|
f"موجودی فعلی: {available_amount:,.0f} {currency_symbol}\n"
|
|
f"مبلغ مورد نیاز: {required_amount:,.0f} {currency_symbol}\n"
|
|
f"کسری: {shortfall:,.0f} {currency_symbol}\n\n"
|
|
f"لطفاً کیف پول خود را شارژ کنید."
|
|
)
|
|
|
|
# ارسال هشدار به کاربر از طریق In-App Notification
|
|
if user_id and user_id > 0:
|
|
try:
|
|
notification_service = NotificationService(self.db)
|
|
notification_service.send(
|
|
user_id=user_id,
|
|
event_key="wallet.insufficient_funds_notification",
|
|
context={
|
|
"subject": warning_title,
|
|
"message": warning_message,
|
|
"required_amount": float(required_amount),
|
|
"available_amount": float(available_amount),
|
|
"shortfall": shortfall,
|
|
"currency_code": currency_code,
|
|
"currency_symbol": currency_symbol,
|
|
"template_name": template_name,
|
|
"event_type": event_type
|
|
},
|
|
preferred_channels=["inapp", "email"] # اول In-App، سپس Email
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"خطا در ارسال هشدار به کاربر {user_id}: {e}", exc_info=True)
|
|
|
|
# ثبت لاگ در NotificationSendLog با وضعیت "rejected"
|
|
log = self._create_log(
|
|
business_id=business_id,
|
|
template_id=template_id,
|
|
recipient_type='person',
|
|
recipient_id=person_id,
|
|
recipient_identifier=person.mobile if channel == 'sms' else person.email,
|
|
channel=channel,
|
|
subject=None,
|
|
body=rendered_body,
|
|
context_data=context,
|
|
event_type=event_type,
|
|
triggered_by_user_id=user_id,
|
|
send_result={
|
|
"success": False,
|
|
"error": "INSUFFICIENT_FUNDS",
|
|
"cost": float(required_amount)
|
|
},
|
|
template_source=template_source,
|
|
)
|
|
log.status = "rejected"
|
|
log.failure_reason = f"موجودی کافی نیست. مورد نیاز: {required_amount}, موجود: {available_amount}"
|
|
self.db.flush()
|
|
|
|
# برگرداندن خطا
|
|
return {
|
|
"success": False,
|
|
"error": "INSUFFICIENT_FUNDS",
|
|
"error_message": "موجودی کیف پول کافی نیست",
|
|
"log_id": log.id,
|
|
"details": {
|
|
"required_amount": float(required_amount),
|
|
"available_amount": float(available_amount),
|
|
"shortfall": shortfall,
|
|
"currency_code": currency_code,
|
|
"currency_symbol": currency_symbol,
|
|
"warning_sent": user_id and user_id > 0
|
|
}
|
|
}
|
|
|
|
|