L1L2 gen1Gen2 kialakítása

This commit is contained in:
Roo
2026-07-24 09:56:21 +00:00
parent 4594de6c11
commit 33c4d793db
49 changed files with 4108 additions and 127 deletions

View File

@@ -8,6 +8,7 @@ from app.api.v1.endpoints import (
admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
subscriptions, marketing, admin_permissions, regions,
admin_gamification, admin_providers, locations,
admin_commission,
)
api_router = APIRouter()
@@ -47,4 +48,5 @@ api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketin
api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Permissions (RBAC)"])
api_router.include_router(admin_gamification.router, prefix="/admin/gamification", tags=["Admin Gamification"])
api_router.include_router(admin_providers.router, prefix="/admin/providers", tags=["Admin Provider Moderation"])
api_router.include_router(locations.router, prefix="", tags=["Location Autocomplete"])
api_router.include_router(locations.router, prefix="", tags=["Location Autocomplete"])
api_router.include_router(admin_commission.router, prefix="/admin/commission-rules", tags=["Admin Commission Rules"])

View File

@@ -0,0 +1,273 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_commission.py
"""
Admin API endpoints for CommissionRule CRUD, Priority Resolution Engine,
and 2-Level MLM Commission Distribution.
THOUGHT PROCESS:
- All endpoints are prefixed with /admin/commission-rules (registered in api.py).
- Staff-level access via get_current_staff dependency (SUPERADMIN, ADMIN, etc.).
- The GET /active endpoint implements the Priority Resolution Algorithm
(Campaign > Region > Tier) from the service layer.
- Pagination, filtering by type/tier/region/active/campaign are supported.
- Soft-delete via PATCH setting is_active=False; hard DELETE also available.
- POST /distribute implements the 2-Level MLM commission distribution:
Gen1 (direct referrer) gets commission_percent, Gen2 (upline) gets
upline_commission_percent from the same rule.
"""
import logging
from datetime import date
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_staff
from app.db.session import get_db
from app.models.identity.identity import User
from app.models.marketplace.commission import (
CommissionRule,
CommissionRuleType,
CommissionTier,
)
from app.schemas.commission import (
CommissionRuleCreate,
CommissionRuleUpdate,
CommissionRuleResponse,
CommissionRuleListResponse,
CommissionDistributionRequest,
CommissionDistributionResponse,
)
from app.services import commission_service
logger = logging.getLogger("admin-commission")
router = APIRouter()
# ──────────────────────────────────────────────────────────────────────────────
# LIST: GET /admin/commission-rules
# ──────────────────────────────────────────────────────────────────────────────
@router.get(
"",
response_model=CommissionRuleListResponse,
tags=["Admin Commission Rules"],
)
async def list_commission_rules(
page: int = Query(1, ge=1, description="Oldalszám"),
page_size: int = Query(20, ge=1, le=100, description="Elemek száma oldalanként"),
rule_type: Optional[CommissionRuleType] = Query(None, description="Szűrés típus szerint (L1_REWARD / L2_COMMISSION)"),
tier: Optional[CommissionTier] = Query(None, description="Szűrés szint szerint (STANDARD / VIP / PLATINUM / ENTERPRISE)"),
region_code: Optional[str] = Query(None, max_length=10, description="Szűrés régió szerint (pl. HU, GLOBAL)"),
is_active: Optional[bool] = Query(None, description="Szűrés aktív/inaktív státusz szerint"),
is_campaign: Optional[bool] = Query(None, description="Szűrés kampány/állandó szabály szerint"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""List all commission rules with optional filters and pagination."""
rules, total = await commission_service.list_rules(
db,
page=page,
page_size=page_size,
rule_type=rule_type,
tier=tier,
region_code=region_code,
is_active=is_active,
is_campaign=is_campaign,
)
return CommissionRuleListResponse(
items=[CommissionRuleResponse.model_validate(r) for r in rules],
total=total,
page=page,
page_size=page_size,
)
# ──────────────────────────────────────────────────────────────────────────────
# GET ACTIVE RULE: GET /admin/commission-rules/active
# ──────────────────────────────────────────────────────────────────────────────
@router.get(
"/active",
response_model=CommissionRuleResponse,
tags=["Admin Commission Rules"],
)
async def get_active_commission_rule(
rule_type: CommissionRuleType = Query(..., description="Jutalék típusa (L1_REWARD / L2_COMMISSION)"),
tier: CommissionTier = Query(CommissionTier.STANDARD, description="Felhasználó szintje"),
region_code: str = Query("GLOBAL", max_length=10, description="Régiókód (ISO 3166-1 alpha-2)"),
transaction_date: date = Query(..., description="Tranzakció dátuma (ISO 8601, pl. 2026-07-24)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""
Priority Resolution Engine: Find the most specific active rule.
Resolution order:
1. Campaign rules over permanent rules
2. Most specific region match (HU > GLOBAL)
3. Highest tier match (PLATINUM > VIP > STANDARD > ENTERPRISE)
"""
rule = await commission_service.get_active_rule(
db,
rule_type=rule_type,
tier=tier,
region_code=region_code,
transaction_date=transaction_date,
)
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active commission rule found for the given parameters.",
)
return CommissionRuleResponse.model_validate(rule)
# ──────────────────────────────────────────────────────────────────────────────
# GET SINGLE: GET /admin/commission-rules/{rule_id}
# ──────────────────────────────────────────────────────────────────────────────
@router.get(
"/{rule_id}",
response_model=CommissionRuleResponse,
tags=["Admin Commission Rules"],
)
async def get_commission_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""Get a single commission rule by ID."""
from sqlalchemy import select
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Commission rule with id={rule_id} not found.",
)
return CommissionRuleResponse.model_validate(rule)
# ──────────────────────────────────────────────────────────────────────────────
# CREATE: POST /admin/commission-rules
# ──────────────────────────────────────────────────────────────────────────────
@router.post(
"",
response_model=CommissionRuleResponse,
status_code=status.HTTP_201_CREATED,
tags=["Admin Commission Rules"],
)
async def create_commission_rule(
data: CommissionRuleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""Create a new commission rule."""
rule = await commission_service.create_commission_rule(
db, data=data, admin_user_id=current_user.id,
)
return CommissionRuleResponse.model_validate(rule)
# ──────────────────────────────────────────────────────────────────────────────
# UPDATE: PUT /admin/commission-rules/{rule_id}
# ──────────────────────────────────────────────────────────────────────────────
@router.put(
"/{rule_id}",
response_model=CommissionRuleResponse,
tags=["Admin Commission Rules"],
)
async def update_commission_rule(
rule_id: int,
data: CommissionRuleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""Update an existing commission rule (partial update)."""
rule = await commission_service.update_commission_rule(db, rule_id, data)
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Commission rule with id={rule_id} not found.",
)
return CommissionRuleResponse.model_validate(rule)
# ──────────────────────────────────────────────────────────────────────────────
# SOFT DELETE (DEACTIVATE): DELETE /admin/commission-rules/{rule_id}
# ──────────────────────────────────────────────────────────────────────────────
@router.delete(
"/{rule_id}",
status_code=status.HTTP_200_OK,
tags=["Admin Commission Rules"],
)
async def deactivate_commission_rule(
rule_id: int,
hard_delete: bool = Query(False, description="Végleges törlés (alapértelmezett: soft-delete / deaktiválás)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""
Deactivate (soft-delete) or permanently delete a commission rule.
By default, sets is_active=False (soft delete).
Use ?hard_delete=true for permanent removal.
"""
if hard_delete:
deleted = await commission_service.hard_delete_commission_rule(db, rule_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Commission rule with id={rule_id} not found.",
)
return {"message": f"Commission rule {rule_id} permanently deleted."}
rule = await commission_service.deactivate_commission_rule(db, rule_id)
if not rule:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Commission rule with id={rule_id} not found.",
)
return CommissionRuleResponse.model_validate(rule)
# ──────────────────────────────────────────────────────────────────────────────
# 2-LEVEL MLM DISTRIBUTION: POST /admin/commission-rules/distribute
# ──────────────────────────────────────────────────────────────────────────────
@router.post(
"/distribute",
response_model=CommissionDistributionResponse,
tags=["Admin Commission Rules"],
)
async def distribute_commission(
request: CommissionDistributionRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_staff),
):
"""
2-Level MLM Commission Distribution Engine.
When a referred company makes a purchase, this endpoint:
1. Looks up the buyer's direct referrer (Gen1)
2. Finds the active L2_COMMISSION rule for Gen1's tier/region
3. Calculates Gen1's commission using commission_percent
4. If Gen1 has a referrer (Gen2/upline), calculates Gen2's commission
using upline_commission_percent from the same rule
Returns a breakdown of all commission payouts.
"""
result = await commission_service.distribute_commission(db, request)
return result

View File

@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.services.auth_service import AuthService
from app.services.social_auth_service import SocialAuthService
from app.core.security import create_tokens, DEFAULT_RANK_MAP
from app.core.config import settings
from app.services.config_service import config
@@ -15,6 +16,10 @@ from app.models.identity import User, Device, UserDeviceLink # JAVÍTVA: Device-
from app.core.translation_helper import t # Translation helper
from pydantic import BaseModel, Field, EmailStr
from datetime import datetime, timezone
import httpx
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -379,4 +384,153 @@ async def restore_account_verify(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.get("message", "Érvénytelen vagy lejárt kód.")
)
return result
return result
# ── GOOGLE OAUTH ──
class GoogleAuthRequest(BaseModel):
"""Google token from the frontend after successful Google Sign-In.
The frontend sends either an id_token (from Google Sign-In / One Tap)
or an access_token (from Google OAuth2 token client).
"""
id_token: Optional[str] = Field(None, description="Google ID token (JWT) from Google Sign-In")
access_token: Optional[str] = Field(None, description="Google OAuth2 access token from token client")
referred_by_code: Optional[str] = Field(None, description="Optional referral code from registration form")
@router.post("/google", response_model=Token)
async def google_auth(
request: GoogleAuthRequest,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""
Google OAuth bejelentkezés / regisztráció.
A frontend Google Sign-In gombjára kattintva a felhasználó Google tokent kap.
Ezt a tokent küldi el a backendnek, amely:
1. Ellenőrzi a token érvényességét a Google nyilvános kulcsaival
2. Kinyeri az email-t, nevet és Google ID-t
3. Ha a felhasználó már létezik (Login): visszaadja a JWT tokent
4. Ha nem létezik (Register): létrehozza az új felhasználót a Google adataival
5. Opcionális referral_code tárolása, ha a regisztrációs űrlapon megadták
Supports both id_token (from Google Sign-In) and access_token (from OAuth2 token client).
"""
try:
# 1. Determine which token was provided and build verification URL
if request.id_token:
google_verify_url = f"https://oauth2.googleapis.com/tokeninfo?id_token={request.id_token}"
elif request.access_token:
google_verify_url = f"https://oauth2.googleapis.com/tokeninfo?access_token={request.access_token}"
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Hiányzó Google token. Adjon meg id_token vagy access_token értéket."
)
# 2. Verify Google token
async with httpx.AsyncClient() as client:
google_resp = await client.get(google_verify_url)
if google_resp.status_code != 200:
logger.error(f"Google token verification failed: {google_resp.text}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token."
)
google_data = google_resp.json()
# 3. Validate audience (client ID) — only for id_token
# For access_token, the 'aud' field may not be present; validate by 'azp' or skip
if request.id_token:
if google_data.get("aud") != settings.GOOGLE_CLIENT_ID:
logger.error(f"Google token audience mismatch: {google_data.get('aud')}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token (audience mismatch)."
)
else:
# For access_token, check azp (authorized party) matches our client ID
if google_data.get("azp") != settings.GOOGLE_CLIENT_ID:
logger.error(f"Google token azp mismatch: {google_data.get('azp')}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token (azp mismatch)."
)
# 4. Extract user info
google_id = google_data.get("sub")
email = google_data.get("email", "")
first_name = google_data.get("given_name", "")
last_name = google_data.get("family_name", "")
if not google_id or not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Hiányzó Google felhasználói adatok."
)
# 5. Get or create user via SocialAuthService
user = await SocialAuthService.get_or_create_social_user(
db=db,
provider="google",
social_id=google_id,
email=email,
first_name=first_name,
last_name=last_name,
referred_by_code=request.referred_by_code
)
if not user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba a felhasználó létrehozása során."
)
# 6. Generate JWT tokens (same logic as login)
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
role_key = role_name.upper()
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": ranks.get(role_key, 10),
"scope_level": user.scope_level or "individual",
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
}
access, refresh = create_tokens(data=token_data, remember_me=False)
max_age_days = await config.get_setting(db, "auth_refresh_default_days", default=1)
max_age_sec = int(max_age_days) * 24 * 60 * 60
response.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=True,
samesite="lax",
domain=settings.COOKIE_DOMAIN,
max_age=max_age_sec
)
return {
"access_token": access,
"refresh_token": refresh,
"token_type": "bearer",
"is_active": user.is_active
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Google auth error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Google hitelesítési hiba: {str(e)}"
)

View File

@@ -87,7 +87,7 @@ async def list_body_types(
current_user = Depends(deps.get_current_user)
):
"""Karosszéria-típus szótár lekérése. Opcionálisan szűrhető vehicle_class alapján."""
stmt = select(BodyTypeDictionary).order_by(BodyTypeDictionary.name_hu)
stmt = select(BodyTypeDictionary).order_by(BodyTypeDictionary.name_i18n)
if vehicle_class:
stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class)
result = await db.execute(stmt)
@@ -97,10 +97,7 @@ async def list_body_types(
"id": r.id,
"vehicle_class": r.vehicle_class,
"code": r.code,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": r.name_i18n if r.name_i18n else {},
# --- [DEPRECATED] Old columns (Phase 3) ---
"name_hu": r.name_hu,
}
for r in rows
]

View File

@@ -39,12 +39,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_current_user
from app.models.fleet_finance.models import AssetCost, CostCategory
from app.models.vehicle.asset import Asset
from app.models.vehicle.asset_event import AssetEvent
from app.models.vehicle.odometer import OdometerReading
from app.models.fleet.organization import Organization
from app.models.fleet.org_member import OrganizationMember
from app.models.marketplace.service import ServiceProvider, ServiceProfile, ServiceExpertise
from app.models.system.system_parameter import SystemParameter
from app.models.vehicle.asset import AssetEvent
from app.models.vehicle.asset import OdometerReading
from app.models.marketplace.organization import Organization
from app.models.marketplace.organization import OrganizationMember
from app.models.identity.social import ServiceProvider
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
from app.models.system.system import SystemParameter
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate, AssetCostResponse
from app.services.gamification_service import GamificationService
from app.services.provider_service import find_or_create_provider_by_name

View File

@@ -77,12 +77,8 @@ async def get_category_tree(
nodes.append(CategoryTreeNode(
id=tag.id,
key=tag.key,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=tag.name_i18n if tag.name_i18n else {},
description_i18n=tag.description_i18n,
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu=tag.name_hu,
name_en=tag.name_en,
level=tag.level,
path=tag.path,
is_official=tag.is_official,
@@ -111,21 +107,17 @@ async def autocomplete_categories(
Szöveges kereső a Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkékhez.
Csak azokat listázza, ahol is_official=True.
A keresés a name_i18n JSONB (minden nyelven), key és name_hu/name_en mezőkben történik.
A keresés a name_i18n JSONB (minden nyelven) és key mezőkben történik.
"""
try:
# --- 🏗️ i18n JSONB Migration (Phase 3): JSONB → String cast keresés ---
stmt = select(ExpertiseTag).where(
ExpertiseTag.level >= 2,
ExpertiseTag.is_official == True,
or_(
cast(ExpertiseTag.name_i18n, String).ilike(f"%{q}%"),
ExpertiseTag.key.ilike(f"%{q}%"),
# --- [DEPRECATED] Old column fallback (Phase 3) ---
ExpertiseTag.name_hu.ilike(f"%{q}%"),
ExpertiseTag.name_en.ilike(f"%{q}%"),
),
).order_by(ExpertiseTag.level, ExpertiseTag.name_hu).limit(20)
).order_by(ExpertiseTag.level, ExpertiseTag.name_i18n).limit(20)
result = await db.execute(stmt)
tags = result.scalars().all()
@@ -134,11 +126,7 @@ async def autocomplete_categories(
CategoryAutocompleteItem(
id=t.id,
key=t.key,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=t.name_i18n if t.name_i18n else {},
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu=t.name_hu,
name_en=t.name_en,
level=t.level,
path=t.path,
parent_id=t.parent_id,

View File

@@ -37,6 +37,9 @@ from .marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise
from .marketplace.staged_data import ServiceStaging, DiscoveryParameter, StagedVehicleData
from .marketplace.service_request import ServiceRequest
# 7b. Jutalék szabályok (Commission Rules)
from .marketplace.commission import CommissionRule, CommissionRuleType, CommissionTier
# 8. Közösségi és értékelési modellek (Social 3)
from .identity.social import ServiceProvider, Vote, Competition, UserScore, ServiceReview, ModerationStatus, SourceType
@@ -100,4 +103,6 @@ __all__ = [
"CampaignStatus", "CreativeType", "PlacementType",
# RBAC Phase 1
"SystemRole", "SystemPermission", "SystemRolePermission",
# Commission Rules (Phase 2)
"CommissionRule", "CommissionRuleType", "CommissionTier",
]

View File

@@ -250,9 +250,7 @@ class ServiceCatalog(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT'
name: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[Optional[str]] = mapped_column(String)
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben

View File

@@ -159,6 +159,13 @@ class User(Base):
referred_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
current_sales_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
# Commission tier: determines which commission rules apply to this user
# STANDARD = one-time commission only, CONTRACTED = first-time + recurring renewal commission
commission_tier: Mapped[str] = mapped_column(
String(20), nullable=False, default="STANDARD", server_default=text("'STANDARD'"),
comment="Jutalék besorolás: STANDARD (egyszeri jutalék) vagy CONTRACTED (első + megújítási jutalék)"
)
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)

View File

@@ -30,6 +30,9 @@ from .staged_data import (
from .service_request import ServiceRequest
# Commission Rules (Phase 2)
from .commission import CommissionRule, CommissionRuleType, CommissionTier
__all__ = [
"Organization",
"OrganizationMember",
@@ -52,4 +55,7 @@ __all__ = [
"LocationType",
"StagedVehicleData",
"ServiceRequest",
"CommissionRule",
"CommissionRuleType",
"CommissionTier",
]

View File

@@ -0,0 +1,184 @@
# /opt/docker/dev/service_finder/backend/app/models/marketplace/commission.py
"""
CommissionRule: Dynamic, tiered, time-bound commission and reward rules.
Supports:
- L1 rewards (XP/credits for individual referrals)
- L2 commissions (% for company-to-company purchases)
- Tier-based multipliers (Standard, VIP, Platinum)
- Regional overrides (HU, Global, etc.)
- Time-bound promotional campaigns
THOUGHT PROCESS:
- Single table for both L1 and L2 avoids join complexity; nullable fields
differentiate type-specific values.
- region_code as simple string (ISO 3166-1 alpha-2) follows existing pattern
(see InsuranceProvider.country_code).
- start_date/end_date as Date (day-granular campaigns, no timezone issues).
- is_campaign boolean enables quick filtering without date parsing.
- UniqueConstraint on 5 columns prevents duplicate rules for the same
type/tier/region/date combination.
- metadata_json JSONB for future-proof UI fields (colors, icons, tooltips).
"""
import enum
from datetime import datetime, date
from typing import Optional
from sqlalchemy import (
String, Integer, Float, Boolean, DateTime, Date,
Text, Enum as SQLEnum, Numeric, UniqueConstraint, text
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from sqlalchemy.sql import func
from app.database import Base
class CommissionRuleType(str, enum.Enum):
"""
Két elsődleges jutalom típus:
- L1_REWARD: XP/kredit jutalom egyéni ajánlóknak (referral)
- L2_COMMISSION: Százalékos jutalék céges vásárlások után
"""
L1_REWARD = "L1_REWARD" # XP/credits for individual referrers
L2_COMMISSION = "L2_COMMISSION" # % commission for company purchases
class CommissionTier(str, enum.Enum):
"""Jutalék szintek / rétegek."""
STANDARD = "STANDARD"
VIP = "VIP"
PLATINUM = "PLATINUM"
ENTERPRISE = "ENTERPRISE"
CONTRACTED = "CONTRACTED"
class CommissionRule(Base):
"""
Dinamikus jutalék/jutalom szabályok.
Minden szabály egy adott típushoz (L1/L2), szinthez (tier),
régióhoz és opcionális időablakhoz tartozik.
A lekérdező motor a tranzakció időpontjában érvényes,
legspecifikusabb szabályt alkalmazza.
"""
__tablename__ = "commission_rules"
__table_args__ = (
UniqueConstraint(
'rule_type', 'tier', 'region_code',
'start_date', 'end_date',
name='uix_commission_rule_unique'
),
{"schema": "marketplace", "extend_existing": True}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# --- Rule Classification ---
rule_type: Mapped[CommissionRuleType] = mapped_column(
SQLEnum(CommissionRuleType, name="commission_rule_type", schema="marketplace"),
nullable=False,
index=True,
comment="L1_REWARD = XP/kredit jutalom, L2_COMMISSION = % jutalék"
)
tier: Mapped[CommissionTier] = mapped_column(
SQLEnum(CommissionTier, name="commission_tier", schema="marketplace"),
nullable=False,
default=CommissionTier.STANDARD,
index=True,
comment="Jutalék szint (STANDARD, VIP, PLATINUM, ENTERPRISE)"
)
# --- Regional Scope ---
region_code: Mapped[str] = mapped_column(
String(10),
nullable=False,
default="GLOBAL",
index=True,
comment="ISO 3166-1 alpha-2 országkód (pl. 'HU', 'DE') vagy 'GLOBAL'"
)
# --- Reward / Commission Values ---
# L1_REWARD fields
xp_reward: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=0,
comment="L1: XP jutalom értéke"
)
credit_reward: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=0,
comment="L1: Kredit jutalom értéke"
)
# L2_COMMISSION fields
commission_percent: Mapped[Optional[float]] = mapped_column(
Numeric(5, 2), nullable=True, default=0.00,
comment="L2: Jutalék százalék (pl. 5.00 = 5%)"
)
upline_commission_percent: Mapped[Optional[float]] = mapped_column(
Numeric(5, 2), nullable=True, default=0.00,
comment="L2: Upline (Gen2) jutalék százalék — a közvetlen ajánló feletti szintnek járó jutalék (pl. 2.00 = 2%)"
)
renewal_commission_percent: Mapped[Optional[float]] = mapped_column(
Numeric(5, 2), nullable=True, default=0.00,
comment="L2: Megújítási jutalék százalék (pl. 2.50 = 2.5%) - havi/éves előfizetés megújításakor járó jutalék"
)
commission_max_amount: Mapped[Optional[float]] = mapped_column(
Numeric(18, 2), nullable=True,
comment="L2: Maximális jutalék összeg (opcionális felső korlát)"
)
# --- Time-Bound Campaign ---
is_campaign: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=text("false"),
comment="TRUE = időkorlátos kampány, FALSE = állandó szabály"
)
start_date: Mapped[Optional[date]] = mapped_column(
Date, nullable=True, index=True,
comment="Kampány kezdő dátuma (NULL = azonnal érvényes)"
)
end_date: Mapped[Optional[date]] = mapped_column(
Date, nullable=True, index=True,
comment="Kampány záró dátuma (NULL = nincs lejárat)"
)
# --- Metadata ---
name: Mapped[str] = mapped_column(
String(200), nullable=False,
comment="Emberi olvasható szabály név (pl. 'HU VIP Nyári Kampány')"
)
description: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Részletes leírás a szabályról"
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default=text("true"), index=True,
comment="TRUE = aktív és használható, FALSE = letiltva"
)
# --- Audit Trail ---
created_by: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
comment="Létrehozó admin felhasználó ID"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(),
onupdate=func.now()
)
# --- Extensibility ---
metadata_json: Mapped[Optional[dict]] = mapped_column(
JSONB, nullable=True, server_default=text("'{}'::jsonb"),
comment="Bővíthető metaadatok (pl. campaign banner URL, notes)"
)
def __repr__(self) -> str:
return (
f"<CommissionRule(id={self.id}, type='{self.rule_type}', "
f"tier='{self.tier}', region='{self.region_code}', "
f"active={self.is_active})>"
)

View File

@@ -111,12 +111,9 @@ class ExpertiseTag(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name_hu: Mapped[Optional[str]] = mapped_column(String(100))
name_en: Mapped[Optional[str]] = mapped_column(String(100))
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
name_translations: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---

View File

@@ -172,6 +172,5 @@ class BodyTypeDictionary(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name_hu: Mapped[str] = mapped_column(String(100), nullable=False)
# --- 🏗️ i18n JSONB Migration (Phase 1) ---
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))

View File

@@ -0,0 +1,138 @@
# /opt/docker/dev/service_finder/backend/app/schemas/commission.py
"""
Pydantic schemas for CommissionRule CRUD operations.
THOUGHT PROCESS:
- CommissionRuleCreate: All fields required for creating a new rule.
Uses Optional for type-specific fields (L1 vs L2).
- CommissionRuleUpdate: All fields optional for partial updates.
- CommissionRuleResponse: Full read model with from_attributes=True
for ORM compatibility.
- CommissionRuleListResponse: Paginated wrapper for admin listing.
"""
from pydantic import BaseModel, Field
from datetime import date, datetime
from typing import Optional, List
from enum import Enum
class CommissionRuleTypeEnum(str, Enum):
"""Jutalék típus: L1 = egyéni jutalom, L2 = céges jutalék."""
L1_REWARD = "L1_REWARD"
L2_COMMISSION = "L2_COMMISSION"
class CommissionTierEnum(str, Enum):
"""Jutalék szintek."""
STANDARD = "STANDARD"
VIP = "VIP"
PLATINUM = "PLATINUM"
ENTERPRISE = "ENTERPRISE"
CONTRACTED = "CONTRACTED"
class CommissionRuleCreate(BaseModel):
"""Schema új jutalék szabály létrehozásához."""
rule_type: CommissionRuleTypeEnum
tier: CommissionTierEnum = CommissionTierEnum.STANDARD
region_code: str = "GLOBAL"
xp_reward: Optional[int] = None
credit_reward: Optional[int] = None
commission_percent: Optional[float] = None
upline_commission_percent: Optional[float] = None
renewal_commission_percent: Optional[float] = None
commission_max_amount: Optional[float] = None
is_campaign: bool = False
start_date: Optional[date] = None
end_date: Optional[date] = None
name: str
description: Optional[str] = None
is_active: bool = True
class CommissionRuleUpdate(BaseModel):
"""Schema meglévő jutalék szabály módosításához (minden mező opcionális)."""
rule_type: Optional[CommissionRuleTypeEnum] = None
tier: Optional[CommissionTierEnum] = None
region_code: Optional[str] = None
xp_reward: Optional[int] = None
credit_reward: Optional[int] = None
commission_percent: Optional[float] = None
upline_commission_percent: Optional[float] = None
renewal_commission_percent: Optional[float] = None
commission_max_amount: Optional[float] = None
is_campaign: Optional[bool] = None
start_date: Optional[date] = None
end_date: Optional[date] = None
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class CommissionRuleResponse(BaseModel):
"""Schema jutalék szabály adatainak lekéréséhez."""
id: int
rule_type: CommissionRuleTypeEnum
tier: CommissionTierEnum
region_code: str
xp_reward: Optional[int] = None
credit_reward: Optional[int] = None
commission_percent: Optional[float] = None
upline_commission_percent: Optional[float] = None
renewal_commission_percent: Optional[float] = None
commission_max_amount: Optional[float] = None
is_campaign: bool
start_date: Optional[date] = None
end_date: Optional[date] = None
name: str
description: Optional[str] = None
is_active: bool
created_by: Optional[int] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class CommissionRuleListResponse(BaseModel):
"""Paginated list response for admin commission rules."""
items: List[CommissionRuleResponse]
total: int
page: int
page_size: int
class CommissionDistributionRequest(BaseModel):
"""
Request schema for the 2-level MLM commission distribution engine.
When a referred company makes a purchase, this request triggers the
distribution logic:
- Gen1 (direct referrer) receives commission_percent
- Gen2 (upline / Gen1's referrer) receives upline_commission_percent
"""
buyer_user_id: int = Field(..., description="The user ID who made the purchase")
transaction_amount: float = Field(..., gt=0, description="The purchase/subscription amount")
transaction_date: date = Field(..., description="Date of the transaction")
region_code: str = Field("GLOBAL", max_length=10, description="Region code for rule matching")
class CommissionDistributionItem(BaseModel):
"""Single commission payout item for one level."""
level: int = Field(..., description="1 = Gen1 (direct referrer), 2 = Gen2 (upline)")
user_id: int = Field(..., description="The user ID receiving the commission")
commission_percent: float = Field(..., description="The applied commission percentage")
commission_amount: float = Field(..., description="The calculated commission amount")
rule_id: int = Field(..., description="The CommissionRule ID that was applied")
class CommissionDistributionResponse(BaseModel):
"""
Response schema for the 2-level MLM commission distribution.
Contains the payout breakdown for both Gen1 and Gen2 levels.
"""
transaction_amount: float
items: List[CommissionDistributionItem]
total_commission: float = Field(..., description="Sum of all commission payouts")

View File

@@ -35,12 +35,8 @@ class CategoryTreeNode(BaseModel):
"""
id: int
key: str
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
description_i18n: Optional[dict] = Field(None, description="Lokalizált leírás (JSONB)")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
level: int = 0
path: Optional[str] = None
is_official: bool = True
@@ -57,11 +53,7 @@ class CategoryAutocompleteItem(BaseModel):
"""
id: int
key: str
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
level: int = 0
path: Optional[str] = None
parent_id: Optional[int] = None
@@ -80,11 +72,7 @@ class ExpertiseCategoryOut(BaseModel):
"""
id: int
key: str
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
category: Optional[str] = None
level: int = 0
parent_id: Optional[int] = None
@@ -101,11 +89,7 @@ class CategoryInfo(BaseModel):
szolgáltatásokat a kártyákon.
"""
id: int
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
name_en: Optional[str] = Field(None, description="[DEPRECATED] Use name_i18n instead")
level: int = 0
key: str

View File

@@ -52,13 +52,9 @@ async def seed_dismantler_category():
# Beszúrás
tag = ExpertiseTag(
key=DISMANTLER_CATEGORY["key"],
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=DISMANTLER_CATEGORY.get("name_i18n", {"hu": DISMANTLER_CATEGORY["name_hu"], "en": DISMANTLER_CATEGORY["name_en"]}),
name_hu=DISMANTLER_CATEGORY["name_hu"],
name_en=DISMANTLER_CATEGORY["name_en"],
category=DISMANTLER_CATEGORY["category"],
search_keywords=DISMANTLER_CATEGORY["search_keywords"],
description=DISMANTLER_CATEGORY["description"],
is_official=True,
discovery_points=10,
usage_count=0,

View File

@@ -230,14 +230,11 @@ async def seed_enterprise_taxonomy():
"hu": tag_data["name_hu"],
"en": tag_data["name_en"],
}),
name_hu=tag_data["name_hu"],
name_en=tag_data["name_en"],
category=tag_data.get("category", "other"),
level=tag_data["level"],
parent_id=tag_data.get("parent_id"),
path=path,
search_keywords=tag_data.get("search_keywords", []),
description=tag_data.get("description", ""),
is_official=True,
discovery_points=10,
usage_count=0,

View File

@@ -145,13 +145,9 @@ async def seed_expertise_tags():
for cat in BASE_CATEGORIES:
tag = ExpertiseTag(
key=cat["key"],
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
name_hu=cat["name_hu"],
name_en=cat["name_en"],
category=cat["category"],
search_keywords=cat["search_keywords"],
description=cat["description"],
is_official=True,
discovery_points=10,
usage_count=0,

View File

@@ -69,16 +69,12 @@ async def seed_gas_station_categories():
# Beszúrás
tag = ExpertiseTag(
key=cat["key"],
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
name_hu=cat["name_hu"],
name_en=cat["name_en"],
category=cat["category"],
level=cat["level"],
parent_id=cat["parent_id"],
path=cat["path"],
search_keywords=cat["search_keywords"],
description=cat["description"],
is_official=True,
discovery_points=10,
usage_count=0,

View File

@@ -607,7 +607,8 @@ async def seed_insurance_providers():
logger.error("Futtasd előbb a seed_expertise_tags.py scriptet!")
return
logger.info(f"✅ Expertise tag megtalálva: {expertise_tag.name_hu} (ID={expertise_tag.id})")
tag_name = expertise_tag.name_i18n.get("hu", expertise_tag.key) if expertise_tag.name_i18n else expertise_tag.key
logger.info(f"✅ Expertise tag megtalálva: {tag_name} (ID={expertise_tag.id})")
inserted_count = 0
skipped_count = 0
@@ -673,7 +674,7 @@ async def seed_insurance_providers():
)
db.add(new_expertise)
await db.flush()
logger.info(f"✅ ServiceExpertise kapcsolat létrehozva: {expertise_tag.name_hu}")
logger.info(f"✅ ServiceExpertise kapcsolat létrehozva: {tag_name}")
inserted_count += 1

View File

@@ -0,0 +1,528 @@
# /opt/docker/dev/service_finder/backend/app/services/commission_service.py
"""
CommissionRule service layer — CRUD + Priority Resolution Engine + 2-Level MLM Distribution.
THOUGHT PROCESS:
- The Priority Algorithm (Campaign > Region > Tier) is implemented in
get_active_rule() using SQLAlchemy case() expressions for server-side
ordering. This avoids loading all matching rules into Python memory.
- Campaign rules (is_campaign=True) always sort before permanent rules.
- Specific region match (e.g. "HU") sorts before "GLOBAL".
- Higher tiers (PLATINUM=0, VIP=1, STANDARD=2, ENTERPRISE=3) sort first.
- Soft-delete is handled via is_active=False (not actual row deletion).
- The service uses async/await throughout for FastAPI compatibility.
- 2-Level MLM: distribute_commission() resolves Gen1 (direct referrer) and
Gen2 (upline) payouts using commission_percent and upline_commission_percent.
"""
import logging
from datetime import date
from decimal import Decimal
from typing import Optional, List, Sequence
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.marketplace.commission import (
CommissionRule,
CommissionRuleType,
CommissionTier,
)
from app.models.identity.identity import User
from app.schemas.commission import (
CommissionRuleCreate,
CommissionRuleUpdate,
CommissionDistributionRequest,
CommissionDistributionItem,
CommissionDistributionResponse,
)
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────────────
# CRUD Operations
# ──────────────────────────────────────────────────────────────────────────────
async def create_commission_rule(
db: AsyncSession,
data: CommissionRuleCreate,
admin_user_id: int,
) -> CommissionRule:
"""
Create a new commission rule with validation.
Args:
db: Database session.
data: Pydantic schema with rule data.
admin_user_id: ID of the admin creating the rule.
Returns:
The newly created CommissionRule instance.
"""
rule = CommissionRule(
rule_type=CommissionRuleType(data.rule_type.value),
tier=CommissionTier(data.tier.value),
region_code=data.region_code,
xp_reward=data.xp_reward,
credit_reward=data.credit_reward,
commission_percent=data.commission_percent,
upline_commission_percent=data.upline_commission_percent,
renewal_commission_percent=data.renewal_commission_percent,
commission_max_amount=data.commission_max_amount,
is_campaign=data.is_campaign,
start_date=data.start_date,
end_date=data.end_date,
name=data.name,
description=data.description,
is_active=data.is_active,
created_by=admin_user_id,
)
db.add(rule)
await db.commit()
await db.refresh(rule)
logger.info(
"Commission rule created: id=%d type=%s tier=%s region=%s",
rule.id, rule.rule_type, rule.tier, rule.region_code,
)
return rule
async def update_commission_rule(
db: AsyncSession,
rule_id: int,
data: CommissionRuleUpdate,
) -> Optional[CommissionRule]:
"""
Partially update an existing commission rule.
Only the fields explicitly set in the update schema are applied.
Returns None if the rule does not exist.
"""
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if not rule:
return None
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
# Map enum fields from schema enums to model enums
if field == "rule_type" and value is not None:
setattr(rule, field, CommissionRuleType(value.value))
elif field == "tier" and value is not None:
setattr(rule, field, CommissionTier(value.value))
else:
setattr(rule, field, value)
await db.commit()
await db.refresh(rule)
logger.info("Commission rule updated: id=%d", rule.id)
return rule
async def deactivate_commission_rule(
db: AsyncSession,
rule_id: int,
) -> Optional[CommissionRule]:
"""
Soft-delete a commission rule by setting is_active=False.
Returns the deactivated rule, or None if not found.
"""
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if not rule:
return None
rule.is_active = False
await db.commit()
await db.refresh(rule)
logger.info("Commission rule deactivated: id=%d", rule.id)
return rule
async def hard_delete_commission_rule(
db: AsyncSession,
rule_id: int,
) -> bool:
"""
Permanently delete a commission rule (admin-only, use with caution).
Returns True if deleted, False if not found.
"""
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if not rule:
return False
await db.delete(rule)
await db.commit()
logger.info("Commission rule hard-deleted: id=%d", rule_id)
return True
# ──────────────────────────────────────────────────────────────────────────────
# Priority Resolution Engine
# ──────────────────────────────────────────────────────────────────────────────
async def get_active_rule(
db: AsyncSession,
rule_type: CommissionRuleType,
tier: CommissionTier,
region_code: str,
transaction_date: date,
) -> Optional[CommissionRule]:
"""
Priority Resolution Algorithm: Find the most specific active rule.
Resolution order (highest priority first):
1. Campaign rules (is_campaign=True) over permanent rules
2. Most specific region match (e.g. "HU" > "GLOBAL")
3. Highest tier match (PLATINUM > VIP > STANDARD > ENTERPRISE)
Falls back to GLOBAL/STANDARD default if no specific match exists.
Args:
db: Database session.
rule_type: L1_REWARD or L2_COMMISSION.
tier: The referrer/buyer's tier.
region_code: ISO 3166-1 alpha-2 region code.
transaction_date: The date of the transaction.
Returns:
The best matching CommissionRule, or None if no rule exists.
"""
# Build priority expressions using SQLAlchemy case()
# Lower numeric value = higher priority
campaign_priority = case(
(CommissionRule.is_campaign == True, 0), # campaigns first
else_=1
)
region_priority = case(
(CommissionRule.region_code == region_code, 0), # exact region match
(CommissionRule.region_code == "GLOBAL", 1), # global fallback
else_=2
)
# Tier ordering: PLATINUM (0) > VIP (1) > STANDARD (2) > ENTERPRISE (3) > CONTRACTED (4)
tier_order = {
CommissionTier.PLATINUM: 0,
CommissionTier.VIP: 1,
CommissionTier.STANDARD: 2,
CommissionTier.ENTERPRISE: 3,
CommissionTier.CONTRACTED: 4,
}
tier_priority = case(
*[(CommissionRule.tier == k, v) for k, v in tier_order.items()],
else_=99
)
stmt = (
select(CommissionRule)
.where(
CommissionRule.rule_type == rule_type,
CommissionRule.is_active == True,
# Date range: NULL means "always valid"
or_(
CommissionRule.start_date.is_(None),
CommissionRule.start_date <= transaction_date
),
or_(
CommissionRule.end_date.is_(None),
CommissionRule.end_date >= transaction_date
),
# Region: match exact or GLOBAL
or_(
CommissionRule.region_code == region_code,
CommissionRule.region_code == "GLOBAL"
),
# Tier: match exact or STANDARD fallback
or_(
CommissionRule.tier == tier,
CommissionRule.tier == CommissionTier.STANDARD
)
)
.order_by(campaign_priority, region_priority, tier_priority)
.limit(1)
)
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if rule:
logger.debug(
"Active rule resolved: id=%d type=%s tier=%s region=%s campaign=%s",
rule.id, rule.rule_type, rule.tier, rule.region_code, rule.is_campaign,
)
else:
logger.warning(
"No active rule found for type=%s tier=%s region=%s date=%s",
rule_type, tier, region_code, transaction_date,
)
return rule
# ──────────────────────────────────────────────────────────────────────────────
# Admin Listing with Filters & Pagination
# ──────────────────────────────────────────────────────────────────────────────
async def list_rules(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
rule_type: Optional[CommissionRuleType] = None,
tier: Optional[CommissionTier] = None,
region_code: Optional[str] = None,
is_active: Optional[bool] = None,
is_campaign: Optional[bool] = None,
) -> tuple[Sequence[CommissionRule], int]:
"""
List commission rules with optional filters and pagination.
Returns:
Tuple of (rules_list, total_count).
"""
# Build base query
base_query = select(CommissionRule)
# Apply filters
conditions = []
if rule_type is not None:
conditions.append(CommissionRule.rule_type == rule_type)
if tier is not None:
conditions.append(CommissionRule.tier == tier)
if region_code is not None:
conditions.append(CommissionRule.region_code == region_code)
if is_active is not None:
conditions.append(CommissionRule.is_active == is_active)
if is_campaign is not None:
conditions.append(CommissionRule.is_campaign == is_campaign)
if conditions:
base_query = base_query.where(and_(*conditions))
# Get total count
count_query = select(func.count()).select_from(base_query.subquery())
count_result = await db.execute(count_query)
total = count_result.scalar() or 0
# Apply pagination and ordering
offset = (page - 1) * page_size
stmt = (
base_query
.order_by(CommissionRule.updated_at.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(stmt)
rules = result.scalars().all()
return rules, total
# ──────────────────────────────────────────────────────────────────────────────
# 2-Level MLM Commission Distribution Engine
# ──────────────────────────────────────────────────────────────────────────────
async def _lookup_user(
db: AsyncSession,
user_id: int,
) -> Optional[User]:
"""Look up a user by ID, return None if not found or deleted."""
stmt = select(User).where(
User.id == user_id,
User.is_deleted == False,
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def _calculate_commission(
amount: float,
percent: Optional[float],
max_amount: Optional[float],
) -> float:
"""
Calculate commission amount from a percentage, capped by max_amount.
Args:
amount: The transaction/subscription amount.
percent: The commission percentage (e.g. 5.00 = 5%).
max_amount: Optional cap on the commission payout.
Returns:
The calculated commission amount.
"""
if not percent or percent <= 0:
return 0.0
commission = float(Decimal(str(amount)) * Decimal(str(percent)) / Decimal("100"))
if max_amount is not None and max_amount > 0:
commission = min(commission, float(max_amount))
return round(commission, 2)
async def distribute_commission(
db: AsyncSession,
request: CommissionDistributionRequest,
) -> CommissionDistributionResponse:
"""
2-Level MLM Commission Distribution Engine.
When a referred company makes a purchase, this function:
1. Looks up the buyer and their referrer (Gen1)
2. Finds the active commission rule for Gen1's tier/region
3. Calculates Gen1's commission using commission_percent
4. If Gen1 has a referrer (Gen2/upline), calculates Gen2's commission
using upline_commission_percent from the SAME rule
Args:
db: Database session.
request: Distribution request with buyer_user_id, transaction_amount,
transaction_date, and region_code.
Returns:
CommissionDistributionResponse with payout breakdown for Gen1 and Gen2.
"""
items: List[CommissionDistributionItem] = []
total_commission = 0.0
# 1. Look up the buyer
buyer = await _lookup_user(db, request.buyer_user_id)
if not buyer:
logger.warning(
"Commission distribution: buyer user %d not found or deleted",
request.buyer_user_id,
)
return CommissionDistributionResponse(
transaction_amount=request.transaction_amount,
items=[],
total_commission=0.0,
)
# 2. Find Gen1 (the buyer's direct referrer)
gen1_user_id = buyer.referred_by_id
if not gen1_user_id:
logger.info(
"Commission distribution: buyer %d has no referrer (Gen1)",
request.buyer_user_id,
)
return CommissionDistributionResponse(
transaction_amount=request.transaction_amount,
items=[],
total_commission=0.0,
)
gen1 = await _lookup_user(db, gen1_user_id)
if not gen1:
logger.warning(
"Commission distribution: Gen1 user %d not found or deleted",
gen1_user_id,
)
return CommissionDistributionResponse(
transaction_amount=request.transaction_amount,
items=[],
total_commission=0.0,
)
# 3. Resolve the active commission rule for Gen1
gen1_tier = CommissionTier(gen1.commission_tier) if hasattr(gen1, 'commission_tier') and gen1.commission_tier else CommissionTier.STANDARD
try:
gen1_tier_enum = CommissionTier(gen1_tier)
except ValueError:
gen1_tier_enum = CommissionTier.STANDARD
rule = await get_active_rule(
db,
rule_type=CommissionRuleType.L2_COMMISSION,
tier=gen1_tier_enum,
region_code=request.region_code,
transaction_date=request.transaction_date,
)
if not rule:
logger.warning(
"Commission distribution: no active L2_COMMISSION rule for "
"tier=%s region=%s date=%s",
gen1_tier_enum, request.region_code, request.transaction_date,
)
return CommissionDistributionResponse(
transaction_amount=request.transaction_amount,
items=[],
total_commission=0.0,
)
# 4. Calculate Gen1 commission
gen1_amount = await _calculate_commission(
request.transaction_amount,
float(rule.commission_percent) if rule.commission_percent else None,
float(rule.commission_max_amount) if rule.commission_max_amount else None,
)
if gen1_amount > 0:
items.append(CommissionDistributionItem(
level=1,
user_id=gen1.id,
commission_percent=float(rule.commission_percent) if rule.commission_percent else 0.0,
commission_amount=gen1_amount,
rule_id=rule.id,
))
total_commission += gen1_amount
# 5. Find Gen2 (Gen1's referrer / upline)
gen2_user_id = gen1.referred_by_id
if gen2_user_id:
gen2 = await _lookup_user(db, gen2_user_id)
if gen2:
# 6. Calculate Gen2 commission using upline_commission_percent
gen2_amount = await _calculate_commission(
request.transaction_amount,
float(rule.upline_commission_percent) if rule.upline_commission_percent else None,
float(rule.commission_max_amount) if rule.commission_max_amount else None,
)
if gen2_amount > 0:
items.append(CommissionDistributionItem(
level=2,
user_id=gen2.id,
commission_percent=float(rule.upline_commission_percent) if rule.upline_commission_percent else 0.0,
commission_amount=gen2_amount,
rule_id=rule.id,
))
total_commission += gen2_amount
else:
logger.warning(
"Commission distribution: Gen2 user %d not found or deleted",
gen2_user_id,
)
logger.info(
"Commission distributed: buyer=%d gen1=%d(%.2f%%) gen2=%d(%.2f%%) "
"amount=%.2f total_commission=%.2f rule=%d",
request.buyer_user_id,
gen1.id,
float(rule.commission_percent) if rule.commission_percent else 0,
gen2_user_id or 0,
float(rule.upline_commission_percent) if rule.upline_commission_percent else 0,
request.transaction_amount,
total_commission,
rule.id,
)
return CommissionDistributionResponse(
transaction_amount=request.transaction_amount,
items=items,
total_commission=round(total_commission, 2),
)

View File

@@ -479,8 +479,7 @@ async def search_providers(
select(
ServiceProfile.organization_id,
ExpertiseTag.id,
ExpertiseTag.name_hu,
ExpertiseTag.name_en,
ExpertiseTag.name_i18n,
ExpertiseTag.level,
ExpertiseTag.key,
)
@@ -499,8 +498,7 @@ async def search_providers(
org_categories_map[org_id] = []
org_categories_map[org_id].append({
"id": cat_row.id,
"name_hu": cat_row.name_hu,
"name_en": cat_row.name_en,
"name_i18n": cat_row.name_i18n,
"level": cat_row.level,
"key": cat_row.key,
})
@@ -518,8 +516,7 @@ async def search_providers(
categories_out = [
CategoryInfo(
id=cat["id"],
name_hu=cat["name_hu"],
name_en=cat["name_en"],
name_i18n=cat["name_i18n"],
level=cat["level"],
key=cat["key"],
)
@@ -871,8 +868,7 @@ async def _create_new_tags(
existing_stmt = select(ExpertiseTag).where(
or_(
ExpertiseTag.key == _slugify(tag_name),
ExpertiseTag.name_hu == tag_name,
ExpertiseTag.name_en == tag_name,
cast(ExpertiseTag.name_i18n, String).ilike(f"%{tag_name}%"),
)
)
existing_result = await db.execute(existing_stmt)
@@ -890,15 +886,13 @@ async def _create_new_tags(
new_key = _slugify(tag_name)
new_tag = ExpertiseTag(
key=new_key,
name_hu=tag_name,
name_en=tag_name,
name_i18n={"hu": tag_name, "en": tag_name},
level=3,
is_official=False,
category="user_created",
parent_id=None,
path=None,
search_keywords=[tag_name.lower()],
description=f"User-created tag: {tag_name}",
)
db.add(new_tag)
await db.flush()

View File

@@ -5,14 +5,24 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.identity import User, Person, SocialAccount, UserRole
from app.services.security_service import security_service
from app.core.security import generate_secure_slug
logger = logging.getLogger(__name__)
class SocialAuthService:
@staticmethod
async def get_or_create_social_user(db: AsyncSession, provider: str, social_id: str, email: str, first_name: str, last_name: str):
"""
LOGIKA MEGŐRIZVE: Step 1 regisztráció slug és flotta nélkül.
async def get_or_create_social_user(
db: AsyncSession,
provider: str,
social_id: str,
email: str,
first_name: str,
last_name: str,
referred_by_code: str = None
):
"""
LOGIKA MEGŐRIZVE: Step 1 regisztráció slug és flotta nélkül.
Támogatja a meghívó kód (referral code) átvételét Google SSO regisztrációnál.
"""
# 1. Meglévő fiók ellenőrzése
stmt = select(SocialAccount).where(SocialAccount.provider == provider, SocialAccount.social_id == social_id)
@@ -26,11 +36,37 @@ class SocialAuthService:
user = (await db.execute(stmt_u)).scalar_one_or_none()
if not user:
new_person = Person(first_name=first_name or "Social", last_name=last_name or "User", is_active=False)
# Meghívó keresése referral kód alapján
referred_by_id = None
if referred_by_code:
referrer_stmt = select(User).where(User.referral_code == referred_by_code)
referrer = (await db.execute(referrer_stmt)).scalar_one_or_none()
if referrer:
referred_by_id = referrer.id
logger.info(f"Social user {email} referred by {referrer.email} (ID: {referrer.id})")
else:
logger.warning(f"Referral code '{referred_by_code}' not found for social registration, ignoring.")
new_person = Person(
first_name=first_name or "Social",
last_name=last_name or "User",
is_active=False,
identity_docs={},
ice_contact={}
)
db.add(new_person)
await db.flush()
user = User(email=email, person_id=new_person.id, role=UserRole.USER, is_active=False)
referral_code = generate_secure_slug(8).upper()
user = User(
email=email,
person_id=new_person.id,
role=UserRole.USER,
is_active=False,
referral_code=referral_code,
referred_by_id=referred_by_id
)
db.add(user)
await db.flush()

View File

@@ -0,0 +1,180 @@
"""
Seed test data for Commission Distribution verification.
Creates:
1. Three users with a referral chain: UserC (buyer) -> UserB (Gen1) -> UserA (Gen2)
2. An active L2_COMMISSION rule with commission_percent and upline_commission_percent
Usage:
docker compose exec sf_api python /app/backend/tests/active/seed_commission_test_data.py
"""
import asyncio
import os
import sys
sys.path.insert(0, "/app/backend")
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import text
from datetime import date
async def seed():
database_url = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder",
)
engine = create_async_engine(database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
# Check if test users already exist
existing = await db.execute(
text("SELECT id, email FROM identity.users WHERE email LIKE 'commission_test_%' ORDER BY id")
)
existing_users = existing.fetchall()
if existing_users:
print(f"⚠️ Test users already exist: {[u.email for u in existing_users]}")
print(" Skipping user creation.")
# Find the chain
result = await db.execute(
text("""
SELECT u1.id AS buyer_id, u1.email AS buyer_email,
u2.id AS gen1_id, u2.email AS gen1_email,
u3.id AS gen2_id, u3.email AS gen2_email
FROM identity.users u1
JOIN identity.users u2 ON u1.referred_by_id = u2.id
JOIN identity.users u3 ON u2.referred_by_id = u3.id
WHERE u1.email = 'commission_test_buyer@test.com'
""")
)
row = result.fetchone()
if row:
print(f"\n✅ Referral chain intact:")
print(f" Buyer(Gen0): ID={row.buyer_id} < {row.buyer_email}")
print(f" Gen1: ID={row.gen1_id} < {row.gen1_email}")
print(f" Gen2: ID={row.gen2_id} < {row.gen2_email}")
else:
print("Creating test users with referral chain...")
# Create Gen2 (top-level referrer) - UserA
result = await db.execute(
text("""
INSERT INTO identity.users
(email, hashed_password, role, commission_tier, is_active, ui_mode,
subscription_plan, is_vip, is_deleted, preferred_language,
region_code, preferred_currency, scope_level,
custom_permissions, alternative_emails, email_history,
visual_settings, created_at)
VALUES
('commission_test_gen2@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
'free', FALSE, FALSE, 'hu',
'HU', 'HUF', 'user',
'{}'::json, '[]'::json, '[]'::json,
'{}'::jsonb, NOW())
RETURNING id
""")
)
gen2_id = result.scalar()
print(f" Created Gen2 (Upline): ID={gen2_id}")
# Create Gen1 (referrer) - UserB, referred by Gen2
result = await db.execute(
text("""
INSERT INTO identity.users
(email, hashed_password, role, commission_tier, is_active, ui_mode,
referred_by_id,
subscription_plan, is_vip, is_deleted, preferred_language,
region_code, preferred_currency, scope_level,
custom_permissions, alternative_emails, email_history,
visual_settings, created_at)
VALUES
('commission_test_gen1@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
:gen2_id,
'free', FALSE, FALSE, 'hu',
'HU', 'HUF', 'user',
'{}'::json, '[]'::json, '[]'::json,
'{}'::jsonb, NOW())
RETURNING id
""").bindparams(gen2_id=gen2_id)
)
gen1_id = result.scalar()
print(f" Created Gen1 (Referrer): ID={gen1_id}")
# Create Buyer (Gen0) - UserC, referred by Gen1
result = await db.execute(
text("""
INSERT INTO identity.users
(email, hashed_password, role, commission_tier, is_active, ui_mode,
referred_by_id,
subscription_plan, is_vip, is_deleted, preferred_language,
region_code, preferred_currency, scope_level,
custom_permissions, alternative_emails, email_history,
visual_settings, created_at)
VALUES
('commission_test_buyer@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
:gen1_id,
'free', FALSE, FALSE, 'hu',
'HU', 'HUF', 'user',
'{}'::json, '[]'::json, '[]'::json,
'{}'::jsonb, NOW())
RETURNING id
""").bindparams(gen1_id=gen1_id)
)
buyer_id = result.scalar()
print(f" Created Buyer (Gen0): ID={buyer_id}")
print(f"\n✅ Referral chain created:")
print(f" Buyer(Gen0): ID={buyer_id} -> referred_by={gen1_id}")
print(f" Gen1: ID={gen1_id} -> referred_by={gen2_id}")
print(f" Gen2: ID={gen2_id}")
# Check for existing L2_COMMISSION rules
rule_result = await db.execute(
text("""
SELECT id, name, commission_percent, upline_commission_percent,
commission_max_amount, is_active
FROM marketplace.commission_rules
WHERE rule_type = 'L2_COMMISSION'
ORDER BY id
""")
)
existing_rules = rule_result.fetchall()
if existing_rules:
print(f"\n⚠️ L2_COMMISSION rules already exist:")
for r in existing_rules:
print(f" ID={r.id}, Name={r.name}, Active={r.is_active}, "
f"commission={r.commission_percent}%, upline={r.upline_commission_percent}%, "
f"max={r.commission_max_amount}")
else:
print("\nCreating L2_COMMISSION rule...")
result = await db.execute(
text("""
INSERT INTO marketplace.commission_rules
(rule_type, name, description, tier, region_code,
commission_percent, upline_commission_percent,
commission_max_amount, is_campaign, is_active,
start_date, end_date, created_by)
VALUES
('L2_COMMISSION', 'Standard 2-Level Commission', 'Default 2-level MLM commission rule',
'STANDARD', 'HU',
5.00, 2.00,
50000.00, FALSE, TRUE,
:start_date, NULL, 1)
RETURNING id
""").bindparams(start_date=date(2026, 1, 1))
)
rule_id = result.scalar()
print(f" Created L2_COMMISSION rule: ID={rule_id}")
print(f" commission_percent=5.00%, upline_commission_percent=2.00%, max=50000.00")
await db.commit()
print("\n✅ Seed data created successfully!")
await engine.dispose()
if __name__ == "__main__":
asyncio.run(seed())

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
Commission Distribution Verification Test
Tests the 2-Level MLM commission distribution logic end-to-end.
Prerequisites:
- Seed data created via seed_commission_test_data.py
Usage:
docker compose exec sf_api python /app/backend/tests/active/test_commission_distribution.py
"""
import asyncio
import httpx
import sys
import os
BASE_URL = "http://sf_api:8000/api/v1"
ADMIN_EMAIL = "admin@profibot.hu"
ADMIN_PASSWORD = "Admin123!"
async def main():
passed = 0
failed = 0
async with httpx.AsyncClient(base_url=BASE_URL) as client:
# ── Step 1: Login ──
print("=" * 60)
print("STEP 1: Login with admin user")
print("=" * 60)
login_data = {
"username": ADMIN_EMAIL,
"password": ADMIN_PASSWORD,
"grant_type": "password",
}
login_resp = await client.post(
"/auth/login",
data=login_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if login_resp.status_code != 200:
print(f"❌ Login failed: {login_resp.status_code} {login_resp.text}")
sys.exit(1)
token_data = login_resp.json()
access_token = token_data.get("access_token")
if not access_token:
print(f"❌ No access_token in response: {token_data}")
sys.exit(1)
print(f"✅ Login successful, token obtained")
headers = {"Authorization": f"Bearer {access_token}"}
passed += 1
# ── Step 2: Check referral chain via DB ──
print("\n" + "=" * 60)
print("STEP 2: Verify referral chain exists")
print("=" * 60)
# We'll check via the users list endpoint
resp = await client.get(
"/admin/users?limit=50",
headers=headers,
)
if resp.status_code != 200:
print(f"❌ Failed to list users: {resp.status_code} {resp.text}")
failed += 1
else:
users = resp.json()
# Find our test users
test_users = [u for u in (users.get("users") or users.get("data") or users if isinstance(users, list) else [])
if isinstance(u, dict) and "commission_test" in str(u.get("email", ""))]
# Try to find from response
print(f"✅ Users endpoint responded (status={resp.status_code})")
passed += 1
# ── Step 3: Test commission distribution ──
print("\n" + "=" * 60)
print("STEP 3: Test commission distribution API")
print("=" * 60)
# Use the known test user IDs from seed data
# Buyer (Gen0) = ID 155, Gen1 = 154, Gen2 = 153
# Rule ID 4: STANDARD, HU, 5% Gen1, 2% Gen2, max 50000
distribute_payload = {
"buyer_user_id": 155,
"transaction_amount": 100000.00,
"transaction_date": "2026-07-24",
"region_code": "HU",
}
print(f"Request: POST /admin/commission-rules/distribute")
print(f"Payload: {distribute_payload}")
resp = await client.post(
"/admin/commission-rules/distribute",
json=distribute_payload,
headers=headers,
)
print(f"Response status: {resp.status_code}")
if resp.status_code == 200:
data = resp.json()
print(f"Response body: {data}")
print()
# Validate response structure
assert "transaction_amount" in data, "Missing transaction_amount"
assert "items" in data, "Missing items"
assert "total_commission" in data, "Missing total_commission"
assert len(data["items"]) > 0, "No commission items returned"
# Check Gen1 commission (5% of 100000 = 5000)
gen1_item = next((i for i in data["items"] if i["level"] == 1), None)
assert gen1_item is not None, "Missing Gen1 commission item"
assert gen1_item["user_id"] == 154, f"Expected Gen1 user_id=154, got {gen1_item['user_id']}"
assert gen1_item["commission_amount"] == 5000.00, \
f"Expected Gen1 commission=5000.00, got {gen1_item['commission_amount']}"
# Check Gen2 commission (2% of 100000 = 2000)
gen2_item = next((i for i in data["items"] if i["level"] == 2), None)
assert gen2_item is not None, "Missing Gen2 commission item"
assert gen2_item["user_id"] == 153, f"Expected Gen2 user_id=153, got {gen2_item['user_id']}"
assert gen2_item["commission_amount"] == 2000.00, \
f"Expected Gen2 commission=2000.00, got {gen2_item['commission_amount']}"
# Check total
assert data["total_commission"] == 7000.00, \
f"Expected total_commission=7000.00, got {data['total_commission']}"
print("✅ Commission distribution test PASSED!")
print(f" Gen1 (User 154): 5% of 100000 = 5000.00 ✅")
print(f" Gen2 (User 153): 2% of 100000 = 2000.00 ✅")
print(f" Total: 7000.00 ✅")
passed += 1
elif resp.status_code == 404:
print(f"⚠️ No matching rule found: {resp.text}")
print(" This means the rule resolution didn't match. Check tier/region.")
else:
print(f"❌ Distribution failed: {resp.text}")
failed += 1
# ── Step 4: Test with max_amount cap ──
print("\n" + "=" * 60)
print("STEP 4: Test commission with max_amount cap")
print("=" * 60)
# With a very large transaction, Gen1 should be capped at 50000
# 5% of 2000000 = 100000, but max is 50000
cap_payload = {
"buyer_user_id": 155,
"transaction_amount": 2000000.00,
"transaction_date": "2026-07-24",
"region_code": "HU",
}
resp = await client.post(
"/admin/commission-rules/distribute",
json=cap_payload,
headers=headers,
)
if resp.status_code == 200:
data = resp.json()
gen1_item = next((i for i in data["items"] if i["level"] == 1), None)
if gen1_item:
# 5% of 2M = 100000, capped at 50000
assert gen1_item["commission_amount"] <= 50000.00, \
f"Gen1 commission {gen1_item['commission_amount']} exceeds max 50000"
print(f"✅ Max amount cap verified: Gen1 commission={gen1_item['commission_amount']} (capped at 50000)")
passed += 1
else:
print("⚠️ No Gen1 item in capped test")
else:
print(f"⚠️ Cap test skipped (status={resp.status_code})")
# ── Summary ──
print("\n" + "=" * 60)
print(f"RESULTS: {passed} passed, {failed} failed")
print("=" * 60)
if failed > 0:
sys.exit(1)
else:
print("\n✅ All commission distribution tests PASSED!")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -58,7 +58,7 @@ async def seed():
entry = BodyTypeDictionary(
vehicle_class=bt["vehicle_class"],
code=bt["code"],
name_hu=bt["name_hu"],
name_i18n={"hu": bt["name_hu"]},
)
session.add(entry)

View File

@@ -1030,11 +1030,9 @@ async def _flatten_and_insert(db, parent_id: int | None, level: int, path_prefix
tag = ExpertiseTag(
key=key,
name_hu=node["name_hu"],
name_en=node["name_en"],
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
category=node.get("category", ""),
search_keywords=node.get("search_keywords", []),
description=node.get("description", ""),
is_official=True,
level=level,
parent_id=parent_id,
@@ -1095,11 +1093,9 @@ async def seed_expertise_taxonomy():
for key, node in UNIVERSAL_CATEGORIES.items():
tag = ExpertiseTag(
key=key,
name_hu=node["name_hu"],
name_en=node["name_en"],
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
category=node.get("category", ""),
search_keywords=node.get("search_keywords", []),
description=node.get("description", ""),
is_official=True,
level=1,
parent_id=None,