L1L2 gen1Gen2 kialakítása
This commit is contained in:
@@ -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"])
|
||||
273
backend/app/api/v1/endpoints/admin_commission.py
Normal file
273
backend/app/api/v1/endpoints/admin_commission.py
Normal 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
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
184
backend/app/models/marketplace/commission.py
Normal file
184
backend/app/models/marketplace/commission.py
Normal 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})>"
|
||||
)
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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"))
|
||||
138
backend/app/schemas/commission.py
Normal file
138
backend/app/schemas/commission.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
528
backend/app/services/commission_service.py
Normal file
528
backend/app/services/commission_service.py
Normal 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),
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user