admin_nyelvi_javítas

This commit is contained in:
Roo
2026-07-25 10:22:03 +00:00
parent 33c4d793db
commit c9118bf52f
29 changed files with 4474 additions and 943 deletions

View File

@@ -13,13 +13,17 @@ THOUGHT PROCESS:
- 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.
- Phase 3: Conflict Detection — before creating/updating a rule, the service
checks for overlapping active rules with the same tier/region/is_campaign
combination. If a conflict exists and force_override is False, a 409 is
returned. If force_override is True, the conflicting rule is deactivated.
"""
import logging
from datetime import date
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional, List, Sequence
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete, not_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -28,18 +32,69 @@ from app.models.marketplace.commission import (
CommissionRuleType,
CommissionTier,
)
from app.models.identity.identity import User
from app.models.identity.identity import User, Wallet
from app.models.system.audit import (
FinancialLedger,
LedgerEntryType,
WalletType,
LedgerStatus,
)
from app.schemas.commission import (
CommissionRuleCreate,
CommissionRuleUpdate,
CommissionDistributionRequest,
CommissionDistributionItem,
CommissionDistributionResponse,
ConflictingRuleInfo,
)
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────────────
# Conflict Detection
# ──────────────────────────────────────────────────────────────────────────────
async def check_rule_conflict(
db: AsyncSession,
rule_type: CommissionRuleType,
tier: CommissionTier,
region_code: str,
is_campaign: bool,
exclude_rule_id: Optional[int] = None,
) -> Optional[CommissionRule]:
"""
Check if an active rule already exists with the exact same combination
of tier, region_code, and is_campaign.
Args:
db: Database session.
rule_type: L1_REWARD or L2_COMMISSION.
tier: The tier to check.
region_code: The region code to check.
is_campaign: Whether the rule is a campaign rule.
exclude_rule_id: Optional rule ID to exclude from the check
(used when updating an existing rule).
Returns:
The conflicting CommissionRule if found, None otherwise.
"""
conditions = [
CommissionRule.rule_type == rule_type,
CommissionRule.tier == tier,
CommissionRule.region_code == region_code,
CommissionRule.is_campaign == is_campaign,
CommissionRule.is_active == True,
]
if exclude_rule_id is not None:
conditions.append(CommissionRule.id != exclude_rule_id)
stmt = select(CommissionRule).where(and_(*conditions)).limit(1)
result = await db.execute(stmt)
return result.scalar_one_or_none()
# ──────────────────────────────────────────────────────────────────────────────
# CRUD Operations
# ──────────────────────────────────────────────────────────────────────────────
@@ -51,7 +106,14 @@ async def create_commission_rule(
admin_user_id: int,
) -> CommissionRule:
"""
Create a new commission rule with validation.
Create a new commission rule with validation and conflict detection.
If an active rule with the same tier/region/is_campaign combination exists
and force_override is False, the conflicting rule info is returned instead
(caller should raise 409 Conflict).
If force_override is True, the conflicting rule is deactivated (is_active=False)
and the new rule is saved.
Args:
db: Database session.
@@ -59,8 +121,39 @@ async def create_commission_rule(
admin_user_id: ID of the admin creating the rule.
Returns:
The newly created CommissionRule instance.
The newly created CommissionRule instance, or the conflicting rule
if a conflict was detected and force_override was False.
"""
# ── Phase 3: Conflict Detection ──────────────────────────────────────
conflicting = await check_rule_conflict(
db,
rule_type=CommissionRuleType(data.rule_type.value),
tier=CommissionTier(data.tier.value),
region_code=data.region_code,
is_campaign=data.is_campaign,
)
if conflicting:
if not data.force_override:
logger.warning(
"Conflict detected: existing active rule id=%d conflicts with "
"new rule (type=%s tier=%s region=%s campaign=%s). "
"force_override=False, returning conflict.",
conflicting.id, data.rule_type, data.tier,
data.region_code, data.is_campaign,
)
return conflicting # Caller must raise 409
# Admin override: deactivate the conflicting rule
logger.info(
"Admin override: Deactivating overlapping rule ID %d "
"(type=%s tier=%s region=%s campaign=%s) in favor of new rule.",
conflicting.id, conflicting.rule_type, conflicting.tier,
conflicting.region_code, conflicting.is_campaign,
)
conflicting.is_active = False
db.add(conflicting)
rule = CommissionRule(
rule_type=CommissionRuleType(data.rule_type.value),
tier=CommissionTier(data.tier.value),
@@ -71,6 +164,10 @@ async def create_commission_rule(
upline_commission_percent=data.upline_commission_percent,
renewal_commission_percent=data.renewal_commission_percent,
commission_max_amount=data.commission_max_amount,
# Phase 2: Szintenkénti plafonok és Gen2 megújítás
gen1_max_amount=data.gen1_max_amount,
gen2_max_amount=data.gen2_max_amount,
gen2_renewal_percent=data.gen2_renewal_percent,
is_campaign=data.is_campaign,
start_date=data.start_date,
end_date=data.end_date,
@@ -95,9 +192,15 @@ async def update_commission_rule(
data: CommissionRuleUpdate,
) -> Optional[CommissionRule]:
"""
Partially update an existing commission rule.
Partially update an existing commission rule with conflict detection.
Only the fields explicitly set in the update schema are applied.
If the update would create a conflict with another active rule and
force_override is False, the conflicting rule info is returned instead
(caller should raise 409 Conflict).
If force_override is True, the conflicting rule is deactivated.
Returns None if the rule does not exist.
"""
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
@@ -107,7 +210,64 @@ async def update_commission_rule(
return None
update_data = data.model_dump(exclude_unset=True)
# ── Phase 3: Conflict Detection on update ────────────────────────────
# Determine the effective values after the update
effective_rule_type = (
CommissionRuleType(data.rule_type.value)
if data.rule_type is not None
else rule.rule_type
)
effective_tier = (
CommissionTier(data.tier.value)
if data.tier is not None
else rule.tier
)
effective_region = (
data.region_code
if data.region_code is not None
else rule.region_code
)
effective_campaign = (
data.is_campaign
if data.is_campaign is not None
else rule.is_campaign
)
conflicting = await check_rule_conflict(
db,
rule_type=effective_rule_type,
tier=effective_tier,
region_code=effective_region,
is_campaign=effective_campaign,
exclude_rule_id=rule_id,
)
if conflicting:
if not data.force_override:
logger.warning(
"Conflict detected on update: existing active rule id=%d conflicts "
"with update to rule id=%d (type=%s tier=%s region=%s campaign=%s). "
"force_override=False, returning conflict.",
conflicting.id, rule_id, effective_rule_type,
effective_tier, effective_region, effective_campaign,
)
return conflicting # Caller must raise 409
# Admin override: deactivate the conflicting rule
logger.info(
"Admin override on update: Deactivating overlapping rule ID %d "
"(type=%s tier=%s region=%s campaign=%s) in favor of rule ID %d.",
conflicting.id, conflicting.rule_type, conflicting.tier,
conflicting.region_code, conflicting.is_campaign, rule_id,
)
conflicting.is_active = False
db.add(conflicting)
for field, value in update_data.items():
# Skip force_override — it's not a model field
if field == "force_override":
continue
# Map enum fields from schema enums to model enums
if field == "rule_type" and value is not None:
setattr(rule, field, CommissionRuleType(value.value))
@@ -374,24 +534,132 @@ async def _calculate_commission(
return round(commission, 2)
# ──────────────────────────────────────────────────────────────────────────────
# Phase 2: Wallet Integration Helpers
# ──────────────────────────────────────────────────────────────────────────────
async def _credit_commission_to_wallet(
db: AsyncSession,
user_id: int,
amount: float,
transaction_type: str,
details: Optional[dict] = None,
) -> None:
"""
Credit commission amount to the user's Wallet.earned_credits and record
a FinancialLedger CREDIT entry.
Follows the proven pattern from GamificationService._add_earned_credits().
Args:
db: Database session.
user_id: The user receiving the commission.
amount: The commission amount to credit.
transaction_type: Label for the ledger entry (e.g. 'COMMISSION_GEN1').
details: Optional JSON metadata for the ledger entry.
"""
stmt = select(Wallet).where(Wallet.user_id == user_id)
wallet = (await db.execute(stmt)).scalar_one_or_none()
if not wallet:
logger.error(
"Cannot credit commission: Wallet not found for user_id=%d",
user_id,
)
return
decimal_amount = Decimal(str(amount))
wallet.earned_credits += decimal_amount
ledger_entry = FinancialLedger(
user_id=user_id,
amount=amount,
entry_type=LedgerEntryType.CREDIT,
wallet_type=WalletType.EARNED,
transaction_type=transaction_type,
status=LedgerStatus.COMPLETED,
details=details or {},
)
db.add(ledger_entry)
logger.info(
"Commission credited: user_id=%d amount=%.2f type=%s wallet_balance=%s",
user_id, amount, transaction_type, wallet.earned_credits,
)
async def _is_user_recently_active(
db: AsyncSession,
user_id: int,
inactivity_days: int = 180,
) -> bool:
"""
Check if a user has been active within the last `inactivity_days` days.
A user is considered active if:
1. Their subscription is active (subscription_expires_at > now), OR
2. They have a FinancialLedger entry within the inactivity window.
Args:
db: Database session.
user_id: The user to check.
inactivity_days: Number of days of inactivity before considered inactive.
Returns:
True if the user is recently active, False otherwise.
"""
# Check subscription status first
user = await _lookup_user(db, user_id)
if not user:
return False
# If user has an active subscription, they are active
if user.subscription_expires_at and user.subscription_expires_at > datetime.utcnow():
return True
# Check if there's any FinancialLedger activity within the inactivity window
cutoff_date = datetime.utcnow() - timedelta(days=inactivity_days)
stmt = select(FinancialLedger).where(
FinancialLedger.user_id == user_id,
FinancialLedger.created_at >= cutoff_date,
).limit(1)
result = await db.execute(stmt)
recent_activity = result.scalar_one_or_none()
return recent_activity is not None
# ──────────────────────────────────────────────────────────────────────────────
# Phase 2: Enhanced 2-Level MLM Commission Distribution Engine
# ──────────────────────────────────────────────────────────────────────────────
async def distribute_commission(
db: AsyncSession,
request: CommissionDistributionRequest,
) -> CommissionDistributionResponse:
"""
2-Level MLM Commission Distribution Engine.
2-Level MLM Commission Distribution Engine (Phase 2 Enhanced).
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
- Uses gen1_max_amount as cap (fallback: commission_max_amount)
4. If Gen1 has a referrer (Gen2/upline), checks Gen1's activity status:
- If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
- If Gen1 is active, calculates Gen2's commission
5. For renewals (is_renewal=True):
- Gen1 uses commission_percent (same as first-time)
- Gen2 uses gen2_renewal_percent (fallback: upline_commission_percent)
- Caps use gen2_max_amount (fallback: commission_max_amount)
6. Credits commissions to Wallet.earned_credits + FinancialLedger
Args:
db: Database session.
request: Distribution request with buyer_user_id, transaction_amount,
transaction_date, and region_code.
transaction_date, region_code, and is_renewal.
Returns:
CommissionDistributionResponse with payout breakdown for Gen1 and Gen2.
@@ -464,11 +732,44 @@ async def distribute_commission(
total_commission=0.0,
)
# ── Phase 2: Determine per-level caps ────────────────────────────────
# gen1_max_amount / gen2_max_amount override commission_max_amount
# NULL or 0 means unlimited (no cap)
gen1_cap = (
float(rule.gen1_max_amount)
if rule.gen1_max_amount is not None and rule.gen1_max_amount > 0
else (
float(rule.commission_max_amount)
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
else None
)
)
gen2_cap = (
float(rule.gen2_max_amount)
if rule.gen2_max_amount is not None and rule.gen2_max_amount > 0
else (
float(rule.commission_max_amount)
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
else None
)
)
# ── Phase 2: Determine Gen2 percent (renewal vs first-time) ──────────
gen2_percent = (
float(rule.gen2_renewal_percent)
if request.is_renewal and rule.gen2_renewal_percent is not None
else (
float(rule.upline_commission_percent)
if rule.upline_commission_percent is not None
else None
)
)
# 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,
gen1_cap,
)
if gen1_amount > 0:
@@ -481,44 +782,87 @@ async def distribute_commission(
))
total_commission += gen1_amount
# ── Phase 2: Credit Gen1 commission to Wallet ───────────────────
await _credit_commission_to_wallet(
db,
user_id=gen1.id,
amount=gen1_amount,
transaction_type="COMMISSION_GEN1",
details={
"rule_id": rule.id,
"buyer_user_id": request.buyer_user_id,
"transaction_amount": request.transaction_amount,
"is_renewal": request.is_renewal,
},
)
# 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,
)
# ── Phase 2: Inactive Gen1 prevention ───────────────────────
# If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
gen1_active = await _is_user_recently_active(db, gen1.id)
if not gen1_active:
logger.info(
"Commission distribution: Gen1 user %d is inactive "
"(>180 days). Skipping Gen2 payout.",
gen1.id,
)
else:
# 6. Calculate Gen2 commission
gen2_amount = await _calculate_commission(
request.transaction_amount,
gen2_percent,
gen2_cap,
)
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
if gen2_amount > 0:
items.append(CommissionDistributionItem(
level=2,
user_id=gen2.id,
commission_percent=gen2_percent or 0.0,
commission_amount=gen2_amount,
rule_id=rule.id,
))
total_commission += gen2_amount
# ── Phase 2: Credit Gen2 commission to Wallet ───────
await _credit_commission_to_wallet(
db,
user_id=gen2.id,
amount=gen2_amount,
transaction_type="COMMISSION_GEN2",
details={
"rule_id": rule.id,
"buyer_user_id": request.buyer_user_id,
"gen1_user_id": gen1.id,
"transaction_amount": request.transaction_amount,
"is_renewal": request.is_renewal,
},
)
else:
logger.warning(
"Commission distribution: Gen2 user %d not found or deleted",
gen2_user_id,
)
# ── Phase 2: Commit all wallet/ledger changes ────────────────────────
await db.commit()
logger.info(
"Commission distributed: buyer=%d gen1=%d(%.2f%%) gen2=%d(%.2f%%) "
"amount=%.2f total_commission=%.2f rule=%d",
"amount=%.2f total_commission=%.2f rule=%d is_renewal=%s",
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,
gen2_percent or 0,
request.transaction_amount,
total_commission,
rule.id,
request.is_renewal,
)
return CommissionDistributionResponse(