diff --git a/src/db/models.py b/src/db/models.py index 0bca12d..43f5408 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -1,8 +1,9 @@ from __future__ import annotations from datetime import datetime from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship -from sqlalchemy import String, ForeignKey, BigInteger +from sqlalchemy import String, ForeignKey, BigInteger, UniqueConstraint, Text, Enum from sqlalchemy.dialects.postgresql import UUID, ARRAY +import enum import uuid from typing import List, Optional @@ -179,3 +180,21 @@ class PaymentsRegistered(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) code: Mapped[str] = mapped_column(String(8), unique=True, nullable=False) mapped: Mapped[bool] = mapped_column(nullable=True) + + +class NotificationType(enum.Enum): + PAYMENT_RECEIVED = "payment_received" + +class SessionNotification(Base): + __tablename__ = "session_notifications" + __table_args__ = ( + UniqueConstraint("session_id", "type", name="uq_session_notification_once"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False) + patient_id: Mapped[int] = mapped_column(ForeignKey("patients.id", ondelete="CASCADE"), nullable=False) + type: Mapped[NotificationType] = mapped_column(nullable=False) + created_at: Mapped[datetime] = mapped_column(nullable=False, default=datetime.utcnow) + sent_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) + last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) \ No newline at end of file diff --git a/src/docbot/services/notifications_service.py b/src/docbot/services/notifications_service.py new file mode 100644 index 0000000..7fa1d2d --- /dev/null +++ b/src/docbot/services/notifications_service.py @@ -0,0 +1,60 @@ +from __future__ import annotations +from datetime import datetime +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from db.session import AsyncSessionLocal +from db.models import Sessions, Patients, SessionNotification, NotificationType + +async def create_payment_received_once(session_code: str) -> tuple[bool, int | None, SessionNotification | None]: + """ + Пытается создать запись уведомления 'payment_received' для сессии. + Возвращает: + (created: bool, chat_id: int|None, notif_obj: SessionNotification|None) + Если уже существует — created=False. + chat_id — telegram_id пациента, если найден. + """ + async with AsyncSessionLocal() as session: + # найдём сессию + пациента (для patient_id и chat_id) + s_res = await session.execute( + select(Sessions, Patients) + .join(Patients, Patients.id == Sessions.patient_id) + .where(Sessions.code == session_code) + .limit(1) + ) + row = s_res.first() + if not row: + return False, None, None + patient_session, patient = row + + # upsert по (session_id, type) + stmt = pg_insert(SessionNotification).values( + session_id=patient_session.id, + patient_id=patient.id, + type=NotificationType.PAYMENT_RECEIVED, + created_at=datetime.utcnow(), + ).on_conflict_do_nothing( + index_elements=[SessionNotification.session_id, SessionNotification.type] + ).returning(SessionNotification) + + n_res = await session.execute(stmt) + notif_obj = n_res.scalar_one_or_none() + created = notif_obj is not None + if created: + # сохраним факт создания + await session.commit() + return created, patient.telegram_id, notif_obj + +async def mark_notification_sent(notif_id): + async with AsyncSessionLocal() as session: + async with session.begin(): + n = await session.get(SessionNotification, notif_id) + if n: + n.sent_at = datetime.utcnow() + +async def mark_notification_error(notif_id, err: str): + async with AsyncSessionLocal() as session: + async with session.begin(): + n = await session.get(SessionNotification, notif_id) + if n: + n.last_error = (err or "")[:8000] # не даём разрастись \ No newline at end of file diff --git a/src/docbot/services/payments_service.py b/src/docbot/services/payments_service.py index 0094133..5020186 100644 --- a/src/docbot/services/payments_service.py +++ b/src/docbot/services/payments_service.py @@ -26,7 +26,7 @@ async def save_payment_completed_info_from_prodamus(code: str) -> bool: async def get_not_mapped_payments() -> Sequence[Row[Tuple[PaymentsRegistered]]] | None: async with AsyncSessionLocal() as session: result = await session.execute( - select(PaymentsRegistered) + select(PaymentsRegistered).where(PaymentsRegistered.mapped.is_(None)) ) return result.all() diff --git a/src/docbot/services/session_service.py b/src/docbot/services/session_service.py index 3c8f80d..500e594 100644 --- a/src/docbot/services/session_service.py +++ b/src/docbot/services/session_service.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import select +from sqlalchemy import select, join from db.session import AsyncSessionLocal from db.models import ( @@ -110,4 +110,17 @@ async def get_sessions_awaiting_payments() -> Sequence[Row[Sessions]] | bool: sc: Sessions | None = result.scalars() if not sc: return False - return result.all() \ No newline at end of file + return result.all() + + +async def get_patient_telegram_by_session_code(code: str) -> int | None: + """ + Возвращает telegram_id пациента по коду сессии. + """ + async with AsyncSessionLocal() as session: + result = await session.execute( + select(Patients.telegram_id) + .join(Sessions, Sessions.patient_id == Patients.id) + .where(Sessions.code == code) + ) + return result.scalar_one_or_none() \ No newline at end of file diff --git a/src/docbot/tasks/payments.py b/src/docbot/tasks/payments.py index cb874c6..afdbdd2 100644 --- a/src/docbot/tasks/payments.py +++ b/src/docbot/tasks/payments.py @@ -3,11 +3,24 @@ from telegram.ext import ( ) from typing import Sequence, List from docbot.services.payments_service import get_not_mapped_payments, update_payment_and_session -from docbot.services.session_service import get_sessions_awaiting_payments +from docbot.services.session_service import get_sessions_awaiting_payments, get_patient_telegram_by_session_code +from docbot.services.notifications_service import ( + create_payment_received_once, + mark_notification_sent, + mark_notification_error, +) + from core.logging import logger from sqlalchemy import Row from db.models import PaymentsRegistered, Sessions + +PAYMENT_OK_TEXT = ( + "✅ Оплата получена!\n" + "Позже напомним вам о встрече с врачом.\n" + "Если появятся вопросы — просто напишите сюда." +) + # Сопоставляем оплаты, которые пришли от продамуса с сессиями, по которым ещё не было оплаты async def map_payments(context: ContextTypes.DEFAULT_TYPE) -> None: not_mapped_payments: Sequence[Row[PaymentsRegistered]] = await get_not_mapped_payments() @@ -33,5 +46,27 @@ async def map_payments(context: ContextTypes.DEFAULT_TYPE) -> None: success = await update_payment_and_session(code) if not success: logger.error(f"Failed to update payment and session for {code}") + continue + + # создаём запись уведомления (если уже есть — не шлём повторно) + created, chat_id, notif_obj = await create_payment_received_once(code) + if not created: + logger.info(f"Notification already exists for {code}, skip sending") + continue + if not chat_id: + logger.error(f"chat_id not found for session code {code}") + continue + + try: + await context.bot.send_message( + chat_id=chat_id, + text=PAYMENT_OK_TEXT, + disable_web_page_preview=True, + ) + await mark_notification_sent(notif_obj.id) + logger.info(f"Payment notification sent to patient for {code}") + except Exception as e: + await mark_notification_error(notif_obj.id, str(e)) + logger.exception(f"Failed to send payment message for {code}: {e}") else: logger.info("Совпадения не найдены!")