admin_nyelvi_javítas
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user