L1L2 gen1Gen2 kialakítása

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

View File

@@ -0,0 +1,503 @@
# 🏗️ Architectural Plan: Tiered & Time-Bound Commission Rules
## 1. Executive Summary
This document outlines the architectural design for a **dynamic, multi-dimensional commission and reward rule engine**. The system must support:
- **Tiered rewards** (e.g., Standard, VIP, Platinum) with different XP/credit/commission values per tier
- **Regional overrides** (e.g., HU-specific commission vs. Global default)
- **Time-bound campaigns** (promotional periods with start/end dates)
- **Rule type differentiation** (L1 referral XP/credits for individuals, L2 commission % for company purchases)
The design follows the existing **modular multi-directory pattern** used throughout the Service Finder codebase.
---
## 2. Schema Design: `CommissionRule` Model
### 2.1 Placement Decision
After analyzing the existing model structure:
| Directory | Schema | Purpose | Fit? |
|-----------|--------|---------|------|
| `backend/app/models/marketplace/finance.py` | `finance` | Issuers, financial ledger | ❌ Too narrow (invoicing focus) |
| `backend/app/models/fleet_finance/models.py` | `fleet_finance` | Asset costs, insurance, tax | ❌ Vehicle-centric |
| `backend/app/models/gamification/gamification.py` | `gamification` | Points, levels, badges | ❌ User reward focus, no commission |
| `backend/app/models/system/system.py` | `system` | Parameters, notifications | ❌ Generic config, no domain rules |
| **`backend/app/models/marketplace/`** | **`marketplace`** | **Services, providers, orgs** | ✅ **Best fit — commission rules govern marketplace transactions** |
**Decision:** Create a new file [`backend/app/models/marketplace/commission.py`](backend/app/models/marketplace/commission.py) in the `marketplace` schema. This keeps commission rules alongside the service provider and organization models they govern.
### 2.2 The `CommissionRule` Model
```python
# 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
"""
import enum
import uuid
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"
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%)"
)
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})>"
)
```
### 2.3 Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| **Single table** for both L1 and L2 | Avoids join complexity; nullable fields for type-specific values |
| **`region_code` as simple string** | Follows existing pattern (see `InsuranceProvider.country_code`); avoids FK to a regions table for flexibility |
| **`start_date`/`end_date` as `Date`** | Campaigns are day-granular; timezone issues avoided by using dates |
| **`is_campaign` boolean** | Quick filtering for campaign vs. permanent rules without date parsing |
| **`UniqueConstraint` on 5 columns** | Prevents duplicate rules for the same type/tier/region/date combination |
| **`metadata_json` JSONB** | Future-proofing for UI-specific fields (colors, icons, tooltips) |
---
## 3. Query Engine: Finding the "Active" Rule at Transaction Time
### 3.1 Priority Resolution Algorithm
When a transaction occurs (e.g., a referral signup or a company purchase), the engine must find the **most specific active rule**:
```
1. Filter: rule_type = {L1_REWARD | L2_COMMISSION}
AND is_active = true
AND (start_date IS NULL OR start_date <= transaction_date)
AND (end_date IS NULL OR end_date >= transaction_date)
2. Sort by specificity (most specific first):
a. Campaign rules (is_campaign = true) over permanent rules
b. Most specific region match (HU > GLOBAL)
c. Highest tier match (PLATINUM > VIP > STANDARD)
3. Return the top match, or fall back to the GLOBAL/STANDARD default
```
### 3.2 SQLAlchemy Query Example
```python
from sqlalchemy import select, and_, or_, case
from datetime import date
async def get_active_commission_rule(
db: AsyncSession,
rule_type: CommissionRuleType,
tier: CommissionTier,
region_code: str,
transaction_date: date
) -> Optional[CommissionRule]:
"""
Megkeresi a leginkább releváns aktív jutalék szabályt.
"""
# Build priority: campaign > permanent, specific region > GLOBAL, higher tier first
priority = case(
(CommissionRule.is_campaign == True, 0), # campaigns first
else_=1
)
region_priority = case(
(CommissionRule.region_code == region_code, 0),
(CommissionRule.region_code == "GLOBAL", 1),
else_=2
)
tier_order = {
"PLATINUM": 0,
"VIP": 1,
"STANDARD": 2,
"ENTERPRISE": 3,
}
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,
or_(
CommissionRule.start_date.is_(None),
CommissionRule.start_date <= transaction_date
),
or_(
CommissionRule.end_date.is_(None),
CommissionRule.end_date >= transaction_date
),
or_(
CommissionRule.region_code == region_code,
CommissionRule.region_code == "GLOBAL"
),
or_(
CommissionRule.tier == tier,
CommissionRule.tier == CommissionTier.STANDARD
)
)
.order_by(priority, region_priority, tier_priority)
.limit(1)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
```
---
## 4. Modular Architecture Compliance
### 4.1 File Placement
```
backend/app/models/marketplace/
├── __init__.py # ← Add CommissionRule export
├── commission.py # ← NEW: CommissionRule model
├── finance.py # existing
├── logistics.py # existing
├── organization.py # existing
├── payment.py # existing
├── service.py # existing
├── service_request.py # existing
└── staged_data.py # existing
```
### 4.2 Registration in [`__init__.py`](backend/app/models/marketplace/__init__.py)
Add to the marketplace package exports:
```python
from .commission import CommissionRule, CommissionRuleType, CommissionTier
```
### 4.3 Registration in [`backend/app/models/__init__.py`](backend/app/models/__init__.py)
Add to the master model registry:
```python
# In the marketplace section:
from .marketplace.commission import CommissionRule, CommissionRuleType, CommissionTier
```
And add to `__all__`:
```python
"CommissionRule", "CommissionRuleType", "CommissionTier",
```
---
## 5. Admin API Outline (FastAPI)
### 5.1 Endpoint Structure
All endpoints under `/api/v1/admin/commission-rules` with admin-level authentication.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/admin/commission-rules` | List all rules (with pagination, filtering by type/tier/region/active) |
| `GET` | `/api/v1/admin/commission-rules/{id}` | Get single rule details |
| `POST` | `/api/v1/admin/commission-rules` | Create a new rule |
| `PUT` | `/api/v1/admin/commission-rules/{id}` | Update an existing rule |
| `DELETE` | `/api/v1/admin/commission-rules/{id}` | Soft-delete / deactivate a rule |
| `GET` | `/api/v1/admin/commission-rules/active` | Get currently active rules for a given type/tier/region |
### 5.2 Pydantic Schemas
Create [`backend/app/schemas/commission.py`](backend/app/schemas/commission.py):
```python
from pydantic import BaseModel, Field
from datetime import date, datetime
from typing import Optional
from enum import Enum
class CommissionRuleTypeEnum(str, Enum):
L1_REWARD = "L1_REWARD"
L2_COMMISSION = "L2_COMMISSION"
class CommissionTierEnum(str, Enum):
STANDARD = "STANDARD"
VIP = "VIP"
PLATINUM = "PLATINUM"
ENTERPRISE = "ENTERPRISE"
class CommissionRuleCreate(BaseModel):
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
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):
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
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):
id: int
rule_type: CommissionRuleTypeEnum
tier: CommissionTierEnum
region_code: str
xp_reward: Optional[int]
credit_reward: Optional[int]
commission_percent: Optional[float]
commission_max_amount: Optional[float]
is_campaign: bool
start_date: Optional[date]
end_date: Optional[date]
name: str
description: Optional[str]
is_active: bool
created_by: Optional[int]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class CommissionRuleListResponse(BaseModel):
items: list[CommissionRuleResponse]
total: int
page: int
page_size: int
```
### 5.3 Service Layer
Create [`backend/app/services/commission_service.py`](backend/app/services/commission_service.py) with:
- `create_commission_rule(db, data, admin_user_id)` — Create with validation
- `update_commission_rule(db, rule_id, data)` — Partial update
- `deactivate_commission_rule(db, rule_id)` — Soft delete (set `is_active=False`)
- `get_active_rule(db, rule_type, tier, region, transaction_date)` — The resolution engine
- `list_rules(db, filters, pagination)` — Admin listing with filters
### 5.4 Endpoint File
Create [`backend/app/api/v1/endpoints/admin_commission.py`](backend/app/api/v1/endpoints/admin_commission.py) with CRUD endpoints.
---
## 6. Database Migration
After implementing the model, generate an Alembic migration:
```bash
docker compose exec sf_api alembic revision --autogenerate -m "Add commission_rules table to marketplace schema"
docker compose exec sf_api alembic upgrade head
```
Or use the project's sync engine:
```bash
docker compose exec sf_api python -m app.scripts.sync_engine
```
---
## 7. Future Considerations
| Feature | Approach |
|---------|----------|
| **Multi-currency commission amounts** | Add `currency` column (default: `EUR`); extend `commission_max_amount` |
| **Stacking rules** (multiple campaigns) | Add `stackable` boolean; engine sums matching rules |
| **Minimum transaction thresholds** | Add `min_transaction_amount` column |
| **Referrer tier promotion** | Auto-promote users based on total referred revenue; integrate with `UserStats` |
| **Audit log for rule changes** | Use existing `system.operational_logs` table |
| **Caching active rules** | Redis cache with TTL; invalidate on rule CRUD |
---
## 8. Implementation Order
1. ✅ Create [`backend/app/models/marketplace/commission.py`](backend/app/models/marketplace/commission.py) — Model definition
2. ✅ Update [`backend/app/models/marketplace/__init__.py`](backend/app/models/marketplace/__init__.py) — Export new model
3. ✅ Update [`backend/app/models/__init__.py`](backend/app/models/__init__.py) — Register in master registry
4. 🔲 Create [`backend/app/schemas/commission.py`](backend/app/schemas/commission.py) — Pydantic schemas
5. 🔲 Create [`backend/app/services/commission_service.py`](backend/app/services/commission_service.py) — Business logic
6. 🔲 Create [`backend/app/api/v1/endpoints/admin_commission.py`](backend/app/api/v1/endpoints/admin_commission.py) — API endpoints
7. 🔲 Register endpoint in router
8. 🔲 Run sync engine to create table
9. 🔲 Write tests