# P0 Architecture Plan — Subscription Stacking & Extensible Ad Engine **Dátum:** 2026-06-18 **Szerző:** Fast Coder (Core Developer) **Státusz:** Tervezési fázis — Jóváhagyásra vár **Cél:** Teljes architekturális terv a Subscription Time Stacking (napok halmozása) és a bővíthető Native Ad Engine (kampányok, kreatívok, elhelyezések, analitika) megvalósításához. --- ## 1. SUBSCRIPTION STACKING MATH ### 1.1 Problémafelvetés Jelenleg a [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvény minden híváskor **felülírja** a `valid_from` mezőt `datetime.utcnow()`-ra, és **nem állítja be** a `valid_until` mezőt. Ez azt jelenti, hogy: - Ha egy szervezet ma megveszi a 30 napos csomagot, majd 10 nap múlva **hozzáad** még 30 napot, a hátralévő 20 nap **elveszik**. - A `valid_until` mező minden `OrganizationSubscription` és `UserSubscription` rekordban `NULL` marad. ### 1.2 Megoldás: `duration_days` a JSONB `rules`-ban Minden `subscription_tiers.rules` JSONB blokkba bevezetésre kerül egy új `duration_days` mező, amely fix napokban határozza meg a csomag időtartamát. Ez elkerüli a naptári anomáliákat (pl. február 28., hónap vége). #### 1.2.1 JSONB `rules` bővítés ```json { "type": "private", "display_name": "Privát Pro", "duration_days": 30, "pricing": { "monthly_price": 4.99, "yearly_price": 49.99, "currency": "EUR", "credit_price": 500 }, "allowances": { "max_vehicles": 3, "max_garages": 1, "monthly_free_credits": 50 }, "entitlements": ["SRV_DATA_EXPORT"], "lifecycle": { "is_public": true } } ``` **Tervezett `duration_days` értékek csomagonként:** | Csomag | `duration_days` | Megjegyzés | |--------|----------------|------------| | `private_free_v1` | `null` (korlátlan) | Ingyenes csomag, nem jár le | | `private_pro_v1` | `30` | Havi | | `private_vip_v1` | `30` | Havi | | `corp_premium_v1` | `30` | Havi | | `corp_premium_plus_v1` | `30` | Havi | | `corp_vip_v1` | `30` | Havi | | `private_test_v01` | `365` | Teszt — 1 év | | `org_test_v01` | `365` | Teszt — 1 év | | `corp_free_v1` | `null` (korlátlan) | Ingyenes céges | #### 1.2.2 Pydantic Schema Bővítés A [`SubscriptionRulesModel`](backend/app/schemas/subscription.py:55) kiegészítése: ```python class SubscriptionRulesModel(BaseModel): type: str = Field(..., pattern=r"^(private|corporate)$") display_name: Optional[str] = None duration_days: Optional[int] = Field( default=None, ge=1, description="Csomag időtartama napokban. null = korlátlan (pl. ingyenes csomag)" ) pricing: Optional[PricingModel] = None allowances: Optional[AllowancesModel] = None entitlements: List[str] = [] affiliate: Optional[AffiliateModel] = None lifecycle: Optional[LifecycleModel] = None ad_policy: Optional[AdPolicyModel] = None # Lásd 2. fejezet ``` ### 1.3 Stacking Logika: `upgrade_org_subscription()` Átdolgozása A [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvény új logikája: ```python async def upgrade_org_subscription( db: AsyncSession, org_id: int, tier_id: int, actor_user_id: int ) -> Dict[str, Any]: """ Szervezet előfizetésének beállítása időhalmozással (Stacking). Stacking logika: 1. Ha az aktuális előfizetés MÉG ÉRVÉNYES (valid_until > now): - Az új valid_until = régi valid_until + duration_days nap - Az időhalmozás (stacking) megtörténik 2. Ha az aktuális előfizetés LEJÁRT (valid_until < now) vagy NINCS: - Az új valid_until = now + duration_days nap - Friss kezdés 3. Ha duration_days = null (korlátlan csomag): - valid_until = null (soha nem jár le) """ from app.models.core_logic import SubscriptionTier, OrganizationSubscription from app.models.marketplace.organization import Organization # 1. Ellenőrizze, hogy a tier létezik-e stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id) result = await db.execute(stmt) tier = result.scalar_one_or_none() if not tier: raise ValueError(f"Subscription tier id={tier_id} not found") # 2. Ellenőrizze, hogy a szervezet létezik-e stmt = select(Organization).where(Organization.id == org_id) result = await db.execute(stmt) org = result.scalar_one_or_none() if not org: raise ValueError(f"Organization id={org_id} not found") # 3. Olvassuk ki a duration_days-t a tier rules-ból rules = tier.rules or {} duration_days = rules.get("duration_days") # 4. Számítsuk ki az új valid_until-t (STACKING LOGIKA) now = datetime.utcnow() new_valid_until = None # Alapértelmezett: korlátlan if duration_days is not None: # Korlátozott időtartamú csomag # Keressük meg az aktuális aktív előfizetést sub_stmt = select(OrganizationSubscription).where( OrganizationSubscription.org_id == org_id, OrganizationSubscription.is_active == True ) sub_result = await db.execute(sub_stmt) existing_sub = sub_result.scalar_one_or_none() if existing_sub and existing_sub.valid_until and existing_sub.valid_until > now: # STACKING: Még érvényes előfizetéshez hozzáadjuk az új napokat remaining_days = (existing_sub.valid_until - now).days logger.info( f"Subscription stacking for org_id={org_id}: " f"remaining={remaining_days}d, adding={duration_days}d" ) new_valid_until = existing_sub.valid_until + timedelta(days=duration_days) else: # Nincs érvényes előfizetés — friss kezdés new_valid_until = now + timedelta(days=duration_days) # 5. Állítsa be a subscription_tier_id-t a szervezeten org.subscription_tier_id = tier_id org.subscription_plan = tier.name # 6. Extract max_vehicles from tier rules max_vehicles = rules.get("allowances", {}).get("max_vehicles", 1) org.base_asset_limit = max_vehicles # 7. Deaktiváljuk a régi előfizetést if existing_sub: existing_sub.is_active = False existing_sub.valid_until = now # Meddig volt érvényben # 8. Új aktív subscription rekord new_sub = OrganizationSubscription( org_id=org_id, tier_id=tier_id, valid_from=now, valid_until=new_valid_until, is_active=True ) db.add(new_sub) logger.info( f"Org subscription upgraded: org_id={org_id}, " f"tier={tier.name} (id={tier_id}), " f"valid_from={now}, valid_until={new_valid_until}, " f"max_vehicles={max_vehicles}, actor={actor_user_id}" ) return { "success": True, "organization_id": org_id, "tier_id": tier_id, "tier_name": tier.name, "max_vehicles": max_vehicles, "valid_from": now.isoformat(), "valid_until": new_valid_until.isoformat() if new_valid_until else None, "message": f"Organization {org_id} upgraded to {tier.name}" } ``` ### 1.4 Stacking Matematikai Összefoglaló ``` Képlet: IF existing_sub.valid_until > NOW: new_valid_until = existing_sub.valid_until + duration_days ELSE: new_valid_until = NOW + duration_days Példa: - Március 1.: 30 napos csomag vásárlás → valid_until = március 31. - Március 15.: Újabb 30 nap hozzáadása → valid_until = március 31. + 30 nap = április 30. - Eredmény: 45 nap maradt (március 15-től április 30-ig) ahelyett, hogy csak 30 nap lenne. ``` ### 1.5 `upgrade_subscription()` (User-level) Átdolgozása A [`upgrade_subscription()`](backend/app/services/billing_engine.py:722) függvény hasonló stacking logikát kap a `UserSubscription` rekordokhoz: ```python async def upgrade_subscription( db: AsyncSession, user_id: int, target_package: str ) -> Dict[str, Any]: """ Felhasználói előfizetés frissítése időhalmozással. Ugyanaz a stacking logika, mint az org változatban. """ from app.models.core_logic import SubscriptionTier, UserSubscription from app.models.identity import User # 1. Ellenőrizze, hogy a cél csomag létezik-e stmt = select(SubscriptionTier).where(SubscriptionTier.name == target_package) result = await db.execute(stmt) tier = result.scalar_one_or_none() if not tier: raise ValueError(f"Subscription tier '{target_package}' not found") rules = tier.rules or {} duration_days = rules.get("duration_days") now = datetime.utcnow() # 2. Stacking logika a UserSubscription rekordokon new_valid_until = None if duration_days is not None: sub_stmt = select(UserSubscription).where( UserSubscription.user_id == user_id, UserSubscription.is_active == True ) sub_result = await db.execute(sub_stmt) existing_sub = sub_result.scalar_one_or_none() if existing_sub and existing_sub.valid_until and existing_sub.valid_until > now: new_valid_until = existing_sub.valid_until + timedelta(days=duration_days) else: new_valid_until = now + timedelta(days=duration_days) # 3. Ár kiszámítása és levonás (meglévő logika) price = rules.get("price", 0.0) # ... (ár számítás, wallet levonás) # 4. Frissítse a felhasználó adatait user_stmt = select(User).where(User.id == user_id) user_result = await db.execute(user_stmt) user = user_result.scalar_one() user.subscription_plan = target_package user.subscription_expires_at = new_valid_until # Már nem 30 nap fix! # 5. UserSubscription rekord frissítése if existing_sub: existing_sub.is_active = False existing_sub.valid_until = now new_sub = UserSubscription( user_id=user_id, tier_id=tier.id, valid_from=now, valid_until=new_valid_until, is_active=True ) db.add(new_sub) return { "success": True, "new_plan": target_package, "price_paid": price, "valid_from": now.isoformat(), "valid_until": new_valid_until.isoformat() if new_valid_until else None } ``` --- ## 2. EXTENSIBLE AD ENGINE SCHEMA ### 2.1 Új Adatbázis Séma: `marketing` Schema A Native Ad Engine egy új `marketing` adatbázis sémában kap helyet, elkülönítve a core üzleti logikától. Ez biztosítja a jövőbeli bővíthetőséget (pl. kattintások, konverziók, A/B tesztelés). #### 2.1.1 SQLAlchemy Modellek ```python # backend/app/models/marketing.py """ 📢 Native Ad Engine — Marketing Schema Models Schema: marketing Táblák: - campaigns: Hirdetési kampányok - creatives: Kreatív anyagok (belső/külső) - placements: Elhelyezési zónák - campaign_placements: Kampány ↔ Elhelyezés kapcsolótábla - ad_impressions: Megjelenítések naplózása - ad_clicks: Kattintások naplózása (jövőbeli bővítés) """ from datetime import datetime from typing import Optional from sqlalchemy import ( String, Integer, ForeignKey, Boolean, DateTime, Numeric, Text, Enum as SAEnum ) from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.sql import func import enum import uuid from app.database import Base class CampaignStatus(enum.Enum): DRAFT = "draft" SCHEDULED = "scheduled" ACTIVE = "active" PAUSED = "paused" COMPLETED = "completed" CANCELLED = "cancelled" class CreativeType(enum.Enum): IMAGE = "image" # Belső kép HTML_SNIPPET = "html_snippet" # Külső ad kód (pl. Google Ads) VIDEO = "video" # Videó (jövőbeli) CAROUSEL = "carousel" # Több képből álló (jövőbeli) class Campaign(Base): """ Hirdetési kampány. Egy kampány több kreatívot és több elhelyezést is célozhat. A kampányoknak van prioritása és státusza. """ __tablename__ = "campaigns" __table_args__ = {"schema": "marketing"} id: Mapped[int] = mapped_column(Integer, primary_key=True) uuid: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), server_default=func.gen_random_uuid(), unique=True, comment="Publikus azonosító (UUID) a frontend számára" ) name: Mapped[str] = mapped_column( String(200), nullable=False, comment="Kampány neve (pl. '2026 Q3 Flotta Akció')" ) description: Mapped[Optional[str]] = mapped_column( Text, comment="Belső leírás a kampányról" ) # ── Időbeli korlátok ── start_date: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, comment="Kampány kezdete" ) end_date: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, comment="Kampány vége (None = nincs lejárat)" ) # ── Prioritás és státusz ── priority: Mapped[int] = mapped_column( Integer, server_default=text("0"), comment="Prioritás (magasabb = fontosabb). 0 = legalacsonyabb" ) status: Mapped[CampaignStatus] = mapped_column( SAEnum(CampaignStatus, name="campaign_status", schema="marketing"), server_default=text("'draft'"), comment="Kampány státusza" ) # ── Célzás ── targeting_rules: Mapped[Optional[dict]] = mapped_column( JSONB, server_default=text("'{}'::jsonb"), comment="Célzási szabályok JSONB (pl. {'country_codes': ['HU', 'GB'], 'min_tier': 'premium'})" ) # ── Korlátok ── daily_impression_limit: Mapped[Optional[int]] = mapped_column( Integer, comment="Napi max megjelenítés (None = korlátlan)" ) total_impression_limit: Mapped[Optional[int]] = mapped_column( Integer, comment="Összes max megjelenítés (None = korlátlan)" ) # ── Meta ── created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) updated_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), onupdate=func.now() ) created_by: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("identity.users.id"), nullable=True, comment="Kampány létrehozója (admin user ID)" ) # ── Kapcsolatok ── creatives: Mapped[list["Creative"]] = relationship( "Creative", back_populates="campaign", cascade="all, delete-orphan" ) placements: Mapped[list["Placement"]] = relationship( "Placement", secondary="marketing.campaign_placements", back_populates="campaigns" ) class Creative(Base): """ Kreatív anyag (hirdetés). Két fő típus: - IMAGE: Belső kép + target_url (saját rendszerben tárolt kép) - HTML_SNIPPET: Külső ad kód (pl. Google Ads, Taboola snippet) """ __tablename__ = "creatives" __table_args__ = {"schema": "marketing"} id: Mapped[int] = mapped_column(Integer, primary_key=True) campaign_id: Mapped[int] = mapped_column( Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False ) # ── Típus ── creative_type: Mapped[CreativeType] = mapped_column( SAEnum(CreativeType, name="creative_type", schema="marketing"), nullable=False, comment="Kreatív típusa: image (belső) vagy html_snippet (külső)" ) # ── IMAGE típushoz ── image_url: Mapped[Optional[str]] = mapped_column( String(500), comment="Kép URL (IMAGE típusnál). Lehet relatív (storage) vagy abszolút" ) alt_text: Mapped[Optional[str]] = mapped_column( String(200), comment="Kép alternatív szövege (akadálymentesítés)" ) # ── HTML_SNIPPET típushoz ── html_snippet: Mapped[Optional[str]] = mapped_column( Text, comment="HTML kód külső hirdetési hálózatoktól (pl. Google Ads tag)" ) # ── Közös mezők ── title: Mapped[Optional[str]] = mapped_column( String(200), comment="Hirdetés címe (megjeleníthető a kép alatt)" ) description: Mapped[Optional[str]] = mapped_column( Text, comment="Hirdetés leírása" ) target_url: Mapped[Optional[str]] = mapped_column( String(1000), comment="Cél URL (hova vezet a kattintás)" ) payload: Mapped[Optional[dict]] = mapped_column( JSONB, server_default=text("'{}'::jsonb"), comment="Extra adatok (pl. tracking params, utm_source)" ) # ── Súlyozás ── weight: Mapped[int] = mapped_column( Integer, server_default=text("1"), comment="Súly a rotációban (magasabb = gyakrabban jelenik meg)" ) # ── Kapcsolatok ── campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="creatives") class Placement(Base): """ Elhelyezési zóna a frontenden. Példák: - 'dashboard_sidebar' - 'vehicle_detail_below' - 'service_search_top' - 'footer_banner' """ __tablename__ = "placements" __table_args__ = {"schema": "marketing"} id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column( String(100), unique=True, nullable=False, comment="Zóna neve (pl. 'dashboard_sidebar')" ) display_name: Mapped[Optional[str]] = mapped_column( String(200), comment="Megjelenítési név az admin felületen" ) description: Mapped[Optional[str]] = mapped_column( Text, comment="Leírás a zóna elhelyezkedéséről" ) # ── Korlátok ── max_creatives: Mapped[int] = mapped_column( Integer, server_default=text("1"), comment="Maximum hány kreatív jelenhet meg egyszerre ebben a zónában" ) # ── Tier szűrés ── allowed_subscription_tiers: Mapped[Optional[dict]] = mapped_column( JSONB, server_default=text("'{}'::jsonb"), comment="Engedélyezett előfizetési tier-ek JSONB tömbként (pl. ['free', 'premium'])" ) # ── Meta ── is_active: Mapped[bool] = mapped_column( Boolean, server_default=text("true") ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) # ── Kapcsolatok ── campaigns: Mapped[list["Campaign"]] = relationship( "Campaign", secondary="marketing.campaign_placements", back_populates="placements" ) class CampaignPlacement(Base): """ Kapcsolótábla: Kampány ↔ Elhelyezés (M:N kapcsolat). Lehetővé teszi, hogy egy kampány több zónában is megjelenjen, és egy zónában több kampány is fusson. """ __tablename__ = "campaign_placements" __table_args__ = {"schema": "marketing"} id: Mapped[int] = mapped_column(Integer, primary_key=True) campaign_id: Mapped[int] = mapped_column( Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False ) placement_id: Mapped[int] = mapped_column( Integer, ForeignKey("marketing.placements.id", ondelete="CASCADE"), nullable=False ) # ── Override mezők (felülírhatják a kampány szintű beállításokat) ── priority_override: Mapped[Optional[int]] = mapped_column( Integer, comment="Prioritás felülírása ebben a zónában" ) daily_impression_limit_override: Mapped[Optional[int]] = mapped_column( Integer, comment="Napi limit felülírása ebben a zónában" ) class AdImpression(Base): """ Hirdetés megjelenítések naplózása. Minden egyes megjelenítés rögzítésre kerül a kampány és kreatív szintű analitikához. """ __tablename__ = "ad_impressions" __table_args__ = {"schema": "marketing"} id: Mapped[int] = mapped_column(Integer, primary_key=True) campaign_id: Mapped[int] = mapped_column( Integer, ForeignKey("marketing.campaigns.id"), nullable=False, index=True ) creative_id: Mapped[int] = mapped_column( Integer, ForeignKey("marketing.creatives.id"), nullable=False, index=True ) placement_id: Mapped[int] = mapped_column( Integer, ForeignKey("marketing.placements.id"), nullable=False ) user_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("identity.users.id"), nullable=True, comment="Felhasználó aki látta (None = nem bejelentkezett)" ) # ── Meta ── seen_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) ip_address: Mapped[Optional[str]] = mapped_column( String(45), comment="IP cím (anonimizálható)" ) user_agent: Mapped[Optional[str]] = mapped_column( Text, comment="User agent string" ) session_id: Mapped[Optional[str]] = mapped_column( String(100), index=True, comment="Munkamenet azonosító (deduplikációhoz)" ) # ── Kapcsolatok ── campaign: Mapped["Campaign"] = relationship() creative: Mapped["Creative"] = relationship() placement: Mapped["Placement"] = relationship() class AdClick(Base): """ Hirdetés kattintások naplózása (jövőbeli bővítés). Elkülönítve az impression táblától a jobb skálázhatóság érdekében. """ __tablename__ = "ad_clicks" __table_args__ = {"schema": "marketing"} id: Mapped[int] = mapped_column(Integer, primary_key=True) impression_id: Mapped[int] = mapped_column( Integer, ForeignKey("marketing.ad_impressions.id"), nullable=False, comment="Melyik megjelenítéshez tartozik a kattintás" ) clicked_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) target_url: Mapped[Optional[str]] = mapped_column( String(1000), comment="Cél URL a kattintáskor (tracking paraméterekkel)" ) ``` ### 2.2 Adatbázis Séma Diagram (Text) ``` marketing.campaigns ├── id (PK, SERIAL) ├── uuid (UUID, UNIQUE) ├── name (VARCHAR 200) ├── description (TEXT) ├── start_date (TIMESTAMPTZ) ├── end_date (TIMESTAMPTZ, nullable) ├── priority (INTEGER, default=0) ├── status (ENUM: draft/scheduled/active/paused/completed/cancelled) ├── targeting_rules (JSONB) ├── daily_impression_limit (INTEGER, nullable) ├── total_impression_limit (INTEGER, nullable) ├── created_at (TIMESTAMPTZ) ├── updated_at (TIMESTAMPTZ, nullable) └── created_by (FK → identity.users.id, nullable) marketing.creatives ├── id (PK, SERIAL) ├── campaign_id (FK → marketing.campaigns.id, CASCADE) ├── creative_type (ENUM: image/html_snippet/video/carousel) ├── image_url (VARCHAR 500, nullable) ├── alt_text (VARCHAR 200, nullable) ├── html_snippet (TEXT, nullable) ├── title (VARCHAR 200, nullable) ├── description (TEXT, nullable) ├── target_url (VARCHAR 1000, nullable) ├── payload (JSONB) ├── weight (INTEGER, default=1) └── campaign (relationship → Campaign) marketing.placements ├── id (PK, SERIAL) ├── name (VARCHAR 100, UNIQUE) ├── display_name (VARCHAR 200, nullable) ├── description (TEXT, nullable) ├── max_creatives (INTEGER, default=1) ├── allowed_subscription_tiers (JSONB) ├── is_active (BOOLEAN, default=true) ├── created_at (TIMESTAMPTZ) └── campaigns (M:N → campaigns via campaign_placements) marketing.campaign_placements ├── id (PK, SERIAL) ├── campaign_id (FK → marketing.campaigns.id, CASCADE) ├── placement_id (FK → marketing.placements.id, CASCADE) ├── priority_override (INTEGER, nullable) └── daily_impression_limit_override (INTEGER, nullable) marketing.ad_impressions ├── id (PK, SERIAL) ├── campaign_id (FK → marketing.campaigns.id, INDEX) ├── creative_id (FK → marketing.creatives.id, INDEX) ├── placement_id (FK → marketing.placements.id) ├── user_id (FK → identity.users.id, nullable) ├── seen_at (TIMESTAMPTZ, INDEX) ├── ip_address (VARCHAR 45, nullable) ├── user_agent (TEXT, nullable) └── session_id (VARCHAR 100, INDEX) marketing.ad_clicks (jövőbeli) ├── id (PK, SERIAL) ├── impression_id (FK → marketing.ad_impressions.id) ├── clicked_at (TIMESTAMPTZ) └── target_url (VARCHAR 1000, nullable) ``` ### 2.3 Hirdetés Kiválasztási Logika (Prioritás + Fallback) Amikor a backend kiválasztja a megfelelő hirdetést egy adott zónához, az alábbi algoritmust követi: ``` 1. SZŰRÉS: Aktív kampányok a zónában SELECT c.* FROM campaigns c JOIN campaign_placements cp ON cp.campaign_id = c.id WHERE cp.placement_id = :placement_id AND c.status = 'active' AND c.start_date <= NOW() AND (c.end_date IS NULL OR c.end_date >= NOW()) 2. SZŰRÉS: Felhasználó tier-jének megfelelő kampányok - Ellenőrzi a placement.allowed_subscription_tiers JSONB tömböt - Csak azokat a kampányokat tartja meg, ahol a user tier szerepel a listában 3. SZŰRÉS: Napi/össz limit ellenőrzés - Ellenőrzi a campaign.daily_impression_limit-t - Ellenőrzi a campaign.total_impression_limit-t - Kiszűri a limitet elért kampányokat 4. SORREND: Prioritás szerinti rendezés - Elsődleges: priority (magasabb = előrébb) - Másodlagos: random (egyenlő prioritás esetén rotáció) 5. KIVÁLASZTÁS: A legmagasabb prioritású kampány kreatívjai közül - Súlyozott random választás a creative.weight alapján - Ha nincs megfelelő kampány → FALLBACK: üres placeholder vagy default ad 6. MEGJELENÍTÉS: AdImpression naplózása - Rögzíti a campaign_id, creative_id, placement_id, user_id adatokat ``` #### 2.3.1 Python Pseudo-kód ```python class AdSelectionService: """ Hirdetés kiválasztó szolgáltatás. A megfelelő hirdetés kiválasztása egy adott zónához, figyelembe véve a kampány prioritását, a felhasználó tier-jét és a napi/össz megjelenítési limiteket. """ @staticmethod async def select_ad_for_placement( db: AsyncSession, placement_name: str, user_tier: str = "free", user_id: Optional[int] = None ) -> Optional[dict]: """ Kiválasztja a legmegfelelőbb hirdetést egy adott zónához. Args: db: Database session placement_name: Zóna neve (pl. 'dashboard_sidebar') user_tier: Felhasználó tier szintje user_id: Felhasználó ID (naplózáshoz) Returns: Optional[dict]: Kiválasztott kreatív adatai vagy None """ now = datetime.utcnow() # 1. Keressük meg a zónát placement = await db.execute( select(Placement).where( Placement.name == placement_name, Placement.is_active == True ) ) placement = placement.scalar_one_or_none() if not placement: return None # 2. Ellenőrizzük, hogy a user tier engedélyezett-e ebben a zónában allowed_tiers = placement.allowed_subscription_tiers or [] if allowed_tiers and user_tier not in allowed_tiers: return None # 3. Aktív kampányok lekérdezése ebben a zónában # Rendezés: priority DESC, majd random campaigns_query = ( select(Campaign) .join(CampaignPlacement, CampaignPlacement.campaign_id == Campaign.id) .where( CampaignPlacement.placement_id == placement.id, Campaign.status == CampaignStatus.ACTIVE, Campaign.start_date <= now, or_(Campaign.end_date.is_(None), Campaign.end_date >= now), ) .order_by(Campaign.priority.desc()) ) result = await db.execute(campaigns_query) campaigns = result.scalars().all() if not campaigns: return None # Fallback: nincs aktív kampány # 4. Válasszuk ki a legmagasabb prioritású kampányt # (több azonos prioritás esetén random) top_priority = campaigns[0].priority top_campaigns = [c for c in campaigns if c.priority == top_priority] selected_campaign = random.choice(top_campaigns) # 5. Válasszunk kreatívot a kampányból (súlyozott random) creatives = selected_campaign.creatives if not creatives: return None # Súlyozott random választás total_weight = sum(c.weight for c in creatives) if total_weight == 0: selected_creative = random.choice(creatives) else: r = random.randint(1, total_weight) cumulative = 0 selected_creative = creatives[0] for c in creatives: cumulative += c.weight if r <= cumulative: selected_creative = c break # 6. Naplózzuk a megjelenítést impression = AdImpression( campaign_id=selected_campaign.id, creative_id=selected_creative.id, placement_id=placement.id, user_id=user_id, seen_at=now, ) db.add(impression) await db.flush() # 7. Visszatérés a kreatív adataival result_data = { "creative_id": selected_creative.id, "campaign_id": selected_campaign.id, "creative_type": selected_creative.creative_type.value, "title": selected_creative.title, "description": selected_creative.description, "target_url": selected_creative.target_url, "impression_id": impression.id, } if selected_creative.creative_type == CreativeType.IMAGE: result_data["image_url"] = selected_creative.image_url result_data["alt_text"] = selected_creative.alt_text elif selected_creative.creative_type == CreativeType.HTML_SNIPPET: result_data["html_snippet"] = selected_creative.html_snippet return result_data ### 2.4 Admin API Végpontok (Tervezet) Az Ad Engine kezeléséhez az alábbi admin REST végpontok szükségesek: | Metódus | Végpont | Leírás | |---------|---------|--------| | `GET` | `/api/v1/admin/marketing/campaigns` | Kampányok listázása | | `POST` | `/api/v1/admin/marketing/campaigns` | Új kampány létrehozása | | `GET` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány részletei | | `PATCH` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány módosítása | | `DELETE` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány törlése (soft-delete) | | `GET` | `/api/v1/admin/marketing/creatives` | Kreatívok listázása | | `POST` | `/api/v1/admin/marketing/creatives` | Új kreatív létrehozása | | `GET` | `/api/v1/admin/marketing/placements` | Elhelyezési zónák listázása | | `POST` | `/api/v1/admin/marketing/placements` | Új zóna létrehozása | | `GET` | `/api/v1/admin/marketing/analytics/impressions` | Megjelenítési statisztikák | | `GET` | `/api/v1/admin/marketing/analytics/summary` | Összesített analitika | ### 2.5 Publikus API Végpont (Frontend számára) | Metódus | Végpont | Leírás | |---------|---------|--------| | `GET` | `/api/v1/marketing/ad/{placement_name}` | Hirdetés lekérése egy adott zónához | Ez a végpont meghívja az `AdSelectionService.select_ad_for_placement()` függvényt, és visszaadja a kiválasztott kreatív adatait a frontend számára. --- ## 3. IMPLEMENTÁCIÓS ÜTEMTERV ### Fázis 1: Backend Alapok (Subscription Stacking) | # | Feladat | Fájl | Leírás | |---|---------|------|--------| | 1.1 | `duration_days` hozzáadása a Pydantic sémához | [`subscription.py`](backend/app/schemas/subscription.py:55) | `SubscriptionRulesModel` bővítése `duration_days` mezővel | | 1.2 | `upgrade_org_subscription()` stacking logika | [`billing_engine.py:805`](backend/app/services/billing_engine.py:805) | Teljes átírás: duration_days olvasás, valid_until számítás stackinggel | | 1.3 | `upgrade_subscription()` stacking logika | [`billing_engine.py:722`](backend/app/services/billing_engine.py:722) | User-level stacking implementálása | | 1.4 | `duration_days` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése `duration_days`-szel | | 1.5 | Seed szkript futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/seed_packages.py` | ### Fázis 2: Ad Engine Modellek és Séma | # | Feladat | Fájl | Leírás | |---|---------|------|--------| | 2.1 | `marketing` schema modellek | [`models/marketing.py`](backend/app/models/) | Campaign, Creative, Placement, CampaignPlacement, AdImpression, AdClick | | 2.2 | Sync engine futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/sync_engine.py` | | 2.3 | `AdPolicyModel` Pydantic séma | [`subscription.py`](backend/app/schemas/subscription.py) | `ad_policy` mező hozzáadása a `SubscriptionRulesModel`-hez | | 2.4 | `AdPolicyService` létrehozása | [`services/ad_policy_service.py`](backend/app/services/) | Új service a hirdetési politika lekérdezéséhez | | 2.5 | `AdSelectionService` létrehozása | [`services/ad_selection_service.py`](backend/app/services/) | Hirdetés kiválasztó algoritmus | ### Fázis 3: Admin API Végpontok | # | Feladat | Fájl | Leírás | |---|---------|------|--------| | 3.1 | Admin marketing router | [`endpoints/admin_marketing.py`](backend/app/api/v1/endpoints/) | CRUD végpontok kampányokhoz, kreatívokhoz, elhelyezésekhez | | 3.2 | Publikus ad végpont | [`endpoints/marketing.py`](backend/app/api/v1/endpoints/) | `GET /api/v1/marketing/ad/{placement_name}` | | 3.3 | Router regisztráció | [`api.py`](backend/app/api/v1/api.py) | Új router-ek hozzáadása az API-hoz | ### Fázis 4: Seed Adatok és Tesztelés | # | Feladat | Fájl | Leírás | |---|---------|------|--------| | 4.1 | `ad_policy` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése az `ad_policy` blokkal | | 4.2 | Seed marketing adatok | [`seed_marketing.py`](backend/app/scripts/) | Teszt kampányok, kreatívok, elhelyezések létrehozása | | 4.3 | Tesztelés | — | API tesztek a stacking és ad selection logikára | --- ## 4. FÜGGŐSÉGEK ÉS KOCKÁZATOK | Függőség | Hatás | Megoldás | |----------|-------|----------| | A `valid_until` mező jelenleg NULL minden `org_subscriptions` rekordban | A stacking logika nem működik a meglévő rekordokon | Backfill szkript: `UPDATE finance.org_subscriptions SET valid_until = valid_from + INTERVAL '30 days' WHERE valid_until IS NULL AND is_active = true` | | A `duration_days` mező hiányzik a meglévő seed csomagokból | Az új stacking logika nem tudja kiszámolni az időtartamot | A `seed_packages.py` újrafuttatása a `duration_days` mezővel | | Az új `marketing` séma táblái nem léteznek | Az Ad Engine nem működik | A `sync_engine.py` futtatása a modellek létrehozása után | | A frontend jelenleg nem kérdez le hirdetéseket | A hirdetési zónák üresek maradnak | A frontend `AdService` létrehozása a Vue alkalmazásban | --- ## 5. JÓVÁHAGYÁS A fenti terv jóváhagyása után kezdődhet meg a kódolás az alábbi sorrendben: 1. **Subscription Stacking:** `duration_days` séma → `upgrade_org_subscription()` → `upgrade_subscription()` → seed frissítés 2. **Ad Engine Modellek:** `marketing` séma → sync engine → Pydantic sémák 3. **Ad Engine Service:** `AdPolicyService` → `AdSelectionService` 4. **Admin API:** CRUD végpontok → publikus ad végpont 5. **Seed Adatok:** `ad_policy` → teszt kampányok