""" Aszinkron ütemező (APScheduler) a napi karbantartási feladatokhoz. Integrálva a FastAPI lifespan eseményébe, így az alkalmazás indításakor elindul, és leálláskor megáll. Feladatok: 1. daily_financial_maintenance (00:15 UTC) — Voucher/Withdrawal/User downgrade 2. subscription_monitor (01:00 UTC) — UserSubscription & OrganizationSubscription lejárat 3. inactivity_monitor (02:00 UTC) — 180 napos inaktivitás detektálás Biztonsági Jitter: Minden napi feladat jitter=900 (15 perc) paraméterrel rendelkezik, így véletlenszerűen eltolódnak a megadott időponthoz képest. """ import asyncio import logging import uuid from contextlib import asynccontextmanager from datetime import datetime, time, timedelta from typing import Optional from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.jobstores.memory import MemoryJobStore from app.database import AsyncSessionLocal from app.services.billing_engine import SmartDeduction from app.models.marketplace.payment import WithdrawalRequest, WithdrawalRequestStatus from app.models.identity import User from app.models import ProcessLog, WalletType, FinancialLedger from sqlalchemy import select, update, and_ from sqlalchemy.orm import selectinload logger = logging.getLogger(__name__) # Globális scheduler példány _scheduler: Optional[AsyncIOScheduler] = None def get_scheduler() -> AsyncIOScheduler: """Visszaadja a globális scheduler példányt (lazy initialization).""" global _scheduler if _scheduler is None: jobstores = { 'default': MemoryJobStore() } _scheduler = AsyncIOScheduler( jobstores=jobstores, timezone="UTC", job_defaults={ 'coalesce': True, 'max_instances': 1, 'misfire_grace_time': 3600 # 1 óra } ) return _scheduler async def daily_financial_maintenance() -> None: """ Napi pénzügyi karbantartási feladatok. A. Voucher lejárat kezelése B. Withdrawal Request lejárat (14 nap) és automatikus elutasítás C. Soft Downgrade (lejárt előfizetések) D. Naplózás ProcessLog-ba """ logger.info("Daily financial maintenance started") stats = { "vouchers_expired": 0, "withdrawals_rejected": 0, "users_downgraded": 0, "errors": [] } async with AsyncSessionLocal() as db: try: # A. Voucher lejárat kezelése try: voucher_count = await SmartDeduction.process_voucher_expiration(db) stats["vouchers_expired"] = voucher_count logger.info(f"Expired {voucher_count} vouchers") except Exception as e: stats["errors"].append(f"Voucher expiration error: {str(e)}") logger.error(f"Voucher expiration error: {e}", exc_info=True) # B. Withdrawal Request lejárat (14 nap) try: # Keresd meg a PENDING státuszú, 14 napnál régebbi kéréseket fourteen_days_ago = datetime.utcnow() - timedelta(days=14) stmt = select(WithdrawalRequest).where( and_( WithdrawalRequest.status == WithdrawalRequestStatus.PENDING, WithdrawalRequest.created_at < fourteen_days_ago, WithdrawalRequest.is_deleted == False ) ).options(selectinload(WithdrawalRequest.user)) result = await db.execute(stmt) expired_requests = result.scalars().all() for req in expired_requests: # Állítsd REJECTED-re req.status = WithdrawalRequestStatus.REJECTED req.reason = "Automatikus elutasítás: 14 napig hiányzó bizonylat" # Refund: pénz vissza a user Earned zsebébe # Ehhez létrehozunk egy FinancialLedger bejegyzést (refund) refund_transaction = FinancialLedger( transaction_id=uuid.uuid4(), user_id=req.user_id, wallet_type=WalletType.EARNED, amount=req.amount, currency=req.currency, transaction_type="REFUND", description=f"Refund for expired withdrawal request #{req.id}", metadata={"withdrawal_request_id": req.id} ) db.add(refund_transaction) req.refund_transaction_id = refund_transaction.transaction_id stats["withdrawals_rejected"] += 1 await db.commit() logger.info(f"Rejected {len(expired_requests)} expired withdrawal requests") except Exception as e: await db.rollback() stats["errors"].append(f"Withdrawal expiration error: {str(e)}") logger.error(f"Withdrawal expiration error: {e}", exc_info=True) # C. Soft Downgrade (lejárt előfizetések) try: # Keresd meg a lejárt subscription_expires_at idejű usereket stmt = select(User).where( and_( User.subscription_expires_at < datetime.utcnow(), User.subscription_plan != 'FREE', User.is_deleted == False ) ) result = await db.execute(stmt) expired_users = result.scalars().all() for user in expired_users: # Állítsd a subscription_plan-t 'FREE'-re, role-t 'user'-re user.subscription_plan = 'FREE' user.role = 'user' # Opcionálisan: állítsd be a felfüggesztett státuszt a kapcsolódó entitásokon # (pl. Organization.is_active = False) - ez egy külön logika lehet stats["users_downgraded"] += 1 await db.commit() logger.info(f"Downgraded {len(expired_users)} users to FREE plan") except Exception as e: await db.rollback() stats["errors"].append(f"Soft downgrade error: {str(e)}") logger.error(f"Soft downgrade error: {e}", exc_info=True) # D. Naplózás ProcessLog-ba (JAVÍTOTT RÉSZ) process_log = ProcessLog( process_name="Daily-Financial-Maintenance", items_processed=stats["vouchers_expired"] + stats["withdrawals_rejected"] + stats["users_downgraded"], items_failed=len(stats["errors"]), end_time=datetime.utcnow(), details={ "status": "COMPLETED" if not stats["errors"] else "PARTIAL", **stats } ) db.add(process_log) await db.commit() logger.info(f"Daily financial maintenance completed: {stats}") except Exception as e: logger.error(f"Daily financial maintenance failed: {e}", exc_info=True) # Hiba esetén is naplózzuk a modellnek megfelelő mezőkkel process_log = ProcessLog( process_name="Daily-Financial-Maintenance", items_processed=0, items_failed=1, end_time=datetime.utcnow(), details={ "status": "FAILED", "error": str(e), **stats } ) db.add(process_log) await db.commit() def setup_scheduler() -> None: """Beállítja a scheduler-t a napi feladatokkal.""" scheduler = get_scheduler() # ── 1. Napi pénzügyi karbantartás 00:15-kor ── scheduler.add_job( daily_financial_maintenance, trigger=CronTrigger(hour=0, minute=15, jitter=900), id="daily_financial_maintenance", name="Daily Financial Maintenance", replace_existing=True ) # ── 2. Subscription Monitor 01:00-kor ── # Kezeli a lejárt UserSubscription és OrganizationSubscription rekordokat. from app.workers.system.subscription_monitor_worker import run_full_monitor as run_sub_monitor async def subscription_monitor_job(): logger.info("Scheduler: Starting subscription monitor job...") async with AsyncSessionLocal() as db: try: stats = await run_sub_monitor() total = ( stats["user_subscriptions"]["processed"] + stats["org_subscriptions"]["processed"] ) # Naplózás ProcessLog-ba process_log = ProcessLog( process_name="Subscription-Monitor", items_processed=total, items_failed=len(stats["user_subscriptions"]["errors"]) + len(stats["org_subscriptions"]["errors"]), end_time=datetime.utcnow(), details={ "status": "COMPLETED", "user_processed": stats["user_subscriptions"]["processed"], "org_processed": stats["org_subscriptions"]["processed"], "user_downgraded": len(stats["user_subscriptions"]["downgraded"]), "org_downgraded": len(stats["org_subscriptions"]["downgraded"]), } ) db.add(process_log) await db.commit() logger.info(f"Scheduler: Subscription monitor completed ({total} processed)") except Exception as e: logger.error(f"Scheduler: Subscription monitor failed: {e}", exc_info=True) await db.rollback() scheduler.add_job( subscription_monitor_job, trigger=CronTrigger(hour=1, minute=0, jitter=900), id="subscription_monitor", name="Subscription Monitor", replace_existing=True ) # ── 3. Inactivity Monitor 02:00-kor ── # Detektálja a 180+ napja inaktív usereket. from app.workers.system.inactivity_monitor_worker import run_full_monitor as run_inactivity_monitor async def inactivity_monitor_job(): logger.info("Scheduler: Starting inactivity monitor job...") async with AsyncSessionLocal() as db: try: stats = await run_inactivity_monitor() # Naplózás ProcessLog-ba process_log = ProcessLog( process_name="Inactivity-Monitor", items_processed=stats["processed"], items_failed=len(stats["errors"]), end_time=datetime.utcnow(), details={ "status": "COMPLETED", "processed": stats["processed"], "suspended": len(stats["suspended"]), } ) db.add(process_log) await db.commit() logger.info(f"Scheduler: Inactivity monitor completed ({stats['processed']} processed)") except Exception as e: logger.error(f"Scheduler: Inactivity monitor failed: {e}", exc_info=True) await db.rollback() scheduler.add_job( inactivity_monitor_job, trigger=CronTrigger(hour=2, minute=0, jitter=900), id="inactivity_monitor", name="Inactivity Monitor", replace_existing=True ) logger.info("Scheduler jobs registered: daily_financial, subscription_monitor, inactivity_monitor") @asynccontextmanager async def scheduler_lifespan(app): """ FastAPI lifespan manager, amely elindítja és leállítja a schedulert. """ # Importáljuk a szükséges modulokat import uuid from datetime import timedelta global _scheduler scheduler = get_scheduler() setup_scheduler() logger.info("Starting scheduler...") scheduler.start() # Azonnali tesztfutás (opcionális, csak fejlesztéshez) # scheduler.add_job(daily_financial_maintenance, 'date', run_date=datetime.utcnow()) yield logger.info("Shutting down scheduler...") scheduler.shutdown(wait=False) _scheduler = None