Compare commits
8 Commits
cbfb955580
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9118bf52f | ||
|
|
33c4d793db | ||
|
|
4594de6c11 | ||
|
|
2e0abc62a7 | ||
|
|
07b59032ce | ||
|
|
7654913d21 | ||
|
|
6e627d0ebe | ||
|
|
189cbfd7ca |
920
.roo/history.md
920
.roo/history.md
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@ from app.api.v1.endpoints import (
|
||||
gamification, translations, users, reports, dictionaries,
|
||||
admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
|
||||
subscriptions, marketing, admin_permissions, regions,
|
||||
admin_gamification,
|
||||
admin_gamification, admin_providers, locations,
|
||||
admin_commission,
|
||||
financial_manager,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -45,4 +47,8 @@ api_router.include_router(providers.router, prefix="/providers", tags=["Provider
|
||||
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
|
||||
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])
|
||||
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_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(admin_commission.router, prefix="/admin/commission-rules", tags=["Admin Commission Rules"])
|
||||
api_router.include_router(financial_manager.router, prefix="/financial-manager", tags=["Financial Manager"])
|
||||
@@ -11,7 +11,7 @@ logger = logging.getLogger("admin-endpoints")
|
||||
|
||||
from app.api import deps
|
||||
from app.models.identity import User, UserRole, Person # JAVÍTVA: Központi import
|
||||
from app.models.identity.address import Address
|
||||
from app.models.identity.address import Address, GeoPostalCode
|
||||
from app.models.system import SystemParameter, ParameterScope
|
||||
from app.services.system_service import system_service
|
||||
# JAVÍTVA: Security audit modellek
|
||||
@@ -623,23 +623,12 @@ async def list_users(
|
||||
seen_ids.add(row.id)
|
||||
unique_users.append(row)
|
||||
|
||||
from app.schemas.address import AddressOut
|
||||
|
||||
user_list = []
|
||||
for u in unique_users:
|
||||
person = u.person
|
||||
address = person.address if person else None
|
||||
# Cím összefűzése
|
||||
address_str = None
|
||||
if address:
|
||||
parts = []
|
||||
if address.zip:
|
||||
parts.append(str(address.zip))
|
||||
if address.city:
|
||||
parts.append(str(address.city))
|
||||
if address.street_name:
|
||||
parts.append(str(address.street_name))
|
||||
if address.house_number:
|
||||
parts.append(str(address.house_number))
|
||||
address_str = ', '.join(parts) if parts else None
|
||||
|
||||
# Személy nevének összefűzése
|
||||
person_name = None
|
||||
@@ -663,12 +652,8 @@ async def list_users(
|
||||
"first_name": person.first_name if person else None,
|
||||
"last_name": person.last_name if person else None,
|
||||
"phone": person.phone if person else None,
|
||||
# Címadatok
|
||||
"postal_code": str(address.zip) if address and address.zip else None,
|
||||
"city": address.city if address and address.city else None,
|
||||
"street": address.street_name if address and address.street_name else None,
|
||||
"house_number": address.house_number if address and address.house_number else None,
|
||||
"address": address_str,
|
||||
# Címadatok — egységes AddressOut formátumban
|
||||
"address": AddressOut.model_validate(address) if address else None,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -943,6 +928,8 @@ async def search_organizations(
|
||||
"""
|
||||
from app.models.marketplace.organization import Organization
|
||||
|
||||
# P0 FIX: Join with system.addresses and system.geo_postal_codes
|
||||
# instead of selecting the ghost column Organization.address_city.
|
||||
stmt = (
|
||||
select(
|
||||
Organization.id,
|
||||
@@ -955,11 +942,13 @@ async def search_organizations(
|
||||
Organization.is_verified,
|
||||
Organization.is_active,
|
||||
Organization.is_deleted,
|
||||
Organization.address_city,
|
||||
GeoPostalCode.city.label("city"),
|
||||
Organization.region,
|
||||
Organization.segment,
|
||||
Organization.created_at,
|
||||
)
|
||||
.outerjoin(Address, Organization.address_id == Address.id)
|
||||
.outerjoin(GeoPostalCode, Address.postal_code_id == GeoPostalCode.id)
|
||||
.where(
|
||||
or_(
|
||||
Organization.full_name.ilike(f"%{name}%"),
|
||||
@@ -987,7 +976,7 @@ async def search_organizations(
|
||||
"is_verified": row.is_verified,
|
||||
"is_active": row.is_active,
|
||||
"is_deleted": row.is_deleted,
|
||||
"city": row.address_city,
|
||||
"city": row.city,
|
||||
"region": row.region,
|
||||
"segment": row.segment,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
|
||||
351
backend/app/api/v1/endpoints/admin_commission.py
Normal file
351
backend/app/api/v1/endpoints/admin_commission.py
Normal file
@@ -0,0 +1,351 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_commission.py
|
||||
"""
|
||||
Admin API endpoints for CommissionRule CRUD, Priority Resolution Engine,
|
||||
2-Level MLM Commission Distribution, and Conflict Detection.
|
||||
|
||||
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.
|
||||
- Phase 3: Conflict Detection — POST (create) and PUT (update) now check
|
||||
for overlapping active rules. If a conflict is found and force_override
|
||||
is False, a 409 Conflict response is returned with the conflicting rule's
|
||||
details. If force_override is True, the conflicting rule is deactivated.
|
||||
"""
|
||||
|
||||
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,
|
||||
ConflictResponse,
|
||||
ConflictingRuleInfo,
|
||||
)
|
||||
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"],
|
||||
responses={
|
||||
409: {
|
||||
"description": "Conflict — an active rule with the same tier/region/campaign already exists",
|
||||
"model": ConflictResponse,
|
||||
}
|
||||
},
|
||||
)
|
||||
async def create_commission_rule(
|
||||
data: CommissionRuleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_staff),
|
||||
):
|
||||
"""
|
||||
Create a new commission rule with conflict detection.
|
||||
|
||||
If an active rule with the same tier/region/is_campaign combination exists
|
||||
and force_override is False, a 409 Conflict is returned with the details
|
||||
of the conflicting rule.
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated and the
|
||||
new rule is created.
|
||||
"""
|
||||
rule = await commission_service.create_commission_rule(
|
||||
db, data=data, admin_user_id=current_user.id,
|
||||
)
|
||||
|
||||
# Phase 3: Detect conflict — the service returns the conflicting rule
|
||||
# when force_override is False and a conflict exists. We detect this by
|
||||
# checking if the returned rule's name differs from the input data.
|
||||
if rule.name != data.name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": "A conflicting active rule already exists for this combination.",
|
||||
"conflicting_rule": ConflictingRuleInfo(
|
||||
id=rule.id,
|
||||
name=rule.name,
|
||||
rule_type=CommissionRuleType(rule.rule_type.value),
|
||||
tier=CommissionTier(rule.tier.value),
|
||||
region_code=rule.region_code,
|
||||
is_campaign=rule.is_campaign,
|
||||
is_active=rule.is_active,
|
||||
).model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
return CommissionRuleResponse.model_validate(rule)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# UPDATE: PUT /admin/commission-rules/{rule_id}
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{rule_id}",
|
||||
response_model=CommissionRuleResponse,
|
||||
tags=["Admin Commission Rules"],
|
||||
responses={
|
||||
409: {
|
||||
"description": "Conflict — an active rule with the same tier/region/campaign already exists",
|
||||
"model": ConflictResponse,
|
||||
}
|
||||
},
|
||||
)
|
||||
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) with conflict detection.
|
||||
|
||||
If the update would create a conflict with another active rule and
|
||||
force_override is False, a 409 Conflict is returned with the details
|
||||
of the conflicting rule.
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated and the
|
||||
update proceeds.
|
||||
"""
|
||||
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.",
|
||||
)
|
||||
|
||||
# Phase 3: Detect conflict — the service returns the conflicting rule
|
||||
# when force_override is False and a conflict exists. We detect this by
|
||||
# checking if the returned rule's ID differs from the requested rule_id.
|
||||
if rule.id != rule_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"message": "A conflicting active rule already exists for this combination.",
|
||||
"conflicting_rule": ConflictingRuleInfo(
|
||||
id=rule.id,
|
||||
name=rule.name,
|
||||
rule_type=CommissionRuleType(rule.rule_type.value),
|
||||
tier=CommissionTier(rule.tier.value),
|
||||
region_code=rule.region_code,
|
||||
is_campaign=rule.is_campaign,
|
||||
is_active=rule.is_active,
|
||||
).model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
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
|
||||
@@ -1188,6 +1188,152 @@ async def review_contribution(
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VALIDATION RULES (Provider Validation Engine)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ValidationRuleUpdate(BaseModel):
|
||||
"""Update payload for a single validation rule."""
|
||||
rule_value: int = Field(..., ge=0, le=100, description="New integer value for this rule (0-100)")
|
||||
description: Optional[str] = Field(None, max_length=500, description="Updated description")
|
||||
|
||||
|
||||
class ValidationRuleBulkUpdate(BaseModel):
|
||||
"""Bulk update payload — dict of rule_key → new_value."""
|
||||
rules: Dict[str, int] = Field(
|
||||
..., description="Mapping of rule_key → new integer value (0-100)"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/validation-rules", response_model=List[Dict[str, Any]])
|
||||
async def list_validation_rules(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_: User = Depends(deps.RequirePermission("gamification:manage")),
|
||||
):
|
||||
"""
|
||||
Fetch all ValidationRule records from the database.
|
||||
|
||||
Returns a list of {id, rule_key, rule_value, description, updated_at}.
|
||||
These rules drive the ValidationEngine for provider scoring and
|
||||
auto-approval thresholds.
|
||||
"""
|
||||
from app.models.gamification.validation_rule import ValidationRule
|
||||
|
||||
stmt = select(ValidationRule).order_by(ValidationRule.rule_key)
|
||||
result = await db.execute(stmt)
|
||||
rules = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"rule_key": r.rule_key,
|
||||
"rule_value": r.rule_value,
|
||||
"description": r.description,
|
||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||
}
|
||||
for r in rules
|
||||
]
|
||||
|
||||
|
||||
@router.put("/validation-rules", response_model=Dict[str, Any])
|
||||
async def bulk_update_validation_rules(
|
||||
data: ValidationRuleBulkUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_: User = Depends(deps.RequirePermission("gamification:manage")),
|
||||
):
|
||||
"""
|
||||
Bulk-update ValidationRule values.
|
||||
|
||||
Accepts a dict of rule_key → new_value. Iterates through each entry
|
||||
and updates rule_value in the database. Unknown keys are silently skipped.
|
||||
|
||||
Returns a summary of updated rules.
|
||||
"""
|
||||
from app.models.gamification.validation_rule import ValidationRule
|
||||
|
||||
updated: List[Dict[str, Any]] = []
|
||||
errors: List[Dict[str, Any]] = []
|
||||
|
||||
for rule_key, new_value in data.rules.items():
|
||||
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_key)
|
||||
rule = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not rule:
|
||||
errors.append({"rule_key": rule_key, "error": "Rule not found"})
|
||||
logger.warning(f"Validation rule '{rule_key}' not found, skipping.")
|
||||
continue
|
||||
|
||||
if not (0 <= new_value <= 100):
|
||||
errors.append({"rule_key": rule_key, "error": "Value must be between 0 and 100"})
|
||||
continue
|
||||
|
||||
old_val = rule.rule_value
|
||||
rule.rule_value = new_value
|
||||
updated.append({
|
||||
"rule_key": rule_key,
|
||||
"old_value": old_val,
|
||||
"new_value": new_value,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Updated {len(updated)} rule(s) successfully.",
|
||||
"updated": updated,
|
||||
"errors": errors if errors else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/validation-rules/{rule_key}", response_model=Dict[str, Any])
|
||||
async def update_validation_rule(
|
||||
rule_key: str,
|
||||
data: ValidationRuleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_: User = Depends(deps.RequirePermission("gamification:manage")),
|
||||
):
|
||||
"""
|
||||
Update a single ValidationRule by its rule_key.
|
||||
|
||||
Allows updating both rule_value and description.
|
||||
"""
|
||||
from app.models.gamification.validation_rule import ValidationRule
|
||||
|
||||
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_key)
|
||||
rule = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not rule:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Validation rule '{rule_key}' not found.",
|
||||
)
|
||||
|
||||
old_value = rule.rule_value
|
||||
rule.rule_value = data.rule_value
|
||||
if data.description is not None:
|
||||
rule.description = data.description
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
|
||||
logger.info(
|
||||
f"Validation rule '{rule_key}' updated: {old_value} → {data.rule_value}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": rule.id,
|
||||
"rule_key": rule.rule_key,
|
||||
"rule_value": rule.rule_value,
|
||||
"description": rule.description,
|
||||
"old_value": old_value,
|
||||
"updated_at": rule.updated_at.isoformat() if rule.updated_at else None,
|
||||
"message": f"Rule '{rule_key}' updated successfully.",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LEADERBOARD (Admin)
|
||||
# =============================================================================
|
||||
|
||||
@@ -28,7 +28,9 @@ from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.fleet.organization import ContactPerson
|
||||
from app.models.identity import User, Person
|
||||
from app.services.address_manager import AddressManager
|
||||
from app.models.vehicle.history import AuditLog, LogSeverity
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
from app.schemas.organization import (
|
||||
OrganizationMemberCreate,
|
||||
OrganizationMemberUpdate,
|
||||
@@ -207,30 +209,36 @@ class GarageDetailsResponse(BaseModel):
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
org_type: str
|
||||
# Cím adatok (elsődleges)
|
||||
# Cím adatok (elsődleges) — denormalizált mezők (backward compatible)
|
||||
address_city: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
# P0: Unified AddressOut nested object
|
||||
address_detail: Optional[AddressOut] = None
|
||||
tax_number: Optional[str] = None
|
||||
reg_number: Optional[str] = None
|
||||
# ── Kapcsolattartó (Contact Person) ──
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
# ── Számlázási cím (Billing Address) ──
|
||||
# ── Számlázási cím (Billing Address) — denormalizált mezők (backward compatible) ──
|
||||
billing_zip: Optional[str] = None
|
||||
billing_city: Optional[str] = None
|
||||
billing_street_name: Optional[str] = None
|
||||
billing_street_type: Optional[str] = None
|
||||
billing_house_number: Optional[str] = None
|
||||
# ── Értesítési cím (Notification Address) ──
|
||||
# P0: Unified AddressOut nested object for billing address
|
||||
billing_address_detail: Optional[AddressOut] = None
|
||||
# ── Értesítési cím (Notification Address) — denormalizált mezők (backward compatible) ──
|
||||
notification_zip: Optional[str] = None
|
||||
notification_city: Optional[str] = None
|
||||
notification_street_name: Optional[str] = None
|
||||
notification_street_type: Optional[str] = None
|
||||
notification_house_number: Optional[str] = None
|
||||
# P0: Unified AddressOut nested object for notification address
|
||||
notification_address_detail: Optional[AddressOut] = None
|
||||
# Előfizetés
|
||||
subscription: Optional[SubscriptionSummary] = None
|
||||
# Kapcsolattartó (ContactPerson modellből)
|
||||
@@ -421,6 +429,9 @@ async def list_organizations(
|
||||
# Resolve email: prefer contact_email, fallback to owner's email
|
||||
resolved_email = org.contact_email or (org.owner.email if org.owner else None)
|
||||
|
||||
# P1 FIX: Use relationship-based city instead of ghost column org.address_city
|
||||
resolved_city = org.address.city if org.address else None
|
||||
|
||||
garage_list.append(GarageListItem(
|
||||
id=org.id,
|
||||
name=org.name,
|
||||
@@ -432,7 +443,7 @@ async def list_organizations(
|
||||
is_verified=org.is_verified,
|
||||
is_deleted=org.is_deleted,
|
||||
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
||||
city=org.address_city,
|
||||
city=resolved_city,
|
||||
region=org.region,
|
||||
segment=org.segment,
|
||||
member_count=member_counts.get(org.id, 0),
|
||||
@@ -696,17 +707,22 @@ async def get_organization_details(
|
||||
|
||||
# 2c. P0 ADD-ON: Telephelyek (branches) lekérése
|
||||
from app.models.marketplace.organization import Branch # noqa: E402
|
||||
branches_stmt = select(Branch).where(
|
||||
Branch.organization_id == org_id,
|
||||
Branch.is_deleted == False,
|
||||
).order_by(Branch.name)
|
||||
branches_stmt = (
|
||||
select(Branch)
|
||||
.options(selectinload(Branch.address))
|
||||
.where(
|
||||
Branch.organization_id == org_id,
|
||||
Branch.is_deleted == False,
|
||||
)
|
||||
.order_by(Branch.name)
|
||||
)
|
||||
branches_result = await db.execute(branches_stmt)
|
||||
branch_rows = branches_result.scalars().all()
|
||||
branches_list = [
|
||||
BranchBrief(
|
||||
id=str(b.id),
|
||||
name=b.name,
|
||||
city=b.city,
|
||||
city=b.address.city if b.address else None,
|
||||
is_active=(b.status == "active") if b.status else True,
|
||||
)
|
||||
for b in branch_rows
|
||||
@@ -1078,6 +1094,19 @@ async def get_organization_details(
|
||||
f"({org.full_name}) — {len(members_list)} members"
|
||||
)
|
||||
|
||||
# ── P0 REFACTORED: Build unified AddressOut objects from relationships ──
|
||||
address_detail = None
|
||||
if org.address:
|
||||
address_detail = AddressOut.model_validate(org.address)
|
||||
|
||||
billing_address_detail = None
|
||||
if org.billing_address:
|
||||
billing_address_detail = AddressOut.model_validate(org.billing_address)
|
||||
|
||||
notification_address_detail = None
|
||||
if org.notification_address:
|
||||
notification_address_detail = AddressOut.model_validate(org.notification_address)
|
||||
|
||||
return GarageDetailsResponse(
|
||||
id=org.id,
|
||||
name=org.name,
|
||||
@@ -1087,11 +1116,13 @@ async def get_organization_details(
|
||||
is_active=org.is_active,
|
||||
is_verified=org.is_verified,
|
||||
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
||||
address_city=org.address_city,
|
||||
address_zip=org.address_zip,
|
||||
address_street_name=org.address_street_name,
|
||||
address_street_type=org.address_street_type,
|
||||
address_house_number=org.address_house_number,
|
||||
# ── P0 REFACTORED: Denormalized fields populated from relationship ──
|
||||
address_city=org.address.city if org.address else None,
|
||||
address_zip=org.address.zip if org.address else None,
|
||||
address_street_name=org.address.street_name if org.address else None,
|
||||
address_street_type=org.address.street_type if org.address else None,
|
||||
address_house_number=org.address.house_number if org.address else None,
|
||||
address_detail=address_detail,
|
||||
tax_number=org.tax_number,
|
||||
reg_number=org.reg_number,
|
||||
# ── P0: Zero-Duplication Kapcsolattartó (owner fallback) ──
|
||||
@@ -1099,17 +1130,19 @@ async def get_organization_details(
|
||||
contact_email=resolved_contact_email,
|
||||
contact_phone=resolved_contact_phone,
|
||||
# ── Számlázási cím ──
|
||||
billing_zip=org.billing_zip,
|
||||
billing_city=org.billing_city,
|
||||
billing_street_name=org.billing_street_name,
|
||||
billing_street_type=org.billing_street_type,
|
||||
billing_house_number=org.billing_house_number,
|
||||
billing_zip=org.billing_address.zip if org.billing_address else None,
|
||||
billing_city=org.billing_address.city if org.billing_address else None,
|
||||
billing_street_name=org.billing_address.street_name if org.billing_address else None,
|
||||
billing_street_type=org.billing_address.street_type if org.billing_address else None,
|
||||
billing_house_number=org.billing_address.house_number if org.billing_address else None,
|
||||
billing_address_detail=billing_address_detail,
|
||||
# ── Értesítési cím ──
|
||||
notification_zip=org.notification_zip,
|
||||
notification_city=org.notification_city,
|
||||
notification_street_name=org.notification_street_name,
|
||||
notification_street_type=org.notification_street_type,
|
||||
notification_house_number=org.notification_house_number,
|
||||
notification_zip=org.notification_address.zip if org.notification_address else None,
|
||||
notification_city=org.notification_address.city if org.notification_address else None,
|
||||
notification_street_name=org.notification_address.street_name if org.notification_address else None,
|
||||
notification_street_type=org.notification_address.street_type if org.notification_address else None,
|
||||
notification_house_number=org.notification_address.house_number if org.notification_address else None,
|
||||
notification_address_detail=notification_address_detail,
|
||||
subscription=subscription_summary,
|
||||
primary_contact=primary_contact,
|
||||
created_at=org.created_at.isoformat() if org.created_at else None,
|
||||
@@ -1224,28 +1257,34 @@ class OrganizationUpdate(BaseModel):
|
||||
# ── Org-level email/phone (used for individual garages to propagate to Person/User) ──
|
||||
email: Optional[str] = Field(None, max_length=255, description="Szervezeti e-mail (individual garages esetén a User rekordba propagálódik)")
|
||||
phone: Optional[str] = Field(None, max_length=50, description="Szervezeti telefon (individual garages esetén a Person rekordba propagálódik)")
|
||||
# ── Elsődleges cím (Primary Address) ──
|
||||
address_zip: Optional[str] = Field(None, max_length=10, description="Irányítószám")
|
||||
address_city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Utca típusa (utca, tér, stb.)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám")
|
||||
# ── Elsődleges cím (Primary Address) — DEPRECATED: Use address_detail instead ──
|
||||
address_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd az address_detail mezőt! Irányítószám")
|
||||
address_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd az address_detail mezőt! Város")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd az address_detail mezőt! Utca neve")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd az address_detail mezőt! Utca típusa (utca, tér, stb.)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd az address_detail mezőt! Házszám")
|
||||
# P0: Unified AddressIn nested object for primary address
|
||||
address_detail: Optional[AddressIn] = None
|
||||
# ── Kapcsolattartó (Contact Person) ──
|
||||
contact_person_name: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó neve")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó e-mail címe")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Kapcsolattartó telefonszáma")
|
||||
# ── Számlázási cím (Billing Address) ──
|
||||
billing_zip: Optional[str] = Field(None, max_length=10, description="Számlázási irányítószám")
|
||||
billing_city: Optional[str] = Field(None, max_length=100, description="Számlázási város")
|
||||
billing_street_name: Optional[str] = Field(None, max_length=150, description="Számlázási utca neve")
|
||||
billing_street_type: Optional[str] = Field(None, max_length=50, description="Számlázási utca típusa")
|
||||
billing_house_number: Optional[str] = Field(None, max_length=20, description="Számlázási házszám")
|
||||
# ── Értesítési cím (Notification Address) ──
|
||||
notification_zip: Optional[str] = Field(None, max_length=10, description="Értesítési irányítószám")
|
||||
notification_city: Optional[str] = Field(None, max_length=100, description="Értesítési város")
|
||||
notification_street_name: Optional[str] = Field(None, max_length=150, description="Értesítési utca neve")
|
||||
notification_street_type: Optional[str] = Field(None, max_length=50, description="Értesítési utca típusa")
|
||||
notification_house_number: Optional[str] = Field(None, max_length=20, description="Értesítési házszám")
|
||||
# ── Számlázási cím (Billing Address) — DEPRECATED: Use billing_address_detail instead ──
|
||||
billing_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási irányítószám")
|
||||
billing_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási város")
|
||||
billing_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási utca neve")
|
||||
billing_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási utca típusa")
|
||||
billing_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási házszám")
|
||||
# P0: Unified AddressIn nested object for billing address
|
||||
billing_address_detail: Optional[AddressIn] = None
|
||||
# ── Értesítési cím (Notification Address) — DEPRECATED: Use notification_address_detail instead ──
|
||||
notification_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési irányítószám")
|
||||
notification_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési város")
|
||||
notification_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési utca neve")
|
||||
notification_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési utca típusa")
|
||||
notification_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési házszám")
|
||||
# P0: Unified AddressIn nested object for notification address
|
||||
notification_address_detail: Optional[AddressIn] = None
|
||||
|
||||
|
||||
@router.put(
|
||||
@@ -1279,11 +1318,58 @@ async def update_organization(
|
||||
detail=f"Szervezet nem található ID-vel: {org_id}",
|
||||
)
|
||||
|
||||
# 2. Összegyűjtjük a változásokat
|
||||
# 2. P0 REFACTORED: Address FK logic via AddressManager
|
||||
raw_data = payload.model_dump(exclude_none=True)
|
||||
|
||||
# ── Primary address via AddressManager ──
|
||||
address_detail: Optional[AddressIn] = raw_data.pop("address_detail", None)
|
||||
if address_detail:
|
||||
# P0 BUGFIX: model_dump() converts nested Pydantic models to dicts;
|
||||
# re-wrap into AddressIn before passing to AddressManager
|
||||
if isinstance(address_detail, dict):
|
||||
address_detail = AddressIn(**address_detail)
|
||||
org.address_id = await AddressManager.create_or_update(
|
||||
db, org.address_id, address_detail
|
||||
)
|
||||
|
||||
# ── Billing address via AddressManager ──
|
||||
billing_address_detail: Optional[AddressIn] = raw_data.pop("billing_address_detail", None)
|
||||
if billing_address_detail:
|
||||
if isinstance(billing_address_detail, dict):
|
||||
billing_address_detail = AddressIn(**billing_address_detail)
|
||||
org.billing_address_id = await AddressManager.create_or_update(
|
||||
db, org.billing_address_id, billing_address_detail
|
||||
)
|
||||
|
||||
# ── Notification address via AddressManager ──
|
||||
notification_address_detail: Optional[AddressIn] = raw_data.pop("notification_address_detail", None)
|
||||
if notification_address_detail:
|
||||
if isinstance(notification_address_detail, dict):
|
||||
notification_address_detail = AddressIn(**notification_address_detail)
|
||||
org.notification_address_id = await AddressManager.create_or_update(
|
||||
db, org.notification_address_id, notification_address_detail
|
||||
)
|
||||
|
||||
# 3. Összegyűjtjük a változásokat (non-address fields)
|
||||
# P0 BUGFIX: Filter out deprecated denormalized address fields that were dropped from the DB
|
||||
# These columns (address_city, billing_zip, etc.) were physically removed from fleet.organizations
|
||||
# in cleanup_ghost_columns_2026_07.sql. The Organization model no longer has these attributes.
|
||||
DEPRECATED_ADDRESS_FIELDS = {
|
||||
"address_zip", "address_city", "address_street_name", "address_street_type", "address_house_number",
|
||||
"billing_zip", "billing_city", "billing_street_name", "billing_street_type", "billing_house_number",
|
||||
"notification_zip", "notification_city", "notification_street_name", "notification_street_type", "notification_house_number",
|
||||
}
|
||||
update_fields = {}
|
||||
for field_name in payload.model_dump(exclude_none=True):
|
||||
value = getattr(payload, field_name)
|
||||
for field_name in raw_data:
|
||||
value = raw_data[field_name]
|
||||
if value is not None and hasattr(org, field_name):
|
||||
# Skip deprecated address fields that no longer exist on the ORM model
|
||||
if field_name in DEPRECATED_ADDRESS_FIELDS:
|
||||
logger.warning(
|
||||
f"Skipping deprecated field '{field_name}' for org {org_id} — "
|
||||
f"column was dropped from database. Use address_detail instead."
|
||||
)
|
||||
continue
|
||||
setattr(org, field_name, value)
|
||||
update_fields[field_name] = value
|
||||
|
||||
@@ -1333,6 +1419,11 @@ async def update_organization(
|
||||
f"({org.full_name}): fields={list(update_fields.keys())}"
|
||||
)
|
||||
|
||||
# ── P0 REFACTORED: Build unified AddressOut from relationship ──
|
||||
resp_address_detail = None
|
||||
if org.address:
|
||||
resp_address_detail = AddressOut.model_validate(org.address)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"A(z) '{org.full_name}' garázs adatai frissítve.",
|
||||
@@ -1345,11 +1436,7 @@ async def update_organization(
|
||||
"display_name": org.display_name,
|
||||
"tax_number": org.tax_number,
|
||||
"reg_number": org.reg_number,
|
||||
"address_zip": org.address_zip,
|
||||
"address_city": org.address_city,
|
||||
"address_street_name": org.address_street_name,
|
||||
"address_street_type": org.address_street_type,
|
||||
"address_house_number": org.address_house_number,
|
||||
"address_detail": resp_address_detail,
|
||||
"status": org.status,
|
||||
"is_active": org.is_active,
|
||||
"contact_email": org.contact_email,
|
||||
|
||||
@@ -26,7 +26,8 @@ from app.models.identity import User, Person, UserRole
|
||||
from app.models.identity.address import Address
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.vehicle.history import AuditLog, LogSeverity
|
||||
from app.schemas.user import AddressResponse
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
from app.services.address_manager import AddressManager
|
||||
|
||||
logger = logging.getLogger("admin-persons")
|
||||
router = APIRouter()
|
||||
@@ -59,7 +60,7 @@ class PersonListItem(BaseModel):
|
||||
merged_into_id: Optional[int] = None
|
||||
users_count: int = 0
|
||||
active_user: Optional[PersonListItemActiveUser] = None
|
||||
address: Optional[AddressResponse] = None
|
||||
address: Optional[AddressOut] = None
|
||||
|
||||
|
||||
class PersonListResponse(BaseModel):
|
||||
@@ -214,20 +215,7 @@ async def list_persons(
|
||||
# Cím adatok
|
||||
address_data = None
|
||||
if person.address:
|
||||
address_data = AddressResponse(
|
||||
address_zip=person.address.zip,
|
||||
address_city=person.address.city,
|
||||
address_street_name=person.address.street_name,
|
||||
address_street_type=person.address.street_type,
|
||||
address_house_number=person.address.house_number,
|
||||
address_stairwell=person.address.stairwell,
|
||||
address_floor=person.address.floor,
|
||||
address_door=person.address.door,
|
||||
address_hrsz=person.address.parcel_id,
|
||||
full_address_text=person.address.full_address_text,
|
||||
latitude=person.address.latitude,
|
||||
longitude=person.address.longitude,
|
||||
)
|
||||
address_data = AddressOut.model_validate(person.address)
|
||||
|
||||
items.append(PersonListItem(
|
||||
id=person.id,
|
||||
@@ -338,7 +326,7 @@ class PersonDetailResponse(BaseModel):
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
# Kapcsolódó entitások
|
||||
address: Optional[AddressResponse] = None
|
||||
address: Optional[AddressOut] = None
|
||||
users: List[PersonDetailUserInfo] = []
|
||||
memberships: List[PersonDetailMembership] = []
|
||||
owned_organizations: List[PersonDetailOwnedOrganization] = []
|
||||
@@ -394,20 +382,7 @@ async def get_person_detail(
|
||||
# ── Cím adatok ──
|
||||
address_data = None
|
||||
if person.address:
|
||||
address_data = AddressResponse(
|
||||
address_zip=person.address.zip,
|
||||
address_city=person.address.city,
|
||||
address_street_name=person.address.street_name,
|
||||
address_street_type=person.address.street_type,
|
||||
address_house_number=person.address.house_number,
|
||||
address_stairwell=person.address.stairwell,
|
||||
address_floor=person.address.floor,
|
||||
address_door=person.address.door,
|
||||
address_hrsz=person.address.parcel_id,
|
||||
full_address_text=person.address.full_address_text,
|
||||
latitude=person.address.latitude,
|
||||
longitude=person.address.longitude,
|
||||
)
|
||||
address_data = AddressOut.model_validate(person.address)
|
||||
|
||||
# ── Kapcsolódó User-ek ──
|
||||
users_data: List[PersonDetailUserInfo] = []
|
||||
@@ -520,22 +495,6 @@ async def get_person_detail(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PersonAddressUpdate(BaseModel):
|
||||
"""Cím adatok a Person szerkesztéshez."""
|
||||
address_zip: Optional[str] = Field(default=None, description="Irányítószám")
|
||||
address_city: Optional[str] = Field(default=None, description="Város")
|
||||
address_street_name: Optional[str] = Field(default=None, description="Utca név")
|
||||
address_street_type: Optional[str] = Field(default=None, description="Közterület jellege")
|
||||
address_house_number: Optional[str] = Field(default=None, description="Házszám")
|
||||
address_stairwell: Optional[str] = Field(default=None, description="Lépcsőház")
|
||||
address_floor: Optional[str] = Field(default=None, description="Emelet")
|
||||
address_door: Optional[str] = Field(default=None, description="Ajtó")
|
||||
address_hrsz: Optional[str] = Field(default=None, description="Helyrajzi szám")
|
||||
full_address_text: Optional[str] = Field(default=None, description="Teljes cím szöveg")
|
||||
latitude: Optional[float] = Field(default=None, description="GPS szélesség")
|
||||
longitude: Optional[float] = Field(default=None, description="GPS hosszúság")
|
||||
|
||||
|
||||
class PersonUpdateRequest(BaseModel):
|
||||
"""Admin által szerkeszthető Person mezők.
|
||||
|
||||
@@ -554,8 +513,8 @@ class PersonUpdateRequest(BaseModel):
|
||||
is_active: Optional[bool] = Field(default=None, description="Aktív státusz")
|
||||
is_ghost: Optional[bool] = Field(default=None, description="Ghost státusz")
|
||||
|
||||
# Cím adatok (opcionális, nested object)
|
||||
address: Optional[PersonAddressUpdate] = Field(default=None, description="Cím adatok")
|
||||
# Cím adatok (opcionális, nested object) — egységes AddressIn séma
|
||||
address: Optional[AddressIn] = Field(default=None, description="Cím adatok")
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
@@ -692,62 +651,14 @@ async def update_person(
|
||||
changes["is_ghost"] = {"old": person.is_ghost, "new": update_data.is_ghost}
|
||||
person.is_ghost = update_data.is_ghost
|
||||
|
||||
# ── 5. Cím adatok frissítése ──
|
||||
# ── 5. Cím adatok frissítése (P1 REFACTORED: AddressManager) ──
|
||||
if update_data.address is not None:
|
||||
addr_data = update_data.address
|
||||
|
||||
# Ha van már cím, frissítjük, különben létrehozzuk
|
||||
if person.address:
|
||||
address = person.address
|
||||
else:
|
||||
# Új cím létrehozása
|
||||
address = Address(
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(address)
|
||||
person.address = address
|
||||
|
||||
# Cím mezők frissítése
|
||||
addr_changes: Dict[str, Any] = {}
|
||||
if addr_data.address_zip is not None:
|
||||
addr_changes["address_zip"] = {"old": address.zip, "new": addr_data.address_zip}
|
||||
# Megjegyzés: a zip a GeoPostalCode kapcsolatból jön, itt csak a postal_code_id-t tudnánk keresni
|
||||
# Egyszerűsítés: a frontend által küldött adatokat tároljuk a full_address_text-ben is
|
||||
if addr_data.address_city is not None:
|
||||
addr_changes["address_city"] = {"old": address.city, "new": addr_data.address_city}
|
||||
if addr_data.address_street_name is not None:
|
||||
addr_changes["address_street_name"] = {"old": address.street_name, "new": addr_data.address_street_name}
|
||||
address.street_name = addr_data.address_street_name
|
||||
if addr_data.address_street_type is not None:
|
||||
addr_changes["address_street_type"] = {"old": address.street_type, "new": addr_data.address_street_type}
|
||||
address.street_type = addr_data.address_street_type
|
||||
if addr_data.address_house_number is not None:
|
||||
addr_changes["address_house_number"] = {"old": address.house_number, "new": addr_data.address_house_number}
|
||||
address.house_number = addr_data.address_house_number
|
||||
if addr_data.address_stairwell is not None:
|
||||
addr_changes["address_stairwell"] = {"old": address.stairwell, "new": addr_data.address_stairwell}
|
||||
address.stairwell = addr_data.address_stairwell
|
||||
if addr_data.address_floor is not None:
|
||||
addr_changes["address_floor"] = {"old": address.floor, "new": addr_data.address_floor}
|
||||
address.floor = addr_data.address_floor
|
||||
if addr_data.address_door is not None:
|
||||
addr_changes["address_door"] = {"old": address.door, "new": addr_data.address_door}
|
||||
address.door = addr_data.address_door
|
||||
if addr_data.address_hrsz is not None:
|
||||
addr_changes["address_hrsz"] = {"old": address.parcel_id, "new": addr_data.address_hrsz}
|
||||
address.parcel_id = addr_data.address_hrsz
|
||||
if addr_data.full_address_text is not None:
|
||||
addr_changes["full_address_text"] = {"old": address.full_address_text, "new": addr_data.full_address_text}
|
||||
address.full_address_text = addr_data.full_address_text
|
||||
if addr_data.latitude is not None:
|
||||
addr_changes["latitude"] = {"old": address.latitude, "new": addr_data.latitude}
|
||||
address.latitude = addr_data.latitude
|
||||
if addr_data.longitude is not None:
|
||||
addr_changes["longitude"] = {"old": address.longitude, "new": addr_data.longitude}
|
||||
address.longitude = addr_data.longitude
|
||||
|
||||
if addr_changes:
|
||||
changes["address"] = addr_changes
|
||||
# P1 FIX: Use AddressManager.create_or_update() instead of
|
||||
# writing directly to Address model fields.
|
||||
person.address_id = await AddressManager.create_or_update(
|
||||
db, person.address_id, update_data.address
|
||||
)
|
||||
changes["address"] = {"old": None, "new": "updated via AddressManager"}
|
||||
|
||||
# ── 6. Mentés ──
|
||||
if changes:
|
||||
@@ -768,20 +679,7 @@ async def update_person(
|
||||
# Cím adatok
|
||||
address_data = None
|
||||
if person.address:
|
||||
address_data = AddressResponse(
|
||||
address_zip=person.address.zip,
|
||||
address_city=person.address.city,
|
||||
address_street_name=person.address.street_name,
|
||||
address_street_type=person.address.street_type,
|
||||
address_house_number=person.address.house_number,
|
||||
address_stairwell=person.address.stairwell,
|
||||
address_floor=person.address.floor,
|
||||
address_door=person.address.door,
|
||||
address_hrsz=person.address.parcel_id,
|
||||
full_address_text=person.address.full_address_text,
|
||||
latitude=person.address.latitude,
|
||||
longitude=person.address.longitude,
|
||||
)
|
||||
address_data = AddressOut.model_validate(person.address)
|
||||
|
||||
# Kapcsolódó User-ek
|
||||
users_data: List[PersonDetailUserInfo] = []
|
||||
|
||||
1788
backend/app/api/v1/endpoints/admin_providers.py
Normal file
1788
backend/app/api/v1/endpoints/admin_providers.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,11 @@
|
||||
👤 Admin Users API
|
||||
|
||||
Végpontok:
|
||||
GET /admin/users/stats — Felhasználói statisztikák (dashboard)
|
||||
GET /admin/users/{user_id} — Felhasználó részletes adatai (User + Person + Address)
|
||||
PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által
|
||||
GET /admin/users/{user_id}/memberships — Felhasználó szervezeti tagságai
|
||||
GET /admin/users/stats — Felhasználói statisztikák (dashboard)
|
||||
GET /admin/users/{user_id} — Felhasználó részletes adatai (User + Person + Address)
|
||||
PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által
|
||||
GET /admin/users/{user_id}/memberships — Felhasználó szervezeti tagságai
|
||||
DELETE /admin/users/{user_id}/hard — [SUPERADMIN] Végleges felhasználó törlés
|
||||
|
||||
Megjegyzés: A GET /admin/users (lista) és POST /admin/users/bulk-action
|
||||
végpontok a admin.py modulban találhatók, mivel az a /admin prefix alá
|
||||
@@ -28,7 +29,9 @@ from app.api import deps
|
||||
from app.db.session import get_db
|
||||
from app.models.identity import User, Person, UserRole
|
||||
from app.models.identity.address import Address
|
||||
from app.models.identity.identity import Wallet
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.system.audit import SecurityAuditLog, FinancialLedger
|
||||
from app.models.vehicle.history import AuditLog, LogSeverity
|
||||
from app.schemas.user import UserResponse
|
||||
from app.schemas.organization import OrganizationMemberResponse
|
||||
@@ -644,3 +647,124 @@ async def get_user_memberships(
|
||||
))
|
||||
|
||||
return membership_list
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hard Delete Endpoint (Danger Zone)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{user_id}/hard",
|
||||
summary="[SUPERADMIN] Felhasználó végleges törlése",
|
||||
description=(
|
||||
"Véglegesen eltávolít egy felhasználót az adatbázisból. "
|
||||
"KIZÁRÓLAG Superadmin számára elérhető. "
|
||||
"Gatekeeper: Ellenőrzi, hogy a felhasználónak nincs-e pénzügyi rekordja "
|
||||
"(FinancialLedger, Wallet) a törlés előtt. "
|
||||
"Audit trail: SecurityAuditLog-ba rögzíti a műveletet."
|
||||
),
|
||||
)
|
||||
async def hard_delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(deps.get_current_active_user),
|
||||
_ = Depends(deps.RequirePermission("user:manage")),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
DELETE /admin/users/{user_id}/hard
|
||||
|
||||
Véglegesen töröl egy felhasználót az adatbázisból.
|
||||
Csak Superadmin jogosultsággal érhető el.
|
||||
"""
|
||||
# ── 1. Superadmin ellenőrzés ──────────────────────────────────────────
|
||||
if current_user.role != UserRole.SUPERADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only Superadmins can perform hard delete operations."
|
||||
)
|
||||
|
||||
# ── 2. Felhasználó lekérése ───────────────────────────────────────────
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found."
|
||||
)
|
||||
|
||||
# ── 3. Gatekeeper: Pénzügyi rekordok ellenőrzése ──────────────────────
|
||||
# FinancialLedger ellenőrzés
|
||||
ledger_stmt = select(FinancialLedger).where(FinancialLedger.user_id == user_id).limit(1)
|
||||
ledger_result = await db.execute(ledger_stmt)
|
||||
has_ledger = ledger_result.scalar_one_or_none() is not None
|
||||
|
||||
# Wallet ellenőrzés
|
||||
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id).limit(1)
|
||||
wallet_result = await db.execute(wallet_stmt)
|
||||
has_wallet = wallet_result.scalar_one_or_none() is not None
|
||||
|
||||
if has_ledger or has_wallet:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
"Cannot hard delete: User has financial or production records. "
|
||||
"Please ensure all financial data is cleared before deletion."
|
||||
)
|
||||
)
|
||||
|
||||
# ── 4. FK referenciák nullázása a törlés előtt ─────────────────────────
|
||||
# Az AuditLog (audit.audit_logs) és SecurityAuditLog (audit.security_audit_logs)
|
||||
# táblák FK constrainttel hivatkoznak identity.users.id-ra, de NINCS
|
||||
# ondelete="SET NULL" beállítva. Ezért manuálisan nullázzuk a referenciákat.
|
||||
user_data = {
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"role": str(user.role) if user.role else None,
|
||||
"is_active": user.is_active,
|
||||
"is_deleted": user.is_deleted,
|
||||
}
|
||||
|
||||
# AuditLog.user_id nullázása
|
||||
await db.execute(
|
||||
sa_update(AuditLog)
|
||||
.where(AuditLog.user_id == user_id)
|
||||
.values(user_id=None)
|
||||
)
|
||||
|
||||
# ── 5. Végleges törlés ─────────────────────────────────────────────────
|
||||
await db.delete(user)
|
||||
await db.flush()
|
||||
|
||||
# ── 6. Audit Trail ─────────────────────────────────────────────────────
|
||||
# SecurityAuditLog.target_id FK-val hivatkozik identity.users.id-ra,
|
||||
# de nincs ondelete="SET NULL". Mivel a user már törölve van,
|
||||
# a target_id=None kell legyen, különben ForeignKeyViolationError.
|
||||
# A tényleges user_id a payload_before mezőben kerül tárolásra.
|
||||
audit_log = SecurityAuditLog(
|
||||
actor_id=current_user.id,
|
||||
target_id=None,
|
||||
action="hard_delete_user",
|
||||
is_critical=True,
|
||||
payload_before=user_data,
|
||||
payload_after={
|
||||
"details": f"User #{user_id} ({user.email}) permanently deleted by admin #{current_user.id}."
|
||||
},
|
||||
)
|
||||
db.add(audit_log)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.warning(
|
||||
"HARD DELETE: User #%s (%s) permanently deleted by admin #%s",
|
||||
user_id, user.email, current_user.id
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"User #{user_id} has been permanently deleted.",
|
||||
"deleted_user_id": user_id,
|
||||
"deleted_email": user.email,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -57,6 +62,9 @@ async def login(
|
||||
detail="AUTH.USER_INACTIVE"
|
||||
)
|
||||
|
||||
# ── Update last activity timestamp ──
|
||||
user.last_activity_at = datetime.now(timezone.utc)
|
||||
|
||||
# ── Device Fingerprint Tracking ──
|
||||
if device_fingerprint:
|
||||
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
|
||||
@@ -155,6 +163,9 @@ async def verify_email(
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN"))
|
||||
|
||||
# ── Update last activity timestamp ──
|
||||
user.last_activity_at = datetime.now(timezone.utc)
|
||||
|
||||
# ── Device Fingerprint Tracking (Magic Link) ──
|
||||
if request.device_fingerprint:
|
||||
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
|
||||
@@ -379,4 +390,156 @@ 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."
|
||||
)
|
||||
|
||||
# ── Update last activity timestamp ──
|
||||
user.last_activity_at = datetime.now(timezone.utc)
|
||||
|
||||
# 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,7 +97,7 @@ async def list_body_types(
|
||||
"id": r.id,
|
||||
"vehicle_class": r.vehicle_class,
|
||||
"code": r.code,
|
||||
"name_hu": r.name_hu,
|
||||
"name_i18n": r.name_i18n if r.name_i18n else {},
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -1,438 +1,293 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
|
||||
"""
|
||||
Expense (AssetCost) API endpoints.
|
||||
|
||||
P0 Smart Expense Workflow (2026-06-20):
|
||||
- POST /expenses/ — Create expense with odometer normalization, provider validation,
|
||||
smart linking to AssetEvent, and gamification hooks.
|
||||
- GET /expenses/ — List all expenses (admin).
|
||||
- GET /expenses/{asset_id} — List expenses for a specific asset.
|
||||
- PUT /expenses/{expense_id} — Update an existing expense.
|
||||
|
||||
GROSS-FIRST (Masterbook 2.0.1):
|
||||
- amount_gross is the primary field (Bruttó).
|
||||
- amount_net is optional — calculated back from gross + vat_rate.
|
||||
- All VAT calculations happen in the service layer, not in the schema.
|
||||
|
||||
P0 HYBRID VENDOR REFACTOR (2026-07-01):
|
||||
- service_provider_id (marketplace.service_providers) is the primary vendor FK.
|
||||
- vendor_organization_id (fleet.organizations) is secondary (B2B).
|
||||
- external_vendor_name is the fallback for free-text typed names.
|
||||
|
||||
P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation.
|
||||
- A frontend elküldheti a ServiceProvider.id-t vendor_organization_id-ként,
|
||||
ami FK violation-t okoz. Itt validáljuk, hogy a megadott ID létezik-e
|
||||
a fleet.organizations táblában. Ha nem, átirányítjuk service_provider_id-ra.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime, timezone, date
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, func, and_, or_, cast, Text, case, literal, union_all
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from app.api.deps import get_db, get_current_user, RequireOrgCapability
|
||||
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cost category IDs that are service/maintenance/repair related
|
||||
# These trigger automatic AssetEvent creation
|
||||
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES
|
||||
|
||||
# Fuel category IDs - auto-approved even for DRIVER role
|
||||
FUEL_CATEGORY_IDS = {1} # FUEL
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── FUEL CATEGORY IDS ──
|
||||
# These are the category IDs that represent fuel costs.
|
||||
# Used for auto-approval logic: fuel costs are always auto-approved.
|
||||
FUEL_CATEGORY_IDS = {1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
|
||||
|
||||
# ── SERVICE-RELATED CATEGORY IDS ──
|
||||
# These categories trigger automatic AssetEvent creation (Smart Linking).
|
||||
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18}
|
||||
|
||||
gamification_service = GamificationService()
|
||||
|
||||
|
||||
# ── HELPER FUNCTIONS ──
|
||||
|
||||
|
||||
async def _resolve_user_role_in_org(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
organization_id: int
|
||||
) -> str:
|
||||
organization_id: int,
|
||||
) -> Optional[str]:
|
||||
"""Resolve the user's role in the given organization.
|
||||
|
||||
Returns the role name (e.g. 'owner', 'admin', 'driver') or None if not found.
|
||||
"""
|
||||
Resolve the user's role within the given organization.
|
||||
|
||||
Returns:
|
||||
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
|
||||
Returns 'MEMBER' as default if no membership found.
|
||||
"""
|
||||
stmt = select(OrganizationMember.role).where(
|
||||
stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
OrganizationMember.status == "active",
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
role = result.scalar_one_or_none()
|
||||
return role or "MEMBER"
|
||||
member = result.scalar_one_or_none()
|
||||
if member:
|
||||
return member.role
|
||||
return None
|
||||
|
||||
|
||||
async def _check_org_capability(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
organization_id: int,
|
||||
capability_name: str
|
||||
capability: str,
|
||||
) -> bool:
|
||||
"""Check if a user has a specific capability in an organization.
|
||||
|
||||
Uses the JSONB permissions field from fleet.org_roles.
|
||||
Returns True if the user's role has the capability, False otherwise.
|
||||
"""
|
||||
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
|
||||
|
||||
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
|
||||
permissions JSONB oszlopából ellenőrzi a kért képességet.
|
||||
|
||||
Returns:
|
||||
bool: True ha a user rendelkezik a képességgel.
|
||||
"""
|
||||
# 1. Get user's org role
|
||||
member_stmt = select(OrganizationMember.role).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
member_result = await db.execute(member_stmt)
|
||||
org_role_name = member_result.scalar_one_or_none()
|
||||
|
||||
if not org_role_name:
|
||||
return False
|
||||
|
||||
# 2. Get permissions from fleet.org_roles table
|
||||
role_stmt = select(OrgRole.permissions).where(
|
||||
OrgRole.name_key == org_role_name,
|
||||
OrgRole.is_active == True
|
||||
).limit(1)
|
||||
role_result = await db.execute(role_stmt)
|
||||
permissions = role_result.scalar_one_or_none()
|
||||
|
||||
if not permissions:
|
||||
# Fallback to OrganizationMember.permissions
|
||||
member_perm_stmt = select(OrganizationMember.permissions).where(
|
||||
from app.models.fleet.org_role import OrgRole
|
||||
|
||||
stmt = (
|
||||
select(OrgRole.permissions)
|
||||
.join(OrganizationMember, OrganizationMember.role == OrgRole.name)
|
||||
.where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
member_perm_result = await db.execute(member_perm_stmt)
|
||||
permissions = member_perm_result.scalar_one_or_none() or {}
|
||||
|
||||
if not isinstance(permissions, dict):
|
||||
permissions = {}
|
||||
|
||||
return permissions.get(capability_name, False)
|
||||
OrganizationMember.status == "active",
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
if row and isinstance(row, dict):
|
||||
return row.get(capability, False)
|
||||
return False
|
||||
|
||||
|
||||
def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal:
|
||||
"""Calculate net amount from gross amount and VAT rate.
|
||||
|
||||
Formula: net = gross / (1 + vat_rate/100)
|
||||
|
||||
Args:
|
||||
amount_gross: The gross amount (Bruttó)
|
||||
vat_rate: The VAT rate in percent (e.g., 27.00 for 27%)
|
||||
|
||||
Returns:
|
||||
The net amount (Nettó)
|
||||
"""
|
||||
Calculate net amount from gross amount and VAT rate.
|
||||
|
||||
GROSS-FIRST (Masterbook 2.0.1): In EU accounting, the gross amount (Bruttó)
|
||||
is the absolute Source of Truth. Net is calculated back from gross.
|
||||
|
||||
Formula: net = gross / (1 + vat_rate / 100)
|
||||
|
||||
If vat_rate is 0 or None, net = gross (0% VAT content).
|
||||
"""
|
||||
if vat_rate is None or vat_rate == 0:
|
||||
if vat_rate == 0:
|
||||
return amount_gross
|
||||
return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01"))
|
||||
divisor = Decimal("1") + (vat_rate / Decimal("100"))
|
||||
return (amount_gross / divisor).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
# ── ENDPOINTS ──
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_all_expenses(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
asset_id: Optional[str] = Query(None, description="Filter by asset UUID"),
|
||||
organization_id: Optional[int] = Query(None, description="Filter by organization ID. If provided, user must be a member."),
|
||||
current_user=Depends(get_current_user),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
asset_id: Optional[uuid.UUID] = Query(None),
|
||||
organization_id: Optional[int] = Query(None),
|
||||
category_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
sort: Optional[str] = Query("date_desc", description="Sort order: date_desc, date_asc, amount_desc, amount_asc"),
|
||||
):
|
||||
"""
|
||||
List all expenses across all assets for the current user's organization,
|
||||
ordered by date descending with pagination.
|
||||
Supports optional asset_id filter for vehicle-specific cost views.
|
||||
Supports optional organization_id filter for cross-org views (user must be a member).
|
||||
Returns enriched expense data including vehicle info, category name, and vendor.
|
||||
List all expenses (admin endpoint).
|
||||
Supports filtering by asset_id, organization_id, category_id, status.
|
||||
Supports sorting by date or amount.
|
||||
"""
|
||||
# Resolve organization: use explicit organization_id if provided, otherwise fallback to user's active org
|
||||
if organization_id is not None:
|
||||
# Verify user is a member of the requested organization
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
membership = org_result.scalar_one_or_none()
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You are not an active member of the requested organization."
|
||||
)
|
||||
org_id = organization_id
|
||||
stmt = select(AssetCost)
|
||||
|
||||
if asset_id:
|
||||
stmt = stmt.where(AssetCost.asset_id == asset_id)
|
||||
if organization_id:
|
||||
stmt = stmt.where(AssetCost.organization_id == organization_id)
|
||||
if category_id:
|
||||
stmt = stmt.where(AssetCost.category_id == category_id)
|
||||
if status:
|
||||
stmt = stmt.where(AssetCost.status == status)
|
||||
|
||||
# Sorting
|
||||
if sort == "date_asc":
|
||||
stmt = stmt.order_by(AssetCost.date.asc())
|
||||
elif sort == "amount_desc":
|
||||
stmt = stmt.order_by(AssetCost.amount_gross.desc())
|
||||
elif sort == "amount_asc":
|
||||
stmt = stmt.order_by(AssetCost.amount_gross.asc())
|
||||
else:
|
||||
# Fallback: resolve user's first active organization
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
membership = org_result.scalar_one_or_none()
|
||||
if not membership:
|
||||
raise HTTPException(status_code=403, detail="No active organization membership found.")
|
||||
org_id = membership.organization_id
|
||||
stmt = stmt.order_by(AssetCost.date.desc())
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * page_size
|
||||
# Count total
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Build base query with joins
|
||||
base_select = (
|
||||
select(
|
||||
AssetCost,
|
||||
CostCategory.code,
|
||||
CostCategory.name,
|
||||
Organization.name,
|
||||
Asset.license_plate,
|
||||
Asset.brand,
|
||||
Asset.model,
|
||||
)
|
||||
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
|
||||
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
|
||||
.join(Asset, AssetCost.asset_id == Asset.id)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
filters = [AssetCost.organization_id == org_id]
|
||||
if asset_id:
|
||||
filters.append(AssetCost.asset_id == asset_id)
|
||||
|
||||
expense_stmt = (
|
||||
base_select
|
||||
.where(*filters)
|
||||
.order_by(desc(AssetCost.date))
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
expense_result = await db.execute(expense_stmt)
|
||||
rows = expense_result.all()
|
||||
|
||||
# Build enriched response
|
||||
expenses = []
|
||||
for row in rows:
|
||||
cost = row[0]
|
||||
cat_code = row[1]
|
||||
cat_name = row[2]
|
||||
vendor_name = row[3]
|
||||
license_plate = row[4]
|
||||
brand = row[5]
|
||||
model = row[6]
|
||||
|
||||
data = cost.data or {}
|
||||
description = data.get("description")
|
||||
mileage_at_cost = data.get("mileage_at_cost")
|
||||
|
||||
# Build vehicle display name
|
||||
vehicle_name = license_plate or f"{brand or ''} {model or ''}".strip() or "Unknown"
|
||||
|
||||
expenses.append({
|
||||
"id": str(cost.id),
|
||||
"asset_id": str(cost.asset_id),
|
||||
"organization_id": cost.organization_id,
|
||||
"category_id": cost.category_id,
|
||||
"category_code": cat_code,
|
||||
"category_name": cat_name,
|
||||
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
|
||||
"amount_net": str(cost.amount_net),
|
||||
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
|
||||
"currency": cost.currency,
|
||||
"date": cost.date.isoformat() if cost.date else None,
|
||||
"status": cost.status,
|
||||
"description": description,
|
||||
"mileage_at_cost": mileage_at_cost,
|
||||
"invoice_number": cost.invoice_number,
|
||||
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
|
||||
"vendor_organization_id": cost.vendor_organization_id,
|
||||
"external_vendor_name": cost.external_vendor_name,
|
||||
"vendor_name": vendor_name,
|
||||
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
|
||||
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
|
||||
# Vehicle info
|
||||
"vehicle_name": vehicle_name,
|
||||
"license_plate": license_plate,
|
||||
})
|
||||
|
||||
# Count total for pagination (respect asset_id filter)
|
||||
count_filters = [AssetCost.organization_id == org_id]
|
||||
if asset_id:
|
||||
count_filters.append(AssetCost.asset_id == asset_id)
|
||||
count_stmt = (
|
||||
select(func.count(AssetCost.id))
|
||||
.where(*count_filters)
|
||||
)
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar()
|
||||
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
# Paginate
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
expenses = result.scalars().all()
|
||||
|
||||
return {
|
||||
"data": expenses,
|
||||
"status": "success",
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"data": expenses,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/{expense_id}", status_code=200)
|
||||
async def update_expense(
|
||||
expense_id: UUID,
|
||||
update: AssetCostUpdate,
|
||||
expense_id: uuid.UUID,
|
||||
expense_update: AssetCostUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing expense record.
|
||||
|
||||
Only the fields provided in the request body will be updated.
|
||||
The expense_id is the UUID of the existing AssetCost record.
|
||||
The user must be a member of the organization that owns the expense.
|
||||
Update an existing expense (PUT /expenses/{expense_id}).
|
||||
|
||||
All fields in AssetCostUpdate are optional — only provided fields will be updated.
|
||||
The expense_id is passed via path parameter.
|
||||
"""
|
||||
# Fetch the existing expense
|
||||
# Fetch existing expense
|
||||
stmt = select(AssetCost).where(AssetCost.id == expense_id)
|
||||
result = await db.execute(stmt)
|
||||
cost = result.scalar_one_or_none()
|
||||
|
||||
if not cost:
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Expense not found.")
|
||||
|
||||
# Verify user has access to the expense's organization
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.organization_id == cost.organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
membership = org_result.scalar_one_or_none()
|
||||
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You are not an active member of the organization that owns this expense."
|
||||
)
|
||||
|
||||
# Build update dict from non-None fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="No fields provided for update.")
|
||||
|
||||
# Map 'date' field to 'cost_date' column if present
|
||||
if 'date' in update_data:
|
||||
update_data['cost_date'] = update_data.pop('date')
|
||||
|
||||
# Handle mileage_at_cost and description → store in data JSONB
|
||||
data_update = {}
|
||||
if 'mileage_at_cost' in update_data:
|
||||
data_update['mileage_at_cost'] = update_data.pop('mileage_at_cost')
|
||||
if 'description' in update_data:
|
||||
data_update['description'] = update_data.pop('description')
|
||||
|
||||
# Merge data_update into existing data JSONB
|
||||
if data_update:
|
||||
existing_data = dict(cost.data or {})
|
||||
existing_data.update(data_update)
|
||||
update_data['data'] = existing_data
|
||||
|
||||
|
||||
# Update only provided fields
|
||||
update_data = expense_update.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle special fields
|
||||
if "date" in update_data:
|
||||
update_data["date"] = update_data.pop("date")
|
||||
if "mileage_at_cost" in update_data:
|
||||
# mileage_at_cost is stored inside data JSONB
|
||||
if existing.data is None:
|
||||
existing.data = {}
|
||||
existing.data["mileage_at_cost"] = update_data.pop("mileage_at_cost")
|
||||
if "description" in update_data:
|
||||
if existing.data is None:
|
||||
existing.data = {}
|
||||
existing.data["description"] = update_data.pop("description")
|
||||
|
||||
# Apply updates
|
||||
for field, value in update_data.items():
|
||||
setattr(cost, field, value)
|
||||
|
||||
try:
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(cost)
|
||||
|
||||
logger.info(f"Expense {expense_id} updated by user {current_user.id}: fields={list(update_data.keys())}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"id": cost.id,
|
||||
"asset_id": cost.asset_id,
|
||||
"category_id": cost.category_id,
|
||||
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
|
||||
"amount_net": str(cost.amount_net),
|
||||
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
|
||||
"expense_status": cost.status,
|
||||
"date": cost.date.isoformat() if cost.date else None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Expense update error for {expense_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba a költség módosításakor"
|
||||
)
|
||||
setattr(existing, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"id": existing.id,
|
||||
"message": "Expense updated successfully.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{asset_id}")
|
||||
async def list_asset_expenses(
|
||||
asset_id: UUID,
|
||||
asset_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user),
|
||||
limit: int = Query(50, ge=1, le=200, description="Maximum number of expenses to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of expenses to skip"),
|
||||
current_user=Depends(get_current_user),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
sort: Optional[str] = Query("date_desc", description="Sort order: date_desc, date_asc"),
|
||||
):
|
||||
"""
|
||||
List all expenses for a specific asset, ordered by date descending.
|
||||
Returns enriched expense data including category name, code, and vendor info.
|
||||
Used by the OverviewTab and CostManagerModal to display real expense data.
|
||||
List expenses for a specific asset.
|
||||
Supports pagination and sorting by date.
|
||||
"""
|
||||
# Validate asset exists
|
||||
stmt = select(Asset).where(Asset.id == asset_id)
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Asset not found.")
|
||||
|
||||
# Fetch expenses with category join and vendor organization join
|
||||
expense_stmt = (
|
||||
select(AssetCost, CostCategory.code, CostCategory.name, Organization.name)
|
||||
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
|
||||
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
|
||||
stmt = (
|
||||
select(AssetCost)
|
||||
.where(AssetCost.asset_id == asset_id)
|
||||
.order_by(desc(AssetCost.date))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
expense_result = await db.execute(expense_stmt)
|
||||
rows = expense_result.all()
|
||||
|
||||
# Build enriched response
|
||||
expenses = []
|
||||
for row in rows:
|
||||
cost = row[0] # AssetCost instance
|
||||
cat_code = row[1] # CostCategory.code
|
||||
cat_name = row[2] # CostCategory.name
|
||||
vendor_name = row[3] # Organization.name (resolved from vendor_organization_id)
|
||||
# Sorting
|
||||
if sort == "date_asc":
|
||||
stmt = stmt.order_by(AssetCost.date.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(AssetCost.date.desc())
|
||||
|
||||
# Extract description and mileage from data JSONB
|
||||
data = cost.data or {}
|
||||
description = data.get("description")
|
||||
mileage_at_cost = data.get("mileage_at_cost")
|
||||
# Count total
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
expenses.append({
|
||||
"id": str(cost.id),
|
||||
"asset_id": str(cost.asset_id),
|
||||
"organization_id": cost.organization_id,
|
||||
"category_id": cost.category_id,
|
||||
"category_code": cat_code,
|
||||
"category_name": cat_name,
|
||||
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
|
||||
"amount_net": str(cost.amount_net),
|
||||
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
|
||||
"currency": cost.currency,
|
||||
"date": cost.date.isoformat() if cost.date else None,
|
||||
"status": cost.status,
|
||||
"description": description,
|
||||
"mileage_at_cost": mileage_at_cost,
|
||||
"invoice_number": cost.invoice_number,
|
||||
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
|
||||
# === B2B VENDOR FIELDS ===
|
||||
"vendor_organization_id": cost.vendor_organization_id,
|
||||
"external_vendor_name": cost.external_vendor_name,
|
||||
"vendor_name": vendor_name, # Enriched from fleet.organizations.name
|
||||
# === INVOICE DATES ===
|
||||
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
|
||||
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
|
||||
})
|
||||
|
||||
# Count total for pagination
|
||||
count_stmt = select(func.count(AssetCost.id)).where(AssetCost.asset_id == asset_id)
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar()
|
||||
# Paginate
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
expenses = result.scalars().all()
|
||||
|
||||
return {
|
||||
"data": expenses,
|
||||
"status": "success",
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"data": expenses,
|
||||
}
|
||||
|
||||
|
||||
@@ -440,7 +295,7 @@ async def list_asset_expenses(
|
||||
async def create_expense(
|
||||
expense: AssetCostCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create a new expense (fuel, service, tax, insurance) for an asset.
|
||||
@@ -563,8 +418,88 @@ async def create_expense(
|
||||
if expense.description:
|
||||
data["description"] = expense.description
|
||||
|
||||
# ── PROVIDER AUTO-DISCOVERY HOOK (Card #360) ──
|
||||
# Ha external_vendor_name meg van adva, de service_provider_id nincs,
|
||||
# automatikusan felfedezzük vagy létrehozzuk a providert.
|
||||
#
|
||||
# P0 ARCHITECTURE CLEANUP (2026-07-01):
|
||||
# A gamification/XP logika ELTÁVOLÍTVA a service rétegből.
|
||||
# A find_or_create_provider_by_name() már csak (provider, created) tuple-t ad vissza.
|
||||
# Ha created=True (új provider felfedezve), itt az API rétegben hívjuk meg
|
||||
# a gamification_service.award_points()-t a PROVIDER_DISCOVERY action_key-kel.
|
||||
resolved_provider_id = expense.service_provider_id
|
||||
if expense.external_vendor_name and not expense.service_provider_id:
|
||||
try:
|
||||
provider, created = await find_or_create_provider_by_name(
|
||||
db=db,
|
||||
name=expense.external_vendor_name,
|
||||
added_by_user_id=current_user.id,
|
||||
)
|
||||
resolved_provider_id = provider.id
|
||||
|
||||
# P0 ARCHITECTURE CLEANUP: Gamification XP kiosztása az API rétegben
|
||||
# Csak akkor adunk XP-t, ha a provider újonnan lett felfedezve (created=True)
|
||||
if created:
|
||||
await gamification_service.award_points(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
amount=0,
|
||||
reason="PROVIDER_DISCOVERY",
|
||||
commit=False, # A hívó (create_expense) kezeli a commit-ot
|
||||
action_key="PROVIDER_DISCOVERY",
|
||||
)
|
||||
logger.info(
|
||||
f"Gamification XP awarded for PROVIDER_DISCOVERY: "
|
||||
f"name='{expense.external_vendor_name}', "
|
||||
f"provider_id={provider.id}, user_id={current_user.id}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Provider auto-discovery: name='{expense.external_vendor_name}', "
|
||||
f"provider_id={provider.id}, created={created}, "
|
||||
f"user_id={current_user.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
# Ha a provider felderítés hibázik, ne blokkolja a költség rögzítését
|
||||
logger.warning(
|
||||
f"Provider auto-discovery failed for '{expense.external_vendor_name}': {e}. "
|
||||
f"Expense will be created without provider link."
|
||||
)
|
||||
|
||||
try:
|
||||
# Create AssetCost instance with new fields
|
||||
# ── P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation ──
|
||||
# A frontend elküldheti a ServiceProvider.id-t vendor_organization_id-ként,
|
||||
# ami FK violation-t okoz, mert az FK a fleet.organizations táblára hivatkozik.
|
||||
# Itt validáljuk, hogy a megadott vendor_organization_id létezik-e.
|
||||
resolved_vendor_org_id = expense.vendor_organization_id
|
||||
if resolved_vendor_org_id is not None:
|
||||
try:
|
||||
org_check_stmt = select(Organization.id).where(
|
||||
Organization.id == resolved_vendor_org_id,
|
||||
Organization.is_deleted == False,
|
||||
)
|
||||
org_check_result = await db.execute(org_check_stmt)
|
||||
org_exists = org_check_result.scalar_one_or_none()
|
||||
if org_exists is None:
|
||||
# A megadott ID nem létezik fleet.organizations-ben.
|
||||
# Valószínűleg ServiceProvider.id-t küldött a frontend.
|
||||
logger.warning(
|
||||
f"vendor_organization_id={resolved_vendor_org_id} does not exist "
|
||||
f"in fleet.organizations. Setting to None and using service_provider_id."
|
||||
)
|
||||
resolved_vendor_org_id = None
|
||||
# Ha nincs resolved_provider_id, akkor a vendor_organization_id-t
|
||||
# használjuk service_provider_id-ként (P0 Hybrid Vendor Refactor)
|
||||
if resolved_provider_id is None:
|
||||
resolved_provider_id = expense.vendor_organization_id
|
||||
except Exception as org_check_e:
|
||||
logger.warning(
|
||||
f"vendor_organization_id validation failed for "
|
||||
f"id={resolved_vendor_org_id}: {org_check_e}. Setting to None."
|
||||
)
|
||||
resolved_vendor_org_id = None
|
||||
|
||||
# ── PHASE 1: Create AssetCost instance with new fields ──
|
||||
new_cost = AssetCost(
|
||||
asset_id=expense.asset_id,
|
||||
organization_id=organization_id,
|
||||
@@ -578,9 +513,10 @@ async def create_expense(
|
||||
status=expense_status,
|
||||
data=data,
|
||||
# === B2B VENDOR FIELDS ===
|
||||
vendor_organization_id=expense.vendor_organization_id,
|
||||
vendor_organization_id=resolved_vendor_org_id,
|
||||
# P0 HYBRID VENDOR REFACTOR: service_provider_id az expense creation-ben
|
||||
service_provider_id=expense.service_provider_id,
|
||||
# Ha az auto-discovery hook feloldotta a providert, a resolved_provider_id-t használjuk
|
||||
service_provider_id=resolved_provider_id,
|
||||
external_vendor_name=expense.external_vendor_name,
|
||||
# === INVOICE DATES ===
|
||||
invoice_date=expense.invoice_date,
|
||||
@@ -590,7 +526,76 @@ async def create_expense(
|
||||
db.add(new_cost)
|
||||
await db.flush() # Flush to get new_cost.id
|
||||
|
||||
# ── PHASE 2: SMART LINKING - Auto-create AssetEvent for service-related costs ──
|
||||
# ── PHASE 2: ODOMETER NORMALIZATION ──
|
||||
# P0 Smart Expense Workflow: Create a dedicated OdometerReading record
|
||||
# linked to the asset_id and the newly created cost_id.
|
||||
odometer_record = None
|
||||
if expense.mileage_at_cost is not None:
|
||||
odometer_record = OdometerReading(
|
||||
asset_id=expense.asset_id,
|
||||
reading=expense.mileage_at_cost,
|
||||
source="expense",
|
||||
cost_id=new_cost.id,
|
||||
)
|
||||
db.add(odometer_record)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"Odometer normalization: created reading {odometer_record.id} "
|
||||
f"for asset {expense.asset_id}, reading={expense.mileage_at_cost}, "
|
||||
f"linked to cost {new_cost.id}"
|
||||
)
|
||||
|
||||
# ── PHASE 3: PROVIDER VALIDATION SCORE INCREMENT ──
|
||||
# P0 Smart Expense Workflow: If service_provider_id is provided,
|
||||
# increment validation_score by 10. If it reaches 100, set is_verified.
|
||||
provider_validated = False
|
||||
provider_id_for_gamification = resolved_provider_id or expense.service_provider_id
|
||||
if provider_id_for_gamification is not None:
|
||||
try:
|
||||
provider_stmt = select(ServiceProvider).where(
|
||||
ServiceProvider.id == provider_id_for_gamification
|
||||
)
|
||||
provider_result = await db.execute(provider_stmt)
|
||||
provider = provider_result.scalar_one_or_none()
|
||||
|
||||
if provider:
|
||||
old_score = provider.validation_score or 0
|
||||
if old_score < 100:
|
||||
new_score = min(old_score + 10, 100)
|
||||
provider.validation_score = new_score
|
||||
provider_validated = True
|
||||
|
||||
logger.info(
|
||||
f"Provider validation score incremented: provider_id={provider.id}, "
|
||||
f"old_score={old_score}, new_score={new_score}"
|
||||
)
|
||||
|
||||
# If validation_score reaches 100, mark the provider as verified
|
||||
if new_score >= 100:
|
||||
# Update ServiceProvider status to approved
|
||||
from app.models.identity.social import ModerationStatus
|
||||
provider.status = ModerationStatus.approved
|
||||
|
||||
# Also update the linked ServiceProfile.is_verified if exists
|
||||
profile_stmt = select(ServiceProfile).where(
|
||||
ServiceProfile.organization_id == provider.id
|
||||
)
|
||||
profile_result = await db.execute(profile_stmt)
|
||||
profile = profile_result.scalar_one_or_none()
|
||||
if profile:
|
||||
profile.is_verified = True
|
||||
logger.info(
|
||||
f"Provider fully verified: provider_id={provider.id}, "
|
||||
f"profile_id={profile.id}"
|
||||
)
|
||||
except Exception as prov_e:
|
||||
# Provider validation failure should not block expense creation
|
||||
logger.warning(
|
||||
f"Provider validation score increment failed for "
|
||||
f"provider_id={provider_id_for_gamification}: {prov_e}"
|
||||
)
|
||||
|
||||
# ── PHASE 4: SMART LINKING - Auto-create AssetEvent for service-related costs ──
|
||||
event_id = None
|
||||
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
|
||||
# Map cost category to event type
|
||||
@@ -632,6 +637,51 @@ async def create_expense(
|
||||
if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0):
|
||||
asset.current_mileage = expense.mileage_at_cost
|
||||
|
||||
# ── PHASE 5: GAMIFICATION HOOKS ──
|
||||
# P0 Smart Expense Workflow: Award XP for expense logging and provider validation.
|
||||
# All gamification calls use commit=False to stay within the outer transaction.
|
||||
|
||||
# 5a. Always award APP_USAGE_EXPENSE for successful expense logging
|
||||
try:
|
||||
await gamification_service.award_points(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
amount=0,
|
||||
reason="APP_USAGE_EXPENSE",
|
||||
commit=False,
|
||||
action_key="APP_USAGE_EXPENSE",
|
||||
)
|
||||
logger.info(
|
||||
f"Gamification XP awarded for APP_USAGE_EXPENSE: "
|
||||
f"user_id={current_user.id}, cost_id={new_cost.id}"
|
||||
)
|
||||
except Exception as gam_e:
|
||||
logger.warning(
|
||||
f"Gamification APP_USAGE_EXPENSE failed for user {current_user.id}: {gam_e}"
|
||||
)
|
||||
|
||||
# 5b. If provider validation score was incremented, award PROVIDER_VALIDATION_HELP
|
||||
if provider_validated:
|
||||
try:
|
||||
await gamification_service.award_points(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
amount=0,
|
||||
reason="PROVIDER_VALIDATION_HELP",
|
||||
commit=False,
|
||||
action_key="PROVIDER_VALIDATION_HELP",
|
||||
)
|
||||
logger.info(
|
||||
f"Gamification XP awarded for PROVIDER_VALIDATION_HELP: "
|
||||
f"user_id={current_user.id}, provider_id={provider_id_for_gamification}"
|
||||
)
|
||||
except Exception as gam_e:
|
||||
logger.warning(
|
||||
f"Gamification PROVIDER_VALIDATION_HELP failed for "
|
||||
f"user {current_user.id}: {gam_e}"
|
||||
)
|
||||
|
||||
# ── FINAL COMMIT: Single atomic transaction ──
|
||||
await db.commit()
|
||||
await db.refresh(new_cost)
|
||||
|
||||
@@ -646,6 +696,8 @@ async def create_expense(
|
||||
"expense_status": new_cost.status,
|
||||
"date": new_cost.date.isoformat() if new_cost.date else None,
|
||||
"event_id": str(event_id) if event_id else None,
|
||||
"odometer_reading_id": str(odometer_record.id) if odometer_record else None,
|
||||
"provider_validated": provider_validated,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
237
backend/app/api/v1/endpoints/financial_manager.py
Normal file
237
backend/app/api/v1/endpoints/financial_manager.py
Normal file
@@ -0,0 +1,237 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/financial_manager.py
|
||||
"""
|
||||
Financial Manager API Endpoints — Package Purchase Lifecycle.
|
||||
|
||||
Provides the public API for purchasing subscription packages.
|
||||
The FinancialManager orchestrates payment, subscription activation,
|
||||
and MLM commission distribution.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- POST /financial-manager/purchase-package is the main entry point.
|
||||
- Commission distribution runs as a FastAPI BackgroundTask so the
|
||||
payment response is fast and the MLM logic completes asynchronously.
|
||||
- Uses the existing get_current_user dependency for authentication.
|
||||
- Returns a detailed receipt with payment, subscription, and commission info.
|
||||
- Fully async with proper error handling and logging.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity.identity import User
|
||||
from app.schemas.financial_manager import PurchaseRequest, PurchaseResponse
|
||||
from app.services.financial_manager import FinancialManager
|
||||
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||
from app.services.subscription_activator import TierNotFoundError
|
||||
from app.services.financial_interfaces import PaymentGatewayError
|
||||
|
||||
logger = logging.getLogger("financial-manager-api")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Dependency: FinancialManager instance
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_financial_manager() -> FinancialManager:
|
||||
"""
|
||||
Dependency that provides a configured FinancialManager instance.
|
||||
|
||||
Currently uses MockPaymentGateway as default. To switch to Stripe:
|
||||
from app.services.stripe_adapter import StripeAdapter
|
||||
return FinancialManager(payment_gateway=StripeAdapter())
|
||||
|
||||
Returns:
|
||||
A FinancialManager instance with the configured payment gateway.
|
||||
"""
|
||||
return FinancialManager(
|
||||
payment_gateway=MockPaymentGateway(mode="auto_approve"),
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Background Task: Async Commission Distribution
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def distribute_commission_background(
|
||||
db: AsyncSession,
|
||||
buyer_user_id: int,
|
||||
transaction_amount: float,
|
||||
region_code: str,
|
||||
) -> None:
|
||||
"""
|
||||
Background task for async MLM commission distribution.
|
||||
|
||||
Runs after the purchase response is sent to the client.
|
||||
Uses a new database session to avoid sharing the request session.
|
||||
|
||||
Args:
|
||||
db: A new database session.
|
||||
buyer_user_id: The user who made the purchase.
|
||||
transaction_amount: The amount paid.
|
||||
region_code: Region code for rule matching.
|
||||
"""
|
||||
try:
|
||||
manager = get_financial_manager()
|
||||
await manager._distribute_commission(
|
||||
db=db,
|
||||
buyer_user_id=buyer_user_id,
|
||||
transaction_amount=transaction_amount,
|
||||
region_code=region_code,
|
||||
)
|
||||
logger.info(
|
||||
"Background commission distribution completed for buyer=%d",
|
||||
buyer_user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Background commission distribution FAILED for buyer=%d: %s",
|
||||
buyer_user_id, str(e),
|
||||
)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# POST /financial-manager/purchase-package
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/purchase-package",
|
||||
response_model=PurchaseResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
tags=["Financial Manager"],
|
||||
summary="Purchase a subscription package",
|
||||
description="""
|
||||
Purchase a subscription package with full lifecycle management.
|
||||
|
||||
Flow:
|
||||
1. Validates the subscription tier exists
|
||||
2. Calculates the price (region-adjusted)
|
||||
3. Creates a PaymentIntent (PENDING)
|
||||
4. Processes payment via the configured gateway (Mock or Stripe)
|
||||
5. Activates the subscription (User or Organization level, with stacking)
|
||||
6. Distributes MLM commissions asynchronously (BackgroundTask)
|
||||
7. Returns a detailed receipt
|
||||
|
||||
Supports both User-level (private garages) and Organization-level
|
||||
(company fleets) subscriptions.
|
||||
""",
|
||||
)
|
||||
async def purchase_package(
|
||||
request: PurchaseRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
manager: FinancialManager = Depends(get_financial_manager),
|
||||
):
|
||||
"""
|
||||
Purchase a subscription package.
|
||||
|
||||
The commission distribution runs as a background task so the
|
||||
payment response is fast and non-blocking.
|
||||
|
||||
Args:
|
||||
request: Purchase details (tier_id, org_id, region_code, etc.).
|
||||
background_tasks: FastAPI BackgroundTasks for async commission.
|
||||
db: Database session.
|
||||
current_user: Authenticated user.
|
||||
manager: FinancialManager instance.
|
||||
|
||||
Returns:
|
||||
PurchaseResponse with full receipt details.
|
||||
"""
|
||||
logger.info(
|
||||
"Purchase request: user_id=%d tier_id=%d org_id=%s",
|
||||
current_user.id, request.tier_id, request.org_id,
|
||||
)
|
||||
|
||||
# ── Execute the purchase flow ──────────────────────────────────────────
|
||||
result = await manager.purchase_package(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
tier_id=request.tier_id,
|
||||
org_id=request.org_id,
|
||||
region_code=request.region_code,
|
||||
currency=request.currency,
|
||||
duration_days=request.duration_days,
|
||||
metadata=request.metadata,
|
||||
)
|
||||
|
||||
# ── Handle failure ─────────────────────────────────────────────────────
|
||||
if not result.success:
|
||||
error_detail = result.error or "Unknown error during purchase"
|
||||
logger.warning(
|
||||
"Purchase failed: user_id=%d tier_id=%d error=%s",
|
||||
current_user.id, request.tier_id, error_detail,
|
||||
)
|
||||
|
||||
# Determine appropriate HTTP status code
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
if "not found" in error_detail.lower():
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
elif "payment" in error_detail.lower():
|
||||
status_code = status.HTTP_402_PAYMENT_REQUIRED
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail=error_detail,
|
||||
)
|
||||
|
||||
# ── Schedule async commission distribution ─────────────────────────────
|
||||
# We pass the commission result from the sync call, but also schedule
|
||||
# a background task for any additional async processing.
|
||||
# The sync commission result is already included in the response.
|
||||
if result.commission_result and result.commission_result.items:
|
||||
background_tasks.add_task(
|
||||
distribute_commission_background,
|
||||
db=db, # Note: In production, use a new session
|
||||
buyer_user_id=current_user.id,
|
||||
transaction_amount=result.amount_paid,
|
||||
region_code=request.region_code,
|
||||
)
|
||||
logger.info(
|
||||
"Background commission task scheduled for buyer=%d",
|
||||
current_user.id,
|
||||
)
|
||||
|
||||
# ── Build response ─────────────────────────────────────────────────────
|
||||
response_data = result.to_dict()
|
||||
return PurchaseResponse(**response_data)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Health / Status Endpoint
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/financial-manager/status",
|
||||
tags=["Financial Manager"],
|
||||
summary="Check FinancialManager status",
|
||||
)
|
||||
async def financial_manager_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Check the FinancialManager service status.
|
||||
|
||||
Returns the configured payment gateway type and current mode.
|
||||
Useful for debugging and monitoring.
|
||||
"""
|
||||
manager = get_financial_manager()
|
||||
gateway = manager.payment_gateway
|
||||
|
||||
return {
|
||||
"service": "FinancialManager",
|
||||
"gateway_type": type(gateway).__name__,
|
||||
"gateway_mode": getattr(gateway, "mode", "unknown"),
|
||||
"status": "operational",
|
||||
}
|
||||
93
backend/app/api/v1/endpoints/locations.py
Normal file
93
backend/app/api/v1/endpoints/locations.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
🌍 Location Autocomplete API Endpoint.
|
||||
|
||||
Provides autocomplete search for postal codes and cities from the
|
||||
system.geo_postal_codes table. Used by the frontend SmartLocationInput
|
||||
component to let users quickly find and select a zip/city pair.
|
||||
|
||||
Schema: system.geo_postal_codes
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.models.identity.address import GeoPostalCode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LocationAutocompleteItem(BaseModel):
|
||||
"""Egy találat a település autocomplete keresőben."""
|
||||
id: int
|
||||
zip_code: str
|
||||
city: str
|
||||
country_code: str = "HU"
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/locations/autocomplete", response_model=List[LocationAutocompleteItem])
|
||||
async def autocomplete_locations(
|
||||
q: str = Query(..., min_length=1, max_length=100, description="Keresőszó (irányítószám vagy városrészlet)"),
|
||||
country_code: str = Query("HU", max_length=5, description="Országkód szűrő (alapértelmezett: HU)"),
|
||||
limit: int = Query(15, ge=1, le=50, description="Max találatok száma"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
GET /api/v1/locations/autocomplete?q=budapest
|
||||
|
||||
Autocomplete keresés a system.geo_postal_codes táblában.
|
||||
A keresés az irányítószámra (zip_code) és a város névre (city) is
|
||||
történik ILIKE operátorral.
|
||||
|
||||
Példák:
|
||||
/api/v1/locations/autocomplete?q=1011 -> 1011 Budapest
|
||||
/api/v1/locations/autocomplete?q=budapest -> 1011 Budapest, 1012 Budapest, ...
|
||||
/api/v1/locations/autocomplete?q=101 -> 1011 Budapest, 1012 Budapest, ...
|
||||
/api/v1/locations/autocomplete?q=bud&country_code=AT -> 5020 Salzburg, ...
|
||||
"""
|
||||
if not q.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="A keresőszó (q) nem lehet üres.",
|
||||
)
|
||||
|
||||
try:
|
||||
stmt = (
|
||||
select(GeoPostalCode)
|
||||
.where(
|
||||
GeoPostalCode.country_code == country_code,
|
||||
or_(
|
||||
GeoPostalCode.zip_code.ilike(f"%{q.strip()}%"),
|
||||
GeoPostalCode.city.ilike(f"%{q.strip()}%"),
|
||||
),
|
||||
)
|
||||
.order_by(GeoPostalCode.zip_code, GeoPostalCode.city)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
items = result.scalars().all()
|
||||
|
||||
return [
|
||||
LocationAutocompleteItem(
|
||||
id=item.id,
|
||||
zip_code=item.zip_code,
|
||||
city=item.city,
|
||||
country_code=item.country_code,
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a location autocomplete során: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba történt a település keresés során.",
|
||||
)
|
||||
@@ -17,9 +17,12 @@ from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
|
||||
from app.schemas.subscription import SubscriptionTierResponse
|
||||
from app.schemas.address import AddressOut
|
||||
from app.services.billing_engine import upgrade_org_subscription
|
||||
from app.services.address_manager import AddressManager
|
||||
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
|
||||
from app.models.identity import User, OneTimePassword
|
||||
from app.models.identity.address import Address
|
||||
from app.core.config import settings
|
||||
from app.services.security_service import security_service
|
||||
from app.models import LogSeverity
|
||||
@@ -49,7 +52,7 @@ async def onboard_organization(
|
||||
if org_in.country_code == "HU":
|
||||
if not re.match(r"^\d{8}-\d-\d{2}$", org_in.tax_number):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=_("ORGANIZATION.ERROR.INVALID_TAX_FORMAT")
|
||||
)
|
||||
|
||||
@@ -65,7 +68,10 @@ async def onboard_organization(
|
||||
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
|
||||
temp_slug = hashlib.md5(f"{org_in.tax_number}-{uuid.uuid4()}".encode()).hexdigest()[:12]
|
||||
|
||||
# 4. Mentés
|
||||
# 4. P0 REFACTORED: Create Address record via AddressManager, then link via address_id FK
|
||||
address_id = await AddressManager.create_or_update(db, None, org_in.address)
|
||||
|
||||
# 5. Mentés
|
||||
new_org = Organization(
|
||||
full_name=org_in.full_name,
|
||||
name=org_in.name,
|
||||
@@ -73,12 +79,7 @@ async def onboard_organization(
|
||||
tax_number=org_in.tax_number,
|
||||
reg_number=org_in.reg_number,
|
||||
folder_slug=temp_slug,
|
||||
address_zip=org_in.address_zip,
|
||||
address_city=org_in.address_city,
|
||||
address_street_name=org_in.address_street_name,
|
||||
address_street_type=org_in.address_street_type,
|
||||
address_house_number=org_in.address_house_number,
|
||||
address_hrsz=org_in.address_hrsz,
|
||||
address_id=address_id,
|
||||
country_code=org_in.country_code,
|
||||
org_type=OrgType.business,
|
||||
status="pending_verification",
|
||||
@@ -96,22 +97,17 @@ async def onboard_organization(
|
||||
db.add(new_org)
|
||||
await db.flush()
|
||||
|
||||
# 5. ALAPÉRTELMEZETT KÖZPONTI TELEPHELY LÉTREHOZÁSA
|
||||
# 6. ALAPÉRTELMEZETT KÖZPONTI TELEPHELY LÉTREHOZÁSA (with address_id FK)
|
||||
main_branch = Branch(
|
||||
organization_id=new_org.id,
|
||||
name=_("ORGANIZATION.BRANCH.DEFAULT_NAME"),
|
||||
is_main=True,
|
||||
postal_code=org_in.address_zip,
|
||||
city=org_in.address_city,
|
||||
street_name=org_in.address_street_name,
|
||||
street_type=org_in.address_street_type,
|
||||
house_number=org_in.address_house_number,
|
||||
hrsz=org_in.address_hrsz,
|
||||
address_id=address_id,
|
||||
status="active"
|
||||
)
|
||||
db.add(main_branch)
|
||||
|
||||
# 6. TULAJDONOS RÖGZÍTÉSE
|
||||
# 7. TULAJDONOS RÖGZÍTÉSE
|
||||
owner_member = OrganizationMember(
|
||||
organization_id=new_org.id,
|
||||
user_id=current_user.id,
|
||||
@@ -119,7 +115,7 @@ async def onboard_organization(
|
||||
)
|
||||
db.add(owner_member)
|
||||
|
||||
# 7. NAS Mappa létrehozása
|
||||
# 8. NAS Mappa létrehozása
|
||||
try:
|
||||
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data")
|
||||
org_path = os.path.join(base_path, "organizations", str(new_org.id))
|
||||
@@ -185,6 +181,7 @@ async def get_my_organizations(
|
||||
A bejelentkezett felhasználóhoz tartozó szervezetek listázása.
|
||||
P0: Minden szervezethez visszaadja a valós max_vehicles és max_garages limiteket
|
||||
a subscription_tier JSONB rules alapján.
|
||||
P0 REFACTORED: Address adatok az address relációból (AddressOut séma).
|
||||
"""
|
||||
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||
|
||||
@@ -205,7 +202,10 @@ async def get_my_organizations(
|
||||
stmt = (
|
||||
select(Organization)
|
||||
.where(Organization.id.in_(select(subq.c.id)))
|
||||
.options(selectinload(Organization.members))
|
||||
.options(
|
||||
selectinload(Organization.members),
|
||||
selectinload(Organization.address),
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
orgs = result.scalars().all()
|
||||
@@ -256,13 +256,8 @@ async def get_my_organizations(
|
||||
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
|
||||
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
|
||||
"user_role": _get_user_role(o, current_user.id),
|
||||
# ── Cím adatok (Address fields) ──
|
||||
"address_zip": o.address_zip,
|
||||
"address_city": o.address_city,
|
||||
"address_street_name": o.address_street_name,
|
||||
"address_street_type": o.address_street_type,
|
||||
"address_house_number": o.address_house_number,
|
||||
"address_hrsz": o.address_hrsz,
|
||||
# ── P0 REFACTORED: Address from relationship ──
|
||||
"address": AddressOut.model_validate(o.address).model_dump() if o.address else None,
|
||||
}
|
||||
for o in orgs
|
||||
]
|
||||
@@ -704,6 +699,12 @@ async def update_organization(
|
||||
"""
|
||||
Szervezet adatainak részleges frissítése (általános PATCH).
|
||||
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
||||
|
||||
P0 REFACTORED: Address kezelés a unified AddressIn/AddressOut séma alapján.
|
||||
- Ha payload.address meg van adva, akkor:
|
||||
* Ha van meglévő address_id → frissíti a system.addresses rekordot
|
||||
* Ha nincs → létrehoz egy új system.addresses rekordot és beállítja az address_id FK-t
|
||||
- A régi denormalizált mezők (address_zip, address_city, stb.) ELTÁVOLÍTVA.
|
||||
"""
|
||||
# 1. Jogosultság ellenőrzése
|
||||
# Először lekérjük a szervezetet, hogy ellenőrizhessük az owner_id-t is
|
||||
@@ -745,7 +746,15 @@ async def update_organization(
|
||||
org.visual_settings = current_vs
|
||||
del update_dict["visual_settings"]
|
||||
|
||||
# 4. Többi mező frissítése
|
||||
# 3. P0 REFACTORED: Address FK logic via AddressManager
|
||||
if "address" in update_dict:
|
||||
addr_in = update_dict["address"]
|
||||
org.address_id = await AddressManager.create_or_update(
|
||||
db, org.address_id, addr_in
|
||||
)
|
||||
del update_dict["address"]
|
||||
|
||||
# 4. Többi mező frissítése (kivéve a régi denormalizált címmezőket)
|
||||
for field, value in update_dict.items():
|
||||
if hasattr(org, field) and value is not None:
|
||||
setattr(org, field, value)
|
||||
|
||||
@@ -15,9 +15,10 @@ import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy import select, or_, cast, String
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
@@ -76,8 +77,8 @@ async def get_category_tree(
|
||||
nodes.append(CategoryTreeNode(
|
||||
id=tag.id,
|
||||
key=tag.key,
|
||||
name_hu=tag.name_hu,
|
||||
name_en=tag.name_en,
|
||||
name_i18n=tag.name_i18n if tag.name_i18n else {},
|
||||
description_i18n=tag.description_i18n,
|
||||
level=tag.level,
|
||||
path=tag.path,
|
||||
is_official=tag.is_official,
|
||||
@@ -106,18 +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_hu, name_en és key mezőkben történik (ILIKE).
|
||||
A keresés a name_i18n JSONB (minden nyelven) és key mezőkben történik.
|
||||
"""
|
||||
try:
|
||||
stmt = select(ExpertiseTag).where(
|
||||
ExpertiseTag.level >= 2,
|
||||
ExpertiseTag.is_official == True,
|
||||
or_(
|
||||
ExpertiseTag.name_hu.ilike(f"%{q}%"),
|
||||
ExpertiseTag.name_en.ilike(f"%{q}%"),
|
||||
cast(ExpertiseTag.name_i18n, String).ilike(f"%{q}%"),
|
||||
ExpertiseTag.key.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()
|
||||
@@ -126,8 +126,7 @@ async def autocomplete_categories(
|
||||
CategoryAutocompleteItem(
|
||||
id=t.id,
|
||||
key=t.key,
|
||||
name_hu=t.name_hu,
|
||||
name_en=t.name_en,
|
||||
name_i18n=t.name_i18n if t.name_i18n else {},
|
||||
level=t.level,
|
||||
path=t.path,
|
||||
parent_id=t.parent_id,
|
||||
@@ -164,6 +163,9 @@ async def list_expertise_categories(
|
||||
ExpertiseCategoryOut(
|
||||
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,
|
||||
category=t.category,
|
||||
@@ -183,7 +185,7 @@ async def list_expertise_categories(
|
||||
|
||||
@router.get("/search", response_model=ProviderSearchResponse)
|
||||
async def search_service_providers(
|
||||
q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"),
|
||||
q: Optional[str] = Query(None, min_length=3, max_length=200, description="Keresőszó (min. 3 karakter)"),
|
||||
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
|
||||
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
|
||||
category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"),
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.marketplace.organization import Organization, Branch
|
||||
from app.models.identity.address import Address, GeoPostalCode
|
||||
from geoalchemy2 import WKTElement
|
||||
from typing import Optional
|
||||
|
||||
@@ -23,16 +24,24 @@ async def match_service(
|
||||
"""
|
||||
Geofencing keresőmotor PostGIS segítségével.
|
||||
Ha nincs megadva lat/lng, akkor nem alkalmazunk távolságszűrést.
|
||||
|
||||
P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A Branch.city denormalizált
|
||||
mező helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el
|
||||
a város adatokat. LEFT JOIN-t használunk, mert az address_id lehet NULL.
|
||||
"""
|
||||
# Alap lekérdezés: aktív szervezetek és telephelyek
|
||||
query = select(
|
||||
Organization.id,
|
||||
Organization.name,
|
||||
Branch.city,
|
||||
GeoPostalCode.city.label("city"),
|
||||
Branch.branch_rating,
|
||||
Branch.location
|
||||
).join(
|
||||
Branch, Organization.id == Branch.organization_id
|
||||
).outerjoin(
|
||||
Address, Address.id == Branch.address_id
|
||||
).outerjoin(
|
||||
GeoPostalCode, GeoPostalCode.id == Address.postal_code_id
|
||||
).where(
|
||||
Organization.is_active == True,
|
||||
Branch.is_deleted == False
|
||||
|
||||
@@ -36,8 +36,8 @@ async def register_service_hunt(
|
||||
"""), {"n": name, "f": f"{name}-{lat}-{lng}", "lat": lat, "lng": lng, "user_id": current_user.id})
|
||||
|
||||
# MB 2.0 Gamification: Dinamikus pontszám a felfedezésért
|
||||
reward_points = await ConfigService.get_int(db, "GAMIFICATION_HUNT_REWARD", 50)
|
||||
await GamificationService.award_points(db, current_user.id, reward_points, f"Service Hunt: {name}")
|
||||
# A pontérték a point_rules táblából jön (action_key='SERVICE_HUNT')
|
||||
await GamificationService.award_points(db, current_user.id, amount=0, reason=f"Service Hunt: {name}", action_key="SERVICE_HUNT")
|
||||
await db.commit()
|
||||
return {"status": "success", "message": "Discovery registered and points awarded."}
|
||||
|
||||
@@ -86,8 +86,8 @@ async def validate_staged_service(
|
||||
)
|
||||
|
||||
# 5. Adományozz dinamikus XP-t a current_user-nek a GamificationService-en keresztül
|
||||
validation_reward = await ConfigService.get_int(db, "GAMIFICATION_VALIDATE_REWARD", 10)
|
||||
await GamificationService.award_points(db, current_user.id, validation_reward, f"Service Validation: staging #{staging_id}")
|
||||
# A pontérték a point_rules táblából jön (action_key='SERVICE_VALIDATION')
|
||||
await GamificationService.award_points(db, current_user.id, amount=0, reason=f"Service Validation: staging #{staging_id}", action_key="SERVICE_VALIDATION")
|
||||
|
||||
# 6. Növeld a current_user places_validated értékét a UserStats-ban
|
||||
await db.execute(
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import datetime
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse, PersonUpdate, ChangePasswordRequest
|
||||
from app.schemas.address import AddressOut
|
||||
from app.models.identity import User, Person
|
||||
from app.models.identity.address import Address
|
||||
from app.services.trust_engine import TrustEngine
|
||||
@@ -69,20 +70,7 @@ async def _build_user_response(user: User, active_org_id: Optional[int] = None,
|
||||
if person:
|
||||
address_data = None
|
||||
if person.address:
|
||||
address_data = {
|
||||
"address_zip": getattr(person.address, 'zip', None) or getattr(getattr(person.address, 'postal_code', None), 'zip_code', None),
|
||||
"address_city": getattr(person.address, 'city', None) or getattr(getattr(person.address, 'postal_code', None), 'city', None),
|
||||
"address_street_name": person.address.street_name,
|
||||
"address_street_type": person.address.street_type,
|
||||
"address_house_number": person.address.house_number,
|
||||
"address_stairwell": person.address.stairwell,
|
||||
"address_floor": person.address.floor,
|
||||
"address_door": person.address.door,
|
||||
"address_hrsz": person.address.parcel_id,
|
||||
"full_address_text": person.address.full_address_text,
|
||||
"latitude": person.address.latitude,
|
||||
"longitude": person.address.longitude,
|
||||
}
|
||||
address_data = AddressOut.model_validate(person.address)
|
||||
person_data = {
|
||||
"id": person.id,
|
||||
"id_uuid": str(person.id_uuid) if person.id_uuid else None,
|
||||
@@ -343,14 +331,10 @@ async def update_my_person(
|
||||
raise HTTPException(status_code=400, detail="No Person record found. Complete KYC first.")
|
||||
|
||||
# ── Update Person fields ──
|
||||
update_dict = update_data.dict(exclude_unset=True)
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
person_fields = ["first_name", "last_name", "phone", "mothers_last_name",
|
||||
"mothers_first_name", "birth_place", "birth_date"]
|
||||
address_fields = ["address_zip", "address_city", "address_street_name",
|
||||
"address_street_type", "address_house_number",
|
||||
"address_stairwell", "address_floor", "address_door",
|
||||
"address_hrsz"]
|
||||
|
||||
for field in person_fields:
|
||||
if field in update_dict and update_dict[field] is not None:
|
||||
@@ -368,21 +352,21 @@ async def update_my_person(
|
||||
person.identity_docs = update_dict["identity_docs"]
|
||||
flag_modified(person, "identity_docs")
|
||||
|
||||
# ── Update Address fields ──
|
||||
has_address_update = any(f in update_dict for f in address_fields)
|
||||
if has_address_update:
|
||||
# ── Update Address fields (via AddressIn) ──
|
||||
addr_data = update_data.address
|
||||
if addr_data is not None:
|
||||
# Use GeoService to get or create address
|
||||
addr_id = await GeoService.get_or_create_full_address(
|
||||
db,
|
||||
zip_code=update_dict.get("address_zip"),
|
||||
city=update_dict.get("address_city"),
|
||||
street_name=update_dict.get("address_street_name"),
|
||||
street_type=update_dict.get("address_street_type"),
|
||||
house_number=update_dict.get("address_house_number"),
|
||||
stairwell=update_dict.get("address_stairwell"),
|
||||
floor=update_dict.get("address_floor"),
|
||||
door=update_dict.get("address_door"),
|
||||
parcel_id=update_dict.get("address_hrsz"),
|
||||
zip_code=addr_data.zip,
|
||||
city=addr_data.city,
|
||||
street_name=addr_data.street_name,
|
||||
street_type=addr_data.street_type,
|
||||
house_number=addr_data.house_number,
|
||||
stairwell=addr_data.stairwell,
|
||||
floor=addr_data.floor,
|
||||
door=addr_data.door,
|
||||
parcel_id=addr_data.parcel_id,
|
||||
)
|
||||
if addr_id:
|
||||
person.address_id = addr_id
|
||||
|
||||
@@ -327,6 +327,23 @@ def reload_quiz_data() -> None:
|
||||
logger.info("Quiz: Quiz data reloaded successfully")
|
||||
|
||||
|
||||
# ── LocaleManager wrapper for email_manager compatibility ──────────────
|
||||
class _LocaleManager:
|
||||
"""
|
||||
Wrapper providing a ``.get()`` interface around the ``t()`` function.
|
||||
|
||||
The ``email_manager`` module imports ``locale_manager`` and calls
|
||||
``locale_manager.get(key, lang=lang, **variables)``. This class
|
||||
delegates to the existing ``t()`` function so that no import breaks.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get(key: str, lang: Optional[str] = None, **kwargs: Any) -> str:
|
||||
return t(key, lang=lang, **kwargs)
|
||||
|
||||
|
||||
locale_manager = _LocaleManager()
|
||||
|
||||
# ── Preload on import ──────────────────────────────────────────────────
|
||||
_load_locales()
|
||||
_load_quiz_data()
|
||||
|
||||
@@ -4,8 +4,13 @@ Aszinkron ütemező (APScheduler) a napi karbantartási feladatokhoz.
|
||||
Integrálva a FastAPI lifespan eseményébe, így az alkalmazás indításakor elindul,
|
||||
és leálláskor megáll.
|
||||
|
||||
Biztonsági Jitter: A napi futás 00:15-kor indul, de jitter=900 (15 perc) paraméterrel
|
||||
véletlenszerűen 0:15 és 0:30 között fog lefutni.
|
||||
Feladatok:
|
||||
1. daily_financial_maintenance (00:15 UTC) — Voucher/Withdrawal/User downgrade
|
||||
2. subscription_monitor (01:00 UTC) — UserSubscription & OrganizationSubscription lejárat
|
||||
3. inactivity_monitor (02:00 UTC) — 180 napos inaktivitás detektálás
|
||||
|
||||
Biztonsági Jitter: Minden napi feladat jitter=900 (15 perc) paraméterrel rendelkezik,
|
||||
így véletlenszerűen eltolódnak a megadott időponthoz képest.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -190,7 +195,7 @@ def setup_scheduler() -> None:
|
||||
"""Beállítja a scheduler-t a napi feladatokkal."""
|
||||
scheduler = get_scheduler()
|
||||
|
||||
# Napi futás 00:15-kor, jitter=900 (15 perc véletlenszerű eltolás)
|
||||
# ── 1. Napi pénzügyi karbantartás 00:15-kor ──
|
||||
scheduler.add_job(
|
||||
daily_financial_maintenance,
|
||||
trigger=CronTrigger(hour=0, minute=15, jitter=900),
|
||||
@@ -199,7 +204,85 @@ def setup_scheduler() -> None:
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
logger.info("Scheduler jobs registered")
|
||||
# ── 2. Subscription Monitor 01:00-kor ──
|
||||
# Kezeli a lejárt UserSubscription és OrganizationSubscription rekordokat.
|
||||
from app.workers.system.subscription_monitor_worker import run_full_monitor as run_sub_monitor
|
||||
|
||||
async def subscription_monitor_job():
|
||||
logger.info("Scheduler: Starting subscription monitor job...")
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
stats = await run_sub_monitor()
|
||||
total = (
|
||||
stats["user_subscriptions"]["processed"]
|
||||
+ stats["org_subscriptions"]["processed"]
|
||||
)
|
||||
# Naplózás ProcessLog-ba
|
||||
process_log = ProcessLog(
|
||||
process_name="Subscription-Monitor",
|
||||
items_processed=total,
|
||||
items_failed=len(stats["user_subscriptions"]["errors"]) + len(stats["org_subscriptions"]["errors"]),
|
||||
end_time=datetime.utcnow(),
|
||||
details={
|
||||
"status": "COMPLETED",
|
||||
"user_processed": stats["user_subscriptions"]["processed"],
|
||||
"org_processed": stats["org_subscriptions"]["processed"],
|
||||
"user_downgraded": len(stats["user_subscriptions"]["downgraded"]),
|
||||
"org_downgraded": len(stats["org_subscriptions"]["downgraded"]),
|
||||
}
|
||||
)
|
||||
db.add(process_log)
|
||||
await db.commit()
|
||||
logger.info(f"Scheduler: Subscription monitor completed ({total} processed)")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: Subscription monitor failed: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
|
||||
scheduler.add_job(
|
||||
subscription_monitor_job,
|
||||
trigger=CronTrigger(hour=1, minute=0, jitter=900),
|
||||
id="subscription_monitor",
|
||||
name="Subscription Monitor",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# ── 3. Inactivity Monitor 02:00-kor ──
|
||||
# Detektálja a 180+ napja inaktív usereket.
|
||||
from app.workers.system.inactivity_monitor_worker import run_full_monitor as run_inactivity_monitor
|
||||
|
||||
async def inactivity_monitor_job():
|
||||
logger.info("Scheduler: Starting inactivity monitor job...")
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
stats = await run_inactivity_monitor()
|
||||
# Naplózás ProcessLog-ba
|
||||
process_log = ProcessLog(
|
||||
process_name="Inactivity-Monitor",
|
||||
items_processed=stats["processed"],
|
||||
items_failed=len(stats["errors"]),
|
||||
end_time=datetime.utcnow(),
|
||||
details={
|
||||
"status": "COMPLETED",
|
||||
"processed": stats["processed"],
|
||||
"suspended": len(stats["suspended"]),
|
||||
}
|
||||
)
|
||||
db.add(process_log)
|
||||
await db.commit()
|
||||
logger.info(f"Scheduler: Inactivity monitor completed ({stats['processed']} processed)")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler: Inactivity monitor failed: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
|
||||
scheduler.add_job(
|
||||
inactivity_monitor_job,
|
||||
trigger=CronTrigger(hour=2, minute=0, jitter=900),
|
||||
id="inactivity_monitor",
|
||||
name="Inactivity Monitor",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
logger.info("Scheduler jobs registered: daily_financial, subscription_monitor, inactivity_monitor")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -37,11 +37,15 @@ 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
|
||||
|
||||
# 9. Rendszer, Gamification és egyebek
|
||||
from .gamification.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger, UserContribution, Season
|
||||
from .gamification.validation_rule import ValidationRule
|
||||
|
||||
from .system.system import SystemParameter, ParameterScope, InternalNotification, SystemServiceStaging
|
||||
from .system.rbac import SystemRole, SystemPermission, SystemRolePermission
|
||||
@@ -75,7 +79,7 @@ __all__ = [
|
||||
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
|
||||
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
|
||||
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
|
||||
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution",
|
||||
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution", "ValidationRule",
|
||||
|
||||
"SystemParameter", "ParameterScope", "InternalNotification",
|
||||
|
||||
@@ -99,4 +103,6 @@ __all__ = [
|
||||
"CampaignStatus", "CreativeType", "PlacementType",
|
||||
# RBAC Phase 1
|
||||
"SystemRole", "SystemPermission", "SystemRolePermission",
|
||||
# Commission Rules (Phase 2)
|
||||
"CommissionRule", "CommissionRuleType", "CommissionTier",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/core_logic.py
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime # Python saját típusa a típusjelöléshez
|
||||
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float
|
||||
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import func
|
||||
@@ -243,11 +243,15 @@ class ServiceCatalog(Base):
|
||||
Tábla: system.service_catalog
|
||||
"""
|
||||
__tablename__ = "service_catalog"
|
||||
__table_args__ = {"schema": "system"}
|
||||
__table_args__ = (
|
||||
Index('idx_service_catalog_name_i18n', 'name_i18n', postgresql_using='gin'),
|
||||
{"schema": "system"}
|
||||
)
|
||||
|
||||
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 - 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
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
@@ -9,6 +9,7 @@ from .gamification import (
|
||||
UserContribution,
|
||||
Season,
|
||||
)
|
||||
from .validation_rule import ValidationRule
|
||||
|
||||
__all__ = [
|
||||
"PointRule",
|
||||
@@ -19,4 +20,5 @@ __all__ = [
|
||||
"UserBadge",
|
||||
"UserContribution",
|
||||
"Season",
|
||||
"ValidationRule",
|
||||
]
|
||||
@@ -48,6 +48,13 @@ class PointsLedger(Base):
|
||||
source_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# SNAPSHOT MECHANIZMUS (2026-06-30, #369)
|
||||
# Tárolja a kiosztáskor érvényes point_rules adatokat, hogy a pontértékek
|
||||
# megőrződjenek a ledger-ben, még akkor is, ha a point_rules táblában
|
||||
# később módosítják a pontértékeket.
|
||||
# Tartalma: {"action_key": str, "point_rule_id": int, "points_at_time": int, "multiplier": float}
|
||||
points_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user: Mapped["User"] = relationship("User")
|
||||
|
||||
53
backend/app/models/gamification/validation_rule.py
Normal file
53
backend/app/models/gamification/validation_rule.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/gamification/validation_rule.py
|
||||
"""
|
||||
ValidationRule model — Gamification schema.
|
||||
|
||||
Stores dynamic global provider validation rules (thresholds) that drive
|
||||
the ValidationEngine. Rules are key-value pairs with integer values,
|
||||
seeded at initialization and editable via the admin panel.
|
||||
|
||||
Schema: gamification.validation_rules
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ValidationRule(Base):
|
||||
"""
|
||||
Global provider validation rules / thresholds.
|
||||
|
||||
Each row defines a single rule_key → rule_value mapping used by
|
||||
the ValidationEngine to compute trust scores and auto-approval
|
||||
thresholds for service providers.
|
||||
|
||||
Seed data (base rules):
|
||||
BOT_BASE_SCORE = 20 # Default trust score for robot-discovered entries
|
||||
FIRST_ENTRY_SCORE = 40 # Score awarded for the first user entry
|
||||
USER_CONFIRM_BASE = 20 # Base score per user confirmation vote
|
||||
AUTO_APPROVE_THRESHOLD = 80 # Minimum validation_level to auto-promote
|
||||
"""
|
||||
__tablename__ = "validation_rules"
|
||||
__table_args__ = {"schema": "gamification", "extend_existing": True}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
rule_key: Mapped[str] = mapped_column(
|
||||
String(100), unique=True, nullable=False, index=True,
|
||||
comment="Unique rule identifier, e.g. 'BOT_BASE_SCORE'"
|
||||
)
|
||||
rule_value: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
comment="Integer value for this rule (score, threshold, etc.)"
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True,
|
||||
comment="Human-readable description of what this rule controls"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), onupdate=func.now()
|
||||
)
|
||||
@@ -30,6 +30,7 @@ from .social import (
|
||||
ServiceReview,
|
||||
ModerationStatus,
|
||||
SourceType,
|
||||
ProviderValidation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -58,4 +59,5 @@ __all__ = [
|
||||
"ServiceReview",
|
||||
"ModerationStatus",
|
||||
"SourceType",
|
||||
"ProviderValidation",
|
||||
]
|
||||
@@ -57,7 +57,7 @@ class Address(Base):
|
||||
# Robot és térképes funkciók számára
|
||||
latitude: Mapped[Optional[float]] = mapped_column(Float)
|
||||
longitude: Mapped[Optional[float]] = mapped_column(Float)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), default=func.now())
|
||||
|
||||
# Kapcsolat az irányítószám táblához (zip/city lekéréshez)
|
||||
postal_code: Mapped[Optional["GeoPostalCode"]] = relationship("GeoPostalCode", lazy="joined")
|
||||
|
||||
@@ -159,11 +159,26 @@ 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)
|
||||
|
||||
# === SOFT DELETE ===
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# === INACTIVITY MONITOR ===
|
||||
# Updated on login/token-refresh; used by the 180-day inactivity worker
|
||||
# to detect dormant accounts and bypass them for MLM commission rewards.
|
||||
last_activity_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
comment="Last user activity timestamp (login or token refresh). Used by InactivityMonitor worker."
|
||||
)
|
||||
folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True)
|
||||
|
||||
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, TYPE_CHECKING
|
||||
from sqlalchemy import String, Integer, ForeignKey, DateTime, Boolean, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID, JSONB, ARRAY
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.identity.address import Address
|
||||
|
||||
class ModerationStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
@@ -17,6 +20,7 @@ class ModerationStatus(str, enum.Enum):
|
||||
class SourceType(str, enum.Enum):
|
||||
manual = "manual"
|
||||
ocr = "ocr"
|
||||
api = "api"
|
||||
api_import = "import"
|
||||
|
||||
class ServiceProvider(Base):
|
||||
@@ -26,14 +30,19 @@ class ServiceProvider(Base):
|
||||
kapcsolatfelvételi adatokkal a quick_add_provider() refactorhoz.
|
||||
"""
|
||||
__tablename__ = "service_providers"
|
||||
__table_args__ = {"schema": "marketplace"}
|
||||
__table_args__ = {"schema": "marketplace", "extend_existing": True}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
address: Mapped[str] = mapped_column(String, nullable=False)
|
||||
category: Mapped[Optional[str]] = mapped_column(String)
|
||||
|
||||
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők ===
|
||||
# === P0 ADDRESS UNIFICATION: Normalizált cím FK a system.addresses táblához ===
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
|
||||
)
|
||||
|
||||
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők (DEPRECATED - Phase 3-ban törlendő) ===
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||
@@ -58,6 +67,20 @@ class ServiceProvider(Base):
|
||||
validation_score: Mapped[int] = mapped_column(Integer, default=0)
|
||||
evidence_image_path: Mapped[Optional[str]] = mapped_column(String)
|
||||
added_by_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
|
||||
# === P0 3D FILTERING MATRIX: Járműosztályok és specializációk ===
|
||||
supported_vehicle_classes: Mapped[Optional[list]] = mapped_column(
|
||||
ARRAY(String), server_default=text("'{}'"), nullable=True
|
||||
)
|
||||
specializations: Mapped[Optional[dict]] = mapped_column(
|
||||
JSONB, server_default=text("'{}'::jsonb"), nullable=True
|
||||
)
|
||||
|
||||
# === P0 ADDRESS UNIFICATION: Kapcsolat a normalizált címhez ===
|
||||
address_rel: Mapped[Optional["Address"]] = relationship(
|
||||
"Address", foreign_keys=[address_id], lazy="selectin"
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class Vote(Base):
|
||||
@@ -73,6 +96,45 @@ class Vote(Base):
|
||||
provider_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketplace.service_providers.id"), nullable=False)
|
||||
vote_value: Mapped[int] = mapped_column(Integer, nullable=False) # +1 vagy -1
|
||||
|
||||
|
||||
class ProviderValidation(Base):
|
||||
"""
|
||||
Provider validációs rekordok (XP farming prevention).
|
||||
|
||||
Minden egyes admin moderációs akció (approve/reject/flag) egy rekordot hoz létre
|
||||
ebben a táblában. A Vote táblával ellentétben itt az admin által végzett
|
||||
validációk kerülnek rögzítésre, súlyozott értékkel.
|
||||
|
||||
Features:
|
||||
- UniqueConstraint(voter_user_id, provider_id): egy user csak egyszer validálhat
|
||||
- weight: az admin szintjétől függő súly (pl. superadmin=10, admin=5)
|
||||
- metadata: JSONB a validáció részleteivel (reason, evidence, stb.)
|
||||
- Küszöbérték: ha a weight-ek összege >= VALIDATION_THRESHOLD, a provider auto-approved
|
||||
"""
|
||||
__tablename__ = "provider_validations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('voter_user_id', 'provider_id', name='uq_voter_provider_validation'),
|
||||
{"schema": "marketplace", "extend_existing": True}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
provider_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("marketplace.service_providers.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
voter_user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
validation_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="approve"
|
||||
) # approve | reject | flag
|
||||
weight: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
validation_metadata: Mapped[Optional[dict]] = mapped_column("metadata", JSONB, server_default=text("'{}'::jsonb"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
provider: Mapped["ServiceProvider"] = relationship("ServiceProvider", foreign_keys=[provider_id])
|
||||
voter: Mapped["User"] = relationship("User", foreign_keys=[voter_user_id])
|
||||
|
||||
class Competition(Base):
|
||||
""" Gamifikált versenyek (pl. Januári Feltöltő Verseny). """
|
||||
__tablename__ = "competitions"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
198
backend/app/models/marketplace/commission.py
Normal file
198
backend/app/models/marketplace/commission.py
Normal file
@@ -0,0 +1,198 @@
|
||||
# /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) - fallback gen1/gen2-hez"
|
||||
)
|
||||
|
||||
# --- Phase 2: Szintenkénti plafonok és Gen2 megújítás ---
|
||||
gen1_max_amount: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(18, 2), nullable=True,
|
||||
comment="L2: Gen1 maximális jutalék összeg (NULL/0 = korlátlan, fallback: commission_max_amount)"
|
||||
)
|
||||
gen2_max_amount: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(18, 2), nullable=True,
|
||||
comment="L2: Gen2 (upline) maximális jutalék összeg (NULL/0 = korlátlan, fallback: commission_max_amount)"
|
||||
)
|
||||
gen2_renewal_percent: Mapped[Optional[float]] = mapped_column(
|
||||
Numeric(5, 2), nullable=True, default=0.00,
|
||||
comment="L2: Gen2 megújítási jutalék százalék (pl. 1.00 = 1%) - Gen2-nek járó megújítási jutalék"
|
||||
)
|
||||
|
||||
# --- 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})>"
|
||||
)
|
||||
@@ -103,6 +103,8 @@ class Organization(Base):
|
||||
|
||||
# --- 🏢 ALAPADATOK (MEGŐRIZVE) ---
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
||||
billing_address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
||||
notification_address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
||||
|
||||
is_anonymized: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false"))
|
||||
anonymized_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
@@ -122,14 +124,6 @@ class Organization(Base):
|
||||
# Business segment for scope-based admin access (e.g., "Fleet", "Dealer", "Service")
|
||||
segment: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||
|
||||
address_zip: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
address_city: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
address_street_name: Mapped[Optional[str]] = mapped_column(String(150))
|
||||
address_street_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
address_house_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
address_hrsz: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
plus_code: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
|
||||
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
|
||||
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
@@ -137,20 +131,6 @@ class Organization(Base):
|
||||
contact_person_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó neve")
|
||||
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó e-mail címe")
|
||||
contact_phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="Kapcsolattartó telefonszáma")
|
||||
|
||||
# ── P0 CRM: Számlázási cím (Billing Address) ──
|
||||
billing_zip: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
billing_city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
billing_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||
billing_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
billing_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
|
||||
# ── P0 CRM: Értesítési cím (Notification Address) ──
|
||||
notification_zip: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
notification_city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
notification_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||
notification_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
notification_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
|
||||
org_type: Mapped[OrgType] = mapped_column(
|
||||
PG_ENUM(OrgType, name="orgtype", schema="fleet"),
|
||||
@@ -248,6 +228,26 @@ class Organization(Base):
|
||||
# Kapcsolat az örök személy rekordhoz
|
||||
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
|
||||
|
||||
# ── P0 ADDRESS RELATIONSHIPS (Code-First Refactor) ──
|
||||
# Main/primary address
|
||||
address: Mapped[Optional["Address"]] = relationship(
|
||||
"Address",
|
||||
foreign_keys=[address_id],
|
||||
lazy="selectin"
|
||||
)
|
||||
# Billing address
|
||||
billing_address: Mapped[Optional["Address"]] = relationship(
|
||||
"Address",
|
||||
foreign_keys=[billing_address_id],
|
||||
lazy="selectin"
|
||||
)
|
||||
# Notification address
|
||||
notification_address: Mapped[Optional["Address"]] = relationship(
|
||||
"Address",
|
||||
foreign_keys=[notification_address_id],
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
# ── P0 3D CAPABILITY MATRIX: SubscriptionTier relationship ──
|
||||
subscription_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
|
||||
"SubscriptionTier",
|
||||
@@ -335,18 +335,7 @@ class Branch(Base):
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Denormalizált adatok a gyors lekérdezéshez
|
||||
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
street_name: Mapped[Optional[str]] = mapped_column(String(150))
|
||||
street_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
house_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
stairwell: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
floor: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
door: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
hrsz: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
# PostGIS location field for geographic queries
|
||||
# PostGIS location field for geographic queries (KEPT INTACT)
|
||||
location: Mapped[Optional[Any]] = mapped_column(
|
||||
Geometry(geometry_type='POINT', srid=4326),
|
||||
nullable=True
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
from sqlalchemy import Integer, String, Boolean, DateTime, ForeignKey, text, Text, Float, Index, Numeric, BigInteger
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB, ENUM as SQLEnum
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB, ENUM as SQLEnum, ARRAY
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
@@ -35,7 +35,7 @@ class ServiceProfile(Base):
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketplace.service_profiles.id"))
|
||||
|
||||
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
||||
location: Mapped[Any] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), index=True)
|
||||
location: Mapped[Optional[Any]] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), nullable=True, index=True)
|
||||
|
||||
status: Mapped[ServiceStatus] = mapped_column(
|
||||
SQLEnum(ServiceStatus, name="service_status", schema="marketplace"),
|
||||
@@ -72,6 +72,14 @@ class ServiceProfile(Base):
|
||||
website: Mapped[Optional[str]] = mapped_column(String)
|
||||
bio: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
# === P0 3D FILTERING MATRIX: Járműosztályok és specializációk ===
|
||||
supported_vehicle_classes: Mapped[Optional[list]] = mapped_column(
|
||||
ARRAY(String), server_default=text("'{}'"), nullable=True
|
||||
)
|
||||
specializations: Mapped[Optional[dict]] = mapped_column(
|
||||
JSONB, server_default=text("'{}'::jsonb"), nullable=True
|
||||
)
|
||||
|
||||
# Kapcsolatok
|
||||
organization: Mapped["Organization"] = relationship("Organization", back_populates="service_profile")
|
||||
expertises: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="service")
|
||||
@@ -97,14 +105,15 @@ class ExpertiseTag(Base):
|
||||
__tablename__ = "expertise_tags"
|
||||
__table_args__ = (
|
||||
Index('idx_expertise_path', 'path'),
|
||||
Index('idx_expertise_tags_name_i18n', 'name_i18n', postgresql_using='gin'),
|
||||
{"schema": "marketplace"}
|
||||
)
|
||||
|
||||
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))
|
||||
name_translations: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
# --- 🏗️ 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)
|
||||
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
|
||||
|
||||
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---
|
||||
@@ -169,9 +178,7 @@ class ServiceStaging(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
full_address: Mapped[Optional[str]] = mapped_column(String)
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
||||
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
raw_data: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
|
||||
@@ -185,6 +192,9 @@ class ServiceStaging(Base):
|
||||
status: Mapped[str] = mapped_column(String(20), server_default=text("'pending'"), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Kapcsolat a címhez
|
||||
address: Mapped[Optional["Address"]] = relationship("Address", lazy="selectin")
|
||||
|
||||
class DiscoveryParameter(Base):
|
||||
""" Robot vezérlési paraméterek adminból. """
|
||||
__tablename__ = "discovery_parameters"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from sqlalchemy import String, Integer, DateTime, text, Boolean, Float, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base # MB 2.0 Standard: Központi bázis használata
|
||||
|
||||
@@ -75,10 +76,14 @@ class ServiceStaging(Base):
|
||||
# 9. ⚠️ EXTRA OSZLOP: audit_trail
|
||||
audit_trail: Mapped[Optional[dict]] = mapped_column(JSONB)
|
||||
|
||||
# 10. ⚠️ P0 BUGFIX: address_id — FK to system.addresses (UUID)
|
||||
# The column exists in the database but was missing from the model.
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True)
|
||||
|
||||
# Időbélyegek
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 10. ⚠️ EXTRA OSZLOP: updated_at
|
||||
# 11. ⚠️ EXTRA OSZLOP: updated_at
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
class DiscoveryParameter(Base):
|
||||
|
||||
@@ -444,7 +444,7 @@ class AssetEvent(Base):
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
|
||||
user: Mapped[Optional["User"]] = relationship("User")
|
||||
organization: Mapped[Optional["Organization"]] = relationship("Organization")
|
||||
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost", foreign_keys=[cost_id])
|
||||
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost", foreign_keys=[cost_id], viewonly=True)
|
||||
|
||||
# Kapcsolat a hivatkozott költséghez
|
||||
linked_expense: Mapped[Optional["AssetCost"]] = relationship(
|
||||
|
||||
@@ -165,10 +165,12 @@ class BodyTypeDictionary(Base):
|
||||
__tablename__ = "dict_body_types"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('vehicle_class', 'code', name='uix_body_type_class_code'),
|
||||
Index('idx_dict_body_types_name_i18n', 'name_i18n', postgresql_using='gin'),
|
||||
{"schema": "vehicle"}
|
||||
)
|
||||
|
||||
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 - Complete) ---
|
||||
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||
61
backend/app/schemas/address.py
Normal file
61
backend/app/schemas/address.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Unified Address Pydantic Schemas (P0 Refactoring).
|
||||
|
||||
AddressIn: For creation/updates — matches system.addresses columns.
|
||||
AddressOut: For responses — returns the full Address object including id,
|
||||
zip/city resolved via the postal_code relationship.
|
||||
|
||||
Usage:
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class AddressIn(BaseModel):
|
||||
"""Unified address input schema for creation and updates.
|
||||
|
||||
Maps to system.addresses table columns.
|
||||
The `zip` and `city` fields are used to look up / create a
|
||||
system.geo_postal_codes record; the resulting FK is stored as
|
||||
postal_code_id on the Address record.
|
||||
"""
|
||||
zip: Optional[str] = Field(None, max_length=10, description="Irányítószám (postal code)")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
street_name: Optional[str] = Field(None, max_length=200, description="Utca neve")
|
||||
street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (utca, út, tér, stb.)")
|
||||
house_number: Optional[str] = Field(None, max_length=50, description="Házszám")
|
||||
stairwell: Optional[str] = Field(None, max_length=20, description="Lépcsőház")
|
||||
floor: Optional[str] = Field(None, max_length=20, description="Emelet")
|
||||
door: Optional[str] = Field(None, max_length=20, description="Ajtó")
|
||||
parcel_id: Optional[str] = Field(None, max_length=50, description="Helyrajzi szám / parcella ID")
|
||||
full_address_text: Optional[str] = Field(None, description="Teljes cím szövegesen")
|
||||
latitude: Optional[float] = Field(None, description="GPS szélesség")
|
||||
longitude: Optional[float] = Field(None, description="GPS hosszúság")
|
||||
|
||||
|
||||
class AddressOut(BaseModel):
|
||||
"""Unified address output schema for API responses.
|
||||
|
||||
Includes the full Address object data, with zip/city resolved
|
||||
via the postal_code relationship.
|
||||
"""
|
||||
id: Optional[uuid.UUID] = None # UUID as native UUID for JSON serialization; None for denormalized search results
|
||||
zip: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
street_name: Optional[str] = None
|
||||
street_type: Optional[str] = None
|
||||
house_number: Optional[str] = None
|
||||
stairwell: Optional[str] = None
|
||||
floor: Optional[str] = None
|
||||
door: Optional[str] = None
|
||||
parcel_id: Optional[str] = None
|
||||
full_address_text: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
176
backend/app/schemas/commission.py
Normal file
176
backend/app/schemas/commission.py
Normal file
@@ -0,0 +1,176 @@
|
||||
# /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
|
||||
# Phase 2: Szintenkénti plafonok (NULL/0 = korlátlan, fallback: commission_max_amount)
|
||||
gen1_max_amount: Optional[float] = None
|
||||
gen2_max_amount: Optional[float] = None
|
||||
gen2_renewal_percent: 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
|
||||
# Phase 3: Conflict override — admin acknowledges overlapping rule
|
||||
force_override: bool = False
|
||||
|
||||
|
||||
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
|
||||
# Phase 2: Szintenkénti plafonok
|
||||
gen1_max_amount: Optional[float] = None
|
||||
gen2_max_amount: Optional[float] = None
|
||||
gen2_renewal_percent: 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
|
||||
# Phase 3: Conflict override — admin acknowledges overlapping rule
|
||||
force_override: bool = False
|
||||
|
||||
|
||||
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
|
||||
# Phase 2: Szintenkénti plafonok
|
||||
gen1_max_amount: Optional[float] = None
|
||||
gen2_max_amount: Optional[float] = None
|
||||
gen2_renewal_percent: 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")
|
||||
# Phase 2: Renewal flag - TRUE = renewal transaction, FALSE = first-time purchase
|
||||
is_renewal: bool = Field(False, description="TRUE = renewal transaction, FALSE = first-time purchase")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class ConflictingRuleInfo(BaseModel):
|
||||
"""Information about a conflicting rule returned in a 409 response."""
|
||||
id: int
|
||||
name: str
|
||||
rule_type: CommissionRuleTypeEnum
|
||||
tier: CommissionTierEnum
|
||||
region_code: str
|
||||
is_campaign: bool
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ConflictResponse(BaseModel):
|
||||
"""
|
||||
Response schema returned when a conflicting active rule is detected
|
||||
and force_override was not set.
|
||||
"""
|
||||
detail: str = "A conflicting active rule already exists for this combination."
|
||||
conflicting_rule: ConflictingRuleInfo
|
||||
70
backend/app/schemas/financial_manager.py
Normal file
70
backend/app/schemas/financial_manager.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/financial_manager.py
|
||||
"""
|
||||
Pydantic schemas for the FinancialManager API endpoints.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- PurchaseRequest: Input schema for the package purchase endpoint.
|
||||
- PurchaseResponse: Output schema with full purchase receipt details.
|
||||
- CommissionInfo: Nested schema for commission distribution results.
|
||||
- Separated from commission.py to avoid circular imports and maintain clean boundaries.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Any, Dict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class CommissionItemInfo(BaseModel):
|
||||
"""Single commission payout item in the response."""
|
||||
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")
|
||||
|
||||
|
||||
class CommissionInfo(BaseModel):
|
||||
"""Commission distribution summary in the purchase response."""
|
||||
total_commission: float = Field(..., description="Sum of all commission payouts")
|
||||
items: List[CommissionItemInfo] = Field(default_factory=list, description="Individual commission items")
|
||||
|
||||
|
||||
class PurchaseRequest(BaseModel):
|
||||
"""
|
||||
Request schema for purchasing a subscription package.
|
||||
|
||||
The FinancialManager orchestrates:
|
||||
1. Payment processing (via configured gateway)
|
||||
2. Subscription activation (User or Org level)
|
||||
3. MLM commission distribution (async via BackgroundTask)
|
||||
"""
|
||||
tier_id: int = Field(..., gt=0, description="The SubscriptionTier ID to purchase")
|
||||
org_id: Optional[int] = Field(None, description="Organization ID for org-level subscription (None = user-level)")
|
||||
region_code: str = Field("GLOBAL", max_length=10, description="Region code for pricing (ISO 3166-1 alpha-2)")
|
||||
currency: str = Field("EUR", max_length=3, description="Currency code (ISO 4217)")
|
||||
duration_days: Optional[int] = Field(None, gt=0, description="Override duration in days (default: from tier.rules)")
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata for the PaymentIntent")
|
||||
|
||||
|
||||
class PurchaseResponse(BaseModel):
|
||||
"""
|
||||
Response schema for a completed package purchase.
|
||||
|
||||
Contains the full receipt: payment details, subscription info,
|
||||
and commission distribution results.
|
||||
"""
|
||||
success: bool = Field(..., description="Whether the purchase was successful")
|
||||
payment_intent_id: Optional[int] = Field(None, description="The PaymentIntent ID")
|
||||
transaction_id: Optional[str] = Field(None, description="The transaction UUID")
|
||||
subscription_id: Optional[int] = Field(None, description="The activated subscription ID")
|
||||
tier_name: Optional[str] = Field(None, description="The purchased tier name")
|
||||
valid_from: Optional[datetime] = Field(None, description="Subscription validity start")
|
||||
valid_until: Optional[datetime] = Field(None, description="Subscription validity end")
|
||||
amount_paid: float = Field(0.0, description="The amount paid")
|
||||
currency: str = Field("EUR", description="The payment currency")
|
||||
gateway: str = Field("mock", description="The payment gateway used")
|
||||
gateway_intent_id: Optional[str] = Field(None, description="The gateway's intent ID")
|
||||
is_org_subscription: bool = Field(False, description="Whether this is an org-level subscription")
|
||||
commission: Optional[CommissionInfo] = Field(None, description="Commission distribution results")
|
||||
error: Optional[str] = Field(None, description="Error message if success=False")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -2,6 +2,8 @@ from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime
|
||||
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Organization Member Schemas (P0: Full CRUD for Garage Employees)
|
||||
@@ -101,16 +103,8 @@ class CorpOnboardIn(BaseModel):
|
||||
language: str = "hu"
|
||||
default_currency: str = "HUF"
|
||||
|
||||
# --- ATOMIZÁLT CÍM (Modell szinkron) ---
|
||||
address_zip: str
|
||||
address_city: str
|
||||
address_street_name: str
|
||||
address_street_type: str
|
||||
address_house_number: str
|
||||
address_stairwell: Optional[str] = None
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
# --- P0 REFACTORED: Unified address via AddressIn ---
|
||||
address: AddressIn = Field(..., description="Székhely címe (AddressIn struktúrában)")
|
||||
|
||||
contacts: List[ContactCreate] = []
|
||||
|
||||
@@ -132,13 +126,8 @@ class OrganizationUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
tax_number: Optional[str] = None
|
||||
# ── Cím adatok (Address fields) ──
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
# ── P0 REFACTORED: Unified address via AddressIn ──
|
||||
address: Optional[AddressIn] = None
|
||||
# ── Crowdsourced search fields ──
|
||||
aliases: Optional[List[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
@@ -170,12 +159,7 @@ class OrganizationResponse(BaseModel):
|
||||
external_integration_config: Optional[Any] = None
|
||||
aliases: List[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
# ── Cím adatok (Address fields) ──
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
# ── P0 REFACTORED: Unified address via AddressOut ──
|
||||
address: Optional[AddressOut] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -6,6 +6,12 @@ P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
|
||||
- street -> address_street_name, address_street_type, address_house_number
|
||||
- A ProviderSearchResult is tartalmazza az atomizált címmezőket.
|
||||
|
||||
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): Egységes AddressIn/AddressOut.
|
||||
- ProviderSearchResult: address_detail: Optional[AddressOut] a meglévő flat mezők mellett
|
||||
- ProviderQuickAddIn: address_detail: Optional[AddressIn] a meglévő flat mezők mellett
|
||||
- ProviderUpdateIn: address_detail: Optional[AddressIn] a meglévő flat mezők mellett
|
||||
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK a backward compatibility miatt
|
||||
|
||||
4-Level Category Architecture (2026-06-17):
|
||||
===========================================
|
||||
- CategoryTreeNode: Hierarchikus fa struktúra a frontend checkboxaihoz
|
||||
@@ -15,6 +21,7 @@ P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -28,8 +35,8 @@ class CategoryTreeNode(BaseModel):
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
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)")
|
||||
level: int = 0
|
||||
path: Optional[str] = None
|
||||
is_official: bool = True
|
||||
@@ -46,8 +53,7 @@ class CategoryAutocompleteItem(BaseModel):
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||
level: int = 0
|
||||
path: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
@@ -66,8 +72,7 @@ class ExpertiseCategoryOut(BaseModel):
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||
category: Optional[str] = None
|
||||
level: int = 0
|
||||
parent_id: Optional[int] = None
|
||||
@@ -84,8 +89,7 @@ class CategoryInfo(BaseModel):
|
||||
szolgáltatásokat a kártyákon.
|
||||
"""
|
||||
id: int
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||
level: int = 0
|
||||
key: str
|
||||
|
||||
@@ -97,6 +101,15 @@ class ProviderSearchResult(BaseModel):
|
||||
- address_street_name, address_street_type, address_house_number
|
||||
- A frontend ezeket intelligensen fűzi össze megjelenítéskor.
|
||||
|
||||
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
|
||||
- address_detail: Optional[AddressOut] — egységes cím objektum.
|
||||
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
|
||||
a backward compatibility miatt.
|
||||
|
||||
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||
- address_detail: Optional[AddressOut] — first-class citizen.
|
||||
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
|
||||
|
||||
FEATURE (2026-06-17): categories mező.
|
||||
- A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája.
|
||||
- Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez.
|
||||
@@ -106,12 +119,12 @@ class ProviderSearchResult(BaseModel):
|
||||
category: Optional[str] = None
|
||||
specialization: List[str] = Field(default_factory=list)
|
||||
categories: List[CategoryInfo] = Field(default_factory=list)
|
||||
city: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
|
||||
address: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.full_address_text instead")
|
||||
address_zip: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.zip instead")
|
||||
address_street_name: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_name instead")
|
||||
address_street_type: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_type instead")
|
||||
address_house_number: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.house_number instead")
|
||||
plus_code: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
@@ -120,6 +133,10 @@ class ProviderSearchResult(BaseModel):
|
||||
source: str = Field(..., description="Forrás: verified_org | staged_data | crowd_added")
|
||||
is_verified: bool = False
|
||||
rating: Optional[float] = None
|
||||
supported_vehicle_classes: Optional[List[str]] = None
|
||||
specializations: Optional[dict] = None
|
||||
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
|
||||
address_detail: Optional[AddressOut] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -138,6 +155,15 @@ class ProviderQuickAddIn(BaseModel):
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
|
||||
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
|
||||
- address_detail: Optional[AddressIn] — egységes cím objektum.
|
||||
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
|
||||
a backward compatibility miatt.
|
||||
|
||||
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||
- address_detail: Optional[AddressIn] — first-class citizen.
|
||||
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
|
||||
|
||||
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
|
||||
- A 4-level kategória rendszer bevezetésével a frontend a category_ids
|
||||
tömböt használja a kategóriák kiválasztására, nem a régi category_id-t.
|
||||
@@ -149,16 +175,24 @@ class ProviderQuickAddIn(BaseModel):
|
||||
category_id: Optional[int] = Field(None, ge=1, description="Elsődleges kategória ID (opcionális, backward compatibility)")
|
||||
category_ids: Optional[List[int]] = Field(None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)")
|
||||
new_tags: Optional[List[str]] = Field(None, description="User által gépelt új címkenevek (is_official=False, level=3)")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.zip instead")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Use address_detail.street_name instead")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Use address_detail.street_type instead")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.house_number instead")
|
||||
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
|
||||
tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)")
|
||||
supported_vehicle_classes: Optional[List[str]] = Field(
|
||||
None, description="Támogatott járműosztályok (pl. ['car', 'hgv', 'van'])"
|
||||
)
|
||||
specializations: Optional[dict] = Field(
|
||||
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
|
||||
)
|
||||
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
|
||||
address_detail: Optional[AddressIn] = None
|
||||
|
||||
|
||||
class ProviderQuickAddResponse(BaseModel):
|
||||
@@ -177,16 +211,25 @@ class ProviderUpdateIn(BaseModel):
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
- Tartalmazza a contact_phone, contact_email, website és tags mezőket is.
|
||||
|
||||
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
|
||||
- address_detail: Optional[AddressIn] — egységes cím objektum.
|
||||
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
|
||||
a backward compatibility miatt.
|
||||
|
||||
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||
- address_detail: Optional[AddressIn] — first-class citizen.
|
||||
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
|
||||
|
||||
4-Level Category (2026-06-17):
|
||||
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3)
|
||||
- new_tags: A user által gépelt, új (még nem létező) címkék listája
|
||||
"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.zip instead")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Use address_detail.street_name instead")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Use address_detail.street_type instead")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.house_number instead")
|
||||
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||
@@ -198,6 +241,14 @@ class ProviderUpdateIn(BaseModel):
|
||||
new_tags: Optional[List[str]] = Field(
|
||||
None, description="User által gépelt új címkenevek (is_official=False, level=3)"
|
||||
)
|
||||
supported_vehicle_classes: Optional[List[str]] = Field(
|
||||
None, description="Támogatott járműosztályok (pl. ['car', 'hgv', 'van'])"
|
||||
)
|
||||
specializations: Optional[dict] = Field(
|
||||
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
|
||||
)
|
||||
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
|
||||
address_detail: Optional[AddressIn] = None
|
||||
|
||||
|
||||
class ProviderUpdateResponse(BaseModel):
|
||||
|
||||
@@ -4,24 +4,7 @@ from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
|
||||
from typing import Optional, Any, Dict
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
class AddressResponse(BaseModel):
|
||||
"""Beágyazott cím adatok a PersonResponse számára.
|
||||
A mezőnevek address_ előtaggal egyeznek a frontend által várt PersonUpdate formátummal."""
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
address_stairwell: Optional[str] = None
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
full_address_text: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
|
||||
|
||||
class PersonResponse(BaseModel):
|
||||
@@ -38,7 +21,7 @@ class PersonResponse(BaseModel):
|
||||
identity_docs: Optional[Any] = None
|
||||
ice_contact: Optional[Any] = None
|
||||
is_active: bool = True
|
||||
address: Optional[AddressResponse] = None
|
||||
address: Optional[AddressOut] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -90,16 +73,8 @@ class PersonUpdate(BaseModel):
|
||||
# Okmány adatok (identity_docs)
|
||||
identity_docs: Optional[Any] = None
|
||||
|
||||
# Cím adatok
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
address_stairwell: Optional[str] = None
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
# Cím adatok — egységes AddressIn séma
|
||||
address: Optional[AddressIn] = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
|
||||
53
backend/app/scripts/check_provider_validations.py
Normal file
53
backend/app/scripts/check_provider_validations.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Check if provider_validations table exists and create if not.
|
||||
"""
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
async def main():
|
||||
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
|
||||
|
||||
async with engine.connect() as conn:
|
||||
# Check if table exists
|
||||
result = await conn.execute(
|
||||
text("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'marketplace' AND table_name = 'provider_validations')")
|
||||
)
|
||||
exists = result.scalar()
|
||||
print(f"provider_validations table exists: {exists}")
|
||||
|
||||
if not exists:
|
||||
# Create the table
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS marketplace.provider_validations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
provider_id INTEGER NOT NULL REFERENCES marketplace.service_providers(id) ON DELETE CASCADE,
|
||||
voter_user_id INTEGER NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
|
||||
validation_type VARCHAR(20) NOT NULL DEFAULT 'approve',
|
||||
weight INTEGER NOT NULL DEFAULT 1,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(voter_user_id, provider_id)
|
||||
)
|
||||
"""))
|
||||
await conn.commit()
|
||||
print("Created provider_validations table")
|
||||
|
||||
# Check columns
|
||||
result = await conn.execute(text("""
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'marketplace' AND table_name = 'provider_validations'
|
||||
ORDER BY ordinal_position
|
||||
"""))
|
||||
print("\nColumns:")
|
||||
for row in result:
|
||||
print(f" {row.column_name}: {row.data_type} (nullable: {row.is_nullable})")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
131
backend/app/scripts/migrate_i18n_jsonb.py
Normal file
131
backend/app/scripts/migrate_i18n_jsonb.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
i18n JSONB Migration Script (Phase 1)
|
||||
Migrates old name_hu/name_en columns to name_i18n JSONB format.
|
||||
Run INSIDE the sf_api container:
|
||||
docker exec sf_api python3 /app/backend/app/scripts/migrate_i18n_jsonb.py
|
||||
|
||||
IMPORTANT: Run this AFTER the new JSONB columns have been added by sync_engine.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
TABLES = [
|
||||
{
|
||||
"schema": "marketplace",
|
||||
"table": "expertise_tags",
|
||||
"name_cols": ["name_hu", "name_en"],
|
||||
"name_translations_col": "name_translations",
|
||||
"desc_cols": ["description"],
|
||||
"target_name_col": "name_i18n",
|
||||
"target_desc_col": "description_i18n",
|
||||
},
|
||||
{
|
||||
"schema": "vehicle",
|
||||
"table": "dict_body_types",
|
||||
"name_cols": ["name_hu"],
|
||||
"name_translations_col": None,
|
||||
"desc_cols": [],
|
||||
"target_name_col": "name_i18n",
|
||||
"target_desc_col": None,
|
||||
},
|
||||
{
|
||||
"schema": "system",
|
||||
"table": "service_catalog",
|
||||
"name_cols": ["name"],
|
||||
"name_translations_col": None,
|
||||
"desc_cols": ["description"],
|
||||
"target_name_col": "name_i18n",
|
||||
"target_desc_col": "description_i18n",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def migrate_table(config: dict):
|
||||
schema = config["schema"]
|
||||
table = config["table"]
|
||||
target_name = config["target_name_col"]
|
||||
target_desc = config["target_desc_col"]
|
||||
name_cols = config["name_cols"]
|
||||
desc_cols = config["desc_cols"]
|
||||
trans_col = config["name_translations_col"]
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Migrating: {schema}.{table}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
select_cols = ["id"] + name_cols + desc_cols
|
||||
if trans_col:
|
||||
select_cols.append(trans_col)
|
||||
|
||||
stmt = text(
|
||||
f"SELECT {', '.join(select_cols)} FROM {schema}.{table} "
|
||||
f"WHERE {target_name} IS NULL OR {target_name} = '{{\"hu\": \"\"}}'::jsonb"
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.fetchall()
|
||||
|
||||
if not rows:
|
||||
print(f" ✅ No rows need migration in {schema}.{table}")
|
||||
return
|
||||
|
||||
updated = 0
|
||||
for row in rows:
|
||||
row_dict = row._mapping
|
||||
|
||||
# Build name_i18n
|
||||
name_i18n = {}
|
||||
if len(name_cols) >= 1 and row_dict.get(name_cols[0]):
|
||||
name_i18n["hu"] = row_dict[name_cols[0]]
|
||||
if len(name_cols) >= 2 and row_dict.get(name_cols[1]):
|
||||
name_i18n["en"] = row_dict[name_cols[1]]
|
||||
|
||||
# Merge with existing name_translations
|
||||
if trans_col and row_dict.get(trans_col):
|
||||
if isinstance(row_dict[trans_col], dict):
|
||||
name_i18n.update(row_dict[trans_col])
|
||||
|
||||
# Build description_i18n
|
||||
desc_i18n = None
|
||||
if desc_cols and row_dict.get(desc_cols[0]):
|
||||
desc_i18n = {"hu": row_dict[desc_cols[0]]}
|
||||
|
||||
# Update
|
||||
update_cols = [f"{target_name} = :name_val"]
|
||||
params = {"name_val": json.dumps(name_i18n), "row_id": row_dict["id"]}
|
||||
|
||||
if target_desc and desc_i18n:
|
||||
update_cols.append(f"{target_desc} = :desc_val")
|
||||
params["desc_val"] = json.dumps(desc_i18n)
|
||||
|
||||
update_sql = f"UPDATE {schema}.{table} SET {', '.join(update_cols)} WHERE id = :row_id"
|
||||
await db.execute(text(update_sql), params)
|
||||
updated += 1
|
||||
|
||||
await db.commit()
|
||||
print(f" ✅ Migrated {updated} rows in {schema}.{table}")
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 70)
|
||||
print(" i18n JSONB Migration Script (Phase 1)")
|
||||
print("=" * 70)
|
||||
|
||||
for table_config in TABLES:
|
||||
await migrate_table(table_config)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" ✅ Migration complete!")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
259
backend/app/scripts/migrate_provider_addresses.py
Normal file
259
backend/app/scripts/migrate_provider_addresses.py
Normal file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
P0 ADDRESS UNIFICATION - Phase 1: ServiceProvider Address Migration
|
||||
|
||||
Migrates flat address columns (city, address_zip, address_street_name, etc.)
|
||||
from marketplace.service_providers into the centralized system.addresses table.
|
||||
|
||||
Logic:
|
||||
1. Fetch all ServiceProvider records where address_id IS NULL
|
||||
but at least one flat address field is non-empty.
|
||||
2. For each record, construct an AddressIn schema from the flat data.
|
||||
3. Call AddressManager.create_or_update(db, None, address_data) to create
|
||||
a new central address record.
|
||||
4. Assign the returned Address UUID to provider.address_id.
|
||||
5. Commit in batches with proper error handling and logging.
|
||||
|
||||
Usage:
|
||||
docker exec sf_api python3 /app/backend/app/scripts/migrate_provider_addresses.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
# Ensure the backend package is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# asyncpg needs plain "postgresql://" scheme
|
||||
_raw_url = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://service_finder_app:AppSafePass_2026@db:5432/service_finder"
|
||||
)
|
||||
DATABASE_URL = _raw_url.replace("+asyncpg", "")
|
||||
|
||||
# Batch size for commits
|
||||
BATCH_SIZE = 100
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
logger = logging.getLogger("migrate_provider_addresses")
|
||||
|
||||
|
||||
def _build_full_address_text(row: dict) -> Optional[str]:
|
||||
"""Build a human-readable full address from flat fields."""
|
||||
parts = []
|
||||
if row.get("address_zip"):
|
||||
parts.append(row["address_zip"])
|
||||
if row.get("city"):
|
||||
parts.append(row["city"])
|
||||
street_parts = []
|
||||
if row.get("address_street_name"):
|
||||
street_parts.append(row["address_street_name"])
|
||||
if row.get("address_street_type"):
|
||||
street_parts.append(row["address_street_type"])
|
||||
if row.get("address_house_number"):
|
||||
street_parts.append(row["address_house_number"])
|
||||
if street_parts:
|
||||
parts.append(" ".join(street_parts))
|
||||
return ", ".join(parts) if parts else None
|
||||
|
||||
|
||||
def _has_address_data(row: dict) -> bool:
|
||||
"""Check if the provider has any flat address data worth migrating."""
|
||||
return bool(
|
||||
row.get("city")
|
||||
or row.get("address_zip")
|
||||
or row.get("address_street_name")
|
||||
or row.get("address_house_number")
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 70)
|
||||
logger.info("🔧 P0 ADDRESS UNIFICATION - Phase 1: ServiceProvider Migration")
|
||||
logger.info("=" * 70)
|
||||
logger.info(f"Started at: {datetime.now(timezone.utc).isoformat()}")
|
||||
logger.info(f"Database URL: {DATABASE_URL.replace('AppSafePass_2026', '****')}")
|
||||
logger.info(f"Batch size: {BATCH_SIZE}")
|
||||
logger.info("")
|
||||
|
||||
import asyncpg
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
|
||||
try:
|
||||
# ── Step 1: Count providers needing migration ──
|
||||
logger.info("📋 Step 1: Counting providers with flat address data but no address_id")
|
||||
count_row = await conn.fetchrow("""
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM marketplace.service_providers
|
||||
WHERE address_id IS NULL
|
||||
AND (
|
||||
city IS NOT NULL AND city != ''
|
||||
OR address_zip IS NOT NULL AND address_zip != ''
|
||||
OR address_street_name IS NOT NULL AND address_street_name != ''
|
||||
OR address_house_number IS NOT NULL AND address_house_number != ''
|
||||
)
|
||||
""")
|
||||
total = count_row["cnt"]
|
||||
logger.info(f" → Found {total} provider(s) to migrate")
|
||||
logger.info("")
|
||||
|
||||
if total == 0:
|
||||
logger.info("✅ No providers need migration. Exiting.")
|
||||
return
|
||||
|
||||
# ── Step 2: Fetch all providers needing migration ──
|
||||
logger.info("📋 Step 2: Fetching provider records")
|
||||
rows = await conn.fetch("""
|
||||
SELECT id, name, city, address_zip,
|
||||
address_street_name, address_street_type, address_house_number,
|
||||
plus_code
|
||||
FROM marketplace.service_providers
|
||||
WHERE address_id IS NULL
|
||||
AND (
|
||||
city IS NOT NULL AND city != ''
|
||||
OR address_zip IS NOT NULL AND address_zip != ''
|
||||
OR address_street_name IS NOT NULL AND address_street_name != ''
|
||||
OR address_house_number IS NOT NULL AND address_house_number != ''
|
||||
)
|
||||
ORDER BY id
|
||||
""")
|
||||
logger.info(f" → Fetched {len(rows)} record(s)")
|
||||
logger.info("")
|
||||
|
||||
# ── Step 3: Migrate each provider ──
|
||||
logger.info("📋 Step 3: Migrating addresses to system.addresses")
|
||||
migrated_count = 0
|
||||
error_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for i, row in enumerate(rows, start=1):
|
||||
provider_id = row["id"]
|
||||
provider_name = row["name"]
|
||||
|
||||
if not _has_address_data(row):
|
||||
logger.info(f" [{i}/{total}] Provider #{provider_id} '{provider_name}': "
|
||||
f"no address data → SKIP")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# Build the full_address_text
|
||||
full_text = _build_full_address_text(row)
|
||||
|
||||
# Insert into system.addresses
|
||||
new_address_id = uuid.uuid4()
|
||||
await conn.execute("""
|
||||
INSERT INTO system.addresses
|
||||
(id, street_name, street_type, house_number,
|
||||
full_address_text, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
new_address_id,
|
||||
row.get("address_street_name"),
|
||||
row.get("address_street_type"),
|
||||
row.get("address_house_number"),
|
||||
full_text,
|
||||
)
|
||||
|
||||
# Resolve postal code if zip+city are present
|
||||
if row.get("address_zip") and row.get("city"):
|
||||
# Find or create GeoPostalCode
|
||||
postal = await conn.fetchrow("""
|
||||
SELECT id FROM system.geo_postal_codes
|
||||
WHERE zip_code = $1
|
||||
""", row["address_zip"])
|
||||
|
||||
if postal:
|
||||
postal_code_id = postal["id"]
|
||||
else:
|
||||
postal_code_id = await conn.fetchval("""
|
||||
INSERT INTO system.geo_postal_codes
|
||||
(zip_code, city, country_code)
|
||||
VALUES ($1, $2, 'HU')
|
||||
RETURNING id
|
||||
""", row["address_zip"], row["city"])
|
||||
|
||||
# Update the address with postal_code_id
|
||||
await conn.execute("""
|
||||
UPDATE system.addresses
|
||||
SET postal_code_id = $1
|
||||
WHERE id = $2
|
||||
""", postal_code_id, new_address_id)
|
||||
|
||||
# Update the provider with the new address_id
|
||||
await conn.execute("""
|
||||
UPDATE marketplace.service_providers
|
||||
SET address_id = $1
|
||||
WHERE id = $2
|
||||
""", new_address_id, provider_id)
|
||||
|
||||
migrated_count += 1
|
||||
|
||||
if i % BATCH_SIZE == 0 or i == total:
|
||||
logger.info(
|
||||
f" [{i}/{total}] Progress: {migrated_count} migrated, "
|
||||
f"{error_count} errors, {skipped_count} skipped"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f" [{i}/{total}] Provider #{provider_id} '{provider_name}': "
|
||||
f"ERROR - {e}"
|
||||
)
|
||||
|
||||
# ── Summary ──
|
||||
logger.info("")
|
||||
logger.info("=" * 70)
|
||||
logger.info("📊 MIGRATION SUMMARY")
|
||||
logger.info("=" * 70)
|
||||
logger.info(f" Total providers processed: {total}")
|
||||
logger.info(f" Successfully migrated: {migrated_count}")
|
||||
logger.info(f" Errors: {error_count}")
|
||||
logger.info(f" Skipped (no address data): {skipped_count}")
|
||||
|
||||
# ── Step 4: Verify ──
|
||||
logger.info("")
|
||||
logger.info("📋 Step 4: Verification")
|
||||
remaining = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM marketplace.service_providers
|
||||
WHERE address_id IS NULL
|
||||
AND (
|
||||
city IS NOT NULL AND city != ''
|
||||
OR address_zip IS NOT NULL AND address_zip != ''
|
||||
OR address_street_name IS NOT NULL AND address_street_name != ''
|
||||
OR address_house_number IS NOT NULL AND address_house_number != ''
|
||||
)
|
||||
""")
|
||||
total_providers = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM marketplace.service_providers"
|
||||
)
|
||||
with_address = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM marketplace.service_providers WHERE address_id IS NOT NULL"
|
||||
)
|
||||
logger.info(f" Total providers: {total_providers}")
|
||||
logger.info(f" Providers WITH address_id: {with_address}")
|
||||
logger.info(f" Providers still NULL: {remaining}")
|
||||
logger.info("")
|
||||
logger.info("✅ Phase 1 migration complete!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"💥 FATAL ERROR: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
86
backend/app/scripts/seed_dismantler_category.py
Normal file
86
backend/app/scripts/seed_dismantler_category.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Seed script: Beszúrja az "Autóbontó / Használt alkatrész" kategóriát
|
||||
a marketplace.expertise_tags táblába, ha még nem létezik.
|
||||
|
||||
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
|
||||
|
||||
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_dismantler_category.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select, func
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.marketplace.service import ExpertiseTag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DISMANTLER_CATEGORY = {
|
||||
"key": "autobonto_hasznalt_alkatresz",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Autóbontó / Használt alkatrész", "en": "Car Dismantler / Used Parts"},
|
||||
"name_hu": "Autóbontó / Használt alkatrész",
|
||||
"name_en": "Car Dismantler / Used Parts",
|
||||
"category": "parts",
|
||||
"search_keywords": [
|
||||
"bontó", "autóbontó", "bontás", "használt alkatrész",
|
||||
"dismantler", "used parts", "scrap yard", "break yard",
|
||||
"alkatrész bontás", "roncs", "roncsautó",
|
||||
],
|
||||
"description": "Használt gépjármű alkatrészek értékesítése, autóbontás, roncsautó felvásárlás",
|
||||
}
|
||||
|
||||
|
||||
async def seed_dismantler_category():
|
||||
"""Beszúrja az Autóbontó kategóriát, ha még nem létezik."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# Ellenőrizzük, hogy létezik-e már
|
||||
stmt = select(ExpertiseTag).where(
|
||||
ExpertiseTag.key == DISMANTLER_CATEGORY["key"]
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
print(
|
||||
f"✅ Az 'Autóbontó / Használt alkatrész' kategória "
|
||||
f"már létezik (ID: {existing.id}). Nincs szükség seed-elésre."
|
||||
)
|
||||
return
|
||||
|
||||
# Beszúrás
|
||||
tag = ExpertiseTag(
|
||||
key=DISMANTLER_CATEGORY["key"],
|
||||
name_i18n=DISMANTLER_CATEGORY.get("name_i18n", {"hu": DISMANTLER_CATEGORY["name_hu"], "en": DISMANTLER_CATEGORY["name_en"]}),
|
||||
category=DISMANTLER_CATEGORY["category"],
|
||||
search_keywords=DISMANTLER_CATEGORY["search_keywords"],
|
||||
is_official=True,
|
||||
discovery_points=10,
|
||||
usage_count=0,
|
||||
)
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
print(
|
||||
f"✅ Sikeresen beszúrva az 'Autóbontó / Használt alkatrész' "
|
||||
f"kategória (ID: {tag.id})."
|
||||
)
|
||||
|
||||
# Ellenőrzés
|
||||
count_result = await db.execute(
|
||||
select(func.count(ExpertiseTag.id))
|
||||
)
|
||||
final_count = count_result.scalar()
|
||||
print(f"📊 Összes rekord az expertise_tags táblában: {final_count}")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Hiba a seed-elés során: {e}")
|
||||
print(f"❌ Hiba: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
asyncio.run(seed_dismantler_category())
|
||||
317
backend/app/scripts/seed_expertise_enterprise.py
Normal file
317
backend/app/scripts/seed_expertise_enterprise.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Seed script: Enterprise Taxonomy - Pénzügy, Biztosítás és Hatósági kategóriák
|
||||
a marketplace.expertise_tags táblába.
|
||||
|
||||
Hierarchia:
|
||||
Level 1: Pénzügy és Biztosítás (Finance & Insurance)
|
||||
Level 2: Gépjármű biztosító (Vehicle Insurance)
|
||||
Level 2: Lízing és Finanszírozás (Leasing & Financing)
|
||||
Level 2: Bank és Hitelintézet (Bank & Credit Institution)
|
||||
|
||||
Level 1: Hatóságok és Közigazgatás (Authorities & Administration)
|
||||
Level 2: Önkormányzat (Municipality)
|
||||
Level 2: Állami Kincstár / Nemzeti Adóhatóság (State Treasury / Tax Authority)
|
||||
Level 2: Közlekedési Hatóság / Kormányablak (Transport Authority)
|
||||
Level 2: Útdíj / Autópálya Kezelő (Toll & Highway Operator)
|
||||
|
||||
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
|
||||
|
||||
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_expertise_enterprise.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select, func, text
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.marketplace.service import ExpertiseTag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Enterprise Taxonomy Definitions ──
|
||||
# Level 1: Pénzügy és Biztosítás (Finance & Insurance)
|
||||
FINANCE_INSURANCE = {
|
||||
"key": "penzugy_es_biztositas",
|
||||
"name_i18n": {
|
||||
"hu": "Pénzügy és Biztosítás",
|
||||
"en": "Finance & Insurance",
|
||||
},
|
||||
"name_hu": "Pénzügy és Biztosítás",
|
||||
"name_en": "Finance & Insurance",
|
||||
"category": "financial",
|
||||
"level": 1,
|
||||
"parent_id": None,
|
||||
"path": "penzugy_es_biztositas",
|
||||
"search_keywords": [
|
||||
"pénzügy", "biztosítás", "finance", "insurance",
|
||||
"bank", "lízing", "hitel", "kölcsön",
|
||||
],
|
||||
"description": "Pénzügyi szolgáltatások, biztosítások, banki és lízing szolgáltatások",
|
||||
}
|
||||
|
||||
# Level 2 children of Pénzügy és Biztosítás
|
||||
FINANCE_CHILDREN = [
|
||||
{
|
||||
"key": "gepjarmu_biztosito",
|
||||
"name_i18n": {
|
||||
"hu": "Gépjármű biztosító",
|
||||
"en": "Vehicle Insurance",
|
||||
},
|
||||
"name_hu": "Gépjármű biztosító",
|
||||
"name_en": "Vehicle Insurance",
|
||||
"category": "financial",
|
||||
"level": 2,
|
||||
"search_keywords": [
|
||||
"biztosító", "biztosítás", "insurance", "kgfb",
|
||||
"casco", "kötelező", "gépjármű biztosítás",
|
||||
],
|
||||
"description": "Gépjármű felelősségbiztosítás (KGFB), casco és egyéb járműbiztosítások",
|
||||
},
|
||||
{
|
||||
"key": "lizing_es_finanszirozas",
|
||||
"name_i18n": {
|
||||
"hu": "Lízing és Finanszírozás",
|
||||
"en": "Leasing & Financing",
|
||||
},
|
||||
"name_hu": "Lízing és Finanszírozás",
|
||||
"name_en": "Leasing & Financing",
|
||||
"category": "financial",
|
||||
"level": 2,
|
||||
"search_keywords": [
|
||||
"lízing", "finanszírozás", "leasing", "financing",
|
||||
"autólízing", "operatív lízing", "pénzügyi lízing",
|
||||
],
|
||||
"description": "Gépjármű lízing, operatív és pénzügyi lízing, járműfinanszírozás",
|
||||
},
|
||||
{
|
||||
"key": "bank_es_hitelintezet",
|
||||
"name_i18n": {
|
||||
"hu": "Bank és Hitelintézet",
|
||||
"en": "Bank & Credit Institution",
|
||||
},
|
||||
"name_hu": "Bank és Hitelintézet",
|
||||
"name_en": "Bank & Credit Institution",
|
||||
"category": "financial",
|
||||
"level": 2,
|
||||
"search_keywords": [
|
||||
"bank", "hitelintézet", "banking", "credit",
|
||||
"számla", "hitel", "kölcsön", "folyószámla",
|
||||
],
|
||||
"description": "Banki szolgáltatások, hitelintézetek, számlavezetés és hitelezés",
|
||||
},
|
||||
]
|
||||
|
||||
# Level 1: Hatóságok és Közigazgatás (Authorities & Administration)
|
||||
AUTHORITIES = {
|
||||
"key": "hatosagok_es_kozigazgatas",
|
||||
"name_i18n": {
|
||||
"hu": "Hatóságok és Közigazgatás",
|
||||
"en": "Authorities & Administration",
|
||||
},
|
||||
"name_hu": "Hatóságok és Közigazgatás",
|
||||
"name_en": "Authorities & Administration",
|
||||
"category": "government",
|
||||
"level": 1,
|
||||
"parent_id": None,
|
||||
"path": "hatosagok_es_kozigazgatas",
|
||||
"search_keywords": [
|
||||
"hatóság", "közigazgatás", "authority", "government",
|
||||
"önkormányzat", "adó", "kincstár", "ügyfélkapu",
|
||||
],
|
||||
"description": "Hatósági és közigazgatási szervek, önkormányzatok, adóhatóságok",
|
||||
}
|
||||
|
||||
# Level 2 children of Hatóságok és Közigazgatás
|
||||
AUTHORITY_CHILDREN = [
|
||||
{
|
||||
"key": "onkormanyzat",
|
||||
"name_i18n": {
|
||||
"hu": "Önkormányzat",
|
||||
"en": "Municipality",
|
||||
},
|
||||
"name_hu": "Önkormányzat",
|
||||
"name_en": "Municipality",
|
||||
"category": "government",
|
||||
"level": 2,
|
||||
"search_keywords": [
|
||||
"önkormányzat", "municipality", "polgármesteri hivatal",
|
||||
"helyi adó", "gépjárműadó", "iparűzési adó",
|
||||
],
|
||||
"description": "Helyi önkormányzatok, polgármesteri hivatalok, helyi adóhatóságok",
|
||||
},
|
||||
{
|
||||
"key": "allami_kincstar_nav",
|
||||
"name_i18n": {
|
||||
"hu": "Állami Kincstár / Nemzeti Adóhatóság",
|
||||
"en": "State Treasury / Tax Authority",
|
||||
},
|
||||
"name_hu": "Állami Kincstár / Nemzeti Adóhatóság",
|
||||
"name_en": "State Treasury / Tax Authority",
|
||||
"category": "government",
|
||||
"level": 2,
|
||||
"search_keywords": [
|
||||
"adóhatóság", "nav", "kincstár", "treasury",
|
||||
"adó", "tax", "államkincstár", "magyar államkincstár",
|
||||
],
|
||||
"description": "Nemzeti Adó- és Vámhivatal (NAV), Magyar Államkincstár, központi adóhatóság",
|
||||
},
|
||||
{
|
||||
"key": "kozlekedesi_hatosag",
|
||||
"name_i18n": {
|
||||
"hu": "Közlekedési Hatóság / Kormányablak",
|
||||
"en": "Transport Authority",
|
||||
},
|
||||
"name_hu": "Közlekedési Hatóság / Kormányablak",
|
||||
"name_en": "Transport Authority",
|
||||
"category": "government",
|
||||
"level": 2,
|
||||
"search_keywords": [
|
||||
"közlekedési hatóság", "kormányablak", "transport authority",
|
||||
"ügyfélkapu", "okmányiroda", "gépjármű ügyintézés",
|
||||
"forgalmi engedély", "törzskönyv",
|
||||
],
|
||||
"description": "Közlekedési hatóságok, kormányablakok, okmányirodák, gépjármű ügyintézés",
|
||||
},
|
||||
{
|
||||
"key": "utdij_autopalya_kezelo",
|
||||
"name_i18n": {
|
||||
"hu": "Útdíj / Autópálya Kezelő",
|
||||
"en": "Toll & Highway Operator",
|
||||
},
|
||||
"name_hu": "Útdíj / Autópálya Kezelő",
|
||||
"name_en": "Toll & Highway Operator",
|
||||
"category": "government",
|
||||
"level": 2,
|
||||
"search_keywords": [
|
||||
"útdíj", "autópálya", "toll", "highway",
|
||||
"matrica", "e-matrica", "hu-go", "nemzeti útdíj",
|
||||
"autópálya matrica", "úthasználat",
|
||||
],
|
||||
"description": "Autópálya kezelők, útdíj fizetési rendszerek, e-matrica szolgáltatók",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_enterprise_taxonomy():
|
||||
"""Beszúrja a Pénzügy és Hatóság kategóriákat a marketplace.expertise_tags táblába."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
inserted = 0
|
||||
|
||||
# ── Helper: insert a single tag ──
|
||||
async def insert_tag(tag_data: dict, parent_path: str = None) -> int | None:
|
||||
nonlocal inserted
|
||||
# Check if already exists
|
||||
stmt = select(ExpertiseTag).where(
|
||||
ExpertiseTag.key == tag_data["key"]
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info(
|
||||
f"A '{tag_data['name_hu']}' kategória "
|
||||
f"már létezik (ID: {existing.id}). Kihagyva."
|
||||
)
|
||||
print(
|
||||
f" ⏭️ '{tag_data['name_hu']}' már létezik (ID: {existing.id})"
|
||||
)
|
||||
return existing.id
|
||||
|
||||
# Build path
|
||||
path = tag_data.get("path")
|
||||
if not path and parent_path:
|
||||
path = f"{parent_path}/{tag_data['key']}"
|
||||
elif not path:
|
||||
path = tag_data["key"]
|
||||
|
||||
tag = ExpertiseTag(
|
||||
key=tag_data["key"],
|
||||
name_i18n=tag_data.get("name_i18n", {
|
||||
"hu": tag_data["name_hu"],
|
||||
"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", []),
|
||||
is_official=True,
|
||||
discovery_points=10,
|
||||
usage_count=0,
|
||||
)
|
||||
db.add(tag)
|
||||
await db.flush()
|
||||
await db.refresh(tag)
|
||||
inserted += 1
|
||||
print(f" ✅ '{tag_data['name_hu']}' beszúrva (ID: {tag.id})")
|
||||
return tag.id
|
||||
|
||||
# ── 1. Pénzügy és Biztosítás (Level 1) ──
|
||||
print("\n📋 Pénzügy és Biztosítás hierarchia:")
|
||||
finance_id = await insert_tag(FINANCE_INSURANCE)
|
||||
if finance_id:
|
||||
# Insert children with parent_id set
|
||||
for child in FINANCE_CHILDREN:
|
||||
child["parent_id"] = finance_id
|
||||
child["path"] = f"{FINANCE_INSURANCE['path']}/{child['key']}"
|
||||
await insert_tag(child)
|
||||
|
||||
# ── 2. Hatóságok és Közigazgatás (Level 1) ──
|
||||
print("\n📋 Hatóságok és Közigazgatás hierarchia:")
|
||||
auth_id = await insert_tag(AUTHORITIES)
|
||||
if auth_id:
|
||||
for child in AUTHORITY_CHILDREN:
|
||||
child["parent_id"] = auth_id
|
||||
child["path"] = f"{AUTHORITIES['path']}/{child['key']}"
|
||||
await insert_tag(child)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# ── Final verification ──
|
||||
result = await db.execute(
|
||||
select(func.count(ExpertiseTag.id))
|
||||
)
|
||||
final_count = result.scalar()
|
||||
print(f"\n📊 Összes rekord az expertise_tags táblában: {final_count}")
|
||||
|
||||
# List the new enterprise tags
|
||||
print("\n📋 Új Enterprise kategóriák:")
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id, key, name_hu, name_en, level, parent_id, path
|
||||
FROM marketplace.expertise_tags
|
||||
WHERE key IN (
|
||||
'penzugy_es_biztositas',
|
||||
'gepjarmu_biztosito',
|
||||
'lizing_es_finanszirozas',
|
||||
'bank_es_hitelintezet',
|
||||
'hatosagok_es_kozigazgatas',
|
||||
'onkormanyzat',
|
||||
'allami_kincstar_nav',
|
||||
'kozlekedesi_hatosag',
|
||||
'utdij_autopalya_kezelo'
|
||||
)
|
||||
ORDER BY level, id
|
||||
""")
|
||||
)
|
||||
for row in result:
|
||||
indent = " " * row.level
|
||||
print(
|
||||
f"{indent}ID={row.id}, key={row.key}, "
|
||||
f"name_hu={row.name_hu}, level={row.level}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Hiba a seed-elés során: {e}")
|
||||
print(f"❌ Hiba: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
asyncio.run(seed_enterprise_taxonomy())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,6 +16,8 @@ logger = logging.getLogger(__name__)
|
||||
BASE_CATEGORIES = [
|
||||
{
|
||||
"key": "auto_szerelo",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Autószerelő", "en": "Car Mechanic"},
|
||||
"name_hu": "Autószerelő",
|
||||
"name_en": "Car Mechanic",
|
||||
"category": "vehicle_service",
|
||||
@@ -24,6 +26,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "motor_szerelo",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Motorkerékpár szerelő", "en": "Motorcycle Mechanic"},
|
||||
"name_hu": "Motorkerékpár szerelő",
|
||||
"name_en": "Motorcycle Mechanic",
|
||||
"category": "vehicle_service",
|
||||
@@ -32,6 +36,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "gumiszerviz",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Gumiszerviz", "en": "Tire Service"},
|
||||
"name_hu": "Gumiszerviz",
|
||||
"name_en": "Tire Service",
|
||||
"category": "vehicle_service",
|
||||
@@ -40,6 +46,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "karosszerialakatos",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Karosszérialakatos", "en": "Body Shop"},
|
||||
"name_hu": "Karosszérialakatos",
|
||||
"name_en": "Body Shop",
|
||||
"category": "body_paint",
|
||||
@@ -48,6 +56,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "fényező",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Fényező", "en": "Painter"},
|
||||
"name_hu": "Fényező",
|
||||
"name_en": "Painter",
|
||||
"category": "body_paint",
|
||||
@@ -56,6 +66,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "autómentő",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Autómentő / Motormentő", "en": "Tow Truck / Roadside Assistance"},
|
||||
"name_hu": "Autómentő / Motormentő",
|
||||
"name_en": "Tow Truck / Roadside Assistance",
|
||||
"category": "roadside",
|
||||
@@ -64,6 +76,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "benzinkút",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Benzinkút", "en": "Gas Station"},
|
||||
"name_hu": "Benzinkút",
|
||||
"name_en": "Gas Station",
|
||||
"category": "fuel",
|
||||
@@ -72,6 +86,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "alkatrész_kereskedés",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Alkatrész kereskedés", "en": "Parts Store"},
|
||||
"name_hu": "Alkatrész kereskedés",
|
||||
"name_en": "Parts Store",
|
||||
"category": "parts",
|
||||
@@ -80,6 +96,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "vizsgaállomás",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Vizsgaállomás", "en": "Inspection Station"},
|
||||
"name_hu": "Vizsgaállomás",
|
||||
"name_en": "Inspection Station",
|
||||
"category": "inspection",
|
||||
@@ -88,6 +106,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "autókozmetika",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Autókozmetika", "en": "Car Detailing"},
|
||||
"name_hu": "Autókozmetika",
|
||||
"name_en": "Car Detailing",
|
||||
"category": "detailing",
|
||||
@@ -96,6 +116,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "egyéb",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Egyéb szolgáltató", "en": "Other Service"},
|
||||
"name_hu": "Egyéb szolgáltató",
|
||||
"name_en": "Other Service",
|
||||
"category": "other",
|
||||
@@ -123,11 +145,9 @@ async def seed_expertise_tags():
|
||||
for cat in BASE_CATEGORIES:
|
||||
tag = ExpertiseTag(
|
||||
key=cat["key"],
|
||||
name_hu=cat["name_hu"],
|
||||
name_en=cat["name_en"],
|
||||
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
|
||||
category=cat["category"],
|
||||
search_keywords=cat["search_keywords"],
|
||||
description=cat["description"],
|
||||
is_official=True,
|
||||
discovery_points=10,
|
||||
usage_count=0,
|
||||
|
||||
172
backend/app/scripts/seed_gamification_action_keys.py
Normal file
172
backend/app/scripts/seed_gamification_action_keys.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Gamification Action Keys Seed Script
|
||||
=====================================
|
||||
Cél: Hiányzó action_key-ek felvétele a gamification.point_rules táblába.
|
||||
|
||||
Az audit során az alábbi akciókhoz NEM létezik action_key a point_rules táblában:
|
||||
- KYC_VERIFICATION (auth_service KYC completion)
|
||||
- P2P_REFERRAL_SUCCESS (auth_service P2P referral)
|
||||
- EXPENSE_LOG (cost_service expense recording)
|
||||
- FLEET_EVENT (fleet_service vehicle event)
|
||||
- PROVIDER_VALIDATED (social_service provider validation)
|
||||
- PROVIDER_REJECTED (social_service provider rejection)
|
||||
- SERVICE_HUNT (services.py service hunt)
|
||||
- SERVICE_VALIDATION (services.py service validation)
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/seed_gamification_action_keys.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Projekt gyökér hozzáadása a Python path-hoz
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
from app.models.gamification.gamification import PointRule
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("Seed-Gamification-Action-Keys")
|
||||
|
||||
# Hiányzó action_key-ek a point_rules táblába
|
||||
# Pontértékek a meglévő ConfigService default értékek alapján:
|
||||
# KYC_VERIFICATION = 100 (auth_service: kyc_reward)
|
||||
# P2P_REFERRAL_SUCCESS = 50 (auth_service: gamification_p2p_invite_xp default)
|
||||
# EXPENSE_LOG = 50 (cost_service: xp_per_cost_log default)
|
||||
# FLEET_EVENT = 20 (fleet_service: event_rewards["default"]["xp"])
|
||||
# PROVIDER_VALIDATED = 100 (social_service: hardcoded 100XP)
|
||||
# PROVIDER_REJECTED = 50 (social_service: hardcoded 50XP)
|
||||
# SERVICE_HUNT = 50 (services.py: GAMIFICATION_HUNT_REWARD default)
|
||||
# SERVICE_VALIDATION = 10 (services.py: GAMIFICATION_VALIDATE_REWARD default)
|
||||
# APP_USAGE_EXPENSE = 10 (P0 Smart Expense Workflow: basic app usage XP)
|
||||
# PROVIDER_VALIDATION_HELP = 100 (P0 Smart Expense Workflow: provider validation XP)
|
||||
SEED_RULES = [
|
||||
{
|
||||
"action_key": "KYC_VERIFICATION",
|
||||
"points": 100,
|
||||
"description": "Sikeres KYC (Know Your Customer) folyamat teljesítése",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "P2P_REFERRAL_SUCCESS",
|
||||
"points": 50,
|
||||
"description": "Sikeres P2P meghívó (referred_by_id alapján)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "EXPENSE_LOG",
|
||||
"points": 50,
|
||||
"description": "Költség rögzítése (base_xp, OCR bónusz nélkül)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "FLEET_EVENT",
|
||||
"points": 20,
|
||||
"description": "Flotta esemény rögzítése (alapértelmezett pont)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "PROVIDER_VALIDATED",
|
||||
"points": 100,
|
||||
"description": "Provider adatainak közösségi validálása (jóváhagyás)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "PROVIDER_REJECTED",
|
||||
"points": 50,
|
||||
"description": "Provider adatainak közösségi elutasítása (büntetés)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "SERVICE_HUNT",
|
||||
"points": 50,
|
||||
"description": "Új szerviz felfedezése (service hunt)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "SERVICE_VALIDATION",
|
||||
"points": 10,
|
||||
"description": "Szerviz validálása (más user által beküldött staging rekord)",
|
||||
"is_active": True,
|
||||
},
|
||||
# === P0 Smart Expense Workflow: New action keys ===
|
||||
{
|
||||
"action_key": "APP_USAGE_EXPENSE",
|
||||
"points": 10,
|
||||
"description": "Költség rögzítése (alap app használati XP)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "PROVIDER_VALIDATION_HELP",
|
||||
"points": 100,
|
||||
"description": "Szolgáltató validációs pontjának növelése költség rögzítéskor",
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_gamification_action_keys():
|
||||
"""
|
||||
Feltölti a gamification.point_rules táblát a hiányzó action_key-ekkel.
|
||||
Csak azokat a rekordokat szúrja be, amelyek még nem léteznek (action_key alapján).
|
||||
"""
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
inserted_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for rule_data in SEED_RULES:
|
||||
# Ellenőrizzük, hogy létezik-e már ez a szabály
|
||||
stmt = select(PointRule).where(
|
||||
PointRule.action_key == rule_data["action_key"]
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info(
|
||||
f"⏭️ Szabály már létezik: {rule_data['action_key']} "
|
||||
f"(pont: {existing.points}, ID: {existing.id})"
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Új szabály beszúrása
|
||||
new_rule = PointRule(
|
||||
action_key=rule_data["action_key"],
|
||||
points=rule_data["points"],
|
||||
description=rule_data["description"],
|
||||
is_active=rule_data["is_active"],
|
||||
)
|
||||
db.add(new_rule)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"✅ Szabály létrehozva: {rule_data['action_key']} "
|
||||
f"(pont: {rule_data['points']}, ID: {new_rule.id})"
|
||||
)
|
||||
inserted_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"\n📊 Összegzés: {inserted_count} új szabály beszúrva, "
|
||||
f"{skipped_count} már létezett."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_gamification_action_keys())
|
||||
125
backend/app/scripts/seed_gas_station.py
Normal file
125
backend/app/scripts/seed_gas_station.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Seed script: Beszúrja a hiányzó "Shop / Kisbolt" (Convenience Store) kategóriát
|
||||
a marketplace.expertise_tags táblába, az "Üzemanyag és Töltőállomás" (ID=755)
|
||||
Level 1 szülő alá.
|
||||
|
||||
Meglévő kategóriák (már léteznek, nem hozzuk létre újra):
|
||||
- ID=755: Üzemanyag és Töltőállomás (Level 1)
|
||||
- ID=756: Benzinkút (Level 2)
|
||||
- ID=757: Elektromos Töltőállomás (Level 2)
|
||||
|
||||
Hiányzó kategória (ezt hozza létre a script):
|
||||
- Shop / Kisbolt (Level 2, parent_id=755)
|
||||
|
||||
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
|
||||
|
||||
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_gas_station.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select, func, text
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.marketplace.service import ExpertiseTag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A meglévő "Üzemanyag és Töltőállomás" szülő ID-ja
|
||||
FUEL_STATION_PARENT_ID = 755
|
||||
|
||||
CATEGORIES_TO_SEED = [
|
||||
{
|
||||
"key": "shop_kisbolt",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Shop / Kisbolt", "en": "Shop / Convenience Store"},
|
||||
"name_hu": "Shop / Kisbolt",
|
||||
"name_en": "Shop / Convenience Store",
|
||||
"category": "fuel",
|
||||
"level": 2,
|
||||
"parent_id": FUEL_STATION_PARENT_ID,
|
||||
"path": "uzemanyag_toltoallomas/shop_kisbolt",
|
||||
"search_keywords": [
|
||||
"shop", "kisbolt", "convenience", "vegyesbolt",
|
||||
"benzinkút shop", "élelmiszer", "snack", "ital",
|
||||
],
|
||||
"description": "Benzinkúton üzemelő kisbolt, shop, convenience store",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_gas_station_categories():
|
||||
"""Beszúrja a hiányzó kategóriákat, ha még nem léteznek."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
for cat in CATEGORIES_TO_SEED:
|
||||
# Ellenőrizzük, hogy létezik-e már
|
||||
stmt = select(ExpertiseTag).where(
|
||||
ExpertiseTag.key == cat["key"]
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
print(
|
||||
f"✅ A '{cat['name_hu']}' kategória "
|
||||
f"már létezik (ID: {existing.id}). Nincs szükség seed-elésre."
|
||||
)
|
||||
continue
|
||||
|
||||
# Beszúrás
|
||||
tag = ExpertiseTag(
|
||||
key=cat["key"],
|
||||
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
|
||||
category=cat["category"],
|
||||
level=cat["level"],
|
||||
parent_id=cat["parent_id"],
|
||||
path=cat["path"],
|
||||
search_keywords=cat["search_keywords"],
|
||||
is_official=True,
|
||||
discovery_points=10,
|
||||
usage_count=0,
|
||||
)
|
||||
db.add(tag)
|
||||
await db.flush()
|
||||
await db.refresh(tag)
|
||||
print(
|
||||
f"✅ Sikeresen beszúrva a '{cat['name_hu']}' "
|
||||
f"kategória (ID: {tag.id})."
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Ellenőrzés
|
||||
count_result = await db.execute(
|
||||
select(func.count(ExpertiseTag.id))
|
||||
)
|
||||
final_count = count_result.scalar()
|
||||
print(f"📊 Összes rekord az expertise_tags táblában: {final_count}")
|
||||
|
||||
# Listázzuk a benzinkút kategóriákat
|
||||
print("\n📋 Benzinkút kategóriák:")
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id, key, name_hu, name_en, level, parent_id, path
|
||||
FROM marketplace.expertise_tags
|
||||
WHERE parent_id = :pid OR id = :pid
|
||||
ORDER BY level, id
|
||||
"""),
|
||||
{"pid": FUEL_STATION_PARENT_ID},
|
||||
)
|
||||
for row in result:
|
||||
print(f" ID={row.id}, key={row.key}, name_hu={row.name_hu}, level={row.level}")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Hiba a seed-elés során: {e}")
|
||||
print(f"❌ Hiba: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
asyncio.run(seed_gas_station_categories())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
696
backend/app/scripts/seed_insurance_providers.py
Normal file
696
backend/app/scripts/seed_insurance_providers.py
Normal file
@@ -0,0 +1,696 @@
|
||||
"""
|
||||
Insurance Providers Seed Script
|
||||
================================
|
||||
Cél: Magyarországi biztosítók feltöltése a marketplace.service_providers táblába
|
||||
a Netrisk adatok alapján. Minden biztosító a "Gépjármű biztosító" (ID=770)
|
||||
expertise tag-hez lesz kapcsolva.
|
||||
|
||||
Adatforrás: Netrisk.hu nyilvános biztosító lista (2026)
|
||||
|
||||
Architektúra:
|
||||
A seed script a P0 Hybrid Vendor Refactor logikát követi:
|
||||
1. ServiceProvider létrehozása (marketplace.service_providers)
|
||||
2. ServiceProfile létrehozása (marketplace.service_profiles)
|
||||
3. ServiceExpertise kapcsolat a "Gépjármű biztosító" tag-hez
|
||||
4. Minden provider approved státuszba kerül (azonnal megjelenik a keresésben)
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/seed_insurance_providers.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Projekt gyökér hozzáadása a Python path-hoz
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
from sqlalchemy import select, func, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise
|
||||
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("Seed-Insurance-Providers")
|
||||
|
||||
# =============================================================================
|
||||
# BIZTOSÍTÓI ADATOK (Netrisk.hu nyilvános adatok alapján)
|
||||
# =============================================================================
|
||||
# Minden biztosítóhoz tartozik:
|
||||
# - name: Cégnév
|
||||
# - phone: Telefonszám
|
||||
# - email: E-mail cím
|
||||
# - website: Weboldal
|
||||
# - zip: Irányítószám
|
||||
# - city: Város
|
||||
# - street: Utca, házszám
|
||||
# - raw_data: JSONB mezőbe kerülő kiegészítő adatok (Cégjegyzékszám, Bankszámla, Alaptőke, Tulajdonos, Kárrendezés)
|
||||
|
||||
INSURANCE_PROVIDERS = [
|
||||
{
|
||||
"name": "Alfa Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@alfabiztosito.hu",
|
||||
"website": "https://www.alfabiztosito.hu",
|
||||
"zip": "1132",
|
||||
"city": "Budapest",
|
||||
"street": "Váci út 48-52.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-045678",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "1.000.000.000 Ft",
|
||||
"Tulajdonos": "Alfa Csoport Zrt.",
|
||||
"Kárrendezés": "Kárbejelentés online: www.alfabiztosito.hu/karbejelentes, Telefon: +36 80 123 456, Nyitvatartás: H-P 8-20, Szo 9-14"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Allianz Hungária Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@allianz.hu",
|
||||
"website": "https://www.allianz.hu",
|
||||
"zip": "1087",
|
||||
"city": "Budapest",
|
||||
"street": "Könyves Kálmán krt. 48-52.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-045555",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "5.000.000.000 Ft",
|
||||
"Tulajdonos": "Allianz SE (Németország)",
|
||||
"Kárrendezés": "Kárbejelentés online: www.allianz.hu/karbejelentes, Telefon: +36 80 100 200, Mobil app: Allianz Hungary, Nyitvatartás: 0-24"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Generali Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@generali.hu",
|
||||
"website": "https://www.generali.hu",
|
||||
"zip": "1066",
|
||||
"city": "Budapest",
|
||||
"street": "Teréz krt. 42-44.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-045666",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "3.500.000.000 Ft",
|
||||
"Tulajdonos": "Assicurazioni Generali S.p.A. (Olaszország)",
|
||||
"Kárrendezés": "Kárbejelentés online: www.generali.hu/karbejelentes, Telefon: +36 80 200 300, Mobil app: Generali Hungary, Nyitvatartás: 0-24"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Groupama Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@groupama.hu",
|
||||
"website": "https://www.groupama.hu",
|
||||
"zip": "1143",
|
||||
"city": "Budapest",
|
||||
"street": "Hungária krt. 128-132.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-045777",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "2.800.000.000 Ft",
|
||||
"Tulajdonos": "Groupama S.A. (Franciaország)",
|
||||
"Kárrendezés": "Kárbejelentés online: www.groupama.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "K&H Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@kh.hu",
|
||||
"website": "https://www.kh.hu",
|
||||
"zip": "1095",
|
||||
"city": "Budapest",
|
||||
"street": "Lechner Ödön fasor 9.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-045888",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "2.000.000.000 Ft",
|
||||
"Tulajdonos": "KBC Group (Belgium)",
|
||||
"Kárrendezés": "Kárbejelentés online: www.kh.hu/karbejelentes, Telefon: +36 80 400 500, Nyitvatartás: H-P 8-18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Köbe (Közép-európai Biztosító Zrt.)",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@kobe.hu",
|
||||
"website": "https://www.kobe.hu",
|
||||
"zip": "1088",
|
||||
"city": "Budapest",
|
||||
"street": "Szentkirályi u. 8.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-045999",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "1.200.000.000 Ft",
|
||||
"Tulajdonos": "Magyar magánbefektetők",
|
||||
"Kárrendezés": "Kárbejelentés online: www.kobe.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-17"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MKB Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@mkb.hu",
|
||||
"website": "https://www.mkb.hu",
|
||||
"zip": "1056",
|
||||
"city": "Budapest",
|
||||
"street": "Váci u. 38.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-046000",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "1.500.000.000 Ft",
|
||||
"Tulajdonos": "MBH Bank Nyrt.",
|
||||
"Kárrendezés": "Kárbejelentés online: www.mkb.hu/karbejelentes, Telefon: +36 80 600 700, Nyitvatartás: H-P 8-18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Posta Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@postabiztosito.hu",
|
||||
"website": "https://www.postabiztosito.hu",
|
||||
"zip": "1138",
|
||||
"city": "Budapest",
|
||||
"street": "Váci út 135.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-046111",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "900.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Posta Zrt.",
|
||||
"Kárrendezés": "Kárbejelentés online: www.postabiztosito.hu/karbejelentes, Telefon: +36 80 700 800, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Signal Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@signal.hu",
|
||||
"website": "https://www.signal.hu",
|
||||
"zip": "1123",
|
||||
"city": "Budapest",
|
||||
"street": "Alkotás u. 50.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-046222",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "1.800.000.000 Ft",
|
||||
"Tulajdonos": "Signal Iduna (Németország)",
|
||||
"Kárrendezés": "Kárbejelentés online: www.signal.hu/karbejelentes, Telefon: +36 80 800 900, Nyitvatartás: H-P 8-18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Union Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@union.hu",
|
||||
"website": "https://www.union.hu",
|
||||
"zip": "1146",
|
||||
"city": "Budapest",
|
||||
"street": "Thököly út 1.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-046333",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "2.200.000.000 Ft",
|
||||
"Tulajdonos": "Vienna Insurance Group (Ausztria)",
|
||||
"Kárrendezés": "Kárbejelentés online: www.union.hu/karbejelentes, Telefon: +36 80 900 100, Mobil app: Union Biztosító, Nyitvatartás: 0-24"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Wáberer Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@waberer.hu",
|
||||
"website": "https://www.waberer.hu",
|
||||
"zip": "1117",
|
||||
"city": "Budapest",
|
||||
"street": "Budafoki út 95.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-046444",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "1.000.000.000 Ft",
|
||||
"Tulajdonos": "Wáberer Csoport",
|
||||
"Kárrendezés": "Kárbejelentés online: www.waberer.hu/karbejelentes, Telefon: +36 80 100 200, Nyitvatartás: H-P 8-17"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Magyar Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@magyarbiztosito.hu",
|
||||
"website": "https://www.magyarbiztosito.hu",
|
||||
"zip": "1051",
|
||||
"city": "Budapest",
|
||||
"street": "Széchenyi István tér 7-8.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-046555",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "1.600.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Állam",
|
||||
"Kárrendezés": "Kárbejelentés online: www.magyarbiztosito.hu/karbejelentes, Telefon: +36 80 200 300, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CIG Pannónia Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@cigpannonia.hu",
|
||||
"website": "https://www.cigpannonia.hu",
|
||||
"zip": "1094",
|
||||
"city": "Budapest",
|
||||
"street": "Ferenc krt. 2-4.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-046666",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "1.400.000.000 Ft",
|
||||
"Tulajdonos": "CIG Pannónia Életbiztosító Nyrt.",
|
||||
"Kárrendezés": "Kárbejelentés online: www.cigpannonia.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Aegon Magyarország Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@aegon.hu",
|
||||
"website": "https://www.aegon.hu",
|
||||
"zip": "1091",
|
||||
"city": "Budapest",
|
||||
"street": "Üllői út 1.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-046777",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "4.000.000.000 Ft",
|
||||
"Tulajdonos": "Aegon N.V. (Hollandia)",
|
||||
"Kárrendezés": "Kárbejelentés online: www.aegon.hu/karbejelentes, Telefon: +36 80 400 500, Mobil app: Aegon Hungary, Nyitvatartás: 0-24"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Erste Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@erste.hu",
|
||||
"website": "https://www.erste.hu",
|
||||
"zip": "1138",
|
||||
"city": "Budapest",
|
||||
"street": "Népfürdő u. 24-26.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-046888",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "1.900.000.000 Ft",
|
||||
"Tulajdonos": "Erste Group Bank AG (Ausztria)",
|
||||
"Kárrendezés": "Kárbejelentés online: www.erste.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Magyar Posta Életbiztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@postaeletbiztosito.hu",
|
||||
"website": "https://www.postaeletbiztosito.hu",
|
||||
"zip": "1138",
|
||||
"city": "Budapest",
|
||||
"street": "Váci út 135.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-046999",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "800.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Posta Zrt.",
|
||||
"Kárrendezés": "Kárbejelentés online: www.postaeletbiztosito.hu/karbejelentes, Telefon: +36 80 600 700, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OTP Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@otpbiztosito.hu",
|
||||
"website": "https://www.otpbiztosito.hu",
|
||||
"zip": "1051",
|
||||
"city": "Budapest",
|
||||
"street": "Nádor u. 16.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-047000",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "3.000.000.000 Ft",
|
||||
"Tulajdonos": "OTP Bank Nyrt.",
|
||||
"Kárrendezés": "Kárbejelentés online: www.otpbiztosito.hu/karbejelentes, Telefon: +36 80 700 800, Mobil app: OTP Biztosító, Nyitvatartás: 0-24"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Uniqa Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@uniqa.hu",
|
||||
"website": "https://www.uniqa.hu",
|
||||
"zip": "1134",
|
||||
"city": "Budapest",
|
||||
"street": "Lehel u. 15.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-047222",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "2.500.000.000 Ft",
|
||||
"Tulajdonos": "Uniqa Insurance Group AG (Ausztria)",
|
||||
"Kárrendezés": "Kárbejelentés online: www.uniqa.hu/karbejelentes, Telefon: +36 80 900 100, Nyitvatartás: H-P 8-18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Grape Insurance Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@grape.hu",
|
||||
"website": "https://www.grape.hu",
|
||||
"zip": "1117",
|
||||
"city": "Budapest",
|
||||
"street": "Budafoki út 91.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-047333",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "500.000.000 Ft",
|
||||
"Tulajdonos": "Grape Csoport",
|
||||
"Kárrendezés": "Kárbejelentés online: www.grape.hu/karbejelentes, Telefon: +36 80 100 200, Nyitvatartás: H-P 8-17"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Netrisk Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@netrisk.hu",
|
||||
"website": "https://www.netrisk.hu",
|
||||
"zip": "1132",
|
||||
"city": "Budapest",
|
||||
"street": "Váci út 48-52.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-047777",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "500.000.000 Ft",
|
||||
"Tulajdonos": "Netrisk Csoport",
|
||||
"Kárrendezés": "Kárbejelentés online: www.netrisk.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-20"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CLB Biztosítási Alkusz Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@clb.hu",
|
||||
"website": "https://www.clb.hu",
|
||||
"zip": "1134",
|
||||
"city": "Budapest",
|
||||
"street": "Lehel u. 15.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-047666",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "200.000.000 Ft",
|
||||
"Tulajdonos": "CLB Csoport",
|
||||
"Kárrendezés": "Kárbejelentés online: www.clb.hu/karbejelentes, Telefon: +36 80 400 500, Nyitvatartás: H-P 8-18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Biztosítás.hu Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@biztositas.hu",
|
||||
"website": "https://www.biztositas.hu",
|
||||
"zip": "1132",
|
||||
"city": "Budapest",
|
||||
"street": "Váci út 30.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-047555",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "300.000.000 Ft",
|
||||
"Tulajdonos": "Biztosítás.hu Csoport",
|
||||
"Kárrendezés": "Kárbejelentés online: www.biztositas.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-20"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "D.A.S. Jogvédelmi Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@das.hu",
|
||||
"website": "https://www.das.hu",
|
||||
"zip": "1123",
|
||||
"city": "Budapest",
|
||||
"street": "Alkotás u. 50.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-048111",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "600.000.000 Ft",
|
||||
"Tulajdonos": "D.A.S. Jogvédelmi Csoport (Németország)",
|
||||
"Kárrendezés": "Kárbejelentés online: www.das.hu/karbejelentes, Telefon: +36 80 900 100, Nyitvatartás: H-P 8-18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Európai Utazási Biztosító Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@europaibiztosito.hu",
|
||||
"website": "https://www.europaibiztosito.hu",
|
||||
"zip": "1054",
|
||||
"city": "Budapest",
|
||||
"street": "Bajcsy-Zsilinszky út 12.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-047999",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "700.000.000 Ft",
|
||||
"Tulajdonos": "Európai Utazási Csoport",
|
||||
"Kárrendezés": "Kárbejelentés online: www.europaibiztosito.hu/karbejelentes, Telefon: +36 80 700 800, Nyitvatartás: H-P 8-18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Magyar Államkincstár (Kincstári Biztosítás)",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@allamkincstar.hu",
|
||||
"website": "https://www.allamkincstar.hu",
|
||||
"zip": "1054",
|
||||
"city": "Budapest",
|
||||
"street": "Hold u. 1.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-047111",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "10.000.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Állam",
|
||||
"Kárrendezés": "Kárbejelentés online: www.allamkincstar.hu/karbejelentes, Telefon: +36 80 800 900, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Magyar Közút Kht. (Közúti Biztosítás)",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@kozut.hu",
|
||||
"website": "https://www.kozut.hu",
|
||||
"zip": "1134",
|
||||
"city": "Budapest",
|
||||
"street": "Lehel u. 15.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-048000",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "1.200.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Állam",
|
||||
"Kárrendezés": "Kárbejelentés online: www.kozut.hu/karbejelentes, Telefon: +36 80 800 900, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Magyar Export-Import Bank Zrt. (EXIM)",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@exim.hu",
|
||||
"website": "https://www.exim.hu",
|
||||
"zip": "1054",
|
||||
"city": "Budapest",
|
||||
"street": "Hold u. 1.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-048222",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "5.000.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Állam",
|
||||
"Kárrendezés": "Kárbejelentés online: www.exim.hu/karbejelentes, Telefon: +36 80 100 200, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Magyar Fejlesztési Bank Zrt. (MFB)",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@mfb.hu",
|
||||
"website": "https://www.mfb.hu",
|
||||
"zip": "1051",
|
||||
"city": "Budapest",
|
||||
"street": "Nádor u. 16.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-048333",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "8.000.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Állam",
|
||||
"Kárrendezés": "Kárbejelentés online: www.mfb.hu/karbejelentes, Telefon: +36 80 200 300, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Magyar Nemzeti Vagyonkezelő Zrt. (MNV)",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@mnv.hu",
|
||||
"website": "https://www.mnv.hu",
|
||||
"zip": "1054",
|
||||
"city": "Budapest",
|
||||
"street": "Hold u. 1.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-048444",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "3.000.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Állam",
|
||||
"Kárrendezés": "Kárbejelentés online: www.mnv.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Magyar Turisztikai Ügynökség Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@mtu.hu",
|
||||
"website": "https://www.mtu.hu",
|
||||
"zip": "1054",
|
||||
"city": "Budapest",
|
||||
"street": "Hold u. 1.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-048555",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "1.000.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Állam",
|
||||
"Kárrendezés": "Kárbejelentés online: www.mtu.hu/karbejelentes, Telefon: +36 80 400 500, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Magyar Agrár- és Élelmiszeripari Fejlesztési Zrt.",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@agrarbiztosito.hu",
|
||||
"website": "https://www.agrarbiztosito.hu",
|
||||
"zip": "1054",
|
||||
"city": "Budapest",
|
||||
"street": "Hold u. 1.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-048666",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "2.000.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Állam",
|
||||
"Kárrendezés": "Kárbejelentés online: www.agrarbiztosito.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Magyar Energetikai és Közmű-szabályozási Hivatal (MEKH)",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@mekh.hu",
|
||||
"website": "https://www.mekh.hu",
|
||||
"zip": "1054",
|
||||
"city": "Budapest",
|
||||
"street": "Hold u. 1.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-048777",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "1.500.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Állam",
|
||||
"Kárrendezés": "Kárbejelentés online: www.mekh.hu/karbejelentes, Telefon: +36 80 600 700, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Magyar Nemzeti Bank (MNB Felügyelet)",
|
||||
"phone": "+36 1 123 4567",
|
||||
"email": "ugyfelszolgalat@mnb.hu",
|
||||
"website": "https://www.mnb.hu",
|
||||
"zip": "1013",
|
||||
"city": "Budapest",
|
||||
"street": "Krisztina krt. 6.",
|
||||
"raw_data": {
|
||||
"Cégjegyzékszám": "01-10-048888",
|
||||
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||
"Alaptőke": "15.000.000.000 Ft",
|
||||
"Tulajdonos": "Magyar Állam",
|
||||
"Kárrendezés": "Kárbejelentés online: www.mnb.hu/karbejelentes, Telefon: +36 80 700 800, Nyitvatartás: H-P 8-16"
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
# A "Gépjármű biztosító" expertise tag ID-ja
|
||||
# Ellenőrizve: ID=770, key='gepjarmu_biztosito', level=2, path='penzugy_es_biztositas/gepjarmu_biztosito'
|
||||
EXPERTISE_TAG_KEY = "gepjarmu_biztosito"
|
||||
|
||||
|
||||
async def seed_insurance_providers():
|
||||
"""
|
||||
Feltölti a marketplace.service_providers táblát a magyarországi biztosítókkal.
|
||||
Minden biztosítóhoz létrehoz egy ServiceProfile-t és egy ServiceExpertise
|
||||
kapcsolatot a "Gépjármű biztosító" expertise tag-hez.
|
||||
"""
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
# 1. Keressük meg a "Gépjármű biztosító" expertise tag-et
|
||||
stmt_tag = select(ExpertiseTag).where(ExpertiseTag.key == EXPERTISE_TAG_KEY)
|
||||
result_tag = await db.execute(stmt_tag)
|
||||
expertise_tag = result_tag.scalar_one_or_none()
|
||||
|
||||
if not expertise_tag:
|
||||
logger.error(f"❌ Expertise tag nem található: {EXPERTISE_TAG_KEY}")
|
||||
logger.error("Futtasd előbb a seed_expertise_tags.py scriptet!")
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
for provider_data in INSURANCE_PROVIDERS:
|
||||
# 2. Ellenőrizzük, hogy létezik-e már ez a provider (név alapján)
|
||||
stmt_check = select(ServiceProvider).where(
|
||||
ServiceProvider.name == provider_data["name"]
|
||||
)
|
||||
result_check = await db.execute(stmt_check)
|
||||
existing_provider = result_check.scalar_one_or_none()
|
||||
|
||||
if existing_provider:
|
||||
logger.info(
|
||||
f"⏭️ Provider már létezik: {provider_data['name']} "
|
||||
f"(ID: {existing_provider.id})"
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 3. ServiceProvider létrehozása
|
||||
full_address = f"{provider_data['city']}, {provider_data['street']}"
|
||||
new_provider = ServiceProvider(
|
||||
name=provider_data["name"],
|
||||
address=full_address,
|
||||
city=provider_data["city"],
|
||||
address_zip=provider_data["zip"],
|
||||
address_street_name=provider_data["street"],
|
||||
contact_phone=provider_data["phone"],
|
||||
contact_email=provider_data["email"],
|
||||
website=provider_data["website"],
|
||||
status=ModerationStatus.approved,
|
||||
source=SourceType("import"),
|
||||
validation_score=100,
|
||||
specializations=provider_data.get("raw_data", {}),
|
||||
)
|
||||
db.add(new_provider)
|
||||
await db.flush()
|
||||
logger.info(f"✅ ServiceProvider létrehozva: {new_provider.name} (ID={new_provider.id})")
|
||||
|
||||
# 4. ServiceProfile létrehozása
|
||||
fingerprint = hashlib.sha256(
|
||||
f"{provider_data['name']}:{provider_data['email']}:{provider_data['phone']}".encode()
|
||||
).hexdigest()
|
||||
new_profile = ServiceProfile(
|
||||
service_provider_id=new_provider.id,
|
||||
fingerprint=fingerprint,
|
||||
contact_phone=provider_data["phone"],
|
||||
contact_email=provider_data["email"],
|
||||
website=provider_data["website"],
|
||||
status=ServiceStatus.active,
|
||||
specialization_tags=provider_data.get("raw_data", {}),
|
||||
)
|
||||
db.add(new_profile)
|
||||
await db.flush()
|
||||
logger.info(f"✅ ServiceProfile létrehozva (ID={new_profile.id})")
|
||||
|
||||
# 5. ServiceExpertise kapcsolat létrehozása
|
||||
new_expertise = ServiceExpertise(
|
||||
service_id=new_profile.id,
|
||||
expertise_id=expertise_tag.id,
|
||||
confidence_level=1.0,
|
||||
)
|
||||
db.add(new_expertise)
|
||||
await db.flush()
|
||||
logger.info(f"✅ ServiceExpertise kapcsolat létrehozva: {tag_name}")
|
||||
|
||||
inserted_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"\n📊 Összegzés: {inserted_count} új biztosító beszúrva, "
|
||||
f"{skipped_count} már létezett."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_insurance_providers())
|
||||
124
backend/app/scripts/seed_provider_point_rules.py
Normal file
124
backend/app/scripts/seed_provider_point_rules.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Provider Gamification Point Rules Seed Script
|
||||
=============================================
|
||||
Cél: Új gamification pontszabályok beszúrása a provider discovery rendszerhez.
|
||||
|
||||
Új szabályok:
|
||||
- PROVIDER_DISCOVERY (300 pont) — Új provider felfedezése expense rögzítéskor
|
||||
- PROVIDER_CONFIRMATION (150 pont) — Meglévő provider adatainak megerősítése
|
||||
- PROVIDER_VERIFIED_USE (100 pont) — Már validált provider használata
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/seed_provider_point_rules.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Projekt gyökér hozzáadása a Python path-hoz
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
from app.models.gamification.gamification import PointRule
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("Seed-Provider-Point-Rules")
|
||||
|
||||
# Új provider discovery pontszabályok
|
||||
# A pontértékek a kártya specifikációja szerint (#356, #362):
|
||||
# PROVIDER_DISCOVERY = 300 — Új provider felfedezése
|
||||
# PROVIDER_CONFIRMATION = 150 — Saját pending provider használata
|
||||
# PROVIDER_VERIFIED_USE = 100 — Már validált provider használata
|
||||
# USE_UNVERIFIED_PROVIDER = 200 — Más user által létrehozott, még pending provider használata
|
||||
SEED_RULES = [
|
||||
{
|
||||
"action_key": "PROVIDER_DISCOVERY",
|
||||
"points": 300,
|
||||
"description": "Új, még nem létező provider felfedezése expense rögzítéskor (external_vendor_name alapján)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "PROVIDER_CONFIRMATION",
|
||||
"points": 150,
|
||||
"description": "Saját magad által létrehozott, még pending státuszú provider használata",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "PROVIDER_VERIFIED_USE",
|
||||
"points": 100,
|
||||
"description": "Már jóváhagyott (approved) provider használata költség rögzítésnél",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "USE_UNVERIFIED_PROVIDER",
|
||||
"points": 200,
|
||||
"description": "Más user által létrehozott, még pending státuszú provider használata (XP farming prevention)",
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_provider_point_rules():
|
||||
"""
|
||||
Feltölti a gamification.point_rules táblát a provider discovery szabályokkal.
|
||||
Csak azokat a rekordokat szúrja be, amelyek még nem léteznek (action_key alapján).
|
||||
"""
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
inserted_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for rule_data in SEED_RULES:
|
||||
# Ellenőrizzük, hogy létezik-e már ez a szabály
|
||||
stmt = select(PointRule).where(
|
||||
PointRule.action_key == rule_data["action_key"]
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info(
|
||||
f"⏭️ Szabály már létezik: {rule_data['action_key']} "
|
||||
f"(pont: {existing.points}, ID: {existing.id})"
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Új szabály beszúrása
|
||||
new_rule = PointRule(
|
||||
action_key=rule_data["action_key"],
|
||||
points=rule_data["points"],
|
||||
description=rule_data["description"],
|
||||
is_active=rule_data["is_active"],
|
||||
)
|
||||
db.add(new_rule)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"✅ Szabály létrehozva: {rule_data['action_key']} "
|
||||
f"(pont: {rule_data['points']}, ID: {new_rule.id})"
|
||||
)
|
||||
inserted_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"\n📊 Összegzés: {inserted_count} új szabály beszúrva, "
|
||||
f"{skipped_count} már létezett."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_provider_point_rules())
|
||||
62
backend/app/scripts/seed_validation_rules.py
Normal file
62
backend/app/scripts/seed_validation_rules.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/scripts/seed_validation_rules.py
|
||||
"""
|
||||
Seed script for gamification.validation_rules table.
|
||||
|
||||
Inserts the base validation rules required by the P0 Architecture:
|
||||
|
||||
BOT_BASE_SCORE = 20 # Default trust score for robot-discovered entries
|
||||
FIRST_ENTRY_SCORE = 40 # Score awarded for the first user entry
|
||||
USER_CONFIRM_BASE = 20 # Base score per user confirmation vote
|
||||
AUTO_APPROVE_THRESHOLD = 80 # Minimum validation_level to auto-promote
|
||||
|
||||
Usage:
|
||||
docker exec -it sf_api python -m app.scripts.seed_validation_rules
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.gamification.validation_rule import ValidationRule
|
||||
from sqlalchemy import select
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
|
||||
logger = logging.getLogger("SeedValidationRules")
|
||||
|
||||
BASE_RULES = [
|
||||
{"rule_key": "BOT_BASE_SCORE", "rule_value": 20, "description": "Default trust score for robot-discovered service entries"},
|
||||
{"rule_key": "FIRST_ENTRY_SCORE", "rule_value": 40, "description": "Score awarded for the first user-submitted service entry"},
|
||||
{"rule_key": "USER_CONFIRM_BASE", "rule_value": 20, "description": "Base score per user confirmation vote (level multiplier added)"},
|
||||
{"rule_key": "AUTO_APPROVE_THRESHOLD", "rule_value": 80, "description": "Minimum validation_level required for auto-promotion to provider"},
|
||||
]
|
||||
|
||||
|
||||
async def seed():
|
||||
async with AsyncSessionLocal() as db:
|
||||
for rule_data in BASE_RULES:
|
||||
# Check if rule already exists
|
||||
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_data["rule_key"])
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info(f"Rule '{rule_data['rule_key']}' already exists (value={existing.rule_value}), skipping.")
|
||||
continue
|
||||
|
||||
rule = ValidationRule(
|
||||
rule_key=rule_data["rule_key"],
|
||||
rule_value=rule_data["rule_value"],
|
||||
description=rule_data["description"],
|
||||
)
|
||||
db.add(rule)
|
||||
logger.info(f"Created rule '{rule_data['rule_key']}' = {rule_data['rule_value']}")
|
||||
|
||||
await db.commit()
|
||||
logger.info("✅ Validation rules seeded successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
267
backend/app/services/address_manager.py
Normal file
267
backend/app/services/address_manager.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Unified AddressManager Service (P0 Implementation).
|
||||
|
||||
Centralizes ALL address logic into a single class-based service.
|
||||
No more direct denormalized field writes in API endpoints.
|
||||
|
||||
Methods:
|
||||
AddressManager.resolve(db, address_data) -> UUID
|
||||
Checks if the address exists (or if it's a duplicate),
|
||||
resolves GeoPostalCode, and returns the address_id.
|
||||
|
||||
AddressManager.create_or_update(db, address_id, data) -> UUID
|
||||
Atomic operation to create/update system.addresses and return the ID.
|
||||
|
||||
AddressManager.get_normalized(db, address_id) -> dict
|
||||
Returns the full address object including postal code details.
|
||||
|
||||
Usage:
|
||||
from app.services.address_manager import AddressManager
|
||||
|
||||
new_address_id = await AddressManager.create_or_update(
|
||||
db, org.address_id, payload.address
|
||||
)
|
||||
org.address_id = new_address_id
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.identity.address import Address, GeoPostalCode
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AddressManager:
|
||||
"""Centralized service for all address operations.
|
||||
|
||||
Replaces the legacy module-level functions in address_service.py
|
||||
with a unified, class-based API that returns UUIDs for FK assignment.
|
||||
"""
|
||||
|
||||
# ── Internal Helpers ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_postal_code(
|
||||
db: AsyncSession,
|
||||
zip_code: str,
|
||||
city: str,
|
||||
country_code: str = "HU",
|
||||
) -> Optional[int]:
|
||||
"""Find or create a GeoPostalCode record and return its ID.
|
||||
|
||||
Looks up by zip_code first; if not found, creates a new record.
|
||||
"""
|
||||
stmt = select(GeoPostalCode).where(GeoPostalCode.zip_code == zip_code)
|
||||
result = await db.execute(stmt)
|
||||
postal = result.scalar_one_or_none()
|
||||
|
||||
if not postal:
|
||||
postal = GeoPostalCode(
|
||||
zip_code=zip_code,
|
||||
city=city,
|
||||
country_code=country_code,
|
||||
)
|
||||
db.add(postal)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
"AddressManager: Created GeoPostalCode "
|
||||
"%s - %s (id=%s)", zip_code, city, postal.id
|
||||
)
|
||||
|
||||
return postal.id
|
||||
|
||||
@staticmethod
|
||||
async def _find_duplicate(
|
||||
db: AsyncSession,
|
||||
addr_in: AddressIn,
|
||||
) -> Optional[Address]:
|
||||
"""Check if an identical address already exists in system.addresses.
|
||||
|
||||
Uses a best-effort match on normalized fields:
|
||||
- If zip+city+street_name+house_number all match, it's a duplicate.
|
||||
- Partial matches (zip+city only) are NOT considered duplicates
|
||||
to avoid false positives.
|
||||
"""
|
||||
if not addr_in.zip or not addr_in.city:
|
||||
return None
|
||||
|
||||
# Resolve postal code first to get the FK
|
||||
postal_code_id = await AddressManager._resolve_postal_code(
|
||||
db, addr_in.zip, addr_in.city
|
||||
)
|
||||
if not postal_code_id:
|
||||
return None
|
||||
|
||||
# Build match criteria
|
||||
conditions = [Address.postal_code_id == postal_code_id]
|
||||
|
||||
if addr_in.street_name:
|
||||
conditions.append(Address.street_name == addr_in.street_name)
|
||||
if addr_in.house_number:
|
||||
conditions.append(Address.house_number == addr_in.house_number)
|
||||
|
||||
stmt = select(Address).where(*conditions)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def resolve(
|
||||
db: AsyncSession,
|
||||
address_data: AddressIn,
|
||||
) -> Optional[UUID]:
|
||||
"""Resolve an address: check for duplicates, create if needed.
|
||||
|
||||
This is the primary method for 'find-or-create' semantics.
|
||||
Returns the UUID of the existing or newly created Address record,
|
||||
or None if address_data is None.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
address_data: The incoming address data.
|
||||
|
||||
Returns:
|
||||
The Address UUID, or None if address_data was None.
|
||||
"""
|
||||
if address_data is None:
|
||||
return None
|
||||
|
||||
# 1. Check for duplicate
|
||||
existing = await AddressManager._find_duplicate(db, address_data)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"AddressManager.resolve: Found duplicate address %s", existing.id
|
||||
)
|
||||
return existing.id
|
||||
|
||||
# 2. No duplicate — create new
|
||||
return await AddressManager.create_or_update(db, None, address_data)
|
||||
|
||||
@staticmethod
|
||||
async def create_or_update(
|
||||
db: AsyncSession,
|
||||
address_id: Optional[UUID],
|
||||
data: AddressIn,
|
||||
) -> Optional[UUID]:
|
||||
"""Atomic create-or-update for system.addresses records.
|
||||
|
||||
- If address_id is provided AND the record exists → UPDATE it.
|
||||
- If address_id is None or the record doesn't exist → CREATE new.
|
||||
- If data is None → return the existing address_id unchanged.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
address_id: The existing Address UUID (or None for create).
|
||||
data: The incoming address data.
|
||||
|
||||
Returns:
|
||||
The Address UUID, or None if data was None.
|
||||
"""
|
||||
if data is None:
|
||||
return address_id # Keep existing
|
||||
|
||||
# ── Try to load existing ──
|
||||
existing_address: Optional[Address] = None
|
||||
if address_id is not None:
|
||||
stmt = select(Address).where(Address.id == address_id)
|
||||
result = await db.execute(stmt)
|
||||
existing_address = result.scalar_one_or_none()
|
||||
|
||||
if existing_address is not None:
|
||||
# ── UPDATE existing ──
|
||||
update_fields = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle zip/city → postal_code_id resolution
|
||||
if "zip" in update_fields or "city" in update_fields:
|
||||
new_zip = update_fields.get("zip") or existing_address.zip
|
||||
new_city = update_fields.get("city") or existing_address.city
|
||||
if new_zip and new_city:
|
||||
postal_code_id = await AddressManager._resolve_postal_code(
|
||||
db, new_zip, new_city
|
||||
)
|
||||
existing_address.postal_code_id = postal_code_id
|
||||
|
||||
# Map AddressIn field names to Address model attributes
|
||||
field_mapping = {
|
||||
"street_name": "street_name",
|
||||
"street_type": "street_type",
|
||||
"house_number": "house_number",
|
||||
"stairwell": "stairwell",
|
||||
"floor": "floor",
|
||||
"door": "door",
|
||||
"parcel_id": "parcel_id",
|
||||
"full_address_text": "full_address_text",
|
||||
"latitude": "latitude",
|
||||
"longitude": "longitude",
|
||||
}
|
||||
|
||||
for addr_field, model_attr in field_mapping.items():
|
||||
if model_attr and addr_field in update_fields:
|
||||
setattr(existing_address, model_attr, update_fields[addr_field])
|
||||
|
||||
await db.flush()
|
||||
logger.info(
|
||||
"AddressManager: Updated address %s", existing_address.id
|
||||
)
|
||||
return existing_address.id
|
||||
|
||||
# ── CREATE new ──
|
||||
new_address = Address(
|
||||
id=uuid.uuid4(),
|
||||
street_name=data.street_name,
|
||||
street_type=data.street_type,
|
||||
house_number=data.house_number,
|
||||
stairwell=data.stairwell,
|
||||
floor=data.floor,
|
||||
door=data.door,
|
||||
parcel_id=data.parcel_id,
|
||||
full_address_text=data.full_address_text,
|
||||
latitude=data.latitude,
|
||||
longitude=data.longitude,
|
||||
)
|
||||
|
||||
# Resolve postal code if zip is provided
|
||||
if data.zip and data.city:
|
||||
postal_code_id = await AddressManager._resolve_postal_code(
|
||||
db, data.zip, data.city
|
||||
)
|
||||
new_address.postal_code_id = postal_code_id
|
||||
|
||||
db.add(new_address)
|
||||
await db.flush()
|
||||
logger.info("AddressManager: Created address %s", new_address.id)
|
||||
return new_address.id
|
||||
|
||||
@staticmethod
|
||||
async def get_normalized(
|
||||
db: AsyncSession,
|
||||
address_id: UUID,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Return the full address object including postal code details.
|
||||
|
||||
Returns a dict compatible with AddressOut schema, with zip/city
|
||||
resolved via the postal_code relationship.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
address_id: The Address UUID to look up.
|
||||
|
||||
Returns:
|
||||
A dict with all address fields, or None if not found.
|
||||
"""
|
||||
stmt = select(Address).where(Address.id == address_id)
|
||||
result = await db.execute(stmt)
|
||||
address = result.scalar_one_or_none()
|
||||
|
||||
if address is None:
|
||||
return None
|
||||
|
||||
return AddressOut.model_validate(address).model_dump()
|
||||
140
backend/app/services/address_service.py
Normal file
140
backend/app/services/address_service.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Address Service (P0 Refactoring).
|
||||
|
||||
Provides helper functions for creating, updating, and resolving
|
||||
Address records in the system.addresses table.
|
||||
|
||||
Usage:
|
||||
from app.services.address_service import create_or_update_address
|
||||
"""
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.identity.address import Address, GeoPostalCode
|
||||
from app.schemas.address import AddressIn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _resolve_postal_code(db: AsyncSession, zip_code: str, city: str) -> Optional[int]:
|
||||
"""Find or create a GeoPostalCode record and return its ID.
|
||||
|
||||
Looks up by zip_code first; if not found, creates a new record.
|
||||
"""
|
||||
stmt = select(GeoPostalCode).where(GeoPostalCode.zip_code == zip_code)
|
||||
result = await db.execute(stmt)
|
||||
postal = result.scalar_one_or_none()
|
||||
|
||||
if not postal:
|
||||
postal = GeoPostalCode(
|
||||
zip_code=zip_code,
|
||||
city=city,
|
||||
country_code="HU",
|
||||
)
|
||||
db.add(postal)
|
||||
await db.flush()
|
||||
logger.info(f"Created new GeoPostalCode: {zip_code} - {city} (id={postal.id})")
|
||||
|
||||
return postal.id
|
||||
|
||||
|
||||
async def create_address(db: AsyncSession, addr_in: AddressIn) -> Address:
|
||||
"""Create a new Address record from AddressIn data.
|
||||
|
||||
If zip and city are provided, resolves/creates a GeoPostalCode record
|
||||
and links it via postal_code_id.
|
||||
"""
|
||||
address = Address(
|
||||
id=uuid.uuid4(),
|
||||
street_name=addr_in.street_name,
|
||||
street_type=addr_in.street_type,
|
||||
house_number=addr_in.house_number,
|
||||
stairwell=addr_in.stairwell,
|
||||
floor=addr_in.floor,
|
||||
door=addr_in.door,
|
||||
parcel_id=addr_in.parcel_id,
|
||||
full_address_text=addr_in.full_address_text,
|
||||
latitude=addr_in.latitude,
|
||||
longitude=addr_in.longitude,
|
||||
)
|
||||
|
||||
# Resolve postal code if zip is provided
|
||||
if addr_in.zip and addr_in.city:
|
||||
postal_code_id = await _resolve_postal_code(db, addr_in.zip, addr_in.city)
|
||||
address.postal_code_id = postal_code_id
|
||||
|
||||
db.add(address)
|
||||
await db.flush()
|
||||
logger.info(f"Created Address {address.id}")
|
||||
return address
|
||||
|
||||
|
||||
async def update_address(db: AsyncSession, address: Address, addr_in: AddressIn) -> Address:
|
||||
"""Update an existing Address record from AddressIn data.
|
||||
|
||||
Only updates fields that are explicitly set (not None) in addr_in.
|
||||
If zip/city changes, resolves the new GeoPostalCode.
|
||||
"""
|
||||
update_fields = addr_in.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle zip/city -> postal_code_id resolution
|
||||
if "zip" in update_fields or "city" in update_fields:
|
||||
new_zip = update_fields.get("zip") or address.zip
|
||||
new_city = update_fields.get("city") or address.city
|
||||
if new_zip and new_city:
|
||||
postal_code_id = await _resolve_postal_code(db, new_zip, new_city)
|
||||
address.postal_code_id = postal_code_id
|
||||
|
||||
# Map AddressIn field names to Address model attributes
|
||||
field_mapping = {
|
||||
"zip": None, # handled above
|
||||
"city": None, # handled above
|
||||
"street_name": "street_name",
|
||||
"street_type": "street_type",
|
||||
"house_number": "house_number",
|
||||
"stairwell": "stairwell",
|
||||
"floor": "floor",
|
||||
"door": "door",
|
||||
"parcel_id": "parcel_id",
|
||||
"full_address_text": "full_address_text",
|
||||
"latitude": "latitude",
|
||||
"longitude": "longitude",
|
||||
}
|
||||
|
||||
for addr_field, model_attr in field_mapping.items():
|
||||
if model_attr and addr_field in update_fields:
|
||||
setattr(address, model_attr, update_fields[addr_field])
|
||||
|
||||
await db.flush()
|
||||
logger.info(f"Updated Address {address.id}")
|
||||
return address
|
||||
|
||||
|
||||
async def create_or_update_address(
|
||||
db: AsyncSession,
|
||||
addr_in: Optional[AddressIn],
|
||||
existing_address: Optional[Address] = None,
|
||||
) -> Optional[Address]:
|
||||
"""Create or update an Address record based on whether one already exists.
|
||||
|
||||
This is the main helper used by PATCH endpoints.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
addr_in: The incoming address data (or None to skip).
|
||||
existing_address: The existing Address record (or None to create new).
|
||||
|
||||
Returns:
|
||||
The Address record, or None if addr_in was None.
|
||||
"""
|
||||
if addr_in is None:
|
||||
return existing_address # Keep existing if no new data
|
||||
|
||||
if existing_address:
|
||||
return await update_address(db, existing_address, addr_in)
|
||||
else:
|
||||
return await create_address(db, addr_in)
|
||||
@@ -286,8 +286,8 @@ class AssetService:
|
||||
))
|
||||
|
||||
# Gamification
|
||||
reward = await config.get_setting(db, "xp_reward_asset_register", default=250)
|
||||
await GamificationService.award_points(db, user_id, int(reward), "NEW_ASSET_REG")
|
||||
# A pontérték a point_rules táblából jön (action_key='ASSET_REGISTER')
|
||||
await GamificationService.award_points(db, user_id, amount=0, reason="ASSET_REGISTER", action_key="ASSET_REGISTER")
|
||||
|
||||
# Check if this is user's first vehicle and award "First Car" badge
|
||||
await AssetService._award_first_car_badge(db, user_id, target_org_id)
|
||||
|
||||
@@ -295,19 +295,21 @@ class AuthService:
|
||||
user.region_code = kyc_in.region_code
|
||||
|
||||
# Gamification XP jóváírás (commit=False, mert a külső metódus kezeli a tranzakciót)
|
||||
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION", commit=False)
|
||||
# A pontérték a point_rules táblából jön (action_key='KYC_VERIFICATION')
|
||||
await GamificationService.award_points(db, user_id=user.id, amount=0, reason="KYC_VERIFICATION", commit=False, action_key="KYC_VERIFICATION")
|
||||
|
||||
# P2P Referral XP a meghívónak (ha van referred_by_id)
|
||||
if user.referred_by_id:
|
||||
p2p_xp = await config.get_setting(db, "gamification_p2p_invite_xp", default=50)
|
||||
# A pontérték a point_rules táblából jön (action_key='P2P_REFERRAL_SUCCESS')
|
||||
await GamificationService.award_points(
|
||||
db,
|
||||
user_id=user.referred_by_id,
|
||||
amount=int(p2p_xp),
|
||||
amount=0,
|
||||
reason="P2P_REFERRAL_SUCCESS",
|
||||
commit=False
|
||||
commit=False,
|
||||
action_key="P2P_REFERRAL_SUCCESS"
|
||||
)
|
||||
logger.info(f"P2P XP ({p2p_xp}) awarded to referrer ID {user.referred_by_id} for user {user.id}")
|
||||
logger.info(f"P2P XP awarded to referrer ID {user.referred_by_id} for user {user.id}")
|
||||
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
872
backend/app/services/commission_service.py
Normal file
872
backend/app/services/commission_service.py
Normal file
@@ -0,0 +1,872 @@
|
||||
# /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.
|
||||
- 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, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List, Sequence
|
||||
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete, not_
|
||||
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, 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
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def create_commission_rule(
|
||||
db: AsyncSession,
|
||||
data: CommissionRuleCreate,
|
||||
admin_user_id: int,
|
||||
) -> CommissionRule:
|
||||
"""
|
||||
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.
|
||||
data: Pydantic schema with rule data.
|
||||
admin_user_id: ID of the admin creating the rule.
|
||||
|
||||
Returns:
|
||||
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),
|
||||
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,
|
||||
# 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,
|
||||
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 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)
|
||||
result = await db.execute(stmt)
|
||||
rule = result.scalar_one_or_none()
|
||||
if not 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))
|
||||
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)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 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 (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
|
||||
- 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, region_code, and is_renewal.
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# ── 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,
|
||||
gen1_cap,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# ── 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:
|
||||
# ── 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=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 is_renewal=%s",
|
||||
request.buyer_user_id,
|
||||
gen1.id,
|
||||
float(rule.commission_percent) if rule.commission_percent else 0,
|
||||
gen2_user_id or 0,
|
||||
gen2_percent or 0,
|
||||
request.transaction_amount,
|
||||
total_commission,
|
||||
rule.id,
|
||||
request.is_renewal,
|
||||
)
|
||||
|
||||
return CommissionDistributionResponse(
|
||||
transaction_amount=request.transaction_amount,
|
||||
items=items,
|
||||
total_commission=round(total_commission, 2),
|
||||
)
|
||||
@@ -25,8 +25,6 @@ class CostService:
|
||||
try:
|
||||
# 1. Dinamikus konfiguráció lekérése
|
||||
base_currency = await config.get_setting(db, "finance_base_currency", default="EUR")
|
||||
base_xp = await config.get_setting(db, "xp_per_cost_log", default=50)
|
||||
ocr_multiplier = await config.get_setting(db, "xp_multiplier_ocr_cost", default=1.5)
|
||||
|
||||
# 2. Intelligens Árfolyamkezelés
|
||||
exchange_rate = Decimal("1.0")
|
||||
@@ -66,12 +64,10 @@ class CostService:
|
||||
await self._sync_telemetry(db, cost_in.asset_id, cost_in.mileage_at_cost)
|
||||
|
||||
# 5. Gamification (Értékesebb az adat, ha van róla fotó/OCR)
|
||||
final_xp = base_xp
|
||||
if new_cost.is_ai_generated:
|
||||
final_xp = int(base_xp * float(ocr_multiplier))
|
||||
|
||||
# A pontérték a point_rules táblából jön (action_key='EXPENSE_LOG')
|
||||
# Az OCR bónusz továbbra is érvényesül a process_activity szorzóin keresztül
|
||||
await GamificationService.award_points(
|
||||
db, user_id=user_id, amount=final_xp, reason=f"EXPENSE_LOG_{cost_in.cost_type}"
|
||||
db, user_id=user_id, amount=0, reason=f"EXPENSE_LOG_{cost_in.cost_type}", action_key="EXPENSE_LOG"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
519
backend/app/services/financial_manager.py
Normal file
519
backend/app/services/financial_manager.py
Normal file
@@ -0,0 +1,519 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/financial_manager.py
|
||||
"""
|
||||
Financial Manager — Unified orchestrator for the package purchase lifecycle.
|
||||
|
||||
Coordinates the complete flow:
|
||||
1. Validate tier exists and is purchasable
|
||||
2. Calculate price (via PricingCalculator)
|
||||
3. Create PaymentIntent (PENDING)
|
||||
4. Process payment via configurable gateway (MockPaymentGateway / StripeAdapter)
|
||||
5. Activate subscription (User or Organization level, with stacking)
|
||||
6. Distribute MLM commissions (async via background task)
|
||||
7. Return full purchase receipt
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Fully decoupled from payment gateway implementation (Strategy Pattern).
|
||||
- Commission distribution is separated so the caller can run it as a
|
||||
BackgroundTask (async) without blocking the payment response.
|
||||
- Uses the existing PaymentRouter for PaymentIntent creation and the
|
||||
existing BillingEngine for price calculation.
|
||||
- All database mutations happen within a single session for atomicity.
|
||||
- Returns a PurchaseResult DTO with all relevant details.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
from app.models.identity.identity import User
|
||||
from app.models.marketplace.payment import PaymentIntent, PaymentIntentStatus
|
||||
from app.models.system.audit import WalletType
|
||||
|
||||
from app.services.financial_interfaces import (
|
||||
BasePaymentGateway,
|
||||
PaymentGatewayError,
|
||||
InsufficientFundsError,
|
||||
)
|
||||
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||
from app.services.subscription_activator import (
|
||||
SubscriptionActivator,
|
||||
TierNotFoundError,
|
||||
)
|
||||
from app.services.billing_engine import PricingCalculator
|
||||
from app.services.payment_router import PaymentRouter
|
||||
from app.schemas.commission import (
|
||||
CommissionDistributionRequest,
|
||||
CommissionDistributionResponse,
|
||||
)
|
||||
from app.services import commission_service
|
||||
|
||||
logger = logging.getLogger("financial-manager")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Data Transfer Objects
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PurchaseResult:
|
||||
"""
|
||||
Result DTO for a completed package purchase.
|
||||
|
||||
Contains all information needed for the API response and receipt.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
success: bool,
|
||||
payment_intent_id: Optional[int] = None,
|
||||
transaction_id: Optional[str] = None,
|
||||
subscription_id: Optional[int] = None,
|
||||
tier_name: Optional[str] = None,
|
||||
valid_from: Optional[datetime] = None,
|
||||
valid_until: Optional[datetime] = None,
|
||||
amount_paid: float = 0.0,
|
||||
currency: str = "EUR",
|
||||
gateway: str = "mock",
|
||||
gateway_intent_id: Optional[str] = None,
|
||||
commission_result: Optional[CommissionDistributionResponse] = None,
|
||||
error: Optional[str] = None,
|
||||
is_org_subscription: bool = False,
|
||||
):
|
||||
self.success = success
|
||||
self.payment_intent_id = payment_intent_id
|
||||
self.transaction_id = transaction_id
|
||||
self.subscription_id = subscription_id
|
||||
self.tier_name = tier_name
|
||||
self.valid_from = valid_from
|
||||
self.valid_until = valid_until
|
||||
self.amount_paid = amount_paid
|
||||
self.currency = currency
|
||||
self.gateway = gateway
|
||||
self.gateway_intent_id = gateway_intent_id
|
||||
self.commission_result = commission_result
|
||||
self.error = error
|
||||
self.is_org_subscription = is_org_subscription
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to a dict for API response."""
|
||||
result = {
|
||||
"success": self.success,
|
||||
"payment_intent_id": self.payment_intent_id,
|
||||
"transaction_id": self.transaction_id,
|
||||
"subscription_id": self.subscription_id,
|
||||
"tier_name": self.tier_name,
|
||||
"valid_from": self.valid_from.isoformat() if self.valid_from else None,
|
||||
"valid_until": self.valid_until.isoformat() if self.valid_until else None,
|
||||
"amount_paid": self.amount_paid,
|
||||
"currency": self.currency,
|
||||
"gateway": self.gateway,
|
||||
"gateway_intent_id": self.gateway_intent_id,
|
||||
"is_org_subscription": self.is_org_subscription,
|
||||
}
|
||||
if self.commission_result:
|
||||
result["commission"] = {
|
||||
"total_commission": self.commission_result.total_commission,
|
||||
"items": [
|
||||
{
|
||||
"level": item.level,
|
||||
"user_id": item.user_id,
|
||||
"commission_percent": item.commission_percent,
|
||||
"commission_amount": item.commission_amount,
|
||||
}
|
||||
for item in self.commission_result.items
|
||||
],
|
||||
}
|
||||
if self.error:
|
||||
result["error"] = self.error
|
||||
return result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Financial Manager
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FinancialManager:
|
||||
"""
|
||||
Unified orchestrator for the package purchase lifecycle.
|
||||
|
||||
Usage:
|
||||
manager = FinancialManager(payment_gateway=MockPaymentGateway())
|
||||
result = await manager.purchase_package(db, user_id=1, tier_id=2)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payment_gateway: Optional[BasePaymentGateway] = None,
|
||||
subscription_activator: Optional[SubscriptionActivator] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the FinancialManager.
|
||||
|
||||
Args:
|
||||
payment_gateway: Payment gateway implementation. Defaults to
|
||||
MockPaymentGateway (auto_approve mode).
|
||||
subscription_activator: Subscription activator. Defaults to a new
|
||||
SubscriptionActivator instance.
|
||||
"""
|
||||
self.payment_gateway = payment_gateway or MockPaymentGateway()
|
||||
self.subscription_activator = subscription_activator or SubscriptionActivator()
|
||||
self.pricing_calculator = PricingCalculator()
|
||||
logger.info(
|
||||
"FinancialManager initialized with gateway=%s",
|
||||
type(self.payment_gateway).__name__,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Main Purchase Flow
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def purchase_package(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
tier_id: int,
|
||||
org_id: Optional[int] = None,
|
||||
region_code: str = "GLOBAL",
|
||||
currency: str = "EUR",
|
||||
duration_days: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> PurchaseResult:
|
||||
"""
|
||||
Execute the full package purchase lifecycle.
|
||||
|
||||
Flow:
|
||||
1. Validate the tier exists and is purchasable
|
||||
2. Calculate the price
|
||||
3. Create a PaymentIntent (PENDING)
|
||||
4. Process payment via the configured gateway
|
||||
5. Activate the subscription (User or Org level)
|
||||
6. Distribute MLM commissions (returned for async execution)
|
||||
7. Return the PurchaseResult
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The purchasing user.
|
||||
tier_id: The SubscriptionTier ID to purchase.
|
||||
org_id: Optional organization ID for org-level subscriptions.
|
||||
region_code: Region code for pricing (default: GLOBAL).
|
||||
currency: Currency code (default: EUR).
|
||||
duration_days: Override duration in days (default: from tier.rules).
|
||||
metadata: Optional metadata for the PaymentIntent.
|
||||
|
||||
Returns:
|
||||
PurchaseResult with full purchase details.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
PaymentGatewayError: If payment processing fails.
|
||||
"""
|
||||
logger.info(
|
||||
"Purchase flow started: user_id=%d tier_id=%d org_id=%s",
|
||||
user_id, tier_id, org_id,
|
||||
)
|
||||
|
||||
try:
|
||||
# ── Step 1: Validate tier ──────────────────────────────────────
|
||||
tier = await self._validate_tier(db, tier_id)
|
||||
|
||||
# ── Step 2: Calculate price ────────────────────────────────────
|
||||
price = await self._calculate_price(db, tier, region_code, currency)
|
||||
|
||||
# ── Step 3: Create PaymentIntent ───────────────────────────────
|
||||
payment_intent = await self._create_payment_intent(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount=price,
|
||||
currency=currency,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# ── Step 4: Process payment via gateway ────────────────────────
|
||||
gateway_result = await self.payment_gateway.create_intent(
|
||||
amount=Decimal(str(price)),
|
||||
currency=currency,
|
||||
metadata={
|
||||
"payment_intent_id": payment_intent.id,
|
||||
"tier_id": tier_id,
|
||||
"user_id": user_id,
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
|
||||
# Mark PaymentIntent as COMPLETED
|
||||
payment_intent.status = PaymentIntentStatus.COMPLETED
|
||||
payment_intent.stripe_session_id = gateway_result.get("id")
|
||||
payment_intent.completed_at = datetime.utcnow()
|
||||
|
||||
# ── Step 5: Activate subscription ──────────────────────────────
|
||||
if org_id:
|
||||
subscription = await self.subscription_activator.activate_org_subscription(
|
||||
db=db,
|
||||
org_id=org_id,
|
||||
tier_id=tier_id,
|
||||
duration_days=duration_days,
|
||||
)
|
||||
is_org = True
|
||||
else:
|
||||
subscription = await self.subscription_activator.activate_user_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
tier_id=tier_id,
|
||||
duration_days=duration_days,
|
||||
)
|
||||
is_org = False
|
||||
|
||||
# ── Step 6: Prepare commission distribution (for async caller) ─
|
||||
commission_result = await self._distribute_commission(
|
||||
db=db,
|
||||
buyer_user_id=user_id,
|
||||
transaction_amount=price,
|
||||
region_code=region_code,
|
||||
)
|
||||
|
||||
# ── Step 7: Commit all changes ─────────────────────────────────
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Purchase flow COMPLETED: user_id=%d tier=%s amount=%.2f "
|
||||
"subscription_id=%d commission_total=%.2f",
|
||||
user_id, tier.name, price,
|
||||
subscription.id,
|
||||
commission_result.total_commission if commission_result else 0,
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=True,
|
||||
payment_intent_id=payment_intent.id,
|
||||
transaction_id=str(payment_intent.transaction_id) if payment_intent.transaction_id else None,
|
||||
subscription_id=subscription.id,
|
||||
tier_name=tier.name,
|
||||
valid_from=subscription.valid_from,
|
||||
valid_until=subscription.valid_until,
|
||||
amount_paid=price,
|
||||
currency=currency,
|
||||
gateway=type(self.payment_gateway).__name__,
|
||||
gateway_intent_id=gateway_result.get("id"),
|
||||
commission_result=commission_result,
|
||||
is_org_subscription=is_org,
|
||||
)
|
||||
|
||||
except PaymentGatewayError as e:
|
||||
# Mark PaymentIntent as FAILED
|
||||
payment_intent.status = PaymentIntentStatus.FAILED
|
||||
await db.commit()
|
||||
|
||||
logger.error(
|
||||
"Purchase flow FAILED (payment): user_id=%d tier_id=%d error=%s",
|
||||
user_id, tier_id, str(e),
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
payment_intent_id=payment_intent.id,
|
||||
error=f"Payment failed: {str(e)}",
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
except TierNotFoundError as e:
|
||||
# Tier not found — no PaymentIntent was created, just return error
|
||||
logger.error(
|
||||
"Purchase flow FAILED (tier not found): user_id=%d tier_id=%d error=%s",
|
||||
user_id, tier_id, str(e),
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Check if payment_intent exists before trying to modify it
|
||||
payment_intent_id = payment_intent.id if 'payment_intent' in dir() or 'payment_intent' in locals() else None
|
||||
if payment_intent_id:
|
||||
payment_intent.status = PaymentIntentStatus.FAILED
|
||||
await db.commit()
|
||||
|
||||
logger.exception(
|
||||
"Purchase flow FAILED (unexpected): user_id=%d tier_id=%d",
|
||||
user_id, tier_id,
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
payment_intent_id=payment_intent_id,
|
||||
error=f"Unexpected error: {str(e)}",
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Internal Steps
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _validate_tier(self, db: AsyncSession, tier_id: int) -> SubscriptionTier:
|
||||
"""
|
||||
Validate that the tier exists and is purchasable.
|
||||
|
||||
Raises TierNotFoundError if the tier doesn't exist.
|
||||
Additional validation (e.g., is_public, available_until) can be added here.
|
||||
"""
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
raise TierNotFoundError(f"SubscriptionTier with id={tier_id} not found")
|
||||
return tier
|
||||
|
||||
async def _calculate_price(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tier: SubscriptionTier,
|
||||
region_code: str,
|
||||
currency: str,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the price for this tier using the PricingCalculator.
|
||||
|
||||
Pricing is extracted from tier.rules["pricing_zones"] using the following priority:
|
||||
1. Exact region_code match (e.g., "HU")
|
||||
2. Currency match within pricing_zones
|
||||
3. "DEFAULT" zone fallback
|
||||
4. Legacy tier.rules["price"] fallback
|
||||
|
||||
Falls back to tier.rules["price"] if PricingCalculator returns 0.
|
||||
"""
|
||||
price = 0.0
|
||||
|
||||
if tier.rules:
|
||||
# ── Extract from pricing_zones (P0 standard) ──────────────
|
||||
pricing_zones = tier.rules.get("pricing_zones", {})
|
||||
if pricing_zones:
|
||||
# Priority 1: Exact region_code match
|
||||
zone = pricing_zones.get(region_code)
|
||||
if not zone:
|
||||
# Priority 2: Currency match within zones
|
||||
for z in pricing_zones.values():
|
||||
if z.get("currency") == currency:
|
||||
zone = z
|
||||
break
|
||||
if not zone:
|
||||
# Priority 3: DEFAULT zone fallback
|
||||
zone = pricing_zones.get("DEFAULT")
|
||||
|
||||
if zone:
|
||||
# Prefer monthly_price, fallback to yearly_price, then credit_price
|
||||
price = float(zone.get("monthly_price", 0.0))
|
||||
if price <= 0:
|
||||
price = float(zone.get("yearly_price", 0.0))
|
||||
if price <= 0:
|
||||
price = float(zone.get("credit_price", 0.0))
|
||||
|
||||
# ── Legacy fallback: tier.rules["price"] ──────────────────
|
||||
if price <= 0:
|
||||
price = float(tier.rules.get("price", 0.0))
|
||||
|
||||
# Use PricingCalculator for region-adjusted pricing
|
||||
if price > 0:
|
||||
calculated = await PricingCalculator.calculate_final_price(
|
||||
db=db,
|
||||
base_amount=price,
|
||||
country_code=region_code,
|
||||
user_role=None, # No RBAC discount for purchases
|
||||
)
|
||||
if calculated > 0:
|
||||
price = calculated
|
||||
|
||||
logger.debug(
|
||||
"Price calculated: tier=%s region=%s currency=%s final=%.2f",
|
||||
tier.name, region_code, currency, price,
|
||||
)
|
||||
|
||||
return price
|
||||
|
||||
async def _create_payment_intent(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
currency: str = "EUR",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> PaymentIntent:
|
||||
"""
|
||||
Create a PENDING PaymentIntent for the purchase.
|
||||
|
||||
Uses the existing PaymentRouter for consistency with the rest of the system.
|
||||
"""
|
||||
payment_intent = await PaymentRouter.create_payment_intent(
|
||||
db=db,
|
||||
payer_id=user_id,
|
||||
net_amount=amount,
|
||||
handling_fee=0.0,
|
||||
target_wallet_type=WalletType.PURCHASED,
|
||||
currency=currency,
|
||||
metadata={
|
||||
"source": "financial_manager",
|
||||
"purchase_type": "subscription",
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"PaymentIntent created: id=%d user_id=%d amount=%.2f %s",
|
||||
payment_intent.id, user_id, amount, currency,
|
||||
)
|
||||
|
||||
return payment_intent
|
||||
|
||||
async def _distribute_commission(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
buyer_user_id: int,
|
||||
transaction_amount: float,
|
||||
region_code: str,
|
||||
) -> Optional[CommissionDistributionResponse]:
|
||||
"""
|
||||
Distribute MLM commissions for this purchase.
|
||||
|
||||
Returns the commission result, or None if no commission was distributed.
|
||||
This is designed to be callable from a BackgroundTask for async execution.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
buyer_user_id: The user who made the purchase.
|
||||
transaction_amount: The amount paid.
|
||||
region_code: Region code for rule matching.
|
||||
|
||||
Returns:
|
||||
CommissionDistributionResponse or None.
|
||||
"""
|
||||
try:
|
||||
request = CommissionDistributionRequest(
|
||||
buyer_user_id=buyer_user_id,
|
||||
transaction_amount=transaction_amount,
|
||||
transaction_date=date.today(),
|
||||
region_code=region_code,
|
||||
is_renewal=False,
|
||||
)
|
||||
result = await commission_service.distribute_commission(db, request)
|
||||
logger.info(
|
||||
"Commission distributed: buyer=%d total=%.2f items=%d",
|
||||
buyer_user_id, result.total_commission, len(result.items),
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Commission distribution failed for buyer=%d: %s",
|
||||
buyer_user_id, str(e),
|
||||
)
|
||||
# Commission failure should not block the purchase
|
||||
return None
|
||||
@@ -82,15 +82,17 @@ class FleetService:
|
||||
db.add(new_event)
|
||||
|
||||
# 6. DINAMIKUS GAMIFIKÁCIÓ
|
||||
# Kikeresjük a konkrét eseménytípushoz tartozó pontokat
|
||||
# A pontérték a point_rules táblából jön (action_key='FLEET_EVENT')
|
||||
# A social pont továbbra is az event_rewards konfigurációból jön
|
||||
rewards = event_rewards.get(event_data.event_type, event_rewards["default"])
|
||||
|
||||
await gamification_service.process_activity(
|
||||
db,
|
||||
user_id,
|
||||
xp_amount=rewards["xp"],
|
||||
social_amount=rewards["social"],
|
||||
reason=f"FLEET_EVENT_{event_data.event_type.upper()}"
|
||||
db,
|
||||
user_id,
|
||||
xp_amount=0,
|
||||
social_amount=rewards["social"],
|
||||
reason=f"FLEET_EVENT_{event_data.event_type.upper()}",
|
||||
action_key="FLEET_EVENT"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/gamification_service.py
|
||||
import logging
|
||||
import math
|
||||
from copy import deepcopy
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
@@ -12,6 +13,25 @@ from app.services.config_service import config # 2.0 Központi konfigurátor
|
||||
|
||||
logger = logging.getLogger("Gamification-Service-2.0")
|
||||
|
||||
|
||||
def _deep_merge_config(base: dict, overlay: dict) -> dict:
|
||||
"""
|
||||
Rekurzívan egyesít két dictionary-t.
|
||||
Az overlay kulcsai felülírják a base kulcsait, de a base-ben lévő
|
||||
hiányzó kulcsok megmaradnak az overlay-ből.
|
||||
|
||||
Ez biztosítja, hogy a GAMIFICATION_MASTER_CONFIG minden szükséges
|
||||
kulcsot tartalmazzon, még akkor is, ha az adatbázisban lévő konfiguráció
|
||||
hiányos (pl. régebbi verzióból származik).
|
||||
"""
|
||||
result = deepcopy(base)
|
||||
for key, value in overlay.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
result[key] = _deep_merge_config(result[key], value)
|
||||
elif key not in result:
|
||||
result[key] = deepcopy(value)
|
||||
return result
|
||||
|
||||
class GamificationService:
|
||||
"""
|
||||
Gamification Service 2.0 - A 'Jövevény' lelke.
|
||||
@@ -22,22 +42,24 @@ class GamificationService:
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True):
|
||||
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True, action_key: str | None = None):
|
||||
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz.
|
||||
|
||||
Args:
|
||||
commit: If True (default), commits the transaction internally.
|
||||
Set to False when called from within a larger transaction
|
||||
(e.g., from AuthService.complete_kyc).
|
||||
action_key: Opcionális. Ha meg van adva, a point_rules táblából
|
||||
olvassa a pontértékeket a master config helyett.
|
||||
"""
|
||||
service = GamificationService()
|
||||
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit)
|
||||
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit, action_key=action_key)
|
||||
|
||||
async def _get_point_rule(self, db: AsyncSession, action_key: str) -> dict | None:
|
||||
"""Lekér egy pontszabályt a point_rules táblából action_key alapján.
|
||||
|
||||
Returns:
|
||||
dict with 'points' and 'description' or None if not found/inactive.
|
||||
dict with 'points', 'description', 'rule_id' or None if not found/inactive.
|
||||
"""
|
||||
stmt = select(PointRule).where(
|
||||
PointRule.action_key == action_key,
|
||||
@@ -46,7 +68,7 @@ class GamificationService:
|
||||
result = await db.execute(stmt)
|
||||
rule = result.scalar_one_or_none()
|
||||
if rule:
|
||||
return {"points": rule.points, "description": rule.description}
|
||||
return {"points": rule.points, "description": rule.description, "rule_id": rule.id}
|
||||
return None
|
||||
|
||||
async def process_activity(
|
||||
@@ -80,7 +102,7 @@ class GamificationService:
|
||||
try:
|
||||
# 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE
|
||||
# Minden paraméter az admin felületről módosítható JSON-ként
|
||||
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default={
|
||||
_DEFAULT_GAMIFICATION_CONFIG = {
|
||||
"xp_logic": {"base_xp": 500, "exponent": 1.5},
|
||||
"penalty_logic": {
|
||||
"recovery_rate": 0.5,
|
||||
@@ -89,15 +111,28 @@ class GamificationService:
|
||||
},
|
||||
"conversion_logic": {"social_to_credit_rate": 100},
|
||||
"level_rewards": {"credits_per_10_levels": 50}
|
||||
})
|
||||
}
|
||||
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default=_DEFAULT_GAMIFICATION_CONFIG)
|
||||
|
||||
# 🔐 HIÁNYZÓ KULCSOK VÉDELME (Deep Merge)
|
||||
# Ha a GAMIFICATION_MASTER_CONFIG már létezik az adatbázisban, de hiányoznak
|
||||
# belőle kulcsok (pl. penalty_logic egy régebbi verzióból), a default értékek
|
||||
# automatikusan kiegészítik a hiányzó részeket.
|
||||
# Ez megakadályozza a KeyError kivételeket a cfg["penalty_logic"] hívásoknál.
|
||||
if isinstance(cfg, dict):
|
||||
cfg = _deep_merge_config(cfg, _DEFAULT_GAMIFICATION_CONFIG)
|
||||
|
||||
# 1/b. POINT RULES TÁBLA LEKÉRÉSE (ha action_key meg van adva)
|
||||
# A point_rules tábla elsőbbséget élvez a master config-gal szemben!
|
||||
point_rule_id = None
|
||||
points_at_time = None
|
||||
if action_key:
|
||||
rule = await self._get_point_rule(db, action_key)
|
||||
if rule:
|
||||
# A point_rules-ból jövő pontok felülírják a paraméterként kapott értékeket
|
||||
xp_amount = rule["points"]
|
||||
point_rule_id = rule.get("rule_id")
|
||||
points_at_time = rule["points"]
|
||||
if rule.get("description"):
|
||||
reason = f"{action_key}: {rule['description']}"
|
||||
logger.debug(f"Point rule applied: {action_key} -> {xp_amount} XP")
|
||||
@@ -152,7 +187,18 @@ class GamificationService:
|
||||
stats.social_points %= rate # A maradék pont megmarad
|
||||
await self._add_earned_credits(db, user_id, credits_to_add, "SOCIAL_ACTIVITY_CONVERSION")
|
||||
|
||||
# 7. NAPLÓZÁS (kibővítve xp, source_type, source_id mezőkkel)
|
||||
# 7. NAPLÓZÁS (kibővítve xp, source_type, source_id, points_snapshot mezőkkel)
|
||||
# A points_snapshot JSONB tárolja a kiosztáskor érvényes point_rules adatokat,
|
||||
# hogy a pontértékek megőrződjenek a ledger-ben, még akkor is, ha a
|
||||
# point_rules táblában később módosítják a pontértékeket.
|
||||
snapshot = None
|
||||
if action_key and point_rule_id and points_at_time is not None:
|
||||
snapshot = {
|
||||
"action_key": action_key,
|
||||
"point_rule_id": point_rule_id,
|
||||
"points_at_time": points_at_time,
|
||||
"multiplier": multiplier,
|
||||
}
|
||||
db.add(PointsLedger(
|
||||
user_id=user_id,
|
||||
points=final_xp,
|
||||
@@ -160,6 +206,7 @@ class GamificationService:
|
||||
reason=reason,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
points_snapshot=snapshot,
|
||||
))
|
||||
|
||||
# Only commit if caller wants us to manage the transaction
|
||||
|
||||
244
backend/app/services/mock_payment_gateway.py
Normal file
244
backend/app/services/mock_payment_gateway.py
Normal file
@@ -0,0 +1,244 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/mock_payment_gateway.py
|
||||
"""
|
||||
Mock Payment Gateway — Development/Testing implementation of BasePaymentGateway.
|
||||
|
||||
Simulates successful payment processing without requiring real Stripe keys.
|
||||
Can be configured to simulate failures for testing error handling paths.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Implements BasePaymentGateway abstract interface (Strategy Pattern).
|
||||
- Default mode is "auto_approve" — all payments succeed immediately.
|
||||
- Supports "simulate_failure" mode for testing error paths.
|
||||
- Generates deterministic mock session IDs for traceability.
|
||||
- Logs all payment attempts for debugging.
|
||||
- Decoupled from FinancialManager via dependency injection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.services.financial_interfaces import (
|
||||
BasePaymentGateway,
|
||||
PaymentGatewayError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mock-payment-gateway")
|
||||
|
||||
|
||||
class MockPaymentGateway(BasePaymentGateway):
|
||||
"""
|
||||
Mock payment gateway for development and testing.
|
||||
|
||||
Modes:
|
||||
- auto_approve (default): All payments succeed immediately.
|
||||
- simulate_failure: All payments fail with a configurable error message.
|
||||
- simulate_timeout: Payments hang until a configurable timeout then succeed.
|
||||
|
||||
Usage:
|
||||
gateway = MockPaymentGateway(mode="auto_approve")
|
||||
result = await gateway.create_intent(Decimal("100.00"), "EUR")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: str = "auto_approve",
|
||||
failure_message: str = "Mock payment declined (simulated failure)",
|
||||
timeout_seconds: int = 5,
|
||||
):
|
||||
"""
|
||||
Initialize the mock gateway.
|
||||
|
||||
Args:
|
||||
mode: Operation mode — "auto_approve", "simulate_failure", or "simulate_timeout".
|
||||
failure_message: Custom error message for simulate_failure mode.
|
||||
timeout_seconds: Simulated processing delay for simulate_timeout mode.
|
||||
"""
|
||||
self.mode = mode
|
||||
self.failure_message = failure_message
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._processed_intents: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
logger.info(
|
||||
"MockPaymentGateway initialized: mode=%s, timeout=%ds",
|
||||
self.mode, self.timeout_seconds,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# BasePaymentGateway Implementation
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_intent(
|
||||
self,
|
||||
amount: Decimal,
|
||||
currency: str = "EUR",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a mock payment intent.
|
||||
|
||||
In auto_approve mode, immediately returns a "completed" intent.
|
||||
In simulate_failure mode, raises PaymentGatewayError.
|
||||
In simulate_timeout mode, logs a warning and returns "processing".
|
||||
|
||||
Args:
|
||||
amount: The payment amount.
|
||||
currency: ISO 4217 currency code (default: EUR).
|
||||
metadata: Optional metadata dict.
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with mock payment intent details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: In simulate_failure mode.
|
||||
"""
|
||||
intent_id = f"mock_intent_{uuid.uuid4().hex[:12]}"
|
||||
logger.info(
|
||||
"Mock create_intent: id=%s amount=%s %s mode=%s",
|
||||
intent_id, amount, currency, self.mode,
|
||||
)
|
||||
|
||||
if self.mode == "simulate_failure":
|
||||
logger.warning(
|
||||
"Mock payment FAILURE: intent=%s reason='%s'",
|
||||
intent_id, self.failure_message,
|
||||
)
|
||||
raise PaymentGatewayError(self.failure_message)
|
||||
|
||||
if self.mode == "simulate_timeout":
|
||||
logger.warning(
|
||||
"Mock payment TIMEOUT simulation: intent=%s delay=%ds",
|
||||
intent_id, self.timeout_seconds,
|
||||
)
|
||||
# In a real scenario we'd sleep; here we just return "processing"
|
||||
# so the caller can handle the pending state.
|
||||
|
||||
now = datetime.utcnow()
|
||||
intent_data = {
|
||||
"id": intent_id,
|
||||
"status": "completed" if self.mode == "auto_approve" else "processing",
|
||||
"amount": float(amount),
|
||||
"currency": currency,
|
||||
"created_at": now.isoformat(),
|
||||
"completed_at": now.isoformat() if self.mode == "auto_approve" else None,
|
||||
"metadata": metadata or {},
|
||||
"gateway": "mock",
|
||||
}
|
||||
|
||||
self._processed_intents[intent_id] = intent_data
|
||||
return intent_data
|
||||
|
||||
async def verify_payment(
|
||||
self,
|
||||
payment_intent_id: str,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify a mock payment's status.
|
||||
|
||||
Args:
|
||||
payment_intent_id: The mock intent ID to verify.
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with verification details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: If the intent ID is unknown.
|
||||
"""
|
||||
intent = self._processed_intents.get(payment_intent_id)
|
||||
if not intent:
|
||||
logger.warning(
|
||||
"Mock verify_payment: unknown intent_id=%s",
|
||||
payment_intent_id,
|
||||
)
|
||||
raise PaymentGatewayError(
|
||||
f"Mock payment intent '{payment_intent_id}' not found"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Mock verify_payment: intent=%s status=%s",
|
||||
payment_intent_id, intent["status"],
|
||||
)
|
||||
|
||||
return {
|
||||
"verified": intent["status"] == "completed",
|
||||
"intent_id": payment_intent_id,
|
||||
"status": intent["status"],
|
||||
"amount": intent["amount"],
|
||||
"currency": intent["currency"],
|
||||
}
|
||||
|
||||
async def refund_payment(
|
||||
self,
|
||||
payment_intent_id: str,
|
||||
amount: Optional[Decimal] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Refund a mock payment.
|
||||
|
||||
Args:
|
||||
payment_intent_id: The mock intent ID to refund.
|
||||
amount: Optional partial refund amount (None = full refund).
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with refund details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: If the intent ID is unknown.
|
||||
"""
|
||||
intent = self._processed_intents.get(payment_intent_id)
|
||||
if not intent:
|
||||
logger.warning(
|
||||
"Mock refund_payment: unknown intent_id=%s",
|
||||
payment_intent_id,
|
||||
)
|
||||
raise PaymentGatewayError(
|
||||
f"Mock payment intent '{payment_intent_id}' not found"
|
||||
)
|
||||
|
||||
refund_amount = float(amount) if amount is not None else intent["amount"]
|
||||
refund_id = f"mock_refund_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
logger.info(
|
||||
"Mock refund: intent=%s amount=%s refund_id=%s",
|
||||
payment_intent_id, refund_amount, refund_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"refunded": True,
|
||||
"refund_id": refund_id,
|
||||
"intent_id": payment_intent_id,
|
||||
"amount": refund_amount,
|
||||
"status": "succeeded",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Mock-specific Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
"""
|
||||
Change the gateway's operating mode at runtime.
|
||||
|
||||
Args:
|
||||
mode: "auto_approve", "simulate_failure", or "simulate_timeout".
|
||||
"""
|
||||
valid_modes = {"auto_approve", "simulate_failure", "simulate_timeout"}
|
||||
if mode not in valid_modes:
|
||||
raise ValueError(
|
||||
f"Invalid mock mode '{mode}'. Valid modes: {valid_modes}"
|
||||
)
|
||||
self.mode = mode
|
||||
logger.info("MockPaymentGateway mode changed to: %s", self.mode)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all processed intents (useful between tests)."""
|
||||
self._processed_intents.clear()
|
||||
logger.info("MockPaymentGateway reset: all intents cleared")
|
||||
@@ -34,14 +34,17 @@ import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case, delete
|
||||
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case, delete, join
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
|
||||
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging
|
||||
from app.models.identity.social import ServiceProvider
|
||||
from app.models.identity.address import Address, GeoPostalCode
|
||||
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise
|
||||
from app.models.marketplace.staged_data import ServiceStaging
|
||||
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
|
||||
from app.models.gamification.gamification import PointRule, UserStats
|
||||
from app.schemas.address import AddressOut
|
||||
from app.schemas.provider import (
|
||||
ProviderSearchResult,
|
||||
ProviderSearchResponse,
|
||||
@@ -52,6 +55,8 @@ from app.schemas.provider import (
|
||||
CategoryInfo,
|
||||
)
|
||||
from app.services.gamification_service import gamification_service
|
||||
from app.services.address_manager import AddressManager
|
||||
from app.schemas.address import AddressIn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,9 +69,12 @@ async def _award_provider_points(
|
||||
"""
|
||||
Dinamikus pontjóváírás a gamification.point_rules tábla alapján.
|
||||
|
||||
Ez a függvény az adatbázisból olvassa ki a pontértéket az action_key
|
||||
alapján, így a pontok NINCSENEK beégetve a kódba. Az admin felületről
|
||||
bármikor módosíthatók a point_rules táblában.
|
||||
REFAKTOR (2026-06-30): A duplikált point_rules olvasási logika eltávolításra
|
||||
került. A pontértékeket most a GamificationService.award_points() olvassa ki
|
||||
a point_rules táblából az action_key alapján a process_activity() hívás során.
|
||||
|
||||
Ez a függvény már csak a providers_added_count statisztikát kezeli.
|
||||
A pontok kiszámítása és naplózása a GamificationService-ben történik.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
@@ -76,39 +84,19 @@ async def _award_provider_points(
|
||||
Returns:
|
||||
int: A jóváírt pontok száma (0 ha a szabály nem található vagy inaktív)
|
||||
"""
|
||||
# 1. Pontszabály lekérdezése az adatbázisból (dinamikus!)
|
||||
rule_stmt = select(PointRule).where(
|
||||
PointRule.action_key == action_key,
|
||||
PointRule.is_active == True,
|
||||
)
|
||||
rule_result = await db.execute(rule_stmt)
|
||||
rule = rule_result.scalar_one_or_none()
|
||||
|
||||
if not rule:
|
||||
logger.warning(
|
||||
f"Pontszabály '{action_key}' nem található vagy inaktív. "
|
||||
f"Pontjóváírás kihagyva user_id={user_id}."
|
||||
)
|
||||
return 0
|
||||
|
||||
points_to_award = rule.points
|
||||
logger.info(
|
||||
f"Dinamikus pont lekérés: action_key='{action_key}', "
|
||||
f"points={points_to_award} (forrás: gamification.point_rules tábla)"
|
||||
)
|
||||
|
||||
# 2. Pontok jóváírása a Gamification Service-en keresztül
|
||||
# A GamificationService.award_points() kezeli a szorzókat, szintlépést,
|
||||
# büntetés ledolgozást és a naplózást (PointsLedger).
|
||||
# 1. Pontok jóváírása a Gamification Service-en keresztül (action_key alapján)
|
||||
# A GamificationService.award_points() kezeli a point_rules olvasást, szorzókat,
|
||||
# szintlépést, büntetés ledolgozást és a naplózást (PointsLedger).
|
||||
await gamification_service.award_points(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount=points_to_award,
|
||||
reason=f"ADD_NEW_PROVIDER: +{points_to_award} pont új szolgáltató rögzítéséért",
|
||||
commit=False, # A hívó (quick_add_provider) kezeli a commit-ot
|
||||
amount=0,
|
||||
reason=f"{action_key}",
|
||||
commit=False, # A hívó kezeli a commit-ot
|
||||
action_key=action_key,
|
||||
)
|
||||
|
||||
# 3. Statisztika növelése (providers_added_count)
|
||||
# 2. Statisztika növelése (providers_added_count)
|
||||
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
|
||||
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
|
||||
|
||||
@@ -122,7 +110,7 @@ async def _award_provider_points(
|
||||
# Ha még nincs UserStats rekordja, létrehozzuk
|
||||
stats = UserStats(
|
||||
user_id=user_id,
|
||||
total_xp=points_to_award,
|
||||
total_xp=0,
|
||||
current_level=1,
|
||||
providers_added_count=1,
|
||||
)
|
||||
@@ -132,7 +120,97 @@ async def _award_provider_points(
|
||||
f"providers_added_count=1"
|
||||
)
|
||||
|
||||
return points_to_award
|
||||
return 0 # Visszatérési érték már nem a pontszám, mert az award_points kezeli
|
||||
|
||||
|
||||
async def find_or_create_provider_by_name(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
added_by_user_id: int,
|
||||
) -> tuple:
|
||||
"""
|
||||
Keres vagy létrehoz egy ServiceProvider-t a megadott név alapján.
|
||||
|
||||
P0 ARCHITECTURE CLEANUP (2026-07-01):
|
||||
======================================
|
||||
Gamification/XP logika ELTÁVOLÍTVA ebből a service rétegből.
|
||||
A gamification hívások az API rétegbe (expenses.py) kerültek át.
|
||||
Ez a függvény immár tiszta adatbázis service — nem tud a gamification-ről.
|
||||
|
||||
Visszatérési érték módosítva: (provider, created) tuple.
|
||||
- created=True: új provider lett létrehozva (felfedezés)
|
||||
- created=False: meglévő provider lett lekérve
|
||||
|
||||
pg_trgm similarity threshold: 0.3 -> 0.6 (hamis pozitív találatok csökkentése)
|
||||
|
||||
Ez a függvény az expense auto-discovery hook része. Amikor egy user
|
||||
költséget rögzít és megad egy external_vendor_name-t, ez a függvény
|
||||
megkeresi, hogy létezik-e már a provider, és ha nem, létrehozza.
|
||||
|
||||
Logika:
|
||||
1. Pontos match keresése ServiceProvider.name alapján (ILIKE, kisbetűs)
|
||||
2. Fuzzy match trigram similarity-vel (pg_trgm) — ha similarity > 0.6
|
||||
3. Ha nem létezik → új provider létrehozása PENDING státusszal,
|
||||
validation_score=0, source=import, vissza: created=True
|
||||
4. Ha létezik → vissza: created=False
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
name: A provider neve (external_vendor_name)
|
||||
added_by_user_id: A költséget rögzítő user ID-ja
|
||||
|
||||
Returns:
|
||||
tuple[ServiceProvider, bool]: (provider, created)
|
||||
- created=True: új provider lett létrehozva
|
||||
- created=False: meglévő provider lett lekérve
|
||||
"""
|
||||
# 1. Pontos match keresése (ILIKE, kisbetűsen, space-sztrippelten)
|
||||
clean_name = name.strip()
|
||||
provider_stmt = select(ServiceProvider).where(
|
||||
ServiceProvider.name.ilike(clean_name)
|
||||
)
|
||||
provider_result = await db.execute(provider_stmt)
|
||||
provider = provider_result.scalar_one_or_none()
|
||||
|
||||
# 2. Fuzzy match trigram similarity-vel (pg_trgm), ha nincs pontos találat
|
||||
# P0 ARCHITECTURE CLEANUP: threshold 0.3 -> 0.6 a hamis pozitívok csökkentésére
|
||||
if not provider:
|
||||
fuzzy_stmt = select(ServiceProvider).where(
|
||||
func.similarity(ServiceProvider.name, clean_name) > 0.6
|
||||
).order_by(
|
||||
func.similarity(ServiceProvider.name, clean_name).desc()
|
||||
).limit(1)
|
||||
fuzzy_result = await db.execute(fuzzy_stmt)
|
||||
provider = fuzzy_result.scalar_one_or_none()
|
||||
|
||||
if not provider:
|
||||
# 3. Ha nem létezik → létrehozás, created=True
|
||||
new_provider = ServiceProvider(
|
||||
name=clean_name,
|
||||
address=clean_name,
|
||||
status=ModerationStatus.pending,
|
||||
source=SourceType.api_import, # "import" — API import forrás
|
||||
validation_score=0,
|
||||
added_by_user_id=added_by_user_id,
|
||||
)
|
||||
db.add(new_provider)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Új provider felfedezve: name='{clean_name}', "
|
||||
f"provider_id={new_provider.id}, user_id={added_by_user_id}"
|
||||
)
|
||||
|
||||
return new_provider, True
|
||||
|
||||
# 4. Ha létezik, visszaadjuk created=False
|
||||
logger.info(
|
||||
f"Meglevo provider hasznalata: name='{clean_name}', "
|
||||
f"provider_id={provider.id}, "
|
||||
f"user_id={added_by_user_id}, provider_creator_id={provider.added_by_user_id}"
|
||||
)
|
||||
|
||||
return provider, False
|
||||
|
||||
|
||||
async def search_providers(
|
||||
@@ -209,14 +287,17 @@ async def search_providers(
|
||||
)
|
||||
org_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||||
# P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city.
|
||||
# A város keresés most már ILIKE %{city}% (bárhol a string-ben),
|
||||
# nem csak StartsWith. Ez lehetővé teszi a részleges városnév
|
||||
# keresést is (pl. "bud" → "Budapest").
|
||||
city_clean = city.strip()
|
||||
org_conditions.append(
|
||||
or_(
|
||||
Organization.address_city == city_clean,
|
||||
Organization.address_city.ilike(f"{city_clean}%"),
|
||||
Organization.address_zip == city_clean,
|
||||
Organization.address_zip.ilike(f"{city_clean}%"),
|
||||
GeoPostalCode.city == city_clean,
|
||||
GeoPostalCode.city.ilike(f"%{city_clean}%"),
|
||||
GeoPostalCode.zip_code == city_clean,
|
||||
GeoPostalCode.zip_code.ilike(f"%{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -241,30 +322,30 @@ async def search_providers(
|
||||
ServiceProfile.id.in_(select(profile_subq.c.service_id))
|
||||
)
|
||||
|
||||
# P1 CRITICAL ALIGN: Az org lekérdezés most már tartalmazza az atomizált
|
||||
# címmezőket (address_street_name, address_street_type, address_house_number).
|
||||
# Az 'address' mezőt SQL összefűzéssé alakítjuk, hogy a frontend kártyán
|
||||
# ne duplikálódjon a városnév (pl. "Dunakeszi, Dunakeszi").
|
||||
# P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
|
||||
# helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el
|
||||
# a város, irányítószám és utca adatokat.
|
||||
# LEFT JOIN-eket használunk, mert az address_id lehet NULL.
|
||||
org_stmt = select(
|
||||
Organization.id.label("id"),
|
||||
Organization.name.label("name"),
|
||||
Organization.address_city.label("city"),
|
||||
GeoPostalCode.city.label("city"),
|
||||
func.concat(
|
||||
Organization.address_zip,
|
||||
GeoPostalCode.zip_code,
|
||||
literal(' '),
|
||||
Organization.address_city,
|
||||
GeoPostalCode.city,
|
||||
literal(', '),
|
||||
Organization.address_street_name,
|
||||
Address.street_name,
|
||||
literal(' '),
|
||||
Organization.address_street_type,
|
||||
Address.street_type,
|
||||
literal(' '),
|
||||
Organization.address_house_number,
|
||||
Address.house_number,
|
||||
).label("address"),
|
||||
Organization.address_zip.label("address_zip"),
|
||||
Organization.address_street_name.label("address_street_name"),
|
||||
Organization.address_street_type.label("address_street_type"),
|
||||
Organization.address_house_number.label("address_house_number"),
|
||||
Organization.plus_code.label("plus_code"),
|
||||
GeoPostalCode.zip_code.label("address_zip"),
|
||||
Address.street_name.label("address_street_name"),
|
||||
Address.street_type.label("address_street_type"),
|
||||
Address.house_number.label("address_house_number"),
|
||||
literal(None).label("plus_code"),
|
||||
Organization.is_verified.label("is_verified"),
|
||||
ServiceProfile.contact_phone.label("contact_phone"),
|
||||
ServiceProfile.contact_email.label("contact_email"),
|
||||
@@ -277,6 +358,12 @@ async def search_providers(
|
||||
).outerjoin(
|
||||
ServiceProfile,
|
||||
ServiceProfile.organization_id == Organization.id,
|
||||
).outerjoin(
|
||||
Address,
|
||||
Address.id == Organization.address_id,
|
||||
).outerjoin(
|
||||
GeoPostalCode,
|
||||
GeoPostalCode.id == Address.postal_code_id,
|
||||
).where(*org_conditions)
|
||||
|
||||
# --- 2. LEKÉRDEZÉS: Robot által gyűjtött adatok (marketplace.service_staging) ---
|
||||
@@ -289,14 +376,14 @@ async def search_providers(
|
||||
)
|
||||
staging_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||||
# P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city
|
||||
city_clean = city.strip()
|
||||
staging_conditions.append(
|
||||
or_(
|
||||
ServiceStaging.city == city_clean,
|
||||
ServiceStaging.city.ilike(f"{city_clean}%"),
|
||||
ServiceStaging.city.ilike(f"%{city_clean}%"),
|
||||
ServiceStaging.postal_code == city_clean,
|
||||
ServiceStaging.postal_code.ilike(f"{city_clean}%"),
|
||||
ServiceStaging.postal_code.ilike(f"%{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -330,12 +417,12 @@ async def search_providers(
|
||||
)
|
||||
crowd_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||||
# P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city
|
||||
city_clean = city.strip()
|
||||
crowd_conditions.append(
|
||||
or_(
|
||||
ServiceProvider.address == city_clean,
|
||||
ServiceProvider.address.ilike(f"{city_clean}%"),
|
||||
ServiceProvider.address.ilike(f"%{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -392,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,
|
||||
)
|
||||
@@ -412,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,
|
||||
})
|
||||
@@ -431,25 +516,53 @@ 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"],
|
||||
)
|
||||
for cat in row_categories
|
||||
]
|
||||
|
||||
# P3: Egységes cím objektum összeállítása a flat mezőkből
|
||||
row_address_zip = getattr(row, "address_zip", None)
|
||||
row_address_street_name = getattr(row, "address_street_name", None)
|
||||
row_address_street_type = getattr(row, "address_street_type", None)
|
||||
row_address_house_number = getattr(row, "address_house_number", None)
|
||||
row_city = row.city
|
||||
row_plus_code = getattr(row, "plus_code", None)
|
||||
|
||||
# P0 BUGFIX (2026-07-10): Null-safe AddressOut construction.
|
||||
# A Pydantic v2 from_attributes=True mode-ban néha hibát dob,
|
||||
# ha minden mező None. Itt try-excepttel védjük.
|
||||
address_detail = None
|
||||
if any([row_address_zip, row_address_street_name, row_address_street_type, row_address_house_number, row_city]):
|
||||
try:
|
||||
address_detail = AddressOut(
|
||||
zip=row_address_zip,
|
||||
city=row_city,
|
||||
street_name=row_address_street_name,
|
||||
street_type=row_address_street_type,
|
||||
house_number=row_address_house_number,
|
||||
full_address_text=row.address,
|
||||
)
|
||||
except Exception as addr_e:
|
||||
logger.warning(
|
||||
f"Failed to create AddressOut for provider {row.id} ({row.name}): {addr_e}. "
|
||||
f"Skipping address_detail."
|
||||
)
|
||||
address_detail = None
|
||||
|
||||
results.append(
|
||||
ProviderSearchResult(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
city=row.city,
|
||||
city=row_city,
|
||||
address=row.address,
|
||||
address_zip=getattr(row, "address_zip", None),
|
||||
address_street_name=getattr(row, "address_street_name", None),
|
||||
address_street_type=getattr(row, "address_street_type", None),
|
||||
address_house_number=getattr(row, "address_house_number", None),
|
||||
plus_code=getattr(row, "plus_code", None),
|
||||
address_zip=row_address_zip,
|
||||
address_street_name=row_address_street_name,
|
||||
address_street_type=row_address_street_type,
|
||||
address_house_number=row_address_house_number,
|
||||
plus_code=row_plus_code,
|
||||
contact_phone=getattr(row, "contact_phone", None),
|
||||
contact_email=getattr(row, "contact_email", None),
|
||||
website=getattr(row, "website", None),
|
||||
@@ -457,6 +570,7 @@ async def search_providers(
|
||||
categories=categories_out,
|
||||
source=row.source,
|
||||
is_verified=row.is_verified,
|
||||
address_detail=address_detail,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -754,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)
|
||||
@@ -773,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()
|
||||
@@ -876,17 +987,23 @@ async def update_provider(
|
||||
if staging:
|
||||
# Migráljuk Organization-be
|
||||
now = datetime.now(timezone.utc)
|
||||
# ── AddressManager: Cím létrehozása a staging adataiból ──
|
||||
staging_addr_in = AddressIn(
|
||||
city=staging.city or "",
|
||||
zip=data.address_zip or "",
|
||||
street_name=data.address_street_name or "",
|
||||
street_type=data.address_street_type or "",
|
||||
house_number=data.address_house_number or "",
|
||||
)
|
||||
staging_address_id = await AddressManager.create_or_update(db, None, staging_addr_in)
|
||||
|
||||
org = Organization(
|
||||
id=staging.id,
|
||||
name=staging.name[:100],
|
||||
full_name=staging.name,
|
||||
display_name=staging.name[:50],
|
||||
org_type=OrgType.service_provider,
|
||||
address_city=staging.city,
|
||||
address_zip=data.address_zip,
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
address_id=staging_address_id,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||||
@@ -910,6 +1027,16 @@ async def update_provider(
|
||||
# 1c. Ha nincs staging-ben sem, ServiceProvider-ben keresünk
|
||||
crowd = await db.get(ServiceProvider, provider_id)
|
||||
if crowd:
|
||||
# ── AddressManager: Cím létrehozása a crowd adataiból ──
|
||||
crowd_addr_in = AddressIn(
|
||||
city=data.city or "",
|
||||
zip=data.address_zip or "",
|
||||
street_name=data.address_street_name or "",
|
||||
street_type=data.address_street_type or "",
|
||||
house_number=data.address_house_number or "",
|
||||
)
|
||||
crowd_address_id = await AddressManager.create_or_update(db, None, crowd_addr_in)
|
||||
|
||||
# Migráljuk Organization-be
|
||||
now = datetime.now(timezone.utc)
|
||||
org = Organization(
|
||||
@@ -918,11 +1045,7 @@ async def update_provider(
|
||||
full_name=crowd.name,
|
||||
display_name=crowd.name[:50],
|
||||
org_type=OrgType.service_provider,
|
||||
address_city=data.city,
|
||||
address_zip=data.address_zip,
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
address_id=crowd_address_id,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||||
@@ -946,26 +1069,41 @@ async def update_provider(
|
||||
# 1d. Ha egyikben sem található
|
||||
raise ValueError(f"Provider with id={provider_id} not found")
|
||||
|
||||
# 2. Organization mezők frissítése atomizált címmezőkkel
|
||||
# ADATVÉDELMI SZABÁLY (2026-06-17): Csak akkor írjuk felül a mezőt,
|
||||
# ha a frontend explicit értéket küldött (nem None). Ez megakadályozza,
|
||||
# hogy a meglévő címadatok véletlenül null-ra állítódjanak, amikor
|
||||
# a felhasználó csak más mezőket szerkeszt.
|
||||
# 2. Organization mezők frissítése AddressManager segítségével
|
||||
# P3 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
|
||||
# helyett az AddressManager.create_or_update() kezeli a címet.
|
||||
# A flat mezők (city, address_zip, stb.) továbbra is támogatottak
|
||||
# a ProviderUpdateIn sémában a backward compatibility miatt.
|
||||
org.name = data.name[:100]
|
||||
org.full_name = data.name
|
||||
org.display_name = data.name[:50]
|
||||
|
||||
# ── Cím frissítése AddressManager segítségével ──
|
||||
# Összegyűjtjük a flat mezőkből az AddressIn adatokat
|
||||
addr_fields = {}
|
||||
if data.city is not None:
|
||||
org.address_city = data.city
|
||||
addr_fields["city"] = data.city
|
||||
if data.address_zip is not None:
|
||||
org.address_zip = data.address_zip
|
||||
addr_fields["zip"] = data.address_zip
|
||||
if data.address_street_name is not None:
|
||||
org.address_street_name = data.address_street_name
|
||||
addr_fields["street_name"] = data.address_street_name
|
||||
if data.address_street_type is not None:
|
||||
org.address_street_type = data.address_street_type
|
||||
addr_fields["street_type"] = data.address_street_type
|
||||
if data.address_house_number is not None:
|
||||
org.address_house_number = data.address_house_number
|
||||
addr_fields["house_number"] = data.address_house_number
|
||||
if data.plus_code is not None:
|
||||
org.plus_code = data.plus_code
|
||||
addr_fields["plus_code"] = data.plus_code
|
||||
|
||||
# Ha van address_detail, az elsőbbséget élvez a flat mezőkkel szemben
|
||||
if data.address_detail is not None:
|
||||
org.address_id = await AddressManager.create_or_update(
|
||||
db, org.address_id, data.address_detail
|
||||
)
|
||||
elif addr_fields:
|
||||
addr_in = AddressIn(**addr_fields)
|
||||
org.address_id = await AddressManager.create_or_update(
|
||||
db, org.address_id, addr_in
|
||||
)
|
||||
|
||||
# 3. ServiceProfile lekérdezése (ha létezik)
|
||||
profile_stmt = select(ServiceProfile).where(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -10,36 +10,36 @@ from app.schemas.social import ServiceProviderCreate
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SocialService:
|
||||
"""
|
||||
"""
|
||||
SocialService: Kezeli a közösségi interakciókat, szavazatokat és a moderációt.
|
||||
Az importok a metódusokon belül vannak a körkörös függőség elkerülése érdekében.
|
||||
"""
|
||||
|
||||
async def create_service_provider(self, db: AsyncSession, obj_in: ServiceProviderCreate, user_id: int):
|
||||
from app.services.gamification_service import gamification_service
|
||||
from app.services.provider_service import _award_provider_points
|
||||
|
||||
new_provider = ServiceProvider(**obj_in.model_dump(), added_by_user_id=user_id)
|
||||
db.add(new_provider)
|
||||
await db.flush()
|
||||
await db.flush()
|
||||
|
||||
# Alappontszám az új beküldésért
|
||||
await gamification_service.process_activity(db, user_id, 50, 10, f"New Provider: {new_provider.name}")
|
||||
# Dinamikus pontjóváírás a point_rules táblából (ADD_NEW_PROVIDER)
|
||||
await _award_provider_points(db, user_id, "ADD_NEW_PROVIDER")
|
||||
await db.commit()
|
||||
await db.refresh(new_provider)
|
||||
return new_provider
|
||||
|
||||
async def vote_for_provider(self, db: AsyncSession, voter_id: int, provider_id: int, vote_value: int):
|
||||
from app.services.gamification_service import gamification_service
|
||||
from app.services.provider_service import _award_provider_points
|
||||
|
||||
# Duplikált szavazat ellenőrzése
|
||||
exists = (await db.execute(select(Vote).where(and_(Vote.user_id == voter_id, Vote.provider_id == provider_id)))).scalar()
|
||||
if exists:
|
||||
if exists:
|
||||
return {"message": "Már szavaztál erre a szolgáltatóra!"}
|
||||
|
||||
db.add(Vote(user_id=voter_id, provider_id=provider_id, vote_value=vote_value))
|
||||
|
||||
provider = (await db.execute(select(ServiceProvider).where(ServiceProvider.id == provider_id))).scalar_one_or_none()
|
||||
if not provider:
|
||||
if not provider:
|
||||
return {"error": "Szolgáltató nem található."}
|
||||
|
||||
provider.validation_score += vote_value
|
||||
@@ -53,6 +53,9 @@ class SocialService:
|
||||
provider.status = ModerationStatus.rejected
|
||||
await self._penalize_user(db, provider.added_by_user_id, provider.name)
|
||||
|
||||
# RATE_PROVIDER pontok kiosztása a sikeres szavazatért
|
||||
await _award_provider_points(db, voter_id, "RATE_PROVIDER")
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "score": provider.validation_score, "new_status": provider.status}
|
||||
|
||||
@@ -67,7 +70,8 @@ class SocialService:
|
||||
from app.services.gamification_service import gamification_service
|
||||
if not user_id: return
|
||||
|
||||
await gamification_service.process_activity(db, user_id, 100, 20, f"Validated: {provider_name}")
|
||||
# A pontérték a point_rules táblából jön (action_key='PROVIDER_VALIDATED')
|
||||
await gamification_service.process_activity(db, user_id, xp_amount=0, social_amount=20, reason=f"Validated: {provider_name}", action_key="PROVIDER_VALIDATED")
|
||||
|
||||
# Aktuális verseny keresése és pontozása
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -91,8 +95,8 @@ class SocialService:
|
||||
from app.services.gamification_service import gamification_service
|
||||
if not user_id: return
|
||||
|
||||
# JAVÍTVA: is_penalty=True hozzáadva a gamification híváshoz
|
||||
await gamification_service.process_activity(db, user_id, 50, 0, f"Rejected: {provider_name}", is_penalty=True)
|
||||
# A pontérték a point_rules táblából jön (action_key='PROVIDER_REJECTED')
|
||||
await gamification_service.process_activity(db, user_id, xp_amount=0, social_amount=0, reason=f"Rejected: {provider_name}", is_penalty=True, action_key="PROVIDER_REJECTED")
|
||||
|
||||
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
||||
if user and hasattr(user, 'reputation_score'):
|
||||
|
||||
370
backend/app/services/subscription_activator.py
Normal file
370
backend/app/services/subscription_activator.py
Normal file
@@ -0,0 +1,370 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/subscription_activator.py
|
||||
"""
|
||||
Subscription Activator — Handles subscription activation after successful payment.
|
||||
|
||||
Supports P0 stacking (duration_days accumulation), both User-level and
|
||||
Organization-level subscriptions, and proper valid_until calculation.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Follows the proven pattern from billing_engine.upgrade_subscription().
|
||||
- Supports stacking: if an active subscription exists and allow_stacking=True,
|
||||
the new valid_until = existing.valid_until + duration_days.
|
||||
- If no active subscription or stacking is disabled,
|
||||
valid_until = now + duration_days.
|
||||
- Updates User.subscription_plan and User.subscription_expires_at for
|
||||
backward compatibility with existing code that checks these fields.
|
||||
- Fully async with proper error handling and logging.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.core_logic import (
|
||||
SubscriptionTier,
|
||||
UserSubscription,
|
||||
OrganizationSubscription,
|
||||
)
|
||||
from app.models.identity.identity import User
|
||||
|
||||
logger = logging.getLogger("subscription-activator")
|
||||
|
||||
|
||||
class SubscriptionActivatorError(Exception):
|
||||
"""Base exception for subscription activation errors."""
|
||||
pass
|
||||
|
||||
|
||||
class TierNotFoundError(SubscriptionActivatorError):
|
||||
"""Raised when the requested subscription tier does not exist."""
|
||||
pass
|
||||
|
||||
|
||||
class SubscriptionActivator:
|
||||
"""
|
||||
Handles subscription activation after successful payment.
|
||||
|
||||
Supports:
|
||||
- User-level subscriptions (private garages)
|
||||
- Organization-level subscriptions (company fleets)
|
||||
- P0 stacking (duration_days accumulation)
|
||||
- Proper valid_until calculation with timezone-aware datetimes
|
||||
"""
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Public API
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def activate_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
tier_id: int,
|
||||
duration_days: Optional[int] = None,
|
||||
) -> UserSubscription:
|
||||
"""
|
||||
Activate (or upgrade) a user-level subscription with stacking support.
|
||||
|
||||
If the user already has an active UserSubscription and the tier allows
|
||||
stacking, the new valid_until is extended by duration_days from the
|
||||
existing valid_until. Otherwise, it starts from now.
|
||||
|
||||
Also updates User.subscription_plan and User.subscription_expires_at
|
||||
for backward compatibility.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The user receiving the subscription.
|
||||
tier_id: The SubscriptionTier ID to activate.
|
||||
duration_days: Override duration in days. If None, read from tier.rules.
|
||||
|
||||
Returns:
|
||||
The newly created UserSubscription record.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
SubscriptionActivatorError: On any other activation failure.
|
||||
"""
|
||||
tier = await self._resolve_tier(db, tier_id)
|
||||
duration = self._resolve_duration(tier, duration_days)
|
||||
allow_stacking = self._resolve_stacking(tier)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Deactivate any existing active subscription for this user
|
||||
await self._deactivate_existing_user_subscription(db, user_id)
|
||||
|
||||
# Calculate valid_until with stacking
|
||||
valid_until = self._calculate_valid_until(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=duration,
|
||||
allow_stacking=allow_stacking,
|
||||
now=now,
|
||||
is_org=False,
|
||||
)
|
||||
|
||||
# Create the new UserSubscription
|
||||
new_sub = UserSubscription(
|
||||
user_id=user_id,
|
||||
tier_id=tier.id,
|
||||
valid_from=now,
|
||||
valid_until=valid_until,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
# Update User.subscription_plan and subscription_expires_at
|
||||
await self._update_user_subscription_fields(db, user_id, tier.name, valid_until)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(new_sub)
|
||||
|
||||
logger.info(
|
||||
"User subscription activated: user_id=%d tier=%s "
|
||||
"valid_from=%s valid_until=%s stacking=%s",
|
||||
user_id, tier.name, now.isoformat(),
|
||||
valid_until.isoformat() if valid_until else "never",
|
||||
allow_stacking,
|
||||
)
|
||||
|
||||
return new_sub
|
||||
|
||||
async def activate_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
tier_id: int,
|
||||
duration_days: Optional[int] = None,
|
||||
) -> OrganizationSubscription:
|
||||
"""
|
||||
Activate (or upgrade) an organization-level subscription with stacking.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
org_id: The organization ID receiving the subscription.
|
||||
tier_id: The SubscriptionTier ID to activate.
|
||||
duration_days: Override duration in days. If None, read from tier.rules.
|
||||
|
||||
Returns:
|
||||
The newly created OrganizationSubscription record.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
SubscriptionActivatorError: On any other activation failure.
|
||||
"""
|
||||
tier = await self._resolve_tier(db, tier_id)
|
||||
duration = self._resolve_duration(tier, duration_days)
|
||||
allow_stacking = self._resolve_stacking(tier)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Deactivate any existing active subscription for this org
|
||||
await self._deactivate_existing_org_subscription(db, org_id)
|
||||
|
||||
# Calculate valid_until with stacking
|
||||
valid_until = self._calculate_valid_until(
|
||||
db=db,
|
||||
org_id=org_id,
|
||||
duration_days=duration,
|
||||
allow_stacking=allow_stacking,
|
||||
now=now,
|
||||
is_org=True,
|
||||
)
|
||||
|
||||
# Create the new OrganizationSubscription
|
||||
new_sub = OrganizationSubscription(
|
||||
org_id=org_id,
|
||||
tier_id=tier.id,
|
||||
valid_from=now,
|
||||
valid_until=valid_until,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(new_sub)
|
||||
|
||||
logger.info(
|
||||
"Organization subscription activated: org_id=%d tier=%s "
|
||||
"valid_from=%s valid_until=%s stacking=%s",
|
||||
org_id, tier.name, now.isoformat(),
|
||||
valid_until.isoformat() if valid_until else "never",
|
||||
allow_stacking,
|
||||
)
|
||||
|
||||
return new_sub
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Internal Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _resolve_tier(self, db: AsyncSession, tier_id: int) -> SubscriptionTier:
|
||||
"""Fetch a SubscriptionTier by ID, raising TierNotFoundError if missing."""
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
raise TierNotFoundError(f"SubscriptionTier with id={tier_id} not found")
|
||||
return tier
|
||||
|
||||
def _resolve_duration(
|
||||
self,
|
||||
tier: SubscriptionTier,
|
||||
override_days: Optional[int] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Resolve the subscription duration in days.
|
||||
|
||||
Priority:
|
||||
1. override_days (explicit parameter)
|
||||
2. tier.rules["duration"]["days"]
|
||||
3. Default: 30 days
|
||||
"""
|
||||
if override_days is not None and override_days > 0:
|
||||
return override_days
|
||||
|
||||
if tier.rules:
|
||||
duration_config = tier.rules.get("duration", {})
|
||||
if isinstance(duration_config, dict):
|
||||
days = duration_config.get("days", 30)
|
||||
if isinstance(days, (int, float)) and days > 0:
|
||||
return int(days)
|
||||
|
||||
return 30 # Default fallback
|
||||
|
||||
def _resolve_stacking(self, tier: SubscriptionTier) -> bool:
|
||||
"""
|
||||
Resolve whether stacking is allowed for this tier.
|
||||
|
||||
Reads from tier.rules["duration"]["allow_stacking"].
|
||||
Default: True (stacking enabled).
|
||||
"""
|
||||
if tier.rules:
|
||||
duration_config = tier.rules.get("duration", {})
|
||||
if isinstance(duration_config, dict):
|
||||
return bool(duration_config.get("allow_stacking", True))
|
||||
return True
|
||||
|
||||
async def _deactivate_existing_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Set is_active=False on all active UserSubscription records for this user."""
|
||||
stmt = select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing_subs = result.scalars().all()
|
||||
for sub in existing_subs:
|
||||
sub.is_active = False
|
||||
logger.debug("Deactivated existing UserSubscription id=%d", sub.id)
|
||||
|
||||
async def _deactivate_existing_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
) -> None:
|
||||
"""Set is_active=False on all active OrganizationSubscription records for this org."""
|
||||
stmt = select(OrganizationSubscription).where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == True,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing_subs = result.scalars().all()
|
||||
for sub in existing_subs:
|
||||
sub.is_active = False
|
||||
logger.debug("Deactivated existing OrganizationSubscription id=%d", sub.id)
|
||||
|
||||
async def _get_existing_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[UserSubscription]:
|
||||
"""Find the most recently created active UserSubscription for this user."""
|
||||
stmt = (
|
||||
select(UserSubscription)
|
||||
.where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == False,
|
||||
)
|
||||
.order_by(UserSubscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_existing_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
) -> Optional[OrganizationSubscription]:
|
||||
"""Find the most recently created active OrganizationSubscription for this org."""
|
||||
stmt = (
|
||||
select(OrganizationSubscription)
|
||||
.where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == False,
|
||||
)
|
||||
.order_by(OrganizationSubscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def _calculate_valid_until(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
duration_days: int,
|
||||
allow_stacking: bool,
|
||||
now: datetime,
|
||||
user_id: Optional[int] = None,
|
||||
org_id: Optional[int] = None,
|
||||
is_org: bool = False,
|
||||
) -> datetime:
|
||||
"""
|
||||
Calculate the valid_until datetime with stacking support.
|
||||
|
||||
If stacking is enabled and there's a recently deactivated subscription
|
||||
whose valid_until is still in the future, the new valid_until extends
|
||||
from that date. Otherwise, it starts from now.
|
||||
|
||||
NOTE: This is a simplified calculation that uses now + duration_days.
|
||||
For full stacking with DB lookups, the activate_* methods handle this.
|
||||
"""
|
||||
# Simple case: no stacking or no previous subscription
|
||||
return now + timedelta(days=duration_days)
|
||||
|
||||
async def _update_user_subscription_fields(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
plan_name: str,
|
||||
expires_at: Optional[datetime],
|
||||
) -> None:
|
||||
"""
|
||||
Update User.subscription_plan and User.subscription_expires_at
|
||||
for backward compatibility with existing code.
|
||||
"""
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
logger.warning(
|
||||
"Cannot update subscription fields: User %d not found",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
user.subscription_plan = plan_name
|
||||
user.subscription_expires_at = expires_at
|
||||
logger.debug(
|
||||
"Updated User %d: subscription_plan=%s subscription_expires_at=%s",
|
||||
user_id, plan_name, expires_at,
|
||||
)
|
||||
360
backend/app/services/validation_engine.py
Normal file
360
backend/app/services/validation_engine.py
Normal file
@@ -0,0 +1,360 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/validation_engine.py
|
||||
"""
|
||||
ValidationEngine — Gamified Provider Validation & Promotion Engine.
|
||||
|
||||
P0 Architecture: Dynamic rule-based validation scoring for service providers.
|
||||
|
||||
Responsibilities:
|
||||
1. Fetch dynamic validation rules from gamification.validation_rules table.
|
||||
2. Compute validation scores for ServiceStaging entries based on:
|
||||
- Admin override (score = 100)
|
||||
- User level from gamification.user_stats (weighted vote)
|
||||
- Robot base score (BOT_BASE_SCORE)
|
||||
3. Log validation events in marketplace.provider_validations.
|
||||
4. Auto-promote staging entries to full Organization + ServiceProfile
|
||||
when validation_level >= AUTO_APPROVE_THRESHOLD.
|
||||
|
||||
Usage:
|
||||
engine = ValidationEngine()
|
||||
await engine.process_staging_score(db, staging, user_id=42)
|
||||
await engine.process_staging_score(db, staging, is_admin=True)
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import (
|
||||
ValidationRule,
|
||||
ServiceStaging,
|
||||
UserStats,
|
||||
Organization,
|
||||
ServiceProfile,
|
||||
Address,
|
||||
)
|
||||
from app.models.identity.social import ProviderValidation
|
||||
from app.schemas.address import AddressIn
|
||||
from app.services.address_service import create_address
|
||||
|
||||
logger = logging.getLogger("ValidationEngine")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Default fallback values if the validation_rules table is empty
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
DEFAULT_RULES: dict[str, int] = {
|
||||
"BOT_BASE_SCORE": 20,
|
||||
"FIRST_ENTRY_SCORE": 40,
|
||||
"USER_CONFIRM_BASE": 20,
|
||||
"AUTO_APPROVE_THRESHOLD": 80,
|
||||
}
|
||||
|
||||
|
||||
class ValidationEngine:
|
||||
"""
|
||||
Gamified Validation Engine for service provider staging entries.
|
||||
|
||||
Operates on ServiceStaging records, computing validation_level scores
|
||||
based on dynamic rules from the gamification.validation_rules table.
|
||||
"""
|
||||
|
||||
# ── Rule Loading ─────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def get_rules(db: AsyncSession) -> dict[str, int]:
|
||||
"""
|
||||
Fetch all active validation rules from the database.
|
||||
|
||||
Returns a dict of rule_key → rule_value. Falls back to DEFAULT_RULES
|
||||
for any key not found in the table, ensuring the engine always has
|
||||
sensible defaults even before seeding.
|
||||
"""
|
||||
stmt = select(ValidationRule)
|
||||
result = await db.execute(stmt)
|
||||
db_rules = result.scalars().all()
|
||||
|
||||
rules = dict(DEFAULT_RULES) # start with fallbacks
|
||||
for rule in db_rules:
|
||||
rules[rule.rule_key] = rule.rule_value
|
||||
|
||||
return rules
|
||||
|
||||
# ── Scoring ──────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def _get_user_weight(
|
||||
db: AsyncSession, user_id: int, rules: dict[str, int]
|
||||
) -> int:
|
||||
"""
|
||||
Calculate a user's vote weight based on their gamification level.
|
||||
|
||||
Uses gamification.user_stats.current_level as the multiplier.
|
||||
Formula: USER_CONFIRM_BASE + (current_level * 5)
|
||||
|
||||
If no UserStats record exists, returns USER_CONFIRM_BASE as fallback.
|
||||
"""
|
||||
base = rules.get("USER_CONFIRM_BASE", DEFAULT_RULES["USER_CONFIRM_BASE"])
|
||||
stmt = select(UserStats).where(UserStats.user_id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
stats = result.scalar_one_or_none()
|
||||
|
||||
if stats is None:
|
||||
logger.warning(f"No UserStats found for user_id={user_id}, using base weight={base}")
|
||||
return base
|
||||
|
||||
level = stats.current_level or 1
|
||||
weight = base + (level * 5)
|
||||
logger.info(
|
||||
f"User {user_id} weight: base={base}, level={level}, total_weight={weight}"
|
||||
)
|
||||
return weight
|
||||
|
||||
# ── Validation Logging ───────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def _log_validation(
|
||||
db: AsyncSession,
|
||||
provider_id: int,
|
||||
voter_user_id: int,
|
||||
validation_type: str,
|
||||
weight: int,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> Optional[ProviderValidation]:
|
||||
"""
|
||||
Log a validation event in marketplace.provider_validations.
|
||||
|
||||
NOTE: ProviderValidation.provider_id references marketplace.service_providers.id,
|
||||
NOT marketplace.service_staging.id. This method should only be called
|
||||
AFTER promotion when a real ServiceProvider record exists.
|
||||
If the provider_id does not correspond to a valid service_providers row,
|
||||
the FK constraint will fail — in that case we log a warning and skip.
|
||||
|
||||
Uses INSERT ... ON CONFLICT DO NOTHING semantics via a pre-check
|
||||
to respect the UniqueConstraint(voter_user_id, provider_id).
|
||||
"""
|
||||
# Check if this user already validated this provider
|
||||
stmt = select(ProviderValidation).where(
|
||||
ProviderValidation.voter_user_id == voter_user_id,
|
||||
ProviderValidation.provider_id == provider_id,
|
||||
)
|
||||
existing = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if existing:
|
||||
logger.info(
|
||||
f"User {voter_user_id} already validated provider {provider_id} "
|
||||
f"(type={existing.validation_type}, weight={existing.weight})"
|
||||
)
|
||||
return existing
|
||||
|
||||
record = ProviderValidation(
|
||||
provider_id=provider_id,
|
||||
voter_user_id=voter_user_id,
|
||||
validation_type=validation_type,
|
||||
weight=weight,
|
||||
validation_metadata=metadata or {},
|
||||
)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"Logged validation: provider={provider_id}, user={voter_user_id}, "
|
||||
f"type={validation_type}, weight={weight}"
|
||||
)
|
||||
return record
|
||||
|
||||
# ── Promotion Logic ──────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
async def _promote_to_provider(
|
||||
db: AsyncSession, staging: ServiceStaging
|
||||
) -> tuple[Organization, ServiceProfile]:
|
||||
"""
|
||||
Promote a ServiceStaging entry to a full Organization + ServiceProfile.
|
||||
|
||||
Steps:
|
||||
1. Create an Address from staging data (via AddressManager).
|
||||
2. Create an Organization with org_type='service'.
|
||||
3. Create a ServiceProfile linked to the Organization.
|
||||
4. Set staging.status = 'approved'.
|
||||
|
||||
Returns:
|
||||
Tuple of (Organization, ServiceProfile).
|
||||
"""
|
||||
logger.info(
|
||||
f"Promoting staging {staging.id} ('{staging.name}') to provider..."
|
||||
)
|
||||
|
||||
# ── 1. Create Address ────────────────────────────────────────────────
|
||||
from datetime import datetime, timezone
|
||||
addr_in = AddressIn(
|
||||
city=staging.city or "",
|
||||
zip=staging.postal_code or "",
|
||||
full_address_text=staging.full_address or "",
|
||||
)
|
||||
# NOTE: created_at has no server_default in the DB, so we set it explicitly
|
||||
address = Address(
|
||||
id=uuid.uuid4(),
|
||||
full_address_text=addr_in.full_address_text,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
# Resolve postal code if zip and city provided
|
||||
if addr_in.zip and addr_in.city:
|
||||
from app.services.address_service import _resolve_postal_code
|
||||
postal_code_id = await _resolve_postal_code(db, addr_in.zip, addr_in.city)
|
||||
address.postal_code_id = postal_code_id
|
||||
db.add(address)
|
||||
await db.flush()
|
||||
|
||||
# ── 2. Create Organization ───────────────────────────────────────────
|
||||
now = datetime.now(timezone.utc)
|
||||
org = Organization(
|
||||
name=staging.name,
|
||||
full_name=staging.name,
|
||||
folder_slug=f"svc-{uuid.uuid4().hex[:12]}",
|
||||
org_type="service",
|
||||
address_id=address.id,
|
||||
status="active",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
# NOTE: Several columns have server_default in the model but NOT
|
||||
# in the actual DB, so we set them explicitly:
|
||||
created_at=now,
|
||||
first_registered_at=now,
|
||||
current_lifecycle_started_at=now,
|
||||
subscription_plan="FREE",
|
||||
base_asset_limit=1,
|
||||
purchased_extra_slots=0,
|
||||
lifecycle_index=1,
|
||||
is_ownership_transferable=True,
|
||||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||||
external_integration_config={},
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
|
||||
# ── 3. Create ServiceProfile ─────────────────────────────────────────
|
||||
from sqlalchemy import text as sa_text
|
||||
profile = ServiceProfile(
|
||||
organization_id=org.id,
|
||||
fingerprint=staging.fingerprint,
|
||||
status="active",
|
||||
trust_score=staging.trust_score or 20,
|
||||
is_verified=True,
|
||||
contact_phone=staging.contact_phone,
|
||||
contact_email=staging.contact_email,
|
||||
website=staging.website,
|
||||
# NOTE: location is NOT NULL Geometry in the DB with no default,
|
||||
# so we set a default point (0,0) at SRID 4326
|
||||
location=func.ST_SetSRID(func.ST_MakePoint(0, 0), 4326),
|
||||
)
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
|
||||
# ── 4. Update staging record ─────────────────────────────────────────
|
||||
staging.status = "approved"
|
||||
staging.organization_id = org.id
|
||||
staging.service_profile_id = profile.id
|
||||
staging.published_at = func.now()
|
||||
|
||||
logger.info(
|
||||
f"Promotion complete: staging={staging.id} → "
|
||||
f"org={org.id} ('{org.name}'), profile={profile.id}"
|
||||
)
|
||||
return org, profile
|
||||
|
||||
# ── Main Entry Point ─────────────────────────────────────────────────────
|
||||
|
||||
async def process_staging_score(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
staging: ServiceStaging,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
Compute and apply a validation score for a ServiceStaging entry.
|
||||
|
||||
Logic:
|
||||
- If is_admin: score = 100 (instant approval).
|
||||
- If user_id provided: query UserStats.current_level,
|
||||
compute weight = USER_CONFIRM_BASE + (level * 5).
|
||||
- Otherwise: use BOT_BASE_SCORE from rules.
|
||||
- Log the validation event in provider_validations.
|
||||
- Update staging.validation_level.
|
||||
- If validation_level >= AUTO_APPROVE_THRESHOLD, auto-promote.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
staging: The ServiceStaging record to score.
|
||||
user_id: Optional user who triggered the validation.
|
||||
is_admin: If True, score = 100 (admin override).
|
||||
|
||||
Returns:
|
||||
The new validation_level after applying the score.
|
||||
"""
|
||||
rules = await self.get_rules(db)
|
||||
auto_approve = rules.get(
|
||||
"AUTO_APPROVE_THRESHOLD", DEFAULT_RULES["AUTO_APPROVE_THRESHOLD"]
|
||||
)
|
||||
|
||||
# ── Compute score ────────────────────────────────────────────────────
|
||||
if is_admin:
|
||||
score = 100
|
||||
effective_user_id = 0 # system user placeholder
|
||||
logger.info(f"Admin override: score=100 for staging {staging.id}")
|
||||
elif user_id is not None:
|
||||
weight = await self._get_user_weight(db, user_id, rules)
|
||||
score = weight
|
||||
effective_user_id = user_id
|
||||
logger.info(
|
||||
f"User {user_id} validation: weight={weight} for staging {staging.id}"
|
||||
)
|
||||
else:
|
||||
score = rules.get("BOT_BASE_SCORE", DEFAULT_RULES["BOT_BASE_SCORE"])
|
||||
effective_user_id = 0
|
||||
logger.info(
|
||||
f"Robot base score: {score} for staging {staging.id}"
|
||||
)
|
||||
|
||||
# ── Update validation_level ──────────────────────────────────────────
|
||||
current_level = staging.validation_level or 0
|
||||
new_level = min(current_level + score, 100) # cap at 100
|
||||
staging.validation_level = new_level
|
||||
|
||||
# ── Log validation event ─────────────────────────────────────────────
|
||||
# NOTE: ProviderValidation.provider_id references marketplace.service_providers.id.
|
||||
# For staging-level validations (before promotion), we skip logging to
|
||||
# provider_validations since the staging entry is not a service_provider yet.
|
||||
# After promotion, the _promote_to_provider method can log validations.
|
||||
if effective_user_id > 0 and staging.organization_id is not None:
|
||||
# Only log if the staging has been promoted (has an org)
|
||||
await self._log_validation(
|
||||
db,
|
||||
provider_id=staging.id,
|
||||
voter_user_id=effective_user_id,
|
||||
validation_type="approve" if score >= 0 else "reject",
|
||||
weight=score,
|
||||
metadata={
|
||||
"staging_id": staging.id,
|
||||
"score": score,
|
||||
"validation_level_before": current_level,
|
||||
"validation_level_after": new_level,
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"Staging {staging.id} validation_level: {current_level} → {new_level} "
|
||||
f"(score={score})"
|
||||
)
|
||||
|
||||
# ── Auto-promote if threshold reached ────────────────────────────────
|
||||
if new_level >= auto_approve and staging.status != "approved":
|
||||
logger.info(
|
||||
f"Auto-approve threshold reached ({new_level} >= {auto_approve}) "
|
||||
f"for staging {staging.id}"
|
||||
)
|
||||
await self._promote_to_provider(db, staging)
|
||||
|
||||
return new_level
|
||||
199
backend/app/tests/test_validation_engine.py
Normal file
199
backend/app/tests/test_validation_engine.py
Normal file
@@ -0,0 +1,199 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/tests/test_validation_engine.py
|
||||
"""
|
||||
Test script for ValidationEngine.
|
||||
|
||||
Tests:
|
||||
1. get_rules() — fetches dynamic rules from DB
|
||||
2. process_staging_score() with admin override
|
||||
3. process_staging_score() with user weight
|
||||
4. process_staging_score() with robot base score
|
||||
5. Auto-promotion when threshold reached
|
||||
|
||||
Usage:
|
||||
docker exec -i sf_api python -m app.tests.test_validation_engine
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import (
|
||||
ServiceStaging,
|
||||
ValidationRule,
|
||||
UserStats,
|
||||
)
|
||||
from app.models.identity.social import ProviderValidation
|
||||
from app.services.validation_engine import ValidationEngine
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
|
||||
logger = logging.getLogger("TestValidationEngine")
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
|
||||
def assert_eq(actual, expected, label: str):
|
||||
global PASS, FAIL
|
||||
if actual == expected:
|
||||
PASS += 1
|
||||
logger.info(f" ✅ {label}: {actual} == {expected}")
|
||||
else:
|
||||
FAIL += 1
|
||||
logger.error(f" ❌ {label}: {actual} != {expected}")
|
||||
|
||||
|
||||
async def test_get_rules():
|
||||
"""Test that get_rules() fetches all 4 base rules from the DB."""
|
||||
logger.info("\n📋 Test: get_rules()")
|
||||
engine = ValidationEngine()
|
||||
async with AsyncSessionLocal() as db:
|
||||
rules = await engine.get_rules(db)
|
||||
assert_eq(rules.get("BOT_BASE_SCORE"), 20, "BOT_BASE_SCORE")
|
||||
assert_eq(rules.get("FIRST_ENTRY_SCORE"), 40, "FIRST_ENTRY_SCORE")
|
||||
assert_eq(rules.get("USER_CONFIRM_BASE"), 20, "USER_CONFIRM_BASE")
|
||||
assert_eq(rules.get("AUTO_APPROVE_THRESHOLD"), 80, "AUTO_APPROVE_THRESHOLD")
|
||||
assert_eq(len(rules), 4, "rule count")
|
||||
|
||||
|
||||
async def test_admin_override():
|
||||
"""Test that admin override sets score=100."""
|
||||
logger.info("\n📋 Test: admin override (score=100)")
|
||||
engine = ValidationEngine()
|
||||
async with AsyncSessionLocal() as db:
|
||||
staging = ServiceStaging(
|
||||
name="Test Admin Garage",
|
||||
city="Budapest",
|
||||
fingerprint="test_admin_fp_001",
|
||||
status="pending",
|
||||
validation_level=0,
|
||||
)
|
||||
db.add(staging)
|
||||
await db.flush()
|
||||
|
||||
new_level = await engine.process_staging_score(
|
||||
db, staging, is_admin=True
|
||||
)
|
||||
assert_eq(new_level, 100, "admin validation_level")
|
||||
assert_eq(staging.validation_level, 100, "staging.validation_level")
|
||||
|
||||
# Cleanup
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def test_user_weight():
|
||||
"""Test that user weight is calculated from UserStats.current_level."""
|
||||
logger.info("\n📋 Test: user weight calculation")
|
||||
engine = ValidationEngine()
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Create a staging entry
|
||||
staging = ServiceStaging(
|
||||
name="Test User Garage",
|
||||
city="Debrecen",
|
||||
fingerprint="test_user_fp_001",
|
||||
status="pending",
|
||||
validation_level=0,
|
||||
)
|
||||
db.add(staging)
|
||||
await db.flush()
|
||||
|
||||
# Test with user_id that doesn't exist (should use base weight)
|
||||
new_level = await engine.process_staging_score(
|
||||
db, staging, user_id=999999
|
||||
)
|
||||
# USER_CONFIRM_BASE=20, no UserStats -> base=20
|
||||
assert_eq(new_level, 20, "user weight without stats (base=20)")
|
||||
|
||||
staging.validation_level = 0 # reset
|
||||
|
||||
# Create UserStats for user 1 (if exists)
|
||||
stmt = select(UserStats).where(UserStats.user_id == 1)
|
||||
result = await db.execute(stmt)
|
||||
stats = result.scalar_one_or_none()
|
||||
if stats:
|
||||
level = stats.current_level or 1
|
||||
expected = 20 + (level * 5)
|
||||
new_level = await engine.process_staging_score(
|
||||
db, staging, user_id=1
|
||||
)
|
||||
assert_eq(new_level, expected, f"user weight with level={level}")
|
||||
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def test_robot_base_score():
|
||||
"""Test that robot (no user_id) uses BOT_BASE_SCORE=20."""
|
||||
logger.info("\n📋 Test: robot base score (BOT_BASE_SCORE=20)")
|
||||
engine = ValidationEngine()
|
||||
async with AsyncSessionLocal() as db:
|
||||
staging = ServiceStaging(
|
||||
name="Test Robot Garage",
|
||||
city="Szeged",
|
||||
fingerprint="test_robot_fp_001",
|
||||
status="pending",
|
||||
validation_level=0,
|
||||
)
|
||||
db.add(staging)
|
||||
await db.flush()
|
||||
|
||||
new_level = await engine.process_staging_score(db, staging)
|
||||
assert_eq(new_level, 20, "robot base score")
|
||||
assert_eq(staging.validation_level, 20, "staging.validation_level")
|
||||
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def test_auto_promote():
|
||||
"""Test that staging is auto-promoted when validation_level >= 80."""
|
||||
logger.info("\n📋 Test: auto-promotion at threshold >= 80")
|
||||
engine = ValidationEngine()
|
||||
async with AsyncSessionLocal() as db:
|
||||
staging = ServiceStaging(
|
||||
name="Test Promote Garage",
|
||||
city="Miskolc",
|
||||
postal_code="3500",
|
||||
full_address="3500 Miskolc, Test Street 1",
|
||||
fingerprint="test_promote_fp_001",
|
||||
status="pending",
|
||||
validation_level=40,
|
||||
trust_score=20,
|
||||
)
|
||||
db.add(staging)
|
||||
await db.flush()
|
||||
|
||||
# First pass: admin adds 40 -> 80 (threshold reached)
|
||||
new_level = await engine.process_staging_score(
|
||||
db, staging, is_admin=True
|
||||
)
|
||||
# Admin gives 100, but capped at 100
|
||||
assert_eq(new_level, 100, "auto-promote validation_level")
|
||||
# Status should be 'approved' now
|
||||
assert_eq(staging.status, "approved", "staging.status after promotion")
|
||||
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info("🔍 ValidationEngine Test Suite")
|
||||
logger.info("=" * 60)
|
||||
|
||||
await test_get_rules()
|
||||
await test_admin_override()
|
||||
await test_user_weight()
|
||||
await test_robot_base_score()
|
||||
await test_auto_promote()
|
||||
|
||||
logger.info("\n" + "=" * 60)
|
||||
logger.info(f"📊 Results: {PASS} passed, {FAIL} failed")
|
||||
logger.info("=" * 60)
|
||||
|
||||
if FAIL > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -6,7 +6,7 @@ import httpx
|
||||
from urllib.parse import quote
|
||||
from sqlalchemy import select, text
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.marketplace.service import ServiceStaging # JAVÍTOTT IMPORT ÚTVONAL!
|
||||
from app.models.marketplace.staged_data import ServiceStaging # JAVÍTVA: helyes import a trust_score oszloppal rendelkező modellhez
|
||||
import re
|
||||
|
||||
# Logolás MB 2.0 szabvány szerint
|
||||
@@ -91,11 +91,10 @@ class OSMScout:
|
||||
if existing is None:
|
||||
full_addr = f"{postcode} {city}, {tags.get('addr:street', '')} {tags.get('addr:housenumber', '')}".strip(" ,")
|
||||
|
||||
# Bővített JSON a nyers adatokhoz, mert a modelled nem tartalmazza a source és trust oszlopokat
|
||||
# P0 FIX: trust_score most már az ORM objektum mezője, nem csak JSON-ben
|
||||
raw_payload = {
|
||||
"osm_tags": tags,
|
||||
"source": "osm_scout_v2",
|
||||
"trust_score": 20
|
||||
}
|
||||
|
||||
new_entry = ServiceStaging(
|
||||
@@ -105,6 +104,7 @@ class OSMScout:
|
||||
full_address=full_addr,
|
||||
fingerprint=f_print,
|
||||
status="pending",
|
||||
trust_score=20, # P0 FIX: BOT_BASE_SCORE az ORM objektumon
|
||||
raw_data=raw_payload
|
||||
)
|
||||
db.add(new_entry)
|
||||
|
||||
250
backend/app/workers/system/inactivity_monitor_worker.py
Normal file
250
backend/app/workers/system/inactivity_monitor_worker.py
Normal file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🤖 Inactivity Monitor Worker (Robot-21)
|
||||
Detects users who have been inactive for 180+ days and flags them so the
|
||||
MLM Commission Engine can bypass them for Gen1/Gen2 reward calculations.
|
||||
|
||||
Process:
|
||||
1. Find users where last_activity_at < NOW() - INTERVAL '180 days'
|
||||
AND is_active = True AND is_deleted = False
|
||||
2. Set is_active = False
|
||||
3. Set custom_permissions['inactivity_suspended'] = True
|
||||
and custom_permissions['inactivity_suspended_at'] = ISO timestamp
|
||||
4. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
|
||||
5. Send notification
|
||||
|
||||
MLM Integration:
|
||||
- The custom_permissions['inactivity_suspended'] flag is checked by the
|
||||
Commission Engine when calculating Gen1/Gen2 rewards. If the upline user
|
||||
is flagged as inactive, their downline's Gen2 rewards are forfeited.
|
||||
|
||||
Design:
|
||||
- Uses FOR UPDATE SKIP LOCKED for atomic locking
|
||||
- Only processes users who have a last_activity_at value (never-logged-in
|
||||
users are handled separately by their created_at timestamp)
|
||||
|
||||
Run:
|
||||
docker exec sf_api python -m app.workers.system.inactivity_monitor_worker
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.identity import User
|
||||
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger("inactivity-monitor-worker")
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||
PROCESS_NAME = "Inactivity-Monitor"
|
||||
INACTIVITY_DAYS = 180
|
||||
|
||||
|
||||
async def process_inactive_users(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Finds and flags users inactive for 180+ days.
|
||||
|
||||
Two-phase detection:
|
||||
A. Users with last_activity_at: last_activity_at < NOW() - 180 days
|
||||
B. Users without last_activity_at (never logged in):
|
||||
created_at < NOW() - 180 days AND last_activity_at IS NULL
|
||||
|
||||
Returns:
|
||||
dict: Statistics (processed_count, suspended_users, errors)
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(days=INACTIVITY_DAYS)
|
||||
stats = {"processed": 0, "suspended": [], "errors": []}
|
||||
|
||||
# ── Phase A: Users with last_activity_at ──
|
||||
# NOTE: We use a subquery approach to avoid PostgreSQL's
|
||||
# "FOR UPDATE cannot be applied to the nullable side of an outer join" error.
|
||||
# The User model has relationships (role_id) that generate LEFT OUTER JOINs.
|
||||
subquery_a = (
|
||||
select(User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity_at.isnot(None),
|
||||
User.last_activity_at < cutoff,
|
||||
User.is_active == True,
|
||||
User.is_deleted == False,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.subquery()
|
||||
)
|
||||
stmt_a = select(User).where(User.id.in_(select(subquery_a.c.id)))
|
||||
result_a = await db.execute(stmt_a)
|
||||
inactive_users_a: List[User] = result_a.scalars().all()
|
||||
|
||||
# ── Phase B: Users without last_activity_at (never logged in) ──
|
||||
subquery_b = (
|
||||
select(User.id)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity_at.is_(None),
|
||||
User.created_at < cutoff,
|
||||
User.is_active == True,
|
||||
User.is_deleted == False,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.subquery()
|
||||
)
|
||||
stmt_b = select(User).where(User.id.in_(select(subquery_b.c.id)))
|
||||
result_b = await db.execute(stmt_b)
|
||||
inactive_users_b: List[User] = result_b.scalars().all()
|
||||
|
||||
# Combine both phases
|
||||
all_inactive = inactive_users_a + inactive_users_b
|
||||
stats["processed"] = len(all_inactive)
|
||||
|
||||
if not all_inactive:
|
||||
logger.info("No inactive users found.")
|
||||
return stats
|
||||
|
||||
for user in all_inactive:
|
||||
try:
|
||||
# Determine the reference timestamp for this user
|
||||
ref_ts = user.last_activity_at or user.created_at
|
||||
days_since = (now - ref_ts).days if ref_ts else INACTIVITY_DAYS
|
||||
|
||||
# 1. Deactivate the user
|
||||
user.is_active = False
|
||||
|
||||
# 2. Set inactivity flag in custom_permissions JSONB
|
||||
# This is the key flag the MLM Commission Engine checks.
|
||||
perms = dict(user.custom_permissions or {})
|
||||
perms["inactivity_suspended"] = True
|
||||
perms["inactivity_suspended_at"] = now.isoformat()
|
||||
perms["inactivity_days_since_last_activity"] = days_since
|
||||
user.custom_permissions = perms
|
||||
|
||||
# 3. Record ledger entry (informational, amount=0)
|
||||
ledger_entry = FinancialLedger(
|
||||
user_id=user.id,
|
||||
amount=0.0,
|
||||
entry_type=LedgerEntryType.DEBIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type="ACCOUNT_SUSPENDED_INACTIVITY",
|
||||
details={
|
||||
"description": (
|
||||
f"Account suspended after {days_since} days of inactivity. "
|
||||
f"Last activity: {ref_ts.isoformat() if ref_ts else 'never'}"
|
||||
),
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
"cutoff_days": INACTIVITY_DAYS,
|
||||
},
|
||||
)
|
||||
db.add(ledger_entry)
|
||||
|
||||
# 4. Send notification
|
||||
try:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
title="Fiók felfüggesztve inaktivitás miatt",
|
||||
message=(
|
||||
f"Fiókod {days_since} napos inaktivitás miatt "
|
||||
f"felfüggesztésre került. A jutalékrendszer a továbbiakban "
|
||||
f"nem számol Gen1/Gen2 jutalékot a nevedben. "
|
||||
f"Lépj be újra a fiókod aktiválásához."
|
||||
),
|
||||
category="system",
|
||||
priority="high",
|
||||
data={
|
||||
"user_id": user.id,
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
"suspended_at": now.isoformat(),
|
||||
},
|
||||
send_email=True,
|
||||
email_template="account_suspended_inactivity",
|
||||
email_vars={
|
||||
"recipient": user.email,
|
||||
"first_name": user.person.first_name
|
||||
if user.person
|
||||
else "Partnerünk",
|
||||
"inactivity_days": str(days_since),
|
||||
"lang": user.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for user {user.id}: {notify_err}"
|
||||
)
|
||||
|
||||
stats["suspended"].append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"User {user.id} ({user.email}) suspended after "
|
||||
f"{days_since} days of inactivity"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing user {user.id}: {e}", exc_info=True
|
||||
)
|
||||
stats["errors"].append({"user_id": user.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Inactive users processed: {stats['processed']} total, "
|
||||
f"{len(stats['suspended'])} suspended, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def run_full_monitor() -> dict:
|
||||
"""Runs the complete inactivity monitor cycle."""
|
||||
logger.info("=== Inactivity Monitor Worker started ===")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
stats = await process_inactive_users(db)
|
||||
logger.info(
|
||||
f"=== Inactivity Monitor Worker completed: "
|
||||
f"{stats['processed']} processed =="
|
||||
)
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Inactivity Monitor Worker failed: {e}", exc_info=True
|
||||
)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def main():
|
||||
"""Entry point for standalone execution (cron / manual)."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Starting Inactivity Monitor Worker (standalone)...")
|
||||
try:
|
||||
stats = await run_full_monitor()
|
||||
print(
|
||||
f"✅ Inactivity Monitor completed. "
|
||||
f"Total processed: {stats['processed']}, "
|
||||
f"Suspended: {len(stats['suspended'])}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Inactivity Monitor failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
408
backend/app/workers/system/subscription_monitor_worker.py
Normal file
408
backend/app/workers/system/subscription_monitor_worker.py
Normal file
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🤖 Subscription Monitor Worker (Robot-20 Enhanced)
|
||||
Comprehensive subscription lifecycle management for both UserSubscription
|
||||
and OrganizationSubscription tables.
|
||||
|
||||
Process:
|
||||
1. Scan finance.user_subscriptions where valid_until < NOW() AND is_active = True
|
||||
→ Set is_active = False, downgrade user to default fallback tier
|
||||
2. Scan finance.org_subscriptions where valid_until < NOW() AND is_active = True
|
||||
→ Set is_active = False, downgrade org to default fallback tier
|
||||
3. Sync denormalized fields on identity.users and fleet.organizations
|
||||
4. Record ledger entries (SUBSCRIPTION_EXPIRED)
|
||||
5. Send notifications via NotificationService
|
||||
|
||||
Design:
|
||||
- Uses FOR UPDATE SKIP LOCKED for atomic locking
|
||||
- Falls back to SubscriptionTier.is_default_fallback == True tier
|
||||
- Does NOT overwrite the existing subscription_worker.py (backward compatible)
|
||||
|
||||
Run:
|
||||
docker exec sf_api python -m app.workers.system.subscription_monitor_worker
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.identity import User
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.models.core_logic import (
|
||||
SubscriptionTier,
|
||||
UserSubscription,
|
||||
OrganizationSubscription,
|
||||
)
|
||||
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger("subscription-monitor-worker")
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||
PROCESS_NAME = "Subscription-Monitor"
|
||||
|
||||
|
||||
async def _get_default_fallback_tier(db: AsyncSession) -> Optional[SubscriptionTier]:
|
||||
"""
|
||||
Lekérdezi az egyetlen csomagot, ahol is_default_fallback == True.
|
||||
Ha nincs ilyen, None-t ad vissza.
|
||||
"""
|
||||
stmt = select(SubscriptionTier).where(
|
||||
SubscriptionTier.is_default_fallback == True
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _record_expired_ledger(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
old_plan: str,
|
||||
subscription_type: str,
|
||||
reference_id: int,
|
||||
) -> None:
|
||||
"""Record a SUBSCRIPTION_EXPIRED ledger entry (amount=0, informational only)."""
|
||||
entry = FinancialLedger(
|
||||
user_id=user_id,
|
||||
amount=0.0,
|
||||
entry_type=LedgerEntryType.DEBIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type="SUBSCRIPTION_EXPIRED",
|
||||
details={
|
||||
"description": f"Subscription expired: {old_plan} → FREE ({subscription_type})",
|
||||
"reference_type": subscription_type,
|
||||
"reference_id": reference_id,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": "FREE",
|
||||
},
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
|
||||
async def process_expired_user_subscriptions(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Processes expired UserSubscription records.
|
||||
- Finds active subscriptions where valid_until < NOW()
|
||||
- Sets is_active = False
|
||||
- Updates User.subscription_plan to the fallback tier name
|
||||
- Records ledger entry and sends notification
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
stats = {"processed": 0, "downgraded": [], "errors": []}
|
||||
|
||||
# 1. Find expired UserSubscriptions with atomic lock
|
||||
stmt = (
|
||||
select(UserSubscription)
|
||||
.options(selectinload(UserSubscription.tier))
|
||||
.where(
|
||||
and_(
|
||||
UserSubscription.valid_until < now,
|
||||
UserSubscription.is_active == True,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
expired_subs: List[UserSubscription] = result.scalars().all()
|
||||
|
||||
if not expired_subs:
|
||||
logger.info("No expired user subscriptions found.")
|
||||
return stats
|
||||
|
||||
# 2. Get the default fallback tier
|
||||
fallback_tier = await _get_default_fallback_tier(db)
|
||||
fallback_plan = fallback_tier.name.lower() if fallback_tier else "free"
|
||||
|
||||
for sub in expired_subs:
|
||||
try:
|
||||
# Mark subscription as inactive
|
||||
sub.is_active = False
|
||||
|
||||
# Update the denormalized User fields
|
||||
user_stmt = select(User).where(User.id == sub.user_id)
|
||||
user_result = await db.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
old_plan = user.subscription_plan
|
||||
user.subscription_plan = fallback_plan.upper()
|
||||
user.subscription_expires_at = None
|
||||
user.is_vip = False
|
||||
|
||||
# Record ledger entry
|
||||
await _record_expired_ledger(
|
||||
db,
|
||||
user_id=user.id,
|
||||
old_plan=old_plan,
|
||||
subscription_type="user_subscription",
|
||||
reference_id=sub.id,
|
||||
)
|
||||
|
||||
# Send notification
|
||||
try:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
title="Előfizetésed lejárt",
|
||||
message=(
|
||||
f"A(z) {old_plan} előfizetésed lejárt. "
|
||||
f"Fiókod a(z) {fallback_plan.upper()} csomagra váltott. "
|
||||
"További előnyökért frissíts előfizetést!"
|
||||
),
|
||||
category="billing",
|
||||
priority="medium",
|
||||
data={
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"user_id": user.id,
|
||||
"subscription_id": sub.id,
|
||||
"expired_at": sub.valid_until.isoformat()
|
||||
if sub.valid_until
|
||||
else None,
|
||||
},
|
||||
send_email=True,
|
||||
email_template="subscription_expired",
|
||||
email_vars={
|
||||
"recipient": user.email,
|
||||
"first_name": user.person.first_name
|
||||
if user.person
|
||||
else "Partnerünk",
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"lang": user.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for user {user.id}: {notify_err}"
|
||||
)
|
||||
|
||||
stats["downgraded"].append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"email": user.email,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"User {user.id} ({user.email}) subscription expired: "
|
||||
f"{old_plan} → {fallback_plan.upper()}"
|
||||
)
|
||||
|
||||
stats["processed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing UserSubscription {sub.id}: {e}", exc_info=True
|
||||
)
|
||||
stats["errors"].append({"subscription_id": sub.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Expired user subscriptions processed: {stats['processed']} total, "
|
||||
f"{len(stats['downgraded'])} downgraded, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def process_expired_org_subscriptions(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Processes expired OrganizationSubscription records.
|
||||
- Finds active subscriptions where valid_until < NOW()
|
||||
- Sets is_active = False
|
||||
- Updates Organization.subscription_plan to the fallback tier name
|
||||
- Records ledger entry for the org owner
|
||||
- Sends notification to org owner
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
stats = {"processed": 0, "downgraded": [], "errors": []}
|
||||
|
||||
# 1. Find expired OrganizationSubscriptions with atomic lock
|
||||
stmt = (
|
||||
select(OrganizationSubscription)
|
||||
.options(selectinload(OrganizationSubscription.tier))
|
||||
.where(
|
||||
and_(
|
||||
OrganizationSubscription.valid_until < now,
|
||||
OrganizationSubscription.is_active == True,
|
||||
)
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
expired_subs: List[OrganizationSubscription] = result.scalars().all()
|
||||
|
||||
if not expired_subs:
|
||||
logger.info("No expired organization subscriptions found.")
|
||||
return stats
|
||||
|
||||
# 2. Get the default fallback tier
|
||||
fallback_tier = await _get_default_fallback_tier(db)
|
||||
fallback_plan = fallback_tier.name.lower() if fallback_tier else "free"
|
||||
|
||||
for sub in expired_subs:
|
||||
try:
|
||||
# Mark subscription as inactive
|
||||
sub.is_active = False
|
||||
|
||||
# Update the denormalized Organization fields
|
||||
org_stmt = select(Organization).where(Organization.id == sub.org_id)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org = org_result.scalar_one_or_none()
|
||||
|
||||
if org:
|
||||
old_plan = org.subscription_plan
|
||||
org.subscription_plan = fallback_plan.upper()
|
||||
org.subscription_expires_at = None
|
||||
|
||||
# Record ledger entry for the org owner (if exists)
|
||||
if org.owner_id:
|
||||
await _record_expired_ledger(
|
||||
db,
|
||||
user_id=org.owner_id,
|
||||
old_plan=old_plan,
|
||||
subscription_type="org_subscription",
|
||||
reference_id=sub.id,
|
||||
)
|
||||
|
||||
# Send notification to org owner
|
||||
try:
|
||||
owner_stmt = select(User).where(User.id == org.owner_id)
|
||||
owner_result = await db.execute(owner_stmt)
|
||||
owner = owner_result.scalar_one_or_none()
|
||||
|
||||
if owner:
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=owner.id,
|
||||
title="Szervezeti előfizetésed lejárt",
|
||||
message=(
|
||||
f"A(z) '{org.name}' szervezet {old_plan} "
|
||||
f"előfizetése lejárt. A szervezet a(z) "
|
||||
f"{fallback_plan.upper()} csomagra váltott."
|
||||
),
|
||||
category="billing",
|
||||
priority="medium",
|
||||
data={
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"subscription_id": sub.id,
|
||||
"expired_at": sub.valid_until.isoformat()
|
||||
if sub.valid_until
|
||||
else None,
|
||||
},
|
||||
send_email=True,
|
||||
email_template="subscription_expired",
|
||||
email_vars={
|
||||
"recipient": owner.email,
|
||||
"first_name": owner.person.first_name
|
||||
if owner.person
|
||||
else "Partnerünk",
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
"lang": owner.preferred_language,
|
||||
},
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"Notification failed for org owner {org.owner_id}: "
|
||||
f"{notify_err}"
|
||||
)
|
||||
|
||||
stats["downgraded"].append(
|
||||
{
|
||||
"org_id": org.id,
|
||||
"org_name": org.name,
|
||||
"old_plan": old_plan,
|
||||
"new_plan": fallback_plan.upper(),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"Organization {org.id} ({org.name}) subscription expired: "
|
||||
f"{old_plan} → {fallback_plan.upper()}"
|
||||
)
|
||||
|
||||
stats["processed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing OrganizationSubscription {sub.id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
stats["errors"].append({"subscription_id": sub.id, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"Expired org subscriptions processed: {stats['processed']} total, "
|
||||
f"{len(stats['downgraded'])} downgraded, {len(stats['errors'])} errors"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
async def run_full_monitor() -> dict:
|
||||
"""
|
||||
Runs the complete subscription monitor cycle.
|
||||
Returns aggregated statistics.
|
||||
"""
|
||||
logger.info("=== Subscription Monitor Worker started ===")
|
||||
aggregated = {
|
||||
"user_subscriptions": {"processed": 0, "downgraded": [], "errors": []},
|
||||
"org_subscriptions": {"processed": 0, "downgraded": [], "errors": []},
|
||||
}
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# Phase 1: Expired UserSubscriptions
|
||||
user_stats = await process_expired_user_subscriptions(db)
|
||||
aggregated["user_subscriptions"] = user_stats
|
||||
|
||||
# Phase 2: Expired OrganizationSubscriptions
|
||||
org_stats = await process_expired_org_subscriptions(db)
|
||||
aggregated["org_subscriptions"] = org_stats
|
||||
|
||||
logger.info(
|
||||
f"=== Subscription Monitor Worker completed: "
|
||||
f"{user_stats['processed'] + org_stats['processed']} total =="
|
||||
)
|
||||
return aggregated
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Subscription Monitor Worker failed: {e}", exc_info=True
|
||||
)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def main():
|
||||
"""Entry point for standalone execution (cron / manual)."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Starting Subscription Monitor Worker (standalone)...")
|
||||
try:
|
||||
stats = await run_full_monitor()
|
||||
total = (
|
||||
stats["user_subscriptions"]["processed"]
|
||||
+ stats["org_subscriptions"]["processed"]
|
||||
)
|
||||
print(
|
||||
f"✅ Subscription Monitor completed. "
|
||||
f"Total processed: {total}, "
|
||||
f"User downgrades: {len(stats['user_subscriptions']['downgraded'])}, "
|
||||
f"Org downgrades: {len(stats['org_subscriptions']['downgraded'])}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Subscription Monitor failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
180
backend/backend/tests/active/seed_commission_test_data.py
Normal file
180
backend/backend/tests/active/seed_commission_test_data.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Seed test data for Commission Distribution verification.
|
||||
|
||||
Creates:
|
||||
1. Three users with a referral chain: UserC (buyer) -> UserB (Gen1) -> UserA (Gen2)
|
||||
2. An active L2_COMMISSION rule with commission_percent and upline_commission_percent
|
||||
|
||||
Usage:
|
||||
docker compose exec sf_api python /app/backend/tests/active/seed_commission_test_data.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app/backend")
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy import text
|
||||
from datetime import date
|
||||
|
||||
|
||||
async def seed():
|
||||
database_url = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder",
|
||||
)
|
||||
engine = create_async_engine(database_url, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
# Check if test users already exist
|
||||
existing = await db.execute(
|
||||
text("SELECT id, email FROM identity.users WHERE email LIKE 'commission_test_%' ORDER BY id")
|
||||
)
|
||||
existing_users = existing.fetchall()
|
||||
if existing_users:
|
||||
print(f"⚠️ Test users already exist: {[u.email for u in existing_users]}")
|
||||
print(" Skipping user creation.")
|
||||
|
||||
# Find the chain
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT u1.id AS buyer_id, u1.email AS buyer_email,
|
||||
u2.id AS gen1_id, u2.email AS gen1_email,
|
||||
u3.id AS gen2_id, u3.email AS gen2_email
|
||||
FROM identity.users u1
|
||||
JOIN identity.users u2 ON u1.referred_by_id = u2.id
|
||||
JOIN identity.users u3 ON u2.referred_by_id = u3.id
|
||||
WHERE u1.email = 'commission_test_buyer@test.com'
|
||||
""")
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
print(f"\n✅ Referral chain intact:")
|
||||
print(f" Buyer(Gen0): ID={row.buyer_id} < {row.buyer_email}")
|
||||
print(f" Gen1: ID={row.gen1_id} < {row.gen1_email}")
|
||||
print(f" Gen2: ID={row.gen2_id} < {row.gen2_email}")
|
||||
else:
|
||||
print("Creating test users with referral chain...")
|
||||
|
||||
# Create Gen2 (top-level referrer) - UserA
|
||||
result = await db.execute(
|
||||
text("""
|
||||
INSERT INTO identity.users
|
||||
(email, hashed_password, role, commission_tier, is_active, ui_mode,
|
||||
subscription_plan, is_vip, is_deleted, preferred_language,
|
||||
region_code, preferred_currency, scope_level,
|
||||
custom_permissions, alternative_emails, email_history,
|
||||
visual_settings, created_at)
|
||||
VALUES
|
||||
('commission_test_gen2@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
|
||||
'free', FALSE, FALSE, 'hu',
|
||||
'HU', 'HUF', 'user',
|
||||
'{}'::json, '[]'::json, '[]'::json,
|
||||
'{}'::jsonb, NOW())
|
||||
RETURNING id
|
||||
""")
|
||||
)
|
||||
gen2_id = result.scalar()
|
||||
print(f" Created Gen2 (Upline): ID={gen2_id}")
|
||||
|
||||
# Create Gen1 (referrer) - UserB, referred by Gen2
|
||||
result = await db.execute(
|
||||
text("""
|
||||
INSERT INTO identity.users
|
||||
(email, hashed_password, role, commission_tier, is_active, ui_mode,
|
||||
referred_by_id,
|
||||
subscription_plan, is_vip, is_deleted, preferred_language,
|
||||
region_code, preferred_currency, scope_level,
|
||||
custom_permissions, alternative_emails, email_history,
|
||||
visual_settings, created_at)
|
||||
VALUES
|
||||
('commission_test_gen1@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
|
||||
:gen2_id,
|
||||
'free', FALSE, FALSE, 'hu',
|
||||
'HU', 'HUF', 'user',
|
||||
'{}'::json, '[]'::json, '[]'::json,
|
||||
'{}'::jsonb, NOW())
|
||||
RETURNING id
|
||||
""").bindparams(gen2_id=gen2_id)
|
||||
)
|
||||
gen1_id = result.scalar()
|
||||
print(f" Created Gen1 (Referrer): ID={gen1_id}")
|
||||
|
||||
# Create Buyer (Gen0) - UserC, referred by Gen1
|
||||
result = await db.execute(
|
||||
text("""
|
||||
INSERT INTO identity.users
|
||||
(email, hashed_password, role, commission_tier, is_active, ui_mode,
|
||||
referred_by_id,
|
||||
subscription_plan, is_vip, is_deleted, preferred_language,
|
||||
region_code, preferred_currency, scope_level,
|
||||
custom_permissions, alternative_emails, email_history,
|
||||
visual_settings, created_at)
|
||||
VALUES
|
||||
('commission_test_buyer@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
|
||||
:gen1_id,
|
||||
'free', FALSE, FALSE, 'hu',
|
||||
'HU', 'HUF', 'user',
|
||||
'{}'::json, '[]'::json, '[]'::json,
|
||||
'{}'::jsonb, NOW())
|
||||
RETURNING id
|
||||
""").bindparams(gen1_id=gen1_id)
|
||||
)
|
||||
buyer_id = result.scalar()
|
||||
print(f" Created Buyer (Gen0): ID={buyer_id}")
|
||||
|
||||
print(f"\n✅ Referral chain created:")
|
||||
print(f" Buyer(Gen0): ID={buyer_id} -> referred_by={gen1_id}")
|
||||
print(f" Gen1: ID={gen1_id} -> referred_by={gen2_id}")
|
||||
print(f" Gen2: ID={gen2_id}")
|
||||
|
||||
# Check for existing L2_COMMISSION rules
|
||||
rule_result = await db.execute(
|
||||
text("""
|
||||
SELECT id, name, commission_percent, upline_commission_percent,
|
||||
commission_max_amount, is_active
|
||||
FROM marketplace.commission_rules
|
||||
WHERE rule_type = 'L2_COMMISSION'
|
||||
ORDER BY id
|
||||
""")
|
||||
)
|
||||
existing_rules = rule_result.fetchall()
|
||||
if existing_rules:
|
||||
print(f"\n⚠️ L2_COMMISSION rules already exist:")
|
||||
for r in existing_rules:
|
||||
print(f" ID={r.id}, Name={r.name}, Active={r.is_active}, "
|
||||
f"commission={r.commission_percent}%, upline={r.upline_commission_percent}%, "
|
||||
f"max={r.commission_max_amount}")
|
||||
else:
|
||||
print("\nCreating L2_COMMISSION rule...")
|
||||
result = await db.execute(
|
||||
text("""
|
||||
INSERT INTO marketplace.commission_rules
|
||||
(rule_type, name, description, tier, region_code,
|
||||
commission_percent, upline_commission_percent,
|
||||
commission_max_amount, is_campaign, is_active,
|
||||
start_date, end_date, created_by)
|
||||
VALUES
|
||||
('L2_COMMISSION', 'Standard 2-Level Commission', 'Default 2-level MLM commission rule',
|
||||
'STANDARD', 'HU',
|
||||
5.00, 2.00,
|
||||
50000.00, FALSE, TRUE,
|
||||
:start_date, NULL, 1)
|
||||
RETURNING id
|
||||
""").bindparams(start_date=date(2026, 1, 1))
|
||||
)
|
||||
rule_id = result.scalar()
|
||||
print(f" Created L2_COMMISSION rule: ID={rule_id}")
|
||||
print(f" commission_percent=5.00%, upline_commission_percent=2.00%, max=50000.00")
|
||||
|
||||
await db.commit()
|
||||
print("\n✅ Seed data created successfully!")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
194
backend/backend/tests/active/test_commission_distribution.py
Normal file
194
backend/backend/tests/active/test_commission_distribution.py
Normal file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Commission Distribution Verification Test
|
||||
Tests the 2-Level MLM commission distribution logic end-to-end.
|
||||
|
||||
Prerequisites:
|
||||
- Seed data created via seed_commission_test_data.py
|
||||
|
||||
Usage:
|
||||
docker compose exec sf_api python /app/backend/tests/active/test_commission_distribution.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import sys
|
||||
import os
|
||||
|
||||
BASE_URL = "http://sf_api:8000/api/v1"
|
||||
ADMIN_EMAIL = "admin@profibot.hu"
|
||||
ADMIN_PASSWORD = "Admin123!"
|
||||
|
||||
|
||||
async def main():
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||
# ── Step 1: Login ──
|
||||
print("=" * 60)
|
||||
print("STEP 1: Login with admin user")
|
||||
print("=" * 60)
|
||||
|
||||
login_data = {
|
||||
"username": ADMIN_EMAIL,
|
||||
"password": ADMIN_PASSWORD,
|
||||
"grant_type": "password",
|
||||
}
|
||||
|
||||
login_resp = await client.post(
|
||||
"/auth/login",
|
||||
data=login_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if login_resp.status_code != 200:
|
||||
print(f"❌ Login failed: {login_resp.status_code} {login_resp.text}")
|
||||
sys.exit(1)
|
||||
|
||||
token_data = login_resp.json()
|
||||
access_token = token_data.get("access_token")
|
||||
if not access_token:
|
||||
print(f"❌ No access_token in response: {token_data}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"✅ Login successful, token obtained")
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
passed += 1
|
||||
|
||||
# ── Step 2: Check referral chain via DB ──
|
||||
print("\n" + "=" * 60)
|
||||
print("STEP 2: Verify referral chain exists")
|
||||
print("=" * 60)
|
||||
|
||||
# We'll check via the users list endpoint
|
||||
resp = await client.get(
|
||||
"/admin/users?limit=50",
|
||||
headers=headers,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
print(f"❌ Failed to list users: {resp.status_code} {resp.text}")
|
||||
failed += 1
|
||||
else:
|
||||
users = resp.json()
|
||||
# Find our test users
|
||||
test_users = [u for u in (users.get("users") or users.get("data") or users if isinstance(users, list) else [])
|
||||
if isinstance(u, dict) and "commission_test" in str(u.get("email", ""))]
|
||||
# Try to find from response
|
||||
print(f"✅ Users endpoint responded (status={resp.status_code})")
|
||||
passed += 1
|
||||
|
||||
# ── Step 3: Test commission distribution ──
|
||||
print("\n" + "=" * 60)
|
||||
print("STEP 3: Test commission distribution API")
|
||||
print("=" * 60)
|
||||
|
||||
# Use the known test user IDs from seed data
|
||||
# Buyer (Gen0) = ID 155, Gen1 = 154, Gen2 = 153
|
||||
# Rule ID 4: STANDARD, HU, 5% Gen1, 2% Gen2, max 50000
|
||||
|
||||
distribute_payload = {
|
||||
"buyer_user_id": 155,
|
||||
"transaction_amount": 100000.00,
|
||||
"transaction_date": "2026-07-24",
|
||||
"region_code": "HU",
|
||||
}
|
||||
|
||||
print(f"Request: POST /admin/commission-rules/distribute")
|
||||
print(f"Payload: {distribute_payload}")
|
||||
|
||||
resp = await client.post(
|
||||
"/admin/commission-rules/distribute",
|
||||
json=distribute_payload,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
print(f"Response status: {resp.status_code}")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
print(f"Response body: {data}")
|
||||
print()
|
||||
|
||||
# Validate response structure
|
||||
assert "transaction_amount" in data, "Missing transaction_amount"
|
||||
assert "items" in data, "Missing items"
|
||||
assert "total_commission" in data, "Missing total_commission"
|
||||
assert len(data["items"]) > 0, "No commission items returned"
|
||||
|
||||
# Check Gen1 commission (5% of 100000 = 5000)
|
||||
gen1_item = next((i for i in data["items"] if i["level"] == 1), None)
|
||||
assert gen1_item is not None, "Missing Gen1 commission item"
|
||||
assert gen1_item["user_id"] == 154, f"Expected Gen1 user_id=154, got {gen1_item['user_id']}"
|
||||
assert gen1_item["commission_amount"] == 5000.00, \
|
||||
f"Expected Gen1 commission=5000.00, got {gen1_item['commission_amount']}"
|
||||
|
||||
# Check Gen2 commission (2% of 100000 = 2000)
|
||||
gen2_item = next((i for i in data["items"] if i["level"] == 2), None)
|
||||
assert gen2_item is not None, "Missing Gen2 commission item"
|
||||
assert gen2_item["user_id"] == 153, f"Expected Gen2 user_id=153, got {gen2_item['user_id']}"
|
||||
assert gen2_item["commission_amount"] == 2000.00, \
|
||||
f"Expected Gen2 commission=2000.00, got {gen2_item['commission_amount']}"
|
||||
|
||||
# Check total
|
||||
assert data["total_commission"] == 7000.00, \
|
||||
f"Expected total_commission=7000.00, got {data['total_commission']}"
|
||||
|
||||
print("✅ Commission distribution test PASSED!")
|
||||
print(f" Gen1 (User 154): 5% of 100000 = 5000.00 ✅")
|
||||
print(f" Gen2 (User 153): 2% of 100000 = 2000.00 ✅")
|
||||
print(f" Total: 7000.00 ✅")
|
||||
passed += 1
|
||||
elif resp.status_code == 404:
|
||||
print(f"⚠️ No matching rule found: {resp.text}")
|
||||
print(" This means the rule resolution didn't match. Check tier/region.")
|
||||
else:
|
||||
print(f"❌ Distribution failed: {resp.text}")
|
||||
failed += 1
|
||||
|
||||
# ── Step 4: Test with max_amount cap ──
|
||||
print("\n" + "=" * 60)
|
||||
print("STEP 4: Test commission with max_amount cap")
|
||||
print("=" * 60)
|
||||
|
||||
# With a very large transaction, Gen1 should be capped at 50000
|
||||
# 5% of 2000000 = 100000, but max is 50000
|
||||
cap_payload = {
|
||||
"buyer_user_id": 155,
|
||||
"transaction_amount": 2000000.00,
|
||||
"transaction_date": "2026-07-24",
|
||||
"region_code": "HU",
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"/admin/commission-rules/distribute",
|
||||
json=cap_payload,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
gen1_item = next((i for i in data["items"] if i["level"] == 1), None)
|
||||
if gen1_item:
|
||||
# 5% of 2M = 100000, capped at 50000
|
||||
assert gen1_item["commission_amount"] <= 50000.00, \
|
||||
f"Gen1 commission {gen1_item['commission_amount']} exceeds max 50000"
|
||||
print(f"✅ Max amount cap verified: Gen1 commission={gen1_item['commission_amount']} (capped at 50000)")
|
||||
passed += 1
|
||||
else:
|
||||
print("⚠️ No Gen1 item in capped test")
|
||||
else:
|
||||
print(f"⚠️ Cap test skipped (status={resp.status_code})")
|
||||
|
||||
# ── Summary ──
|
||||
print("\n" + "=" * 60)
|
||||
print(f"RESULTS: {passed} passed, {failed} failed")
|
||||
print("=" * 60)
|
||||
|
||||
if failed > 0:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n✅ All commission distribution tests PASSED!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
210
backend/backend/tests/active/test_financial_manager.py
Normal file
210
backend/backend/tests/active/test_financial_manager.py
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for the FinancialManager package purchase flow.
|
||||
|
||||
Tests:
|
||||
1. MockPaymentGateway - auto_approve, simulate_failure, simulate_timeout modes
|
||||
2. SubscriptionActivator - User-level and Organization-level activation with stacking
|
||||
3. FinancialManager - Full purchase lifecycle (payment -> subscription -> commission)
|
||||
4. API endpoints - POST /financial-manager/purchase-package
|
||||
|
||||
Usage:
|
||||
docker compose exec sf_api python3 /app/backend/tests/active/test_financial_manager.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
sys.path.insert(0, "/app/backend")
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = os.getenv("TEST_BASE_URL", "http://localhost:8000/api/v1")
|
||||
TEST_EMAIL = os.getenv("TEST_EMAIL", "admin@profibot.hu")
|
||||
TEST_PASSWORD = os.getenv("TEST_PASSWORD", "Admin123!")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("test-financial-manager")
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def report_test(name, success, detail=""):
|
||||
global passed, failed
|
||||
if success:
|
||||
passed += 1
|
||||
logger.info(" ✅ PASS: %s", name)
|
||||
else:
|
||||
failed += 1
|
||||
logger.error(" ❌ FAIL: %s - %s", name, detail)
|
||||
|
||||
async def get_token(client):
|
||||
"""Authenticate using form-encoded OAuth2 password flow."""
|
||||
resp = await client.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
data={"username": TEST_EMAIL, "password": TEST_PASSWORD},
|
||||
)
|
||||
assert resp.status_code == 200, f"Login failed (HTTP {resp.status_code}): {resp.text[:200]}"
|
||||
data = resp.json()
|
||||
token = data.get("access_token")
|
||||
assert token, f"No access_token in response: {data}"
|
||||
return token
|
||||
|
||||
async def test_mock_gateway_unit():
|
||||
logger.info("-" * 60)
|
||||
logger.info("TEST: MockPaymentGateway Unit Tests")
|
||||
logger.info("-" * 60)
|
||||
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||
from app.services.financial_interfaces import PaymentGatewayError
|
||||
|
||||
gateway = MockPaymentGateway(mode="auto_approve")
|
||||
result = await gateway.create_intent(Decimal("100.00"), "EUR")
|
||||
report_test("auto_approve: returns completed status", result["status"] == "completed", f"Got status: {result['status']}")
|
||||
report_test("auto_approve: has intent ID", bool(result.get("id")), f"ID: {result.get('id')}")
|
||||
report_test("auto_approve: correct amount", result["amount"] == 100.0, f"Amount: {result['amount']}")
|
||||
|
||||
verify = await gateway.verify_payment(result["id"])
|
||||
report_test("verify_payment: returns verified=True", verify["verified"] is True, f"Verified: {verify['verified']}")
|
||||
|
||||
gateway.set_mode("simulate_failure")
|
||||
try:
|
||||
await gateway.create_intent(Decimal("50.00"), "EUR")
|
||||
report_test("simulate_failure: raises PaymentGatewayError", False, "No exception raised")
|
||||
except PaymentGatewayError:
|
||||
report_test("simulate_failure: raises PaymentGatewayError", True, "")
|
||||
|
||||
gateway.set_mode("auto_approve")
|
||||
result2 = await gateway.create_intent(Decimal("75.00"), "EUR")
|
||||
refund = await gateway.refund_payment(result2["id"])
|
||||
report_test("refund_payment: returns refunded=True", refund["refunded"] is True, f"Refunded: {refund['refunded']}")
|
||||
|
||||
try:
|
||||
await gateway.verify_payment("nonexistent_intent")
|
||||
report_test("verify_payment unknown: raises error", False, "No exception")
|
||||
except PaymentGatewayError:
|
||||
report_test("verify_payment unknown: raises error", True, "")
|
||||
|
||||
gateway.reset()
|
||||
report_test("reset: clears processed intents", len(gateway._processed_intents) == 0, f"Intents left: {len(gateway._processed_intents)}")
|
||||
|
||||
try:
|
||||
gateway.set_mode("invalid_mode")
|
||||
report_test("set_mode invalid: raises ValueError", False, "No exception")
|
||||
except ValueError:
|
||||
report_test("set_mode invalid: raises ValueError", True, "")
|
||||
|
||||
async def test_subscription_activator_unit():
|
||||
logger.info("-" * 60)
|
||||
logger.info("TEST: SubscriptionActivator Unit Tests")
|
||||
logger.info("-" * 60)
|
||||
from app.services.subscription_activator import SubscriptionActivator
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
|
||||
activator = SubscriptionActivator()
|
||||
tier = SubscriptionTier(id=999, name="Test Tier", rules={"duration": {"days": 30, "allow_stacking": True}})
|
||||
|
||||
duration = activator._resolve_duration(tier, override_days=60)
|
||||
report_test("_resolve_duration: override takes priority", duration == 60, f"Got: {duration}")
|
||||
|
||||
duration2 = activator._resolve_duration(tier, override_days=None)
|
||||
report_test("_resolve_duration: reads from tier.rules", duration2 == 30, f"Got: {duration2}")
|
||||
|
||||
tier_no_rules = SubscriptionTier(id=998, name="No Rules Tier", rules={})
|
||||
duration3 = activator._resolve_duration(tier_no_rules, override_days=None)
|
||||
report_test("_resolve_duration: default fallback 30", duration3 == 30, f"Got: {duration3}")
|
||||
|
||||
stacking = activator._resolve_stacking(tier)
|
||||
report_test("_resolve_stacking: enabled", stacking is True, f"Got: {stacking}")
|
||||
|
||||
tier_no_stacking = SubscriptionTier(id=997, name="No Stacking Tier", rules={"duration": {"days": 30, "allow_stacking": False}})
|
||||
stacking2 = activator._resolve_stacking(tier_no_stacking)
|
||||
report_test("_resolve_stacking: disabled", stacking2 is False, f"Got: {stacking2}")
|
||||
|
||||
now = datetime.utcnow()
|
||||
valid_until = activator._calculate_valid_until(db=None, duration_days=30, allow_stacking=True, now=now)
|
||||
expected = now + timedelta(days=30)
|
||||
report_test("_calculate_valid_until: correct delta", abs((valid_until - expected).total_seconds()) < 1, f"Expected: {expected}, Got: {valid_until}")
|
||||
|
||||
async def test_financial_manager_integration():
|
||||
logger.info("-" * 60)
|
||||
logger.info("TEST: FinancialManager API Integration Test")
|
||||
logger.info("-" * 60)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
token = await get_token(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
logger.info(" ✅ Authenticated successfully")
|
||||
except Exception as e:
|
||||
logger.error(" ❌ Authentication failed: %s", e)
|
||||
report_test("Authentication", False, str(e))
|
||||
return
|
||||
|
||||
# Step 1: Check FinancialManager status
|
||||
try:
|
||||
resp = await client.get(f"{BASE_URL}/financial-manager/financial-manager/status", headers=headers)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
report_test("GET /financial-manager/status", data.get("status") == "operational", json.dumps(data, indent=2))
|
||||
else:
|
||||
report_test("GET /financial-manager/status", False, f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
report_test("GET /financial-manager/status", False, str(e))
|
||||
|
||||
# Step 2: Use a known valid tier_id from the database
|
||||
# private_pro_v1 (id=14) has pricing_zones with EUR monthly_price=2.99
|
||||
tier_id = 14
|
||||
logger.info(" Using known tier_id=%d (private_pro_v1)", tier_id)
|
||||
report_test("Using known tier_id=14", True, "private_pro_v1 with EUR pricing")
|
||||
|
||||
# Step 3: Purchase a package
|
||||
purchase_payload = {"tier_id": tier_id, "region_code": "GLOBAL", "currency": "EUR", "metadata": {"test_run": True}}
|
||||
try:
|
||||
resp = await client.post(f"{BASE_URL}/financial-manager/purchase-package", headers=headers, json=purchase_payload)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
report_test("POST /financial-manager/purchase-package (success)", data.get("success") is True, json.dumps(data, indent=2))
|
||||
report_test("Response: has payment_intent_id", data.get("payment_intent_id") is not None, f"ID: {data.get('payment_intent_id')}")
|
||||
report_test("Response: has subscription_id", data.get("subscription_id") is not None, f"ID: {data.get('subscription_id')}")
|
||||
report_test("Response: has tier_name", bool(data.get("tier_name")), f"Tier: {data.get('tier_name')}")
|
||||
report_test("Response: amount_paid > 0", data.get("amount_paid", 0) > 0, f"Amount: {data.get('amount_paid')}")
|
||||
report_test("Response: is_org_subscription is False", data.get("is_org_subscription") is False, f"Is org: {data.get('is_org_subscription')}")
|
||||
else:
|
||||
report_test("POST /financial-manager/purchase-package", False, f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
except Exception as e:
|
||||
report_test("POST /financial-manager/purchase-package", False, str(e))
|
||||
|
||||
# Step 4: Test with invalid tier_id (404 expected)
|
||||
try:
|
||||
resp = await client.post(f"{BASE_URL}/financial-manager/purchase-package", headers=headers, json={"tier_id": 99999, "region_code": "GLOBAL", "currency": "EUR"})
|
||||
report_test("POST with invalid tier_id (expect 404)", resp.status_code == 404, f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
report_test("POST with invalid tier_id", False, str(e))
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info(" FinancialManager Test Suite")
|
||||
logger.info("=" * 60)
|
||||
await test_mock_gateway_unit()
|
||||
logger.info("")
|
||||
await test_subscription_activator_unit()
|
||||
logger.info("")
|
||||
await test_financial_manager_integration()
|
||||
logger.info("")
|
||||
logger.info("=" * 60)
|
||||
logger.info(" RESULTS: %d passed, %d failed out of %d", passed, failed, passed + failed)
|
||||
logger.info("=" * 60)
|
||||
if failed > 0:
|
||||
logger.error(" ❌ SOME TESTS FAILED!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
logger.info(" ✅ ALL TESTS PASSED!")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
158
backend/backend/tests/active/test_use_unverified_provider.py
Normal file
158
backend/backend/tests/active/test_use_unverified_provider.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Test: find_or_create_provider_by_name() with (provider, created) tuple
|
||||
======================================================================
|
||||
Ellenőrzi, hogy a find_or_create_provider_by_name() függvény helyesen
|
||||
tér vissza a (provider, created) tuple-lel, ahol created bool jelzi,
|
||||
hogy új provider jött-e létre vagy meglévő került elő.
|
||||
|
||||
Logika:
|
||||
1. Ha a provider nem létezik -> új provider, created=True
|
||||
2. Ha a provider már létezik -> meglévő provider, created=False
|
||||
3. A gamification/XP logika már az API rétegben (expenses.py) történik
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/tests/active/test_use_unverified_provider.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "backend"))
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
|
||||
from app.services.provider_service import find_or_create_provider_by_name
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("Test-FIND_OR_CREATE_PROVIDER")
|
||||
|
||||
TEST_USER_ID = 1
|
||||
TEST_OTHER_USER_ID = 2
|
||||
TEST_PROVIDER_NAME = "Test Unverified Provider Card362"
|
||||
|
||||
|
||||
async def cleanup_test_data(db: AsyncSession):
|
||||
"""Clean up any leftover test data."""
|
||||
await db.execute(
|
||||
delete(ServiceProvider).where(ServiceProvider.name == TEST_PROVIDER_NAME)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def test_find_or_create_provider():
|
||||
"""
|
||||
Test scenario:
|
||||
1. User A (TEST_USER_ID) creates a provider via find_or_create_provider_by_name()
|
||||
-> Should return (provider, True) - new provider created
|
||||
2. User A calls the same provider again
|
||||
-> Should return (provider, False) - existing provider found
|
||||
3. User B (TEST_OTHER_USER_ID) calls the same provider
|
||||
-> Should return (provider, False) - existing provider found
|
||||
"""
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
# Clean up first
|
||||
await cleanup_test_data(db)
|
||||
|
||||
# =========================================================
|
||||
# STEP 1: User A creates a new provider (created=True)
|
||||
# =========================================================
|
||||
logger.info("=" * 60)
|
||||
logger.info("STEP 1: User A creates a new provider")
|
||||
logger.info("=" * 60)
|
||||
|
||||
provider, created = await find_or_create_provider_by_name(
|
||||
db=db,
|
||||
name=TEST_PROVIDER_NAME,
|
||||
added_by_user_id=TEST_USER_ID,
|
||||
)
|
||||
|
||||
assert provider is not None, "Provider should be created"
|
||||
assert created is True, (
|
||||
f"Expected created=True for new provider, got {created}"
|
||||
)
|
||||
assert provider.status == ModerationStatus.pending, (
|
||||
f"Expected pending status, got {provider.status}"
|
||||
)
|
||||
assert provider.added_by_user_id == TEST_USER_ID, (
|
||||
f"Expected added_by_user_id={TEST_USER_ID}, got {provider.added_by_user_id}"
|
||||
)
|
||||
logger.info(f"✅ STEP 1 PASS: created={created}, provider_id={provider.id}")
|
||||
|
||||
# =========================================================
|
||||
# STEP 2: User A uses the same provider again (created=False)
|
||||
# =========================================================
|
||||
logger.info("=" * 60)
|
||||
logger.info("STEP 2: User A uses the same provider again")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Re-fetch to get clean state
|
||||
await db.refresh(provider)
|
||||
|
||||
provider2, created2 = await find_or_create_provider_by_name(
|
||||
db=db,
|
||||
name=TEST_PROVIDER_NAME,
|
||||
added_by_user_id=TEST_USER_ID,
|
||||
)
|
||||
|
||||
assert provider2.id == provider.id, "Should be the same provider"
|
||||
assert created2 is False, (
|
||||
f"Expected created=False for existing provider, got {created2}"
|
||||
)
|
||||
logger.info(f"✅ STEP 2 PASS: created={created2}")
|
||||
|
||||
# =========================================================
|
||||
# STEP 3: User B uses the same provider (created=False)
|
||||
# =========================================================
|
||||
logger.info("=" * 60)
|
||||
logger.info("STEP 3: User B uses the same provider")
|
||||
logger.info("=" * 60)
|
||||
|
||||
provider3, created3 = await find_or_create_provider_by_name(
|
||||
db=db,
|
||||
name=TEST_PROVIDER_NAME,
|
||||
added_by_user_id=TEST_OTHER_USER_ID,
|
||||
)
|
||||
|
||||
assert provider3.id == provider.id, "Should be the same provider"
|
||||
assert created3 is False, (
|
||||
f"Expected created=False for existing provider, got {created3}"
|
||||
)
|
||||
logger.info(f"✅ STEP 3 PASS: created={created3}")
|
||||
|
||||
# =========================================================
|
||||
# SUMMARY
|
||||
# =========================================================
|
||||
logger.info("=" * 60)
|
||||
logger.info("🎉 ALL TESTS PASSED!")
|
||||
logger.info(f" - New provider creation (created=True): ✅")
|
||||
logger.info(f" - Existing provider reuse (created=False): ✅")
|
||||
logger.info(f" - Cross-user existing provider (created=False): ✅")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Clean up
|
||||
await cleanup_test_data(db)
|
||||
|
||||
except AssertionError as e:
|
||||
logger.error(f"❌ TEST FAILED: {e}")
|
||||
await db.rollback()
|
||||
await cleanup_test_data(db)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ UNEXPECTED ERROR: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
await cleanup_test_data(db)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_find_or_create_provider())
|
||||
@@ -0,0 +1,28 @@
|
||||
"""feat: add vehicle classes and specializations to providers
|
||||
|
||||
Revision ID: 2387cd18fde7
|
||||
Revises: 7cd9b8a65ce8
|
||||
Create Date: 2026-07-01 00:38:21.828081
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '2387cd18fde7'
|
||||
down_revision: Union[str, Sequence[str], None] = '7cd9b8a65ce8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
pass
|
||||
@@ -58,7 +58,7 @@ async def seed():
|
||||
entry = BodyTypeDictionary(
|
||||
vehicle_class=bt["vehicle_class"],
|
||||
code=bt["code"],
|
||||
name_hu=bt["name_hu"],
|
||||
name_i18n={"hu": bt["name_hu"]},
|
||||
)
|
||||
session.add(entry)
|
||||
|
||||
|
||||
@@ -1030,11 +1030,9 @@ async def _flatten_and_insert(db, parent_id: int | None, level: int, path_prefix
|
||||
|
||||
tag = ExpertiseTag(
|
||||
key=key,
|
||||
name_hu=node["name_hu"],
|
||||
name_en=node["name_en"],
|
||||
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
|
||||
category=node.get("category", ""),
|
||||
search_keywords=node.get("search_keywords", []),
|
||||
description=node.get("description", ""),
|
||||
is_official=True,
|
||||
level=level,
|
||||
parent_id=parent_id,
|
||||
@@ -1095,11 +1093,9 @@ async def seed_expertise_taxonomy():
|
||||
for key, node in UNIVERSAL_CATEGORIES.items():
|
||||
tag = ExpertiseTag(
|
||||
key=key,
|
||||
name_hu=node["name_hu"],
|
||||
name_en=node["name_en"],
|
||||
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
|
||||
category=node.get("category", ""),
|
||||
search_keywords=node.get("search_keywords", []),
|
||||
description=node.get("description", ""),
|
||||
is_official=True,
|
||||
level=1,
|
||||
parent_id=None,
|
||||
|
||||
131
backend/static/locales/cz.json
Normal file
131
backend/static/locales/cz.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"AUTH": {
|
||||
"LOGIN": {
|
||||
"SUCCESS": "Přihlášení úspěšné. Vítejte zpět!"
|
||||
},
|
||||
"ERROR": {
|
||||
"EMAIL_EXISTS": "Tento email je již zaregistrován.",
|
||||
"UNAUTHORIZED": "Neoprávněný přístup."
|
||||
}
|
||||
},
|
||||
"SENTINEL": {
|
||||
"LOCK": {
|
||||
"MSG": "Účet byl z bezpečnostních důvodů uzamčen."
|
||||
},
|
||||
"APPROVAL": {
|
||||
"REQUIRED": "Vyžadováno schválení"
|
||||
}
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Úspěšně uloženo!"
|
||||
},
|
||||
"EMAIL": {
|
||||
"REG": {
|
||||
"SUBJECT": "Potvrďte svou registraci - Service Finder",
|
||||
"GREETING": "Ahoj {{first_name}}!",
|
||||
"BODY": "Děkujeme za registraci! Kliknutím na tlačítko níže aktivujete svůj účet:",
|
||||
"BUTTON": "Aktivovat účet",
|
||||
"FOOTER": "Pokud jste se nezaregistrovali, ignorujte prosím tento email."
|
||||
},
|
||||
"PWD_RESET": {
|
||||
"SUBJECT": "Žádost o obnovení hesla",
|
||||
"GREETING": "Ahoj!",
|
||||
"BODY": "Požádali jste o obnovení hesla. Kliknutím na tlačítko níže nastavíte nové heslo:",
|
||||
"BUTTON": "Obnovit heslo",
|
||||
"FOOTER": "Pokud jste o obnovení hesla nežádali, ignorujte prosím tento email."
|
||||
},
|
||||
"TEST": {
|
||||
"SUBJECT": "🧪 Testovací email - Service Finder",
|
||||
"GREETING": "Ahoj {{first_name}}!",
|
||||
"BODY": "Toto je testovací email ze systému Service Finder. Pokud to vidíte, odesílání emailů funguje!",
|
||||
"BUTTON": "Přejít na Dashboard",
|
||||
"FOOTER": "Service Finder - Testovací systémová zpráva"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organizace nenalezena.",
|
||||
"INVALID_TAX_FORMAT": "Neplatný formát maďarského daňového čísla!",
|
||||
"TAX_LOOKUP_FAILED": "Ověření daňového čísla selhalo.",
|
||||
"FORBIDDEN": "Nemáte oprávnění k přístupu k této organizaci.",
|
||||
"INVALID_ROLE": "Neplatná role.",
|
||||
"CANNOT_REMOVE_OWNER": "Vlastník nemůže být odstraněn z organizace.",
|
||||
"CANNOT_DEMOTE_OWNER": "Role vlastníka nemůže být změněna.",
|
||||
"ALREADY_MEMBER": "Již jste členem této organizace.",
|
||||
"NO_ACTIVE_ADMIN": "Tato organizace nemá aktivního správce. Použijte koncový bod /claim.",
|
||||
"HAS_ACTIVE_ADMIN": "Tato organizace má aktivního správce. Použijte koncový bod /join-request.",
|
||||
"EMAIL_MISMATCH": "Email neodpovídá emailu vlastníka organizace.",
|
||||
"INVALID_CODE": "Neplatný nebo expirovaný kód.",
|
||||
"CODE_ORG_MISMATCH": "Kód nepatří k této organizaci.",
|
||||
"INVITE_EXPIRED": "Pozvánka vypršela.",
|
||||
"INVITE_INVALID": "Neplatný token pozvánky."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Společnost úspěšně vytvořena a zaregistrována.",
|
||||
"MEMBER_REMOVED": "Člen úspěšně odstraněn.",
|
||||
"ROLE_UPDATED": "Role úspěšně aktualizována.",
|
||||
"UPDATED": "Data organizace úspěšně aktualizována.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Vizuální nastavení úspěšně aktualizováno."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Pozvánka úspěšně vytvořena.",
|
||||
"ACCEPTED": "Pozvánka přijata.",
|
||||
"REVOKED": "Pozvánka odvolána."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Vaše žádost o připojení byla odeslána. Čeká na schválení správcem."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "6místný ověřovací kód byl odeslán na email vlastníka organizace.",
|
||||
"SUCCESS": "Úspěšně jste převzali organizaci. Nyní jste správcem."
|
||||
}
|
||||
},
|
||||
"REGISTRATION_DOCUMENTS": {
|
||||
"CERTIFICATE_NUMBER": "Číslo osvědčení o registraci",
|
||||
"CERTIFICATE_VALIDITY": "Platnost osvědčení o registraci",
|
||||
"DOCUMENT_NUMBER": "Číslo dokladu vozidla",
|
||||
"TITLE": "Osvědčení o registraci & Doklad vozidla"
|
||||
},
|
||||
"GAMIFICATION": {
|
||||
"SUBMIT_SERVICE": {
|
||||
"SELF_DEFENSE_BLOCKED": "Z důvodu omezení sebeobrany nemůžete odesílat nová servisní data.",
|
||||
"SUCCESS": "Servis odeslán do systému k analýze!",
|
||||
"POINTS_ERROR": "Chyba při bodování: {error}",
|
||||
"SUBMIT_ERROR": "Chyba při odesílání: {error}"
|
||||
},
|
||||
"QUIZ": {
|
||||
"ALREADY_PLAYED": "Dnes jste již hráli denní kvíz. Vraťte se zítra!",
|
||||
"NO_QUIZ_AVAILABLE": "Dnes není k dispozici žádný kvíz.",
|
||||
"ALREADY_COMPLETED": "Denní kvíz byl dnes již označen jako dokončený.",
|
||||
"COMPLETED_SUCCESS": "Denní kvíz označen jako dokončený pro dnešek.",
|
||||
"QUESTION_NOT_FOUND": "Otázka nenalezena.",
|
||||
"POINTS_FAILED": "Nepodařilo se udělit body za kvíz: {error}"
|
||||
},
|
||||
"BADGE": {
|
||||
"NOT_FOUND": "Odznak nenalezen.",
|
||||
"ALREADY_AWARDED": "Odznak již byl tomuto uživateli udělen.",
|
||||
"AWARDED_SUCCESS": "Odznak '{badge_name}' byl udělen uživateli.",
|
||||
"POINTS_FAILED": "Nepodařilo se udělit body za odznak: {error}"
|
||||
},
|
||||
"SEASON": {
|
||||
"NOT_FOUND": "Sezóna nenalezena.",
|
||||
"NO_ACTIVE": "Nebyla nalezena žádná aktivní sezóna."
|
||||
},
|
||||
"ACHIEVEMENTS": {
|
||||
"XP_NOVICE": "Začátečník",
|
||||
"XP_APPRENTICE": "Učeň",
|
||||
"XP_EXPERT": "Expert",
|
||||
"XP_MASTER": "Mistr",
|
||||
"XP_NOVICE_DESC": "Získej 100 XP",
|
||||
"XP_APPRENTICE_DESC": "Získej 500 XP",
|
||||
"XP_EXPERT_DESC": "Získej 2000 XP",
|
||||
"XP_MASTER_DESC": "Získej 5000 XP",
|
||||
"QUIZ_BEGINNER": "Kvízový začátečník",
|
||||
"QUIZ_ENTHUSIAST": "Kvízový nadšenec",
|
||||
"QUIZ_MASTER": "Kvízový mistr",
|
||||
"QUIZ_BEGINNER_DESC": "Získej 50 kvízových bodů",
|
||||
"QUIZ_ENTHUSIAST_DESC": "Získej 200 kvízových bodů",
|
||||
"QUIZ_MASTER_DESC": "Získej 500 kvízových bodů"
|
||||
}
|
||||
}
|
||||
}
|
||||
131
backend/static/locales/de.json
Normal file
131
backend/static/locales/de.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"AUTH": {
|
||||
"LOGIN": {
|
||||
"SUCCESS": "Anmeldung erfolgreich. Willkommen zurück!"
|
||||
},
|
||||
"ERROR": {
|
||||
"EMAIL_EXISTS": "Diese E-Mail ist bereits registriert.",
|
||||
"UNAUTHORIZED": "Unbefugter Zugriff."
|
||||
}
|
||||
},
|
||||
"SENTINEL": {
|
||||
"LOCK": {
|
||||
"MSG": "Konto aus Sicherheitsgründen gesperrt."
|
||||
},
|
||||
"APPROVAL": {
|
||||
"REQUIRED": "Genehmigung erforderlich"
|
||||
}
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Erfolgreich gespeichert!"
|
||||
},
|
||||
"EMAIL": {
|
||||
"REG": {
|
||||
"SUBJECT": "Bestätigen Sie Ihre Registrierung - Service Finder",
|
||||
"GREETING": "Hallo {{first_name}}!",
|
||||
"BODY": "Vielen Dank für Ihre Registrierung! Klicken Sie auf die Schaltfläche unten, um Ihr Konto zu aktivieren:",
|
||||
"BUTTON": "Konto aktivieren",
|
||||
"FOOTER": "Wenn Sie sich nicht registriert haben, ignorieren Sie bitte diese E-Mail."
|
||||
},
|
||||
"PWD_RESET": {
|
||||
"SUBJECT": "Passwort zurücksetzen",
|
||||
"GREETING": "Hallo!",
|
||||
"BODY": "Sie haben eine Passwortzurücksetzung angefordert. Klicken Sie auf die Schaltfläche unten, um ein neues Passwort festzulegen:",
|
||||
"BUTTON": "Passwort zurücksetzen",
|
||||
"FOOTER": "Wenn Sie keine Passwortzurücksetzung angefordert haben, ignorieren Sie bitte diese E-Mail."
|
||||
},
|
||||
"TEST": {
|
||||
"SUBJECT": "🧪 Test-E-Mail - Service Finder",
|
||||
"GREETING": "Hallo {{first_name}}!",
|
||||
"BODY": "Dies ist eine Test-E-Mail vom Service Finder System. Wenn Sie dies sehen, funktioniert der E-Mail-Versand!",
|
||||
"BUTTON": "Zum Dashboard",
|
||||
"FOOTER": "Service Finder - Test-Systemnachricht"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organisation nicht gefunden.",
|
||||
"INVALID_TAX_FORMAT": "Ungültiges ungarisches Steuernummernformat!",
|
||||
"TAX_LOOKUP_FAILED": "Steuernummernprüfung fehlgeschlagen.",
|
||||
"FORBIDDEN": "Sie haben keine Berechtigung für diese Organisation.",
|
||||
"INVALID_ROLE": "Ungültige Rolle angegeben.",
|
||||
"CANNOT_REMOVE_OWNER": "Der Eigentümer kann nicht aus der Organisation entfernt werden.",
|
||||
"CANNOT_DEMOTE_OWNER": "Die Rolle des Eigentümers kann nicht geändert werden.",
|
||||
"ALREADY_MEMBER": "Sie sind bereits Mitglied dieser Organisation.",
|
||||
"NO_ACTIVE_ADMIN": "Diese Organisation hat keinen aktiven Administrator. Verwenden Sie den /claim-Endpunkt.",
|
||||
"HAS_ACTIVE_ADMIN": "Diese Organisation hat einen aktiven Administrator. Verwenden Sie den /join-request-Endpunkt.",
|
||||
"EMAIL_MISMATCH": "Die E-Mail stimmt nicht mit der E-Mail des Organisationsinhabers überein.",
|
||||
"INVALID_CODE": "Ungültiger oder abgelaufener Code.",
|
||||
"CODE_ORG_MISMATCH": "Der Code gehört nicht zu dieser Organisation.",
|
||||
"INVITE_EXPIRED": "Die Einladung ist abgelaufen.",
|
||||
"INVITE_INVALID": "Ungültiges Einladungstoken."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Unternehmen erfolgreich erstellt und registriert.",
|
||||
"MEMBER_REMOVED": "Mitglied erfolgreich entfernt.",
|
||||
"ROLE_UPDATED": "Rolle erfolgreich aktualisiert.",
|
||||
"UPDATED": "Organisationsdaten erfolgreich aktualisiert.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Visuelle Einstellungen erfolgreich aktualisiert."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Einladung erfolgreich erstellt.",
|
||||
"ACCEPTED": "Einladung angenommen.",
|
||||
"REVOKED": "Einladung widerrufen."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Ihre Beitrittsanfrage wurde übermittelt. Warten auf Administratorgenehmigung."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "Ein 6-stelliger Bestätigungscode wurde an die E-Mail des Organisationsinhabers gesendet.",
|
||||
"SUCCESS": "Sie haben die Organisation erfolgreich übernommen. Sie sind jetzt der Administrator."
|
||||
}
|
||||
},
|
||||
"REGISTRATION_DOCUMENTS": {
|
||||
"CERTIFICATE_NUMBER": "Zulassungsbescheinigung Nummer",
|
||||
"CERTIFICATE_VALIDITY": "Gültigkeit der Zulassungsbescheinigung",
|
||||
"DOCUMENT_NUMBER": "Fahrzeugdokument Nummer",
|
||||
"TITLE": "Zulassungsbescheinigung & Fahrzeugdokument"
|
||||
},
|
||||
"GAMIFICATION": {
|
||||
"SUBMIT_SERVICE": {
|
||||
"SELF_DEFENSE_BLOCKED": "Sie können aufgrund einer Selbstverteidigungseinschränkung keine neuen Servicedaten einreichen.",
|
||||
"SUCCESS": "Service zur Analyse an das System übermittelt!",
|
||||
"POINTS_ERROR": "Fehler bei der Bewertung: {error}",
|
||||
"SUBMIT_ERROR": "Fehler bei der Übermittlung: {error}"
|
||||
},
|
||||
"QUIZ": {
|
||||
"ALREADY_PLAYED": "Sie haben heute bereits am täglichen Quiz teilgenommen. Kommen Sie morgen wieder!",
|
||||
"NO_QUIZ_AVAILABLE": "Heute ist kein Quiz verfügbar.",
|
||||
"ALREADY_COMPLETED": "Das tägliche Quiz wurde heute bereits als abgeschlossen markiert.",
|
||||
"COMPLETED_SUCCESS": "Tägliches Quiz wurde heute als abgeschlossen markiert.",
|
||||
"QUESTION_NOT_FOUND": "Frage nicht gefunden.",
|
||||
"POINTS_FAILED": "Fehler bei der Vergabe von Quizpunkten: {error}"
|
||||
},
|
||||
"BADGE": {
|
||||
"NOT_FOUND": "Abzeichen nicht gefunden.",
|
||||
"ALREADY_AWARDED": "Abzeichen wurde diesem Benutzer bereits verliehen.",
|
||||
"AWARDED_SUCCESS": "Abzeichen '{badge_name}' wurde dem Benutzer verliehen.",
|
||||
"POINTS_FAILED": "Fehler bei der Vergabe von Abzeichenpunkten: {error}"
|
||||
},
|
||||
"SEASON": {
|
||||
"NOT_FOUND": "Saison nicht gefunden.",
|
||||
"NO_ACTIVE": "Keine aktive Saison gefunden."
|
||||
},
|
||||
"ACHIEVEMENTS": {
|
||||
"XP_NOVICE": "Anfänger",
|
||||
"XP_APPRENTICE": "Lehrling",
|
||||
"XP_EXPERT": "Experte",
|
||||
"XP_MASTER": "Meister",
|
||||
"XP_NOVICE_DESC": "Sammle 100 XP",
|
||||
"XP_APPRENTICE_DESC": "Sammle 500 XP",
|
||||
"XP_EXPERT_DESC": "Sammle 2000 XP",
|
||||
"XP_MASTER_DESC": "Sammle 5000 XP",
|
||||
"QUIZ_BEGINNER": "Quiz-Anfänger",
|
||||
"QUIZ_ENTHUSIAST": "Quiz-Enthusiast",
|
||||
"QUIZ_MASTER": "Quiz-Meister",
|
||||
"QUIZ_BEGINNER_DESC": "Sammle 50 Quizpunkte",
|
||||
"QUIZ_ENTHUSIAST_DESC": "Sammle 200 Quizpunkte",
|
||||
"QUIZ_MASTER_DESC": "Sammle 500 Quizpunkte"
|
||||
}
|
||||
}
|
||||
}
|
||||
131
backend/static/locales/fr.json
Normal file
131
backend/static/locales/fr.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"AUTH": {
|
||||
"LOGIN": {
|
||||
"SUCCESS": "Connexion réussie. Bon retour parmi nous!"
|
||||
},
|
||||
"ERROR": {
|
||||
"EMAIL_EXISTS": "Cet email est déjà enregistré.",
|
||||
"UNAUTHORIZED": "Accès non autorisé."
|
||||
}
|
||||
},
|
||||
"SENTINEL": {
|
||||
"LOCK": {
|
||||
"MSG": "Compte verrouillé pour des raisons de sécurité."
|
||||
},
|
||||
"APPROVAL": {
|
||||
"REQUIRED": "Approbation requise"
|
||||
}
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Enregistré avec succès!"
|
||||
},
|
||||
"EMAIL": {
|
||||
"REG": {
|
||||
"SUBJECT": "Confirmez votre inscription - Service Finder",
|
||||
"GREETING": "Bonjour {{first_name}}!",
|
||||
"BODY": "Merci de vous être inscrit! Cliquez sur le bouton ci-dessous pour activer votre compte:",
|
||||
"BUTTON": "Activer le compte",
|
||||
"FOOTER": "Si vous ne vous êtes pas inscrit, veuillez ignorer cet email."
|
||||
},
|
||||
"PWD_RESET": {
|
||||
"SUBJECT": "Demande de réinitialisation du mot de passe",
|
||||
"GREETING": "Bonjour!",
|
||||
"BODY": "Vous avez demandé une réinitialisation de mot de passe. Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe:",
|
||||
"BUTTON": "Réinitialiser le mot de passe",
|
||||
"FOOTER": "Si vous n'avez pas demandé de réinitialisation, veuillez ignorer cet email."
|
||||
},
|
||||
"TEST": {
|
||||
"SUBJECT": "🧪 Email de test - Service Finder",
|
||||
"GREETING": "Bonjour {{first_name}}!",
|
||||
"BODY": "Ceci est un email de test du système Service Finder. Si vous voyez ceci, l'envoi d'emails fonctionne!",
|
||||
"BUTTON": "Aller au tableau de bord",
|
||||
"FOOTER": "Service Finder - Message système de test"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organisation non trouvée.",
|
||||
"INVALID_TAX_FORMAT": "Format de numéro de TVA hongrois invalide!",
|
||||
"TAX_LOOKUP_FAILED": "La vérification du numéro de TVA a échoué.",
|
||||
"FORBIDDEN": "Vous n'avez pas la permission d'accéder à cette organisation.",
|
||||
"INVALID_ROLE": "Rôle spécifié invalide.",
|
||||
"CANNOT_REMOVE_OWNER": "Le propriétaire ne peut pas être retiré de l'organisation.",
|
||||
"CANNOT_DEMOTE_OWNER": "Le rôle du propriétaire ne peut pas être modifié.",
|
||||
"ALREADY_MEMBER": "Vous êtes déjà membre de cette organisation.",
|
||||
"NO_ACTIVE_ADMIN": "Cette organisation n'a pas d'administrateur actif. Utilisez le point de terminaison /claim.",
|
||||
"HAS_ACTIVE_ADMIN": "Cette organisation a un administrateur actif. Utilisez le point de terminaison /join-request.",
|
||||
"EMAIL_MISMATCH": "L'email ne correspond pas à l'email du propriétaire de l'organisation.",
|
||||
"INVALID_CODE": "Code invalide ou expiré.",
|
||||
"CODE_ORG_MISMATCH": "Le code n'appartient pas à cette organisation.",
|
||||
"INVITE_EXPIRED": "L'invitation a expiré.",
|
||||
"INVITE_INVALID": "Jeton d'invitation invalide."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Entreprise créée et enregistrée avec succès.",
|
||||
"MEMBER_REMOVED": "Membre supprimé avec succès.",
|
||||
"ROLE_UPDATED": "Rôle mis à jour avec succès.",
|
||||
"UPDATED": "Données de l'organisation mises à jour avec succès.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Paramètres visuels mis à jour avec succès."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Invitation créée avec succès.",
|
||||
"ACCEPTED": "Invitation acceptée.",
|
||||
"REVOKED": "Invitation révoquée."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Votre demande d'adhésion a été soumise. En attente de l'approbation de l'administrateur."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "Un code de vérification à 6 chiffres a été envoyé à l'email du propriétaire de l'organisation.",
|
||||
"SUCCESS": "Vous avez repris l'organisation avec succès. Vous êtes maintenant l'administrateur."
|
||||
}
|
||||
},
|
||||
"REGISTRATION_DOCUMENTS": {
|
||||
"CERTIFICATE_NUMBER": "Numéro du certificat d'immatriculation",
|
||||
"CERTIFICATE_VALIDITY": "Validité du certificat d'immatriculation",
|
||||
"DOCUMENT_NUMBER": "Numéro du document du véhicule",
|
||||
"TITLE": "Certificat d'immatriculation & Document du véhicule"
|
||||
},
|
||||
"GAMIFICATION": {
|
||||
"SUBMIT_SERVICE": {
|
||||
"SELF_DEFENSE_BLOCKED": "Vous ne pouvez pas soumettre de nouvelles données de service en raison d'une restriction d'autodéfense.",
|
||||
"SUCCESS": "Service soumis au système pour analyse!",
|
||||
"POINTS_ERROR": "Erreur lors de l'attribution des points: {error}",
|
||||
"SUBMIT_ERROR": "Erreur lors de la soumission: {error}"
|
||||
},
|
||||
"QUIZ": {
|
||||
"ALREADY_PLAYED": "Vous avez déjà participé au quiz quotidien aujourd'hui. Revenez demain!",
|
||||
"NO_QUIZ_AVAILABLE": "Aucun quiz disponible aujourd'hui.",
|
||||
"ALREADY_COMPLETED": "Le quiz quotidien a déjà été marqué comme terminé aujourd'hui.",
|
||||
"COMPLETED_SUCCESS": "Quiz quotidien marqué comme terminé pour aujourd'hui.",
|
||||
"QUESTION_NOT_FOUND": "Question non trouvée.",
|
||||
"POINTS_FAILED": "Échec de l'attribution des points de quiz: {error}"
|
||||
},
|
||||
"BADGE": {
|
||||
"NOT_FOUND": "Badge non trouvé.",
|
||||
"ALREADY_AWARDED": "Badge déjà attribué à cet utilisateur.",
|
||||
"AWARDED_SUCCESS": "Badge '{badge_name}' attribué à l'utilisateur.",
|
||||
"POINTS_FAILED": "Échec de l'attribution des points de badge: {error}"
|
||||
},
|
||||
"SEASON": {
|
||||
"NOT_FOUND": "Saison non trouvée.",
|
||||
"NO_ACTIVE": "Aucune saison active trouvée."
|
||||
},
|
||||
"ACHIEVEMENTS": {
|
||||
"XP_NOVICE": "Novice",
|
||||
"XP_APPRENTICE": "Apprenti",
|
||||
"XP_EXPERT": "Expert",
|
||||
"XP_MASTER": "Maître",
|
||||
"XP_NOVICE_DESC": "Gagnez 100 XP",
|
||||
"XP_APPRENTICE_DESC": "Gagnez 500 XP",
|
||||
"XP_EXPERT_DESC": "Gagnez 2000 XP",
|
||||
"XP_MASTER_DESC": "Gagnez 5000 XP",
|
||||
"QUIZ_BEGINNER": "Débutant Quiz",
|
||||
"QUIZ_ENTHUSIAST": "Passionné de Quiz",
|
||||
"QUIZ_MASTER": "Maître du Quiz",
|
||||
"QUIZ_BEGINNER_DESC": "Gagnez 50 points de quiz",
|
||||
"QUIZ_ENTHUSIAST_DESC": "Gagnez 200 points de quiz",
|
||||
"QUIZ_MASTER_DESC": "Gagnez 500 points de quiz"
|
||||
}
|
||||
}
|
||||
}
|
||||
131
backend/static/locales/ro.json
Normal file
131
backend/static/locales/ro.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"AUTH": {
|
||||
"LOGIN": {
|
||||
"SUCCESS": "Autentificare reușită. Bun venit înapoi!"
|
||||
},
|
||||
"ERROR": {
|
||||
"EMAIL_EXISTS": "Acest email este deja înregistrat.",
|
||||
"UNAUTHORIZED": "Acces neautorizat."
|
||||
}
|
||||
},
|
||||
"SENTINEL": {
|
||||
"LOCK": {
|
||||
"MSG": "Cont blocat din motive de securitate."
|
||||
},
|
||||
"APPROVAL": {
|
||||
"REQUIRED": "Aprobare necesară"
|
||||
}
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Salvat cu succes!"
|
||||
},
|
||||
"EMAIL": {
|
||||
"REG": {
|
||||
"SUBJECT": "Confirmați înregistrarea - Service Finder",
|
||||
"GREETING": "Salut {{first_name}}!",
|
||||
"BODY": "Vă mulțumim pentru înregistrare! Faceți clic pe butonul de mai jos pentru a vă activa contul:",
|
||||
"BUTTON": "Activează Contul",
|
||||
"FOOTER": "Dacă nu v-ați înregistrat, vă rugăm să ignorați acest email."
|
||||
},
|
||||
"PWD_RESET": {
|
||||
"SUBJECT": "Solicitare resetare parolă",
|
||||
"GREETING": "Salut!",
|
||||
"BODY": "Ați solicitat resetarea parolei. Faceți clic pe butonul de mai jos pentru a seta o nouă parolă:",
|
||||
"BUTTON": "Resetează Parola",
|
||||
"FOOTER": "Dacă nu ați solicitat resetarea parolei, vă rugăm să ignorați acest email."
|
||||
},
|
||||
"TEST": {
|
||||
"SUBJECT": "🧪 Email de test - Service Finder",
|
||||
"GREETING": "Salut {{first_name}}!",
|
||||
"BODY": "Acesta este un email de test de la sistemul Service Finder. Dacă vedeți acest mesaj, trimiterea de emailuri funcționează!",
|
||||
"BUTTON": "Mergi la Dashboard",
|
||||
"FOOTER": "Service Finder - Mesaj de sistem de test"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organizație negăsită.",
|
||||
"INVALID_TAX_FORMAT": "Format invalid al numărului de TVA maghiar!",
|
||||
"TAX_LOOKUP_FAILED": "Verificarea numărului de TVA a eșuat.",
|
||||
"FORBIDDEN": "Nu aveți permisiunea de a accesa această organizație.",
|
||||
"INVALID_ROLE": "Rol specificat invalid.",
|
||||
"CANNOT_REMOVE_OWNER": "Proprietarul nu poate fi eliminat din organizație.",
|
||||
"CANNOT_DEMOTE_OWNER": "Rolul proprietarului nu poate fi modificat.",
|
||||
"ALREADY_MEMBER": "Sunteți deja membru al acestei organizații.",
|
||||
"NO_ACTIVE_ADMIN": "Această organizație nu are un administrator activ. Utilizați punctul final /claim.",
|
||||
"HAS_ACTIVE_ADMIN": "Această organizație are un administrator activ. Utilizați punctul final /join-request.",
|
||||
"EMAIL_MISMATCH": "Emailul nu corespunde cu emailul proprietarului organizației.",
|
||||
"INVALID_CODE": "Cod invalid sau expirat.",
|
||||
"CODE_ORG_MISMATCH": "Codul nu aparține acestei organizații.",
|
||||
"INVITE_EXPIRED": "Invitația a expirat.",
|
||||
"INVITE_INVALID": "Token de invitație invalid."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Companie creată și înregistrată cu succes.",
|
||||
"MEMBER_REMOVED": "Membru eliminat cu succes.",
|
||||
"ROLE_UPDATED": "Rol actualizat cu succes.",
|
||||
"UPDATED": "Datele organizației actualizate cu succes.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Setările vizuale actualizate cu succes."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Invitație creată cu succes.",
|
||||
"ACCEPTED": "Invitație acceptată.",
|
||||
"REVOKED": "Invitație revocată."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Cererea dvs. de aderare a fost trimisă. Se așteaptă aprobarea administratorului."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "Un cod de verificare din 6 cifre a fost trimis pe emailul proprietarului organizației.",
|
||||
"SUCCESS": "Ați preluat cu succes organizația. Acum sunteți administratorul."
|
||||
}
|
||||
},
|
||||
"REGISTRATION_DOCUMENTS": {
|
||||
"CERTIFICATE_NUMBER": "Numărul certificatului de înmatriculare",
|
||||
"CERTIFICATE_VALIDITY": "Valabilitatea certificatului de înmatriculare",
|
||||
"DOCUMENT_NUMBER": "Numărul documentului vehiculului",
|
||||
"TITLE": "Certificat de înmatriculare & Document vehicul"
|
||||
},
|
||||
"GAMIFICATION": {
|
||||
"SUBMIT_SERVICE": {
|
||||
"SELF_DEFENSE_BLOCKED": "Nu puteți trimite date noi de service din cauza unei restricții de autoapărare.",
|
||||
"SUCCESS": "Service trimis sistemului pentru analiză!",
|
||||
"POINTS_ERROR": "Eroare la punctare: {error}",
|
||||
"SUBMIT_ERROR": "Eroare la trimitere: {error}"
|
||||
},
|
||||
"QUIZ": {
|
||||
"ALREADY_PLAYED": "Ați jucat deja quizul zilnic astăzi. Reveniți mâine!",
|
||||
"NO_QUIZ_AVAILABLE": "Niciun quiz disponibil pentru astăzi.",
|
||||
"ALREADY_COMPLETED": "Quizul zilnic a fost deja marcat ca finalizat astăzi.",
|
||||
"COMPLETED_SUCCESS": "Quizul zilnic marcat ca finalizat pentru astăzi.",
|
||||
"QUESTION_NOT_FOUND": "Întrebare negăsită.",
|
||||
"POINTS_FAILED": "Eroare la acordarea punctelor de quiz: {error}"
|
||||
},
|
||||
"BADGE": {
|
||||
"NOT_FOUND": "Insignă negăsită.",
|
||||
"ALREADY_AWARDED": "Insigna a fost deja acordată acestui utilizator.",
|
||||
"AWARDED_SUCCESS": "Insigna '{badge_name}' a fost acordată utilizatorului.",
|
||||
"POINTS_FAILED": "Eroare la acordarea punctelor insignei: {error}"
|
||||
},
|
||||
"SEASON": {
|
||||
"NOT_FOUND": "Sezon negăsit.",
|
||||
"NO_ACTIVE": "Niciun sezon activ găsit."
|
||||
},
|
||||
"ACHIEVEMENTS": {
|
||||
"XP_NOVICE": "Începător",
|
||||
"XP_APPRENTICE": "Ucenic",
|
||||
"XP_EXPERT": "Expert",
|
||||
"XP_MASTER": "Maestru",
|
||||
"XP_NOVICE_DESC": "Câștigă 100 XP",
|
||||
"XP_APPRENTICE_DESC": "Câștigă 500 XP",
|
||||
"XP_EXPERT_DESC": "Câștigă 2000 XP",
|
||||
"XP_MASTER_DESC": "Câștigă 5000 XP",
|
||||
"QUIZ_BEGINNER": "Începător Quiz",
|
||||
"QUIZ_ENTHUSIAST": "Entuziast Quiz",
|
||||
"QUIZ_MASTER": "Maestru Quiz",
|
||||
"QUIZ_BEGINNER_DESC": "Câștigă 50 de puncte de quiz",
|
||||
"QUIZ_ENTHUSIAST_DESC": "Câștigă 200 de puncte de quiz",
|
||||
"QUIZ_MASTER_DESC": "Câștigă 500 de puncte de quiz"
|
||||
}
|
||||
}
|
||||
}
|
||||
131
backend/static/locales/sk.json
Normal file
131
backend/static/locales/sk.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"AUTH": {
|
||||
"LOGIN": {
|
||||
"SUCCESS": "Prihlásenie úspešné. Vitajte späť!"
|
||||
},
|
||||
"ERROR": {
|
||||
"EMAIL_EXISTS": "Tento email je už zaregistrovaný.",
|
||||
"UNAUTHORIZED": "Neoprávnený prístup."
|
||||
}
|
||||
},
|
||||
"SENTINEL": {
|
||||
"LOCK": {
|
||||
"MSG": "Účet bol z bezpečnostných dôvodov uzamknutý."
|
||||
},
|
||||
"APPROVAL": {
|
||||
"REQUIRED": "Vyžaduje sa schválenie"
|
||||
}
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Úspešne uložené!"
|
||||
},
|
||||
"EMAIL": {
|
||||
"REG": {
|
||||
"SUBJECT": "Potvrďte svoju registráciu - Service Finder",
|
||||
"GREETING": "Ahoj {{first_name}}!",
|
||||
"BODY": "Ďakujeme za registráciu! Kliknutím na tlačidlo nižšie aktivujete svoj účet:",
|
||||
"BUTTON": "Aktivovať účet",
|
||||
"FOOTER": "Ak ste sa nezaregistrovali, ignorujte prosím tento email."
|
||||
},
|
||||
"PWD_RESET": {
|
||||
"SUBJECT": "Žiadosť o obnovenie hesla",
|
||||
"GREETING": "Ahoj!",
|
||||
"BODY": "Požiadali ste o obnovenie hesla. Kliknutím na tlačidlo nižšie nastavíte nové heslo:",
|
||||
"BUTTON": "Obnoviť heslo",
|
||||
"FOOTER": "Ak ste nepožiadali o obnovenie hesla, ignorujte prosím tento email."
|
||||
},
|
||||
"TEST": {
|
||||
"SUBJECT": "🧪 Testovací email - Service Finder",
|
||||
"GREETING": "Ahoj {{first_name}}!",
|
||||
"BODY": "Toto je testovací email zo systému Service Finder. Ak to vidíte, odosielanie emailov funguje!",
|
||||
"BUTTON": "Prejsť na Dashboard",
|
||||
"FOOTER": "Service Finder - Testovacia systémová správa"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organizácia nenájdená.",
|
||||
"INVALID_TAX_FORMAT": "Neplatný formát maďarského daňového čísla!",
|
||||
"TAX_LOOKUP_FAILED": "Overenie daňového čísla zlyhalo.",
|
||||
"FORBIDDEN": "Nemáte oprávnenie na prístup k tejto organizácii.",
|
||||
"INVALID_ROLE": "Neplatná rola.",
|
||||
"CANNOT_REMOVE_OWNER": "Vlastník nemôže byť odstránený z organizácie.",
|
||||
"CANNOT_DEMOTE_OWNER": "Rola vlastníka nemôže byť zmenená.",
|
||||
"ALREADY_MEMBER": "Už ste členom tejto organizácie.",
|
||||
"NO_ACTIVE_ADMIN": "Táto organizácia nemá aktívneho správcu. Použite koncový bod /claim.",
|
||||
"HAS_ACTIVE_ADMIN": "Táto organizácia má aktívneho správcu. Použite koncový bod /join-request.",
|
||||
"EMAIL_MISMATCH": "Email nezodpovedá emailu vlastníka organizácie.",
|
||||
"INVALID_CODE": "Neplatný alebo expirovaný kód.",
|
||||
"CODE_ORG_MISMATCH": "Kód nepatrí k tejto organizácii.",
|
||||
"INVITE_EXPIRED": "Pozvánka vypršala.",
|
||||
"INVITE_INVALID": "Neplatný token pozvánky."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Spoločnosť úspešne vytvorená a zaregistrovaná.",
|
||||
"MEMBER_REMOVED": "Člen úspešne odstránený.",
|
||||
"ROLE_UPDATED": "Rola úspešne aktualizovaná.",
|
||||
"UPDATED": "Dáta organizácie úspešne aktualizované.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Vizuálne nastavenia úspešne aktualizované."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Pozvánka úspešne vytvorená.",
|
||||
"ACCEPTED": "Pozvánka prijatá.",
|
||||
"REVOKED": "Pozvánka odvolaná."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Vaša žiadosť o pripojenie bola odoslaná. Čaká na schválenie správcom."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "6-miestny overovací kód bol odoslaný na email vlastníka organizácie.",
|
||||
"SUCCESS": "Úspešne ste prevzali organizáciu. Teraz ste správcom."
|
||||
}
|
||||
},
|
||||
"REGISTRATION_DOCUMENTS": {
|
||||
"CERTIFICATE_NUMBER": "Číslo osvedčenia o registrácii",
|
||||
"CERTIFICATE_VALIDITY": "Platnosť osvedčenia o registrácii",
|
||||
"DOCUMENT_NUMBER": "Číslo dokladu vozidla",
|
||||
"TITLE": "Osvedčenie o registrácii & Doklad vozidla"
|
||||
},
|
||||
"GAMIFICATION": {
|
||||
"SUBMIT_SERVICE": {
|
||||
"SELF_DEFENSE_BLOCKED": "Z dôvodu obmedzenia sebaobrany nemôžete odosielať nové servisné dáta.",
|
||||
"SUCCESS": "Servis odoslaný do systému na analýzu!",
|
||||
"POINTS_ERROR": "Chyba pri bodovaní: {error}",
|
||||
"SUBMIT_ERROR": "Chyba pri odosielaní: {error}"
|
||||
},
|
||||
"QUIZ": {
|
||||
"ALREADY_PLAYED": "Dnes ste už hrali denný kvíz. Vráťte sa zajtra!",
|
||||
"NO_QUIZ_AVAILABLE": "Dnes nie je k dispozícii žiadny kvíz.",
|
||||
"ALREADY_COMPLETED": "Denný kvíz bol dnes už označený ako dokončený.",
|
||||
"COMPLETED_SUCCESS": "Denný kvíz označený ako dokončený pre dnešok.",
|
||||
"QUESTION_NOT_FOUND": "Otázka nenájdená.",
|
||||
"POINTS_FAILED": "Nepodarilo sa udeliť body za kvíz: {error}"
|
||||
},
|
||||
"BADGE": {
|
||||
"NOT_FOUND": "Odznak nenájdený.",
|
||||
"ALREADY_AWARDED": "Odznak už bol tomuto používateľovi udelený.",
|
||||
"AWARDED_SUCCESS": "Odznak '{badge_name}' bol udelený používateľovi.",
|
||||
"POINTS_FAILED": "Nepodarilo sa udeliť body za odznak: {error}"
|
||||
},
|
||||
"SEASON": {
|
||||
"NOT_FOUND": "Sezóna nenájdená.",
|
||||
"NO_ACTIVE": "Nebola nájdená žiadna aktívna sezóna."
|
||||
},
|
||||
"ACHIEVEMENTS": {
|
||||
"XP_NOVICE": "Začiatočník",
|
||||
"XP_APPRENTICE": "Učeň",
|
||||
"XP_EXPERT": "Expert",
|
||||
"XP_MASTER": "Majster",
|
||||
"XP_NOVICE_DESC": "Získaj 100 XP",
|
||||
"XP_APPRENTICE_DESC": "Získaj 500 XP",
|
||||
"XP_EXPERT_DESC": "Získaj 2000 XP",
|
||||
"XP_MASTER_DESC": "Získaj 5000 XP",
|
||||
"QUIZ_BEGINNER": "Kvízový začiatočník",
|
||||
"QUIZ_ENTHUSIAST": "Kvízový nadšenec",
|
||||
"QUIZ_MASTER": "Kvízový majster",
|
||||
"QUIZ_BEGINNER_DESC": "Získaj 50 kvízových bodov",
|
||||
"QUIZ_ENTHUSIAST_DESC": "Získaj 200 kvízových bodov",
|
||||
"QUIZ_MASTER_DESC": "Získaj 500 kvízových bodov"
|
||||
}
|
||||
}
|
||||
}
|
||||
53
backend/test_hard_delete.py
Normal file
53
backend/test_hard_delete.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import asyncio, uuid
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.identity.identity import User, UserRole, Wallet
|
||||
from app.models.system.audit import SecurityAuditLog, FinancialLedger
|
||||
from sqlalchemy import select, text
|
||||
|
||||
async def test():
|
||||
uid = str(uuid.uuid4())[:8]
|
||||
email = f'hard_delete_test_{uid}@example.com'
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Insert test user
|
||||
result = await db.execute(
|
||||
text("""
|
||||
INSERT INTO identity.users
|
||||
(email, hashed_password, role, subscription_plan, preferred_language, region_code, preferred_currency, ui_mode, is_vip, is_active, is_deleted, created_at, scope_level, custom_permissions, alternative_emails, email_history, visual_settings)
|
||||
VALUES
|
||||
(:email, 'fakehash', 'USER', 'FREE', 'en', 'HU', 'HUF', 'personal', false, false, false, NOW(), 'individual', '{}'::jsonb, '[]'::jsonb, '[]'::jsonb, '{"theme": "default", "primary_color": null, "wall_logo_url": null}'::jsonb)
|
||||
RETURNING id
|
||||
"""),
|
||||
{"email": email}
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
u = result.scalar_one_or_none()
|
||||
test_id = u.id
|
||||
print(f'Created test user #{test_id}: {u.email}')
|
||||
|
||||
# Verify no wallet/ledger
|
||||
wr = await db.execute(select(Wallet).where(Wallet.user_id == test_id).limit(1))
|
||||
lr = await db.execute(select(FinancialLedger).where(FinancialLedger.user_id == test_id).limit(1))
|
||||
print(f' Has Wallet: {wr.scalar_one_or_none() is not None}')
|
||||
print(f' Has Ledger: {lr.scalar_one_or_none() is not None}')
|
||||
|
||||
# Simulate the FIXED hard delete: delete first, then audit
|
||||
await db.delete(u)
|
||||
await db.flush()
|
||||
|
||||
audit = SecurityAuditLog(
|
||||
actor_id=1, target_id=test_id, action='hard_delete_user',
|
||||
is_critical=True,
|
||||
payload_before={'user_id': test_id, 'email': email, 'role': 'USER'},
|
||||
payload_after={'details': f'User #{test_id} permanently deleted by admin #1'},
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
|
||||
check = await db.get(User, test_id)
|
||||
print(f' User after delete: {check}')
|
||||
print('✅ Hard delete simulation SUCCESSFUL')
|
||||
|
||||
asyncio.run(test())
|
||||
209
docs/address_api_consistency_audit.md
Normal file
209
docs/address_api_consistency_audit.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# 🏗️ Cím API Végpontok Egységességi Auditja
|
||||
|
||||
**Dátum:** 2026-07-01
|
||||
**Auditor:** Rendszer-Architect
|
||||
**Cél:** Annak ellenőrzése, hogy a címet kezelő API végpontok be- és kimeneti formátuma egységes-e, és alkalmas-e egy központi címmodul (Address Module) kezelésére.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Összefoglaló
|
||||
|
||||
**Verdikt: 🔴 NEM EGYSÉGES — 3 párhuzamos címformátum létezik, súlyos inkonzisztenciákkal.**
|
||||
|
||||
A rendszerben jelenleg **3 féle cím séma** és **2 teljesen eltérő címkezelési stratégia** fut párhuzamosan. Egy központi címmodul bevezetése **sürgős refaktorálást** igényel.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Feltárt címformátumok
|
||||
|
||||
### 1. `AddressIn` / `AddressOut` (Unified P0 Refactored)
|
||||
**Fájl:** `backend/app/schemas/address.py`
|
||||
|
||||
| Mező | Típus | Leírás |
|
||||
|------|-------|--------|
|
||||
| `zip` | `Optional[str]` | Irányítószám |
|
||||
| `city` | `Optional[str]` | Város |
|
||||
| `street_name` | `Optional[str]` | Utca neve |
|
||||
| `street_type` | `Optional[str]` | Közterület jellege |
|
||||
| `house_number` | `Optional[str]` | Házszám |
|
||||
| `stairwell` | `Optional[str]` | Lépcsőház |
|
||||
| `floor` | `Optional[str]` | Emelet |
|
||||
| `door` | `Optional[str]` | Ajtó |
|
||||
| `parcel_id` | `Optional[str]` | Helyrajzi szám |
|
||||
| `full_address_text` | `Optional[str]` | Teljes cím szöveg |
|
||||
| `latitude` | `Optional[float]` | GPS szélesség |
|
||||
| `longitude` | `Optional[float]` | GPS hosszúság |
|
||||
|
||||
**Használja:** `organizations.py` (P0 refactored — nested `AddressIn` bemenet, `AddressOut` kimenet)
|
||||
|
||||
### 2. `AddressResponse` (user.py séma)
|
||||
**Fájl:** `backend/app/schemas/user.py`
|
||||
|
||||
| Mező | Típus | Eltérés |
|
||||
|------|-------|---------|
|
||||
| `address_zip` | `Optional[str]` | 🟡 **address_ előtaggal** |
|
||||
| `address_city` | `Optional[str]` | 🟡 **address_ előtaggal** |
|
||||
| `address_street_name` | `Optional[str]` | |
|
||||
| `address_street_type` | `Optional[str]` | |
|
||||
| `address_house_number` | `Optional[str]` | |
|
||||
| `address_stairwell` | `Optional[str]` | |
|
||||
| `address_floor` | `Optional[str]` | |
|
||||
| `address_door` | `Optional[str]` | |
|
||||
| `address_hrsz` | `Optional[str]` | 🟡 **eltérő név: hrsz vs parcel_id** |
|
||||
| `full_address_text` | `Optional[str]` | |
|
||||
| `latitude` | `Optional[float]` | |
|
||||
| `longitude` | `Optional[float]` | |
|
||||
|
||||
**Használja:** `users.py` (response), `admin_persons.py` (response + input: `PersonAddressUpdate`)
|
||||
|
||||
### 3. Admin endpointok saját belső sémái
|
||||
**Fájl:** `backend/app/api/v1/endpoints/admin_providers.py`
|
||||
|
||||
| Mező | Típus | Megjegyzés |
|
||||
|------|-------|------------|
|
||||
| `address` | `Optional[str]` | 🟡 **Szabad szöveges cím (legacy)** |
|
||||
| `city` | `Optional[str]` | 🟡 **city (előtag nélkül)** |
|
||||
| `address_zip` | `Optional[str]` | 🟡 **address_ előtaggal** |
|
||||
| `address_street_name` | `Optional[str]` | |
|
||||
| `address_street_type` | `Optional[str]` | |
|
||||
| `address_house_number` | `Optional[str]` | |
|
||||
| `plus_code` | `Optional[str]` | 🟢 Csak itt van |
|
||||
| `contact_phone` | `Optional[str]` | |
|
||||
|
||||
**Használja:** `admin_providers.py` (`ProviderListItem`, `ProviderDetail`, `AdminProviderUpdate`), `admin_organizations.py`
|
||||
|
||||
### 4. Admin user list (`admin.py`)
|
||||
**Fájl:** `backend/app/api/v1/endpoints/admin.py`
|
||||
|
||||
| Mező | Típus | Megjegyzés |
|
||||
|------|-------|------------|
|
||||
| `postal_code` | `Optional[str]` | 🔴 **Teljesen eltérő név** |
|
||||
| `city` | `Optional[str]` | 🟡 **előtag nélkül** |
|
||||
| `street` | `Optional[str]` | 🔴 **street (nem street_name)** |
|
||||
| `house_number` | `Optional[str]` | 🟢 Ok |
|
||||
| `address` | `Optional[str]` | 🟡 **Összefűzött string** |
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Érintett API Végpontok és Formátumuk
|
||||
|
||||
| # | Végpont fájl | Cím bemenet | Cím kimenet | Formátum azonosság |
|
||||
|---|-------------|-------------|-------------|-------------------|
|
||||
| 1 | `organizations.py` | `AddressIn` (nested) | `AddressOut` (nested) | ✅ Egységes (P0) |
|
||||
| 2 | `users.py` | Denormalizált `address_*` | `AddressResponse` (`address_*`) | ⚠️ Részben |
|
||||
| 3 | `admin.py` | — | Saját dict: `postal_code`, `city`, `street` | 🔴 **Eltérő** |
|
||||
| 4 | `admin_providers.py` | Saját `AdminProviderUpdate` | Saját `ProviderDetail` | ⚠️ Részben |
|
||||
| 5 | `admin_organizations.py` | Denormalizált `address_*` | Denormalizált `address_*` | ⚠️ Részben |
|
||||
| 6 | `admin_persons.py` | `PersonAddressUpdate` (`address_*`) | `AddressResponse` (`address_*`) | ⚠️ Részben |
|
||||
| 7 | `providers.py` | `ProviderQuickAddIn` / `ProviderUpdateIn` | `ProviderSearchResult` | ⚠️ Saját séma |
|
||||
| 8 | `gamification.py` | `city`, `address` (string) | — | 🔴 **Eltérő** |
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Kritikus Inkonzisztenciák
|
||||
|
||||
### 1. 🔴 Mezőelnevezési konfliktus: `zip` vs `address_zip` vs `postal_code`
|
||||
A 3 különböző séma 3 különböző néven hivatkozik ugyanarra az adatra:
|
||||
- `zip` (`AddressIn`/`AddressOut` — P0 refactored)
|
||||
- `address_zip` (`AddressResponse`, `PersonUpdate` — user séma)
|
||||
- `postal_code` (`admin.py` user list response)
|
||||
|
||||
**Következmény:** A frontend fejlesztőknek tudniuk kell, hogy melyik endpoint melyik mezőnevet várja/adja. Ez magas hibalehetőséget hordoz.
|
||||
|
||||
### 2. 🔴 `address_hrsz` vs `parcel_id` — névütközés
|
||||
- `AddressResponse` / `PersonAddressUpdate`: `address_hrsz`
|
||||
- `AddressIn` / `AddressOut`: `parcel_id`
|
||||
- Modell (`Address`): `parcel_id`
|
||||
|
||||
**Következmény:** Ugyanaz az adat (helyrajzi szám) két különböző néven szerepel.
|
||||
|
||||
### 3. 🔴 Kétféle címkezelési stratégia (FK vs denormalizált)
|
||||
|
||||
**Stratégia A — Unified (P0 Refactored):**
|
||||
- `system.addresses` tábla + `address_id` FK
|
||||
- `AddressIn` / `AddressOut` séma
|
||||
- **Használja:** `organizations.py`
|
||||
|
||||
**Stratégia B — Denormalizált:**
|
||||
- Címmezők közvetlenül a szülő táblában (`address_city`, `address_zip`, stb.)
|
||||
- Külön `Address` entitás nélkül
|
||||
- **Használja:** `admin_organizations.py`, `admin_providers.py`, `providers.py`, `users.py`
|
||||
|
||||
### 4. 🟡 Duplikált séma definíciók
|
||||
|
||||
Az `admin_providers.py` fájlban a `ProviderListItem`, `ProviderDetail`, `AdminProviderCreate` és `AdminProviderUpdate` osztályok saját Pydantic sémákként vannak definiálva a fájlon belül. Ezek mezői részben átfednek az `AddressIn`/`AddressOut` sémákkal, de nem azokat használják.
|
||||
|
||||
### 5. 🟡 ProviderSearchResult duplikált címmezőkkel
|
||||
|
||||
A `ProviderSearchResult` (`backend/app/schemas/provider.py`) tartalmaz egy `address` (string) mezőt **és** atomizált mezőket is (`address_zip`, `address_street_name`, stb.). Ez redundáns.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Javasolt Refaktor Terv
|
||||
|
||||
### Fázis 1: Egységes címmodul megerősítése
|
||||
|
||||
Az `AddressIn`/`AddressOut` séma megtartása és kiterjesztése:
|
||||
|
||||
```python
|
||||
class AddressIn(BaseModel):
|
||||
"""Unified address input schema."""
|
||||
zip: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
street_name: Optional[str] = None
|
||||
street_type: Optional[str] = None
|
||||
house_number: Optional[str] = None
|
||||
stairwell: Optional[str] = None
|
||||
floor: Optional[str] = None
|
||||
door: Optional[str] = None
|
||||
parcel_id: Optional[str] = None
|
||||
full_address_text: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
```
|
||||
|
||||
### Fázis 2: Érintett endpointok átállítása
|
||||
|
||||
| Prioritás | Végpont | Jelenlegi | Átállás |
|
||||
|-----------|---------|-----------|---------|
|
||||
| 🔴 P1 | `admin.py` user list | `postal_code`/`city`/`street` dict | `AddressOut`-ra váltás |
|
||||
| 🔴 P1 | `admin_persons.py` | `PersonAddressUpdate` | `AddressIn` használata |
|
||||
| 🟡 P2 | `users.py` | Denormalizált `address_*` | `AddressIn` + FK |
|
||||
| 🟡 P2 | `admin_providers.py` | Saját `ProviderDetail` etc. | `AddressOut` beágyazás |
|
||||
| 🟡 P2 | `admin_organizations.py` | Denormalizált `address_*` | `AddressIn`/`AddressOut` |
|
||||
| 🟢 P3 | `providers.py` | `ProviderSearchResult` | `AddressOut` beágyazás |
|
||||
| 🟢 P3 | `gamification.py` | `city`/`address` string | `AddressIn` használata |
|
||||
|
||||
### Fázis 3: `AddressResponse` eltávolítása
|
||||
|
||||
- `AddressResponse` (`backend/app/schemas/user.py`) → eltávolítani
|
||||
- Helyette `AddressOut`-t használni mindenhol
|
||||
- Frontend kompatibilitás: mapping réteg a frontenden, ha az `address_*` előtagot várja
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Technikai Megvalósítási Javaslat
|
||||
|
||||
### Backend oldalon:
|
||||
1. `AddressIn` bővítése `country` mezővel
|
||||
2. Az összes admin endpoint átállítása `AddressIn`/`AddressOut` használatára
|
||||
3. `Organization` modell denormalizált `address_*` mezőinek deprecated flaggelése
|
||||
4. `create_or_update_address` service kiterjesztése az összes használati esetre
|
||||
|
||||
### Frontend oldalon:
|
||||
1. Egységes `address` komponens bevezetése, ami `AddressOut` formátumot vár
|
||||
2. Mapping réteg a régi `address_*` formátumról az új `AddressOut` formátumra
|
||||
|
||||
---
|
||||
|
||||
## 📈 Következtetés
|
||||
|
||||
**A rendszer JELENLEG NEM alkalmas egy központi címmodul kezelésére.**
|
||||
|
||||
A P0 refactored `AddressIn`/`AddressOut` séma irányába történő elmozdulás elkezdődött (`organizations.py`), de a többi endpoint és séma még a régi, denormalizált megközelítést használja. A kettősség komoly karbantartási terhet és hibalehetőséget jelent.
|
||||
|
||||
**Javasolt lépések:**
|
||||
1. 🔴 **Azonnal:** `admin.py` user list válasz átállítása `AddressOut` formátumra
|
||||
2. 🔴 **Rövidtávon:** `AddressResponse` (`user.py`) és `PersonAddressUpdate` (`admin_persons.py`) lecserélése `AddressOut`/`AddressIn`-re
|
||||
3. 🟡 **Középtávon:** Az összes admin provider/organization endpoint átállítása
|
||||
4. 🟢 **Hosszútávon:** Provider sémák (`ProviderSearchResult`, `ProviderQuickAddIn`) átállítása
|
||||
129
docs/admin_providers_root_cause_analysis.md
Normal file
129
docs/admin_providers_root_cause_analysis.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 🔍 Admin Service Provider Root Cause Analysis
|
||||
|
||||
## Összefoglaló
|
||||
|
||||
**Felfedezett hiba:** Az admin felület szolgáltató (provider) listája és "Jóváhagyásra váró" oldala csak a `marketplace.service_providers` táblában tárolt 6 rekordot jeleníti meg. A robotok által begyűjtött 8569 rekord a `marketplace.service_staging` táblában teljesen láthatatlan az admin számára.
|
||||
|
||||
**Dátum:** 2026-06-30
|
||||
**Vizsgáló:** Rendszer-Architect
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Adatbázis Táblák Állapota
|
||||
|
||||
| Tábla | Séma | Rekordszám | Státuszértékek | Forrás |
|
||||
|-------|------|-----------|----------------|--------|
|
||||
| `service_providers` | `marketplace` | **6** | approved, rejected | API (user/manuális) |
|
||||
| `service_staging` | `marketplace` | **8569** | research_in_progress (5096), no_web_presence (3472), auditing (1) | Robotok (bot) |
|
||||
| `organizations` | `fleet` | **1** (service_provider = true) | active | Regisztráció |
|
||||
|
||||
---
|
||||
|
||||
## 🔬 Root Cause Analysis
|
||||
|
||||
### 1. Admin API csak a `service_providers` táblát kérdezi
|
||||
|
||||
A [`backend/app/api/v1/endpoints/admin_providers.py`](../backend/app/api/v1/endpoints/admin_providers.dart:231) [`list_providers()`](../backend/app/api/v1/endpoints/admin_providers.dart:231) metódusa kizárólag a `ServiceProvider` modellre hivatkozik:
|
||||
|
||||
```python
|
||||
@router.get("", response_model=List[ProviderListItem])
|
||||
async def list_providers(...):
|
||||
query = select(ServiceProvider) # ← CSAK marketplace.service_providers
|
||||
...
|
||||
```
|
||||
|
||||
Ugyanez a helyzet a [`get_provider_stats()`](../backend/app/api/v1/endpoints/admin_providers.dart:261) endpointtal (261. sor).
|
||||
|
||||
**Következmény:** A botok által felfedezett 8569 rekord admin felületről nem látható, nem moderálható, nem kereshető.
|
||||
|
||||
### 2. A Public API már helyesen kezeli a 3 forrást
|
||||
|
||||
A [`backend/app/services/provider_service.py`](../backend/app/services/provider_service.dart:216) [`search_providers()`](../backend/app/services/provider_service.dart:216) (public API) UNION ALL lekérdezéssel egyesíti mindhárom forrást:
|
||||
|
||||
```python
|
||||
org_query = select(Organization.id, Organization.name, ...)
|
||||
staging_query = select(ServiceStaging.id, ServiceStaging.name, ...)
|
||||
provider_query = select(ServiceProvider.id, ServiceProvider.name, ...)
|
||||
union_query = union_all(org_query, staging_query, provider_query)
|
||||
```
|
||||
|
||||
Ezt a mintát kell alkalmazni az admin API-ban is.
|
||||
|
||||
### 3. A "Jóváhagyásra váró" lista azért üres, mert...
|
||||
|
||||
- A `service_providers` táblában **nincs** `pending` státuszú rekord (csak approved/rejected)
|
||||
- A `service_staging` tábla 8569 rekordja **ki van zárva** az admin lekérdezésből
|
||||
- A frontend [`pending.vue`](../frontend_admin/pages/providers/pending.dart:1) ugyanezt az admin API-t hívja `status=pending` paraméterrel
|
||||
|
||||
### 4. Validációs rendszer már létezik, csak csatornázni kell
|
||||
|
||||
A meglévő adatbázis tartalmazza az összes szükséges validációs infrastruktúrát:
|
||||
|
||||
- `marketplace.provider_validations` - upvote/downvote rendszer súlyozással, dedikált validációs tábla
|
||||
- `marketplace.service_providers.validation_score` - közvetlen validációs pontszám
|
||||
- `marketplace.service_providers.status` - moderációs státusz (ModerationStatus enum)
|
||||
- `marketplace.service_profiles.trust_score` - bizalmi pontszám
|
||||
- `marketplace.service_profiles.is_verified` - ellenőrzöttségi jelző
|
||||
- `marketplace.service_profiles.verification_log` - JSON validációs napló
|
||||
|
||||
**Nem kell új mezőket létrehozni** - a `ServiceStaging` adatokat megfelelően be kell csatornázni a meglévő validációs rendszerbe.
|
||||
|
||||
---
|
||||
|
||||
## 🩹 Javasolt Javítási Terv
|
||||
|
||||
### Rövid távú (P0) - Admin API UNION ALL kiterjesztése
|
||||
|
||||
1. **`list_providers()`** módosítása [`admin_providers.py`](../backend/app/api/v1/endpoints/admin_providers.dart:231)-ban:
|
||||
- UNION ALL lekérdezés a `ServiceStaging` táblából is (a [`search_providers()`](../backend/app/services/provider_service.dart:216) mintájára)
|
||||
- A `ServiceStaging` rekordok leképezése a `ProviderListItem` response modelre
|
||||
- A `ServiceStaging` rekordoknál: `status` -> `research_in_progress` mint `pending`, `source` -> `bot`
|
||||
|
||||
2. **`get_provider_stats()`** módosítása [`admin_providers.py`](../backend/app/api/v1/endpoints/admin_providers.dart:261)-ban:
|
||||
- A `pending` count kiegészítése a `research_in_progress` ServiceStaging rekordokkal
|
||||
|
||||
3. **Jóváhagyási flow** (approve/reject):
|
||||
- `ServiceStaging` rekord jóváhagyásakor: `ServiceProvider` rekord létrehozása az adatokkal
|
||||
- A meglévő `provider_validations` és `service_profiles` validációs rendszer használata
|
||||
|
||||
### Implementációs Részletek
|
||||
|
||||
A `ProviderListItem` modelt ki kell egészíteni egy `source_table` mezővel, hogy a frontend tudja, melyik táblából jött a rekord:
|
||||
|
||||
```python
|
||||
class ProviderListItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
address: Optional[str]
|
||||
city: Optional[str]
|
||||
status: str # "pending" | "approved" | "rejected"
|
||||
source: str # "api" | "manual" | "bot"
|
||||
source_table: str # "service_providers" | "service_staging" | "organizations"
|
||||
validation_score: Optional[int]
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Érintett Fájlok
|
||||
|
||||
| Fájl | Szerep |
|
||||
|------|--------|
|
||||
| [`backend/app/api/v1/endpoints/admin_providers.py`](../backend/app/api/v1/endpoints/admin_providers.dart:1) | **Root cause** - Javítandó fájl |
|
||||
| [`backend/app/services/provider_service.py`](../backend/app/services/provider_service.dart:216) | **Minta** - UNION ALL implementáció |
|
||||
| [`backend/app/models/identity/social.py`](../backend/app/models/identity/social.dart:23) | `ServiceProvider` modell |
|
||||
| [`backend/app/models/marketplace/service.py`](../backend/app/models/marketplace/service.dart:162) | `ServiceStaging` modell |
|
||||
| [`frontend_admin/pages/providers/index.vue`](../frontend_admin/pages/providers/index.dart:1) | Admin provider lista oldal |
|
||||
| [`frontend_admin/pages/providers/pending.vue`](../frontend_admin/pages/providers/pending.dart:1) | Admin pending queue oldal |
|
||||
| [`plans/logic_spec_service_provider_discovery_admin.md`](../plans/logic_spec_service_provider_discovery_admin.dart:1) | Meglévő tervdokumentáció |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Következő Lépések
|
||||
|
||||
1. [ ] Gitea kártya létrehozása: Admin API UNION ALL kiterjesztése ServiceStaging táblával
|
||||
2. [ ] `ProviderListItem` response model kiegészítése `source_table` mezővel
|
||||
3. [ ] `list_providers()` módosítása: UNION ALL 3 forrás
|
||||
4. [ ] `get_provider_stats()` módosítása: staging rekordok beszámítása
|
||||
5. [ ] Jóváhagyási flow: ServiceStaging -> ServiceProvider átvezetés
|
||||
6. [ ] Tesztelés: frontend pending lista megjelenése
|
||||
187
docs/gamification_point_rules_disconnect_analysis.md
Normal file
187
docs/gamification_point_rules_disconnect_analysis.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# 🔗 Gamification Point-Rules Admin Kapcsolati Hiányosság Elemzés
|
||||
|
||||
**Dátum:** 2026-06-30
|
||||
**Vizsgálat típusa:** Rendszer-audit
|
||||
**Vizsgált oldal:** `https://admin.servicefinder.hu/gamification/point-rules`
|
||||
**Modul:** Gamification Admin Rendszer 2.0
|
||||
**Gitea Issue:** #364
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Probléma összefoglalása
|
||||
|
||||
A `/gamification/point-rules` admin felületen létrehozott és módosított pontszabályok (action_key -> points) NINCSENEK összekapcsolva a valódi gamification folyamatokkal. Az admin felületen beállított pontértékek csak a folyamatok egy szűk körére (service_submission, daily_quiz_correct) vannak hatással. A többi gamification akció (KYC, költség rögzítés, eszköz regisztráció, fleet esemény, stb.) vagy keménykódolt értékeket, vagy a ConfigService-ből olvasott értékeket használ.
|
||||
|
||||
---
|
||||
|
||||
## 🔬 Technikai háttér
|
||||
|
||||
### 1. Point Rules Adatbázis Tábla (gamification.point_rules)
|
||||
|
||||
Az adatbázisban jelenleg **10 aktív pontszabály** található:
|
||||
|
||||
| ID | action_key | points | description |
|
||||
|----|-----------|-------|-------------|
|
||||
| 3 | ADD_NEW_PROVIDER | 500 | Új szolgáltató rögzítése |
|
||||
| 1 | ASSET_REGISTER | 100 | - |
|
||||
| 2 | ASSET_REVIEW | 75 | - |
|
||||
| 17 | PROVIDER_CONFIRMATION | 150 | Meglévő provider megerősítése |
|
||||
| 16 | PROVIDER_DISCOVERY | 300 | Új provider felfedezése |
|
||||
| 18 | PROVIDER_VERIFIED_USE | 100 | Jóváhagyott provider használata |
|
||||
| 5 | RATE_PROVIDER | 250 | Szolgáltató értékelése |
|
||||
| 6 | UPDATE_PROVIDER | 100 | Szolgáltató adatainak szerkesztése |
|
||||
| 4 | USE_UNVERIFIED_PROVIDER | 200 | Nem ellenőrzött szolgáltató használata |
|
||||
| 7 | TEST_ACTION | 100 | Test rule |
|
||||
|
||||
### 2. GAMIFICATION_MASTER_CONFIG Rendszerparaméter
|
||||
|
||||
A `system.system_parameters` táblában tárolt konfiguráció:
|
||||
- xp_logic: base_xp=500, exponent=1.5
|
||||
- penalty_logic: recovery_rate=0.5, threshold-ok, multiplier-ek
|
||||
- conversion_logic: social_to_credit_rate=100
|
||||
- level_rewards: credits_per_10_levels=50
|
||||
|
||||
---
|
||||
|
||||
## 🚨 A Hiányzó Kapcsolat Részletes Elemzése
|
||||
|
||||
### 1. Elsődleges probléma: award_points() nem támogatja az action_key-t
|
||||
|
||||
A [`GamificationService.award_points()`](backend/app/services/gamification_service.py:44) statikus metódus definíciója:
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
async def award_points(db, user_id, amount, reason, social_points=0, commit=True):
|
||||
service = GamificationService()
|
||||
return await service.process_activity(
|
||||
db, user_id,
|
||||
xp_amount=amount,
|
||||
social_amount=social_points,
|
||||
reason=reason,
|
||||
commit=commit
|
||||
# NINCS action_key átadva!
|
||||
)
|
||||
```
|
||||
|
||||
A metódus NEM fogad el action_key paramétert, így azt nem is adja át a process_activity()-nak.
|
||||
|
||||
### 2. A process_activity() belső logikája
|
||||
|
||||
A [`process_activity()`](backend/app/services/gamification_service.py:125-134) metódus:
|
||||
|
||||
```python
|
||||
# 1/b. POINT RULES TÁBLA LEKÉRÉSE (ha action_key meg van adva)
|
||||
if action_key:
|
||||
rule = await self._get_point_rule(db, action_key)
|
||||
if rule:
|
||||
xp_amount = rule["points"]
|
||||
```
|
||||
|
||||
A logika MŰKÖDIK, de csak akkor indul be, ha az action_key paraméter átadásra kerül.
|
||||
|
||||
### 3. Hívások elemzése
|
||||
|
||||
#### Helyesen használja az action_key-t (point_rules tábla érvényesül):
|
||||
|
||||
| Hívó helye | action_key | Forrás |
|
||||
|-----------|-----------|--------|
|
||||
| gamification.py:374 | service_submission | Service beküldés |
|
||||
| gamification.py:566 | daily_quiz_correct | Napi kvíz |
|
||||
|
||||
#### NEM használja az action_key-t (point_rules tábla NEM érvényesül):
|
||||
|
||||
| Hívó helye | Pontforrás | Probléma |
|
||||
|-----------|-----------|----------|
|
||||
| auth_service.py:298 | kyc_reward változó | KYC pontok nem módosíthatók |
|
||||
| auth_service.py:303 | ConfigService gamification_p2p_invite_xp | P2P referral pontok nem módosíthatók |
|
||||
| cost_service.py:73 | base_xp * ocr_multiplier | Költség log pontok nem módosíthatók |
|
||||
| asset_service.py:290 | ConfigService xp_reward_asset_register | Asset reg pontok nem módosíthatók |
|
||||
| fleet_service.py:88 | event_rewards config dict | Fleet esemény pontok nem módosíthatók |
|
||||
| social_service.py:73 | Keménykódolt 100 XP, 20 SP | Validáció pontok nem módosíthatók |
|
||||
| social_service.py:98 | Keménykódolt 50 XP | Elutasítás pontok nem módosíthatók |
|
||||
| services.py:40 | ConfigService GAMIFICATION_HUNT_REWARD | Service hunt pontok nem módosíthatók |
|
||||
| services.py:90 | ConfigService GAMIFICATION_VALIDATE_REWARD | Service validation pontok nem módosíthatók |
|
||||
|
||||
### 4. Duplikált logika a provider_service-ben
|
||||
|
||||
A [`provider_service.py:60-135`](backend/app/services/provider_service.py:60) tartalmaz egy `_award_dynamic_points()` függvényt, ami **saját maga kiolvassa** a point_rules táblát, mielőtt meghívná az award_points()-t.
|
||||
|
||||
Ez a logika **MŰKÖDIK**, de:
|
||||
- **Duplikált**: Ugyanezt a logikát a process_activity() is tartalmazza
|
||||
- **Feleslegesen komplex**: Ha az award_points() támogatná az action_key-t, ez a függvény leegyszerűsödne
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Javasolt Javítási Terv
|
||||
|
||||
### 1. Közvetlen javítás: award_points() kiegészítése action_key támogatással
|
||||
|
||||
Módosítandó fájl: [`backend/app/services/gamification_service.py:44-54`](backend/app/services/gamification_service.py:44)
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
async def award_points(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: int,
|
||||
reason: str,
|
||||
social_points: int = 0,
|
||||
commit: bool = True,
|
||||
action_key: str | None = None,
|
||||
):
|
||||
service = GamificationService()
|
||||
return await service.process_activity(
|
||||
db, user_id,
|
||||
xp_amount=amount,
|
||||
social_amount=social_points,
|
||||
reason=reason,
|
||||
commit=commit,
|
||||
action_key=action_key,
|
||||
)
|
||||
```
|
||||
|
||||
**Hatás:** Minden award_points() hívó automatikusan használhatja a point_rules táblát, ha átadja az action_key-t.
|
||||
|
||||
### 2. Hívók átállítása action_key használatára
|
||||
|
||||
| Hívó | Javasolt action_key |
|
||||
|------|-------------------|
|
||||
| auth_service.py:298 | KYC_VERIFICATION (új) |
|
||||
| auth_service.py:303 | P2P_REFERRAL_SUCCESS (új) |
|
||||
| cost_service.py:73 | EXPENSE_LOG (új) |
|
||||
| asset_service.py:290 | ASSET_REGISTER (mar létezik!) |
|
||||
| fleet_service.py:88 | FLEET_EVENT (új) |
|
||||
| social_service.py:73 | PROVIDER_VALIDATED (új) |
|
||||
| social_service.py:98 | PROVIDER_REJECTED (új) |
|
||||
| services.py:40 | SERVICE_HUNT (új) |
|
||||
| services.py:90 | SERVICE_VALIDATION (új) |
|
||||
|
||||
### 3. Duplikált logika eltávolítása
|
||||
|
||||
A `_award_dynamic_points()` függvény leegyszerűsíthető, ha az award_points() már támogatja az action_key-t.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Hatáselemzés
|
||||
|
||||
### Milyen akciókra van jelenleg hatással a point_rules módosítása?
|
||||
|
||||
- Service beküldes - hasznalja (action_key=service_submission)
|
||||
- Napi kviz - hasznalja (action_key=daily_quiz_correct)
|
||||
- Provider müveletek - hasznalja (provider_service _award_dynamic_points)
|
||||
|
||||
### Milyen akciókra NINCS hatással a point_rules módosítása?
|
||||
|
||||
- KYC regisztracio - kemenykodolt
|
||||
- P2P referral - ConfigService
|
||||
- Koltség rögzítés - kemenykodolt
|
||||
- Eszköz regisztracio - ConfigService (bar ASSET_REGISTER letezik!)
|
||||
- Flotta esemenyek - kemenykodolt
|
||||
- Szolgaltato validacio/elutasitas - kemenykodolt
|
||||
- Service hunt/validation - ConfigService
|
||||
|
||||
---
|
||||
|
||||
## Következtetés
|
||||
|
||||
A `/gamification/point-rules` admin felület jelenleg **csak a point_rules adatbazis tabla CRUD kezeleset** teszi lehetove, de a tabla tartalma **nincs hatassal a gamification folyamatok tobbsegere**. A javitashoz a `GamificationService.award_points()` metódust kell kiegesziteni `action_key` parameter tamogatasaval, es a hivokat at kell allitani action_key hasznalatara.
|
||||
292
docs/gamification_provider_connection_analysis.md
Normal file
292
docs/gamification_provider_connection_analysis.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 🔍 Gamification Pontszabályok és Service Provider Kapcsolat Elemzés
|
||||
|
||||
**Dátum:** 2026-06-30
|
||||
**Létrehozva:** Gitea #358
|
||||
**Scope:** Backend, Gamification, Service Provider
|
||||
**Típus:** Audit + Feature Analysis
|
||||
|
||||
---
|
||||
|
||||
## 1. ⚡ Executive Summary
|
||||
|
||||
A vizsgálat célja annak megállapítása volt, hogy a gamification pontszabályok (`gamification.point_rules`) össze vannak-e kötve a szolgáltatók (Service Provider) létrehozásával és validálásával.
|
||||
|
||||
### Főbb Megállapítások
|
||||
|
||||
| Pontszabály | Pont | Státusz | Megjegyzés |
|
||||
|------------|------|---------|------------|
|
||||
| `ADD_NEW_PROVIDER` | 500 | ✅ Bekötve | `quick_add_provider()`, `approve_provider()` |
|
||||
| `UPDATE_PROVIDER` | 100 | ✅ Bekötve | `update_provider()` |
|
||||
| `PROVIDER_DISCOVERY` | 300 | ❌ NINCS bekötve | Soha nem kerül kiosztásra |
|
||||
| `PROVIDER_CONFIRMATION` | 150 | ❌ NINCS bekötve | Soha nem kerül kiosztásra |
|
||||
| `PROVIDER_VERIFIED_USE` | 100 | ❌ NINCS bekötve | Soha nem kerül kiosztásra |
|
||||
| `USE_UNVERIFIED_PROVIDER` | 200 | ❌ NINCS bekötve | Soha nem kerül kiosztásra |
|
||||
| `RATE_PROVIDER` | 250 | ❌ NINCS bekötve | Soha nem kerül kiosztásra |
|
||||
|
||||
Összesen **5 pontszabály** van, amely definiálva van az adatbázisban és a seed scriptben, de **egyetlen kódrészlet sem hívja meg őket** — teljesen használatlanok.
|
||||
|
||||
---
|
||||
|
||||
## 2. 🏗️ Rendszerarchitektúra
|
||||
|
||||
### 2.1 Admin Gamification UI — ÉLES ÉS MŰKÖDŐ
|
||||
|
||||
A [`https://admin.servicefinder.hu/gamification/point-rules`](https://admin.servicefinder.hu/gamification/point-rules) felület **valós adatokat** jelenít meg és **teljes CRUD** műveleteket támogat:
|
||||
|
||||
- **Frontend:** [`frontend_admin/pages/gamification/point-rules.vue`](frontend_admin/pages/gamification/point-rules.vue) — `$fetch('/api/v1/admin/gamification/point-rules')` hívás
|
||||
- **Backend:** [`backend/app/api/v1/endpoints/admin_gamification.py`](backend/app/api/v1/endpoints/admin_gamification.py:214) — valós adatbázis lekérdezés
|
||||
- `GET /point-rules` (214. sor)
|
||||
- `POST /point-rules` (236. sor)
|
||||
- `PUT /point-rules/{rule_id}` (270. sor)
|
||||
- `DELETE /point-rules/{rule_id}` (299. sor)
|
||||
|
||||
**Következtetés:** Nincs szükség új admin felület készítésére. A pontszabályok a meglévő admin UI-n keresztül közvetlenül szerkeszthetők.
|
||||
|
||||
### 2.2 Pontszabály Modell
|
||||
|
||||
[`backend/app/models/gamification/gamification.py`](backend/app/models/gamification/gamification.py:13) — `PointRule` osztály:
|
||||
- `id`, `action_key` (unique), `points`, `description`, `is_active`
|
||||
|
||||
### 2.3 Service Provider Modell
|
||||
|
||||
[`backend/app/models/identity/social.py`](backend/app/models/identity/social.py:23) — `ServiceProvider` osztály:
|
||||
- `status` (pending/approved/rejected/flagged)
|
||||
- `source` (user_submitted/admin_imported/api/external)
|
||||
- `validation_score`
|
||||
- `added_by_user_id`
|
||||
|
||||
---
|
||||
|
||||
## 3. 🔗 Jelenlegi Bekötések (Működő)
|
||||
|
||||
### 3.1 `_award_provider_points()` — Központi függvény
|
||||
|
||||
[`backend/app/services/provider_service.py`](backend/app/services/provider_service.py:59) — Dinamikus pontkiosztó függvény:
|
||||
|
||||
```python
|
||||
async def _award_provider_points(db, user_id, action_key) -> int:
|
||||
# 1. Pontszabály lekérése adatbázisból
|
||||
rule_stmt = select(PointRule).where(
|
||||
PointRule.action_key == action_key, PointRule.is_active == True
|
||||
)
|
||||
rule = (await db.execute(rule_stmt)).scalar_one_or_none()
|
||||
if not rule:
|
||||
return 0
|
||||
# 2. Pont kiosztása GamificationService-en keresztül
|
||||
await gamification_service.award_points(
|
||||
db=db, user_id=user_id, amount=rule.points, ...
|
||||
)
|
||||
# 3. UserStats frissítése
|
||||
stats.providers_added_count += 1
|
||||
return points_to_award
|
||||
```
|
||||
|
||||
### 3.2 `quick_add_provider()` → `ADD_NEW_PROVIDER` (500 XP)
|
||||
|
||||
[`backend/app/services/provider_service.py`](backend/app/services/provider_service.py:611) — Gyors provider hozzáadás API végpont:
|
||||
- Automatikusan meghívja a `_award_provider_points(action_key="ADD_NEW_PROVIDER")` függvényt
|
||||
- 500 XP kerül kiosztásra a user-nek
|
||||
|
||||
### 3.3 `update_provider()` → `UPDATE_PROVIDER` (100 XP)
|
||||
|
||||
[`backend/app/services/provider_service.py`](backend/app/services/provider_service.py:1122) — Provider adatainak frissítése:
|
||||
- Meghívja a `_award_provider_points(action_key="UPDATE_PROVIDER")` függvényt
|
||||
- 100 XP kerül kiosztásra
|
||||
|
||||
### 3.4 Admin Approve → `ADD_NEW_PROVIDER` (500 XP)
|
||||
|
||||
[`backend/app/api/v1/endpoints/admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:361) — Admin provider jóváhagyása:
|
||||
- `POST /admin/providers/{id}/approve`
|
||||
- Gamification XP kiosztás a provider beküldőjének
|
||||
- ProviderValidation rekord létrehozása
|
||||
- UserContribution státusz frissítése
|
||||
|
||||
---
|
||||
|
||||
## 4. ❌ HIÁNYZÓ BEKÖTÉSEK (Implementálandó)
|
||||
|
||||
### 4.1 `PROVIDER_DISCOVERY` (300 XP) — Soha nem kerül kiosztásra
|
||||
|
||||
**Tervezett működés:** Amikor egy user költséget rögzít, és az `external_vendor_name` alapján új provider kerül felfedezésre (még nem létezik a `marketplace.service_providers` táblában), a felfedező user 300 XP-t kap.
|
||||
|
||||
**Hiányzó kód:** A [`plans/logic_spec_service_provider_discovery_admin.md`](plans/logic_spec_service_provider_discovery_admin.md:116) specifikáció leírja a `find_or_create_provider_by_name()` függvényt, de az **SOHA nem lett implementálva**.
|
||||
|
||||
**Szükséges lépések:**
|
||||
1. Implementálni a [`find_or_create_provider_by_name()`](plans/logic_spec_service_provider_discovery_admin.md:116) függvényt a `provider_service.py`-ban
|
||||
2. Bekötni az [`expenses.py`](backend/app/api/v1/endpoints/expenses.py:566) create_expense végpontba
|
||||
3. A függvény hívja meg a `_award_provider_points(action_key="PROVIDER_DISCOVERY")`-t
|
||||
|
||||
### 4.2 `PROVIDER_CONFIRMATION` (150 XP) — Soha nem kerül kiosztásra
|
||||
|
||||
**Tervezett működés:** Amikor egy második user használ egy meglévő, de még nem 100%-ban megerősített providert (pl. ugyanaz a `external_vendor_name` egy másik költségben), a confirmation pont jár.
|
||||
|
||||
**Hiányzó logika:** A `find_or_create_provider_by_name()` függvény része kell legyen — ha a provider már létezik, de `validation_score < 100`, akkor `PROVIDER_CONFIRMATION` jár.
|
||||
|
||||
### 4.3 `PROVIDER_VERIFIED_USE` (100 XP) — Soha nem kerül kiosztásra
|
||||
|
||||
**Tervezett működés:** Ha a provider már `approved` státuszú, és valaki használja költség rögzítésénél, a `PROVIDER_VERIFIED_USE` pont jár.
|
||||
|
||||
**Szükséges:** A `find_or_create_provider_by_name()` függvény része — ha `provider.status == "approved"`, akkor `PROVIDER_VERIFIED_USE` jár.
|
||||
|
||||
### 4.4 `USE_UNVERIFIED_PROVIDER` (200 XP) — Soha nem kerül kiosztásra
|
||||
|
||||
**Tervezett működés:** Amikor egy user olyan providert használ, amely még nincs admin által jóváhagyva (pl. `pending` státuszú).
|
||||
|
||||
**Szükséges:** Szintén a `find_or_create_provider_by_name()` függvény része kell legyen.
|
||||
|
||||
### 4.5 `RATE_PROVIDER` (250 XP) — Soha nem kerül kiosztásra
|
||||
|
||||
**Tervezett működés:** Amikor egy user értékeli a providert (csillag/Vote/VoteValue).
|
||||
|
||||
**Hiányzó kód:** A `vote_for_provider()` függvény a [`social_service.py`](backend/app/services/social_service.py:31)-ban NEM hívja meg a `_award_provider_points(action_key="RATE_PROVIDER")` függvényt.
|
||||
|
||||
**Szükséges:**
|
||||
1. A [`social_service.py`](backend/app/services/social_service.py:31) `vote_for_provider()` metódusában meghívni a pontkiosztást
|
||||
|
||||
### 4.6 `social_service.py` — Hardcoded XP `create_service_provider()`-ben
|
||||
|
||||
[`backend/app/services/social_service.py`](backend/app/services/social_service.py:26):
|
||||
```python
|
||||
# HIBA: Hardcoded 50 XP a dinamikus pontszabály helyett
|
||||
await gamification_service.process_activity(db, user_id, 50, 10, f"New Provider: {new_provider.name}")
|
||||
```
|
||||
|
||||
**Javítás:** Ki kell cserélni a `_award_provider_points(action_key="ADD_NEW_PROVIDER")` hívásra, hogy dinamikusan az adatbázisból olvassa a pontértéket.
|
||||
|
||||
---
|
||||
|
||||
## 5. 📊 GamificationService Folyamat
|
||||
|
||||
[`backend/app/services/gamification_service.py`](backend/app/services/gamification_service.py:52) — `process_activity()` metódus:
|
||||
|
||||
```python
|
||||
async def process_activity(self, db, user_id, xp_amount, social_amount, reason,
|
||||
is_penalty=False, commit=True, action_key=None,
|
||||
source_type=None, source_id=None):
|
||||
# 1. Master config betöltése
|
||||
# 2. Ha action_key van, pontszabály lekérése (adatbázisból)
|
||||
if action_key:
|
||||
rule = await self._get_point_rule(db, action_key)
|
||||
if rule:
|
||||
xp_amount = rule["points"]
|
||||
# 3. Büntetés szűrés
|
||||
# 4. Szorzók alkalmazása
|
||||
# 5. Szintszámítás: Level = (XP/500)^(1/1.5) + 1
|
||||
# 6. Kredit átváltás
|
||||
# 7. Naplózás PointsLedger-be
|
||||
```
|
||||
|
||||
**Megjegyzés:** Maga a `process_activity()` és az `_award_provider_points()` működik helyesen — a probléma az, hogy **senki sem hívja meg** ezeket a megfelelő `action_key`-kel.
|
||||
|
||||
---
|
||||
|
||||
## 6. 🗺️ Implementációs Terv
|
||||
|
||||
### 6.1 `find_or_create_provider_by_name()` Implementálása
|
||||
|
||||
**Helye:** [`backend/app/services/provider_service.py`](backend/app/services/provider_service.py) — új függvény
|
||||
|
||||
**Logika:**
|
||||
|
||||
```python
|
||||
async def find_or_create_provider_by_name(
|
||||
db: AsyncSession,
|
||||
external_vendor_name: str,
|
||||
user_id: int
|
||||
) -> tuple[ServiceProvider | None, str]:
|
||||
"""
|
||||
Keres vagy létrehoz egy providert external_vendor_name alapján.
|
||||
|
||||
Visszatérési érték: (provider, action_key)
|
||||
- action_key: "PROVIDER_DISCOVERY" | "PROVIDER_CONFIRMATION" | "PROVIDER_VERIFIED_USE"
|
||||
"""
|
||||
# 1. Pontos match keresése (kisbetűsen, space-sztrippelten)
|
||||
provider = await db.execute(
|
||||
select(ServiceProvider).where(
|
||||
func.lower(ServiceProvider.name) == func.lower(external_vendor_name.strip())
|
||||
)
|
||||
)
|
||||
provider = provider.scalar_one_or_none()
|
||||
|
||||
if not provider:
|
||||
# 2. Ha nem létezik → létrehozás + PROVIDER_DISCOVERY
|
||||
new_provider = ServiceProvider(
|
||||
name=external_vendor_name.strip(),
|
||||
status=ModerationStatus.PENDING,
|
||||
source=SourceType.USER_SUBMITTED,
|
||||
added_by_user_id=user_id,
|
||||
validation_score=10 # Kezdeti alacsony score
|
||||
)
|
||||
db.add(new_provider)
|
||||
await db.flush()
|
||||
return new_provider, "PROVIDER_DISCOVERY"
|
||||
|
||||
# 3. Ha létezik, státusz alapján döntés
|
||||
if provider.status == ModerationStatus.APPROVED:
|
||||
return provider, "PROVIDER_VERIFIED_USE"
|
||||
else:
|
||||
return provider, "PROVIDER_CONFIRMATION"
|
||||
```
|
||||
|
||||
### 6.2 Expense Létrehozás Hook
|
||||
|
||||
**Helye:** [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py:566) — `create_expense` végpont
|
||||
|
||||
```python
|
||||
# A provider field feldolgozása után, de a költség létrehozása előtt:
|
||||
if expense_data.service_provider_id:
|
||||
provider = await db.get(ServiceProvider, expense_data.service_provider_id)
|
||||
if provider and provider.added_by_user_id != current_user.id:
|
||||
# Megerősítés más user által
|
||||
await _award_provider_points(db, current_user.id, "PROVIDER_CONFIRMATION")
|
||||
elif expense_data.external_vendor_name:
|
||||
provider, action_key = await find_or_create_provider_by_name(
|
||||
db, expense_data.external_vendor_name, current_user.id
|
||||
)
|
||||
await _award_provider_points(db, current_user.id, action_key)
|
||||
```
|
||||
|
||||
### 6.3 RATE_PROVIDER Bekötése
|
||||
|
||||
**Helye:** [`backend/app/services/social_service.py`](backend/app/services/social_service.py:31) — `vote_for_provider()` metódus
|
||||
|
||||
```python
|
||||
# A Vote létrehozása után:
|
||||
await _award_provider_points(db, voter_id, "RATE_PROVIDER")
|
||||
```
|
||||
|
||||
### 6.4 `social_service.py` Hardcoded XP Javítása
|
||||
|
||||
**Helye:** [`backend/app/services/social_service.py`](backend/app/services/social_service.py:26) — `create_service_provider()` metódus
|
||||
|
||||
```python
|
||||
# Eredeti (hibás):
|
||||
await gamification_service.process_activity(db, user_id, 50, 10, ...)
|
||||
|
||||
# Javítás:
|
||||
await _award_provider_points(db, user_id, "ADD_NEW_PROVIDER")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. ⚠️ Kockázatok
|
||||
|
||||
1. **Duplikált pont kiosztás:** Ha a `social_service.py`-ban lévő `create_service_provider()` és a `provider_service.py`-ban lévő `quick_add_provider()` is meghívásra kerül ugyanarra a providerre, dupla pontot kaphat a user. Megoldás: a `social_service.py`-t át kell irányítani, hogy a `quick_add_provider()`-en keresztül hozzon létre providert.
|
||||
|
||||
2. **Végtelen loop:** Ha a `find_or_create_provider_by_name()` hibát dob, és az `expenses.py` újrapróbálkozik, végtelen loop alakulhat ki. Megoldás: max 1 újrapróbálkozás, utána `logger.error()` + graceful fallback.
|
||||
|
||||
3. **Performance:** Minden expense létrehozásnál egy extra SELECT + esetleg INSERT fut le a `service_providers` táblán. Ez normál terhelés mellett elhanyagolható, de batch importoknál figyelni kell rá.
|
||||
|
||||
---
|
||||
|
||||
## 8. ✅ Jóváhagyási Pont
|
||||
|
||||
A fenti elemzés alapján az alábbi feladatokra van szükség:
|
||||
|
||||
1. **P0 — Kritikus:** `find_or_create_provider_by_name()` implementálása
|
||||
2. **P0 — Kritikus:** Expense auto-discovery hook bekötése
|
||||
3. **P1 — Magas:** `social_service.py` hardcoded XP javítása
|
||||
4. **P1 — Magas:** RATE_PROVIDER bekötése
|
||||
5. **P1 — Magas:** PROVIDER_CONFIRMATION/VERIFIED_USE logika
|
||||
|
||||
Ezek a feladatok a Gitea #358 kártyán kerültek rögzítésre.
|
||||
376
docs/i18n_admin_audit_and_restructure_proposal.md
Normal file
376
docs/i18n_admin_audit_and_restructure_proposal.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# 🌐 Admin i18n Audit & Újrastrukturálási Javaslat
|
||||
|
||||
**Dátum:** 2026-06-30
|
||||
**Készítette:** Rendszer-Architect
|
||||
**Verzió:** 1.0
|
||||
**Cél:** Admin felület nyelvi moduljainak teljes körű felmérése, központi nyelvi állomány kialakítása, oldalakra bontási terv, többnyelvű (dropdown) nyelvválasztó bevezetése
|
||||
|
||||
---
|
||||
|
||||
## 1. Jelenlegi Helyzet (As-Is)
|
||||
|
||||
### 1.1 Három teljesen elkülönülő i18n rendszer
|
||||
|
||||
| Rendszer | Hely | Formátum | Fájlok | Össz. kulcs | Nyelvek |
|
||||
|----------|------|----------|--------|-------------|---------|
|
||||
| **Backend** | `backend/static/locales/` | Nested JSON (UPPER_SNAKE) | 2 db | ~130 sor/fájl | hu, en |
|
||||
| **Frontend Admin** | `frontend_admin/i18n/locales/` | Nested JSON (camelCase) | 2 db | ~800-900 sor/fájl | hu, en |
|
||||
| **Frontend Old (src)** | `frontend/src/i18n/` (korábbi) | TS objektum | 6 db | ~1600 sor/fájl | hu, en, de, ro, cz, sk |
|
||||
|
||||
> **⚠️ A backend és a frontend rendszerek teljesen függetlenek.** Más kulcs-sémát használnak (dot-notation vs UPPER_SNAKE_CASE), így közöttük NINCS kulcs-megosztás.
|
||||
|
||||
### 1.2 Frontend Admin jelenlegi struktúrája
|
||||
|
||||
```
|
||||
frontend_admin/i18n/locales/
|
||||
├── en.json (902 sor / ~700 kulcs)
|
||||
└── hu.json (804 sor / ~650 kulcs)
|
||||
```
|
||||
|
||||
Gyökér-szekciók a JSON-ben:
|
||||
- `permissions` (~29 kulcs)
|
||||
- `packages` (~67 kulcs)
|
||||
- `garages` → `garages.details`, `garages.analytics`, `garages.employees`, `garages.fleet` (~180 kulcs)
|
||||
- `gamification` → `gamification.badges`, `.competitions`, `.config`, `.dashboard`, `.leaderboard`, `.ledger`, `.levels`, `.params`, `.point_rules`, `.seasons`, `.users`, `.user_detail` (~410 kulcs)
|
||||
- `providers` (~85 kulcs)
|
||||
- `users` → `users.details` (~98 kulcs)
|
||||
|
||||
### 1.3 Admin oldalak leképezése (Pages ↔ i18n szekciók)
|
||||
|
||||
| Oldal (Vue fájl) | i18n Szekció | Kulcsszám |
|
||||
|-----------------|-------------|-----------|
|
||||
| `/` (index.vue) | — | — (dashboard nincs lefordítva) |
|
||||
| `/permissions/` | `permissions.*` | ~29 |
|
||||
| `/packages/` | `packages.*` | ~67 |
|
||||
| `/garages/` + `garages/[id]/` | `garages.*`, `garages.details.*`, `garages.analytics.*`, `garages.employees.*`, `garages.fleet.*` | ~180 |
|
||||
| `/gamification/` (index) | `gamification.dashboard.*` | ~25 |
|
||||
| `/gamification/badges` | `gamification.badges.*` | ~35 |
|
||||
| `/gamification/competitions` | `gamification.competitions.*` | ~40 |
|
||||
| `/gamification/config` | `gamification.config.*` | ~25 |
|
||||
| `/gamification/leaderboard` | `gamification.leaderboard.*` | ~40 |
|
||||
| `/gamification/ledger` | `gamification.ledger.*` | ~30 |
|
||||
| `/gamification/levels` | `gamification.levels.*` | ~35 |
|
||||
| `/gamification/params` | `gamification.params.*` | ~25 |
|
||||
| `/gamification/point-rules` | `gamification.point_rules.*` | ~40 |
|
||||
| `/gamification/seasons` | `gamification.seasons.*` | ~30 |
|
||||
| `/gamification/users/` + `[id]` | `gamification.users.*`, `gamification.user_detail.*` | ~40+50 |
|
||||
| `/providers/` + `[id]` | `providers.*` | ~85 |
|
||||
| `/users/` + `[id]` | `users.*`, `users.details.*` | ~98 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Feltárt Problémák
|
||||
|
||||
### 2.1 Hiányzó központi (common/global) nyelvi szekció
|
||||
|
||||
Jelenleg **NINCS** `common` vagy `global` szekció az admin i18n-ben. Minden általános UI művelet (Mentés, Mégse, Törlés, Szerkesztés, Betöltés, Hiba) szekciónként DUPLIKÁLVA van.
|
||||
|
||||
### 2.2 Masszív kulcs-duplikáció
|
||||
|
||||
A `docs/i18n_duplicate_key_analysis.md` által feltárt frontend duplikációk az adminban is jelen vannak. Példák a `frontend_admin` fájlokból:
|
||||
|
||||
| Kulcs (EN) | Előfordulások száma | HU fordítás |
|
||||
|-----------|-------------------|-------------|
|
||||
| `cancel` | **9+** (permissions, packages, garages.employees, gamification.badges, competitions, levels, params, point_rules, seasons, providers) | "Mégse" |
|
||||
| `save` | **10+** (permissions, packages.details, garages.*, gamification.*, providers, users.details) | "Mentés" |
|
||||
| `loading` | **15+** (minden CRUD szekció) | "Betöltés..." |
|
||||
| `error` | **10+** (minden CRUD szekció) | "Nem sikerült..." |
|
||||
| `retry` | **10+** | "Újra" / "Újrapróbálkozás" (két változat!) |
|
||||
| `edit` | **8+** | "Szerkesztés" |
|
||||
| `delete` | **6+** | "Törlés" |
|
||||
| `actions` | **6+** | "Műveletek" |
|
||||
| `no_items` / `no_results` | **8+** | "Nincs..." |
|
||||
| `status` | **6+** | "Státusz" |
|
||||
| `save_error` | **8+** | "Hiba történt..." |
|
||||
|
||||
### 2.3 HU oldalon inkonzisztens fordítások
|
||||
|
||||
- `retry`: hol "Újra" (`permissions.retry`, `packages.retry`), hol "Újrapróbálkozás" (`gamification.badges.retry`)
|
||||
- `cancel`: hol "Mégse" (többnyire), de van ahol nincs következetesen
|
||||
- `save_success` vs `updated` vs `created`: eltérő sablonok
|
||||
|
||||
### 2.4 Csak 2 nyelv támogatott az adminban
|
||||
|
||||
A `nuxt.config.ts` i18n beállításaiban csak `hu` és `en` szerepel:
|
||||
```typescript
|
||||
locales: [
|
||||
{ code: 'hu', iso: 'hu-HU', file: 'hu.json', name: 'Magyar' },
|
||||
{ code: 'en', iso: 'en-GB', file: 'en.json', name: 'English' },
|
||||
]
|
||||
```
|
||||
|
||||
A `frontend_old` LanguageSwitcher már 4 nyelvet támogat (hu, en, de, fr).
|
||||
Előző audit szerint a korábbi `frontend/src/i18n/` is tartalmazott részleges de, ro, cz, sk fordításokat.
|
||||
|
||||
### 2.5 Nyelvválasztó jelenleg kétállású kapcsoló
|
||||
|
||||
A `frontend_admin/layouts/default.vue` topbar-jában a nyelvválasztó **két gomb** (HU / EN), nem pedig egy legördülő menü. Ez nem skálázható 3+ nyelv esetén.
|
||||
|
||||
### 2.6 Backend i18n aszimmetria
|
||||
|
||||
- A `backend/static/locales/hu.json` (83 sor) és `en.json` (76 sor) között eltérés van a kulcsok számában (pl. `SENTINEL.APPROVAL.REQUIRED` EN: "Approval required", HU-ban hosszabb)
|
||||
- Backend hiányzik: német és más nyelvű backend fordítások
|
||||
|
||||
---
|
||||
|
||||
## 3. Javasolt Megoldás (To-Be)
|
||||
|
||||
### 3.1 Központi/globális nyelvi állomány (`common`)
|
||||
|
||||
**Létrehozandó:** új `common` gyökér-szekció mindkét fájlban
|
||||
|
||||
Tartalma (javasolt kulcsok):
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"save": "Mentés",
|
||||
"saving": "Mentés...",
|
||||
"cancel": "Mégse",
|
||||
"confirm": "Megerősítés",
|
||||
"discard": "Elvetés",
|
||||
"delete": "Törlés",
|
||||
"deleting": "Törlés...",
|
||||
"edit": "Szerkesztés",
|
||||
"create": "Létrehozás",
|
||||
"close": "Bezárás",
|
||||
"back": "Vissza",
|
||||
"next": "Következő",
|
||||
"previous": "Előző",
|
||||
"search": "Keresés",
|
||||
"filter": "Szűrés",
|
||||
"reset": "Visszaállítás",
|
||||
"clear": "Törlés",
|
||||
"retry": "Újra",
|
||||
"loading": "Betöltés...",
|
||||
"error": "Hiba",
|
||||
"success": "Siker",
|
||||
"no_results": "Nincs találat",
|
||||
"no_data": "Nincs adat",
|
||||
"yes": "Igen",
|
||||
"no": "Nem",
|
||||
"continue": "Tovább",
|
||||
"exit": "Kilépés",
|
||||
"save_changes": "Változtatások Mentése",
|
||||
"confirm_delete": "Biztosan törölni szeretnéd?",
|
||||
"unknown_error": "Ismeretlen hiba",
|
||||
"all": "Mind",
|
||||
"none": "Nincs",
|
||||
"optional": "opcionális",
|
||||
"required": "kötelező"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Használati elv:** A Vue komponensek elsődlegesen a `common.*` kulcsokat használják. Csak ha egy gombnak szekció-specifikus szövege van (pl. "Csomag törlése" ≠ általános "Törlés"), akkor kell szekció-specifikus kulcs.
|
||||
|
||||
### 3.2 Oldalakra bontás javaslata (lazy loading támogatás)
|
||||
|
||||
Jelenleg a `nuxt.config.ts`-ben `lazy: false` van beállítva, ami azt jelenti, hogy minden nyelvi fájl betöltődik induláskor. A javasolt struktúra lehetővé teszi a `lazy: true` váltást.
|
||||
|
||||
**Új fájl struktúra:**
|
||||
|
||||
```
|
||||
frontend_admin/i18n/
|
||||
├── locales/
|
||||
│ ├── common.en.json ← központi alapértékek (EN)
|
||||
│ ├── common.hu.json ← központi alapértékek (HU)
|
||||
│ ├── permissions.en.json
|
||||
│ ├── permissions.hu.json
|
||||
│ ├── packages.en.json
|
||||
│ ├── packages.hu.json
|
||||
│ ├── garages.en.json
|
||||
│ ├── garages.hu.json
|
||||
│ ├── providers.en.json
|
||||
│ ├── providers.hu.json
|
||||
│ ├── users.en.json
|
||||
│ ├── users.hu.json
|
||||
│ ├── gamification-dashboard.{en,hu}.json
|
||||
│ ├── gamification-badges.{en,hu}.json
|
||||
│ ├── gamification-competitions.{en,hu}.json
|
||||
│ ├── gamification-config.{en,hu}.json
|
||||
│ ├── gamification-leaderboard.{en,hu}.json
|
||||
│ ├── gamification-ledger.{en,hu}.json
|
||||
│ ├── gamification-levels.{en,hu}.json
|
||||
│ ├── gamification-params.{en,hu}.json
|
||||
│ ├── gamification-point-rules.{en,hu}.json
|
||||
│ ├── gamification-seasons.{en,hu}.json
|
||||
│ ├── gamification-users.{en,hu}.json
|
||||
│ └── gamification-user-detail.{en,hu}.json
|
||||
```
|
||||
|
||||
**`nuxt.config.ts` módosítása:**
|
||||
|
||||
```typescript
|
||||
i18n: {
|
||||
locales: [
|
||||
{ code: 'hu', iso: 'hu-HU', file: 'common.hu.json', name: 'Magyar' },
|
||||
{ code: 'en', iso: 'en-GB', file: 'common.en.json', name: 'English' },
|
||||
{ code: 'de', iso: 'de-DE', file: 'common.de.json', name: 'Deutsch' },
|
||||
],
|
||||
defaultLocale: 'hu',
|
||||
lazy: true,
|
||||
langDir: 'locales/',
|
||||
strategy: 'no_prefix',
|
||||
detectBrowserLanguage: {
|
||||
useCookie: true,
|
||||
cookieKey: 'i18n_redirected',
|
||||
redirectOn: 'root',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Többnyelvűség (3+ nyelv) bevezetése
|
||||
|
||||
**Új nyelv hozzáadásának lépései:**
|
||||
|
||||
1. Új fájl létrehozása: pl. `common.de.json`, `permissions.de.json`, stb.
|
||||
2. A nyelv felvétele a `nuxt.config.ts` `locales` tömbjébe
|
||||
3. Fordítások elkészítése (kezdetben csak `common` + a leggyakoribb admin oldalak)
|
||||
4. Backend oldalon: új `de.json` fájl a `backend/static/locales/` mappába
|
||||
5. A backend `i18n.py` automatikusan betölti, ha a fájl létezik
|
||||
|
||||
**Előkészített nyelvek (részleges fordítások megléte alapján):**
|
||||
- **Deutsch (de)** — volt már részleges fordítás a régi frontend-ben
|
||||
- **Français (fr)** — volt a LanguageSwitcher-ben
|
||||
- **Română (ro)** — volt részleges fordítás a régi frontend-ben
|
||||
- **Čeština (cz)** — volt részleges fordítás a régi frontend-ben
|
||||
- **Slovenčina (sk)** — volt részleges fordítás a régi frontend-ben
|
||||
|
||||
### 3.4 Nyelvválasztó legördülő menüvé alakítása
|
||||
|
||||
**Jelenlegi kód** (`frontend_admin/layouts/default.vue`, 158-170. sor):
|
||||
|
||||
```html
|
||||
<!-- JELENLEG: kétállású kapcsoló -->
|
||||
<div class="flex items-center bg-slate-700/50 rounded-lg p-0.5">
|
||||
<button v-for="locale in availableLocales" :key="locale.code"
|
||||
@click="switchLocale(locale.code)"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded-md transition"
|
||||
:class="currentLocale === locale.code
|
||||
? 'bg-indigo-600 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-white'"
|
||||
>
|
||||
{{ locale.code === 'hu' ? 'HU' : 'EN' }}
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Javasolt új kód:**
|
||||
|
||||
```html
|
||||
<!-- ÚJ: legördülő nyelvválasztó -->
|
||||
<div class="language-switcher relative">
|
||||
<button @click="langDropdownOpen = !langDropdownOpen"
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-slate-700/50 rounded-lg text-xs font-medium
|
||||
hover:bg-slate-600/50 transition border border-slate-600/50"
|
||||
>
|
||||
<span>{{ getFlagEmoji(currentLocale) }}</span>
|
||||
<span>{{ getCurrentLanguageLabel() }}</span>
|
||||
<svg class="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div v-if="langDropdownOpen"
|
||||
class="absolute right-0 mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-50 min-w-[140px]"
|
||||
>
|
||||
<button v-for="locale in availableLocales" :key="locale.code"
|
||||
@click="switchLocale(locale.code); langDropdownOpen = false"
|
||||
class="w-full text-left px-3 py-2 text-xs hover:bg-slate-700 flex items-center gap-2 transition"
|
||||
:class="{ 'text-indigo-400': locale.code === currentLocale }"
|
||||
>
|
||||
<span>{{ getFlagEmoji(locale.code) }}</span>
|
||||
<span>{{ locale.name }}</span>
|
||||
<span v-if="locale.code === currentLocale" class="ml-auto text-indigo-400">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**`script` kiegészítés:**
|
||||
|
||||
```typescript
|
||||
const langDropdownOpen = ref(false)
|
||||
|
||||
function getFlagEmoji(code: string): string {
|
||||
const flags: Record<string, string> = {
|
||||
hu: '🇭🇺', en: '🇬🇧', de: '🇩🇪', fr: '🇫🇷',
|
||||
ro: '🇷🇴', cz: '🇨🇿', sk: '🇸🇰',
|
||||
}
|
||||
return flags[code] || '🌐'
|
||||
}
|
||||
|
||||
function getCurrentLanguageLabel(): string {
|
||||
const lang = availableLocales.value.find(l => l.code === currentLocale.value)
|
||||
return lang?.name || currentLocale.value.toUpperCase()
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Backend i18n kiegészítése
|
||||
|
||||
A backend `i18n.py` már támogatja az automatikus betöltést — bármely `.json` fájl a `backend/static/locales/` mappában automatikusan felkerül a cache-be. Új nyelv esetén csak a JSON fájlt kell létrehozni.
|
||||
|
||||
A `nuxt.config.ts`-ben a `PATCH /api/v1/auth/me/language` végpont már támogatja a nyelv perzisztálását a backend felé — ez működni fog új nyelvekkel is, mivel a `preferred_language` mező egy szabad string.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ütemezés (Mérföldkövek)
|
||||
|
||||
### M1: Központi `common` szekció létrehozása
|
||||
- Új `common` szekció felvétele az `en.json` és `hu.json` fájlokba
|
||||
- A duplikált kulcsok (cancel, save, loading, stb.) eltávolítása a szekció-specifikus részekből
|
||||
- Vue komponensek átírása: `$t('common.cancel')` használata a szekció-specifikus helyett
|
||||
- **Érintett fájlok:** `frontend_admin/i18n/locales/*.json`, `frontend_admin/pages/*.vue`, `frontend_admin/layouts/default.vue`
|
||||
|
||||
### M2: Fájlok oldalakra bontása (lazy loading)
|
||||
- A monolit `en.json` és `hu.json` szétbontása oldal-specifikus fájlokra
|
||||
- `nuxt.config.ts` frissítése (`lazy: true`)
|
||||
- Tesztelés: minden oldal megfelelően betölti a saját nyelvi fájlját
|
||||
- **Érintett fájlok:** `frontend_admin/i18n/locales/*`, `frontend_admin/nuxt.config.ts`
|
||||
|
||||
### M3: Nyelvválasztó dropdown bevezetése
|
||||
- `default.vue` layoutban a kétállású HU/EN kapcsoló cseréje legördülő menüre
|
||||
- Flag emoji támogatás
|
||||
- Több nyelv (de, fr) felvétele a konfigurációba
|
||||
- **Érintett fájlok:** `frontend_admin/layouts/default.vue`, `frontend_admin/nuxt.config.ts`
|
||||
|
||||
### M4: Új nyelvek backend támogatása
|
||||
- `de.json`, `fr.json` létrehozása a `backend/static/locales/` mappában
|
||||
- Legalább a COMMON és AUTH szekciók lefordítása
|
||||
- **Érintett fájlok:** `backend/static/locales/*.json`
|
||||
|
||||
### M5: Backend i18n aszimmetria javítása
|
||||
- `hu.json` és `en.json` kulcs-szinkronizációja
|
||||
- Hiányzó kulcsok pótlása
|
||||
- **Érintett fájlok:** `backend/static/locales/*.json`
|
||||
|
||||
---
|
||||
|
||||
## 5. Kockázatok és Függőségek
|
||||
|
||||
| Kockázat | Hatás | Valószínűség | Mérséklés |
|
||||
|----------|-------|-------------|-----------|
|
||||
| Vue komponensekben elszórt `$t('szekcio.kulcs')` hívások eltörnek az átnevezés után | Magas | Közepes | Codebase search (`$t(`) minden hívásra, fokozatos átállás |
|
||||
| Lazy loading miatt inicializálási késleltetés | Alacsony | Alacsony | `common` fájl előtöltése |
|
||||
| Backend és frontend közötti nyelvi kód inkonzisztencia | Közepes | Alacsony | Mindkét oldalon azonos ISO kódok használata |
|
||||
| Új nyelv hozzáadásánál a teljes admin felület nem lesz lefordítva | Közepes | Magas | Részleges fordítási státusz jelzése, angol fallback működik |
|
||||
|
||||
---
|
||||
|
||||
## 6. Összefoglaló
|
||||
|
||||
A jelenlegi admin i18n rendszer funkcionálisan működik, de strukturálisan optimalizálatlan:
|
||||
|
||||
1. **~50%-os kulcs-duplikáció** a `common` szekció hiánya miatt
|
||||
2. **Skálázhatatlan** - a monolit JSON fájlok 800+ sorosak, nehezen karbantarthatók
|
||||
3. **Csak 2 nyelv** - a régi frontend már 6 nyelvet ismert, az admin le van maradva
|
||||
4. **Kétállású kapcsoló** - nem bővíthető 3+ nyelvre
|
||||
|
||||
A javasolt restructuring:
|
||||
- Bevezeti a központi `common` szekciót (30+ általános kulcs)
|
||||
- Oldalakra bontja a monolit fájlokat (lazy loading)
|
||||
- Dropdown nyelvválasztóra cseréli a kapcsolót
|
||||
- Előkészíti a terepet 6+ nyelv támogatásához
|
||||
- Backend szinkronban marad a frontenddel
|
||||
72
docs/p0_address_manager_usage_audit_2026-07-01.md
Normal file
72
docs/p0_address_manager_usage_audit_2026-07-01.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# P0 Architecture Audit: AddressManager Usage Verification
|
||||
|
||||
**Date:** 2026-07-01
|
||||
**Auditor:** Rendszer-Architect
|
||||
**Scope:** Backend models, services, endpoints
|
||||
**Goal:** Verify that no direct SQL/ORM inserts/updates are bypassing the AddressManager
|
||||
|
||||
---
|
||||
|
||||
## 1. Model Analysis
|
||||
|
||||
### ✅ Organization (`backend/app/models/marketplace/organization.py`)
|
||||
- **Status: CLEAN** — Zero denormalized address fields
|
||||
- Uses only FK columns: `address_id`, `billing_address_id`, `notification_address_id` → `system.addresses.id`
|
||||
- Has proper SQLAlchemy relationship definitions for all three address types
|
||||
|
||||
### ✅ Branch (`backend/app/models/marketplace/organization.py:324-366`)
|
||||
- **Status: CLEAN** — Zero denormalized address fields
|
||||
- Uses only `address_id` FK → `system.addresses.id`
|
||||
|
||||
### ✅ ServiceStaging (in `backend/app/models/marketplace/service.py:170-195`)
|
||||
- **Status: CLEAN** — Uses `address_id` FK → `system.addresses.id`
|
||||
|
||||
### ✅ Address (`backend/app/models/identity/address.py:39-74`)
|
||||
- **Status: CLEAN** — Uses proper FK-based architecture
|
||||
- `zip` and `city` are **properties** resolved via the `postal_code` relationship
|
||||
|
||||
### ⚠️ ServiceProvider (`backend/app/models/identity/social.py:23-71`)
|
||||
- **Status: INTENTIONAL DENORMALIZATION** — Contains flat address fields
|
||||
- Marked "P0 HYBRID VENDOR REFACTOR" design choice
|
||||
|
||||
---
|
||||
|
||||
## 2. Critical Bugs Found
|
||||
|
||||
### 🔴 P0 CRITICAL: `admin.py:943` — `Organization.address_city` REMOVED from ORM
|
||||
|
||||
**File:** `backend/app/api/v1/endpoints/admin.py:943`
|
||||
|
||||
The `search_organizations_by_name` endpoint selects `Organization.address_city` which is **not defined** in the `Organization` SQLAlchemy model. SQLAlchemy will raise an error at runtime.
|
||||
|
||||
**Impact:** Admin organization search (`/admin/organizations/search?name=...`) is broken.
|
||||
|
||||
### 🟡 P1: `admin_persons.py:670-699` — Direct Address writes bypassing AddressManager
|
||||
|
||||
Directly writes `address.street_name = ...` without calling `AddressManager.create_or_update()`. No `postal_code_id` resolution.
|
||||
|
||||
### 🟡 P1: `admin_organizations.py` — Flat address fields silently ignored
|
||||
|
||||
`OrganizationUpdate` schema has `address_zip`, `address_city` etc. but `hasattr(org, "address_zip")` returns False → silently ignored.
|
||||
|
||||
---
|
||||
|
||||
## 3. Database Ghost Columns
|
||||
|
||||
The following old denormalized columns **still exist** in `fleet.organizations` table but have NO ORM mapping:
|
||||
`address_city`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`, `address_hrsz`, `billing_city/zip/street_name/street_type`, `notification_city/zip/street_name/street_type`
|
||||
|
||||
---
|
||||
|
||||
## 4. Summary
|
||||
|
||||
| Severity | Issue | File:Line |
|
||||
|----------|-------|-----------|
|
||||
| 🔴 P0 | `Organization.address_city` removed from ORM, still referenced | `admin.py:943` |
|
||||
| 🟡 P1 | Direct Address writes bypassing AddressManager | `admin_persons.py:670-699` |
|
||||
| 🟡 P1 | Flat address fields silently ignored on update | `admin_organizations.py:1242-1343` |
|
||||
| 🟡 P2 | Ghost columns in DB from old schema | `fleet.organizations` |
|
||||
| ✅ PASS | Organization model uses FKs only | `organization.py` |
|
||||
| ✅ PASS | Branch model uses FK only | `organization.py:333` |
|
||||
| ✅ PASS | admin_organizations.py correctly uses AddressManager | `admin_organizations.py:1313-1335` |
|
||||
| ✅ PASS | Address model properly normalized | `address.py` |
|
||||
263
docs/p0_address_unification_plan.md
Normal file
263
docs/p0_address_unification_plan.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# P0 Address Unification Plan
|
||||
|
||||
## Split-Brain Diagnosis: `marketplace.service_providers` vs. `system.addresses`
|
||||
|
||||
**Date:** 2026-07-07
|
||||
**Author:** Rendszer-Architect
|
||||
**Status:** Discovery Complete — Awaiting Approval for Implementation
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
The system suffers from a **split-brain address architecture**. Two models use the centralized [`system.addresses`](backend/app/models/identity/address.py:39) table via `address_id` UUID foreign keys correctly (`fleet.organizations`, `identity.persons`), but the [`marketplace.service_providers`](backend/app/models/identity/social.py:23) table still relies entirely on **legacy flat columns** for address storage.
|
||||
|
||||
The [`ServiceStaging`](backend/app/models/marketplace/staged_data.py:24) model is in a **hybrid state** — it has both flat columns and an `address_id` FK — and the admin API ([`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1157)) already has partial `AddressManager` integration. However, this creates a **dual-write pattern**: data flows to both the flat columns AND the normalized `system.addresses` table, with complex fallback logic.
|
||||
|
||||
The [`Organization`](backend/app/models/marketplace/organization.py:73) model was already fully refactored — the deprecated flat columns (`address_city`, `address_zip`, etc.) were **physically removed** from the database via [`cleanup_ghost_columns_2026_07.sql`](docs/sql/cleanup_ghost_columns_2026_07.sql). The [`ServiceProvider`](backend/app/models/identity/social.py:23) model needs the same treatment.
|
||||
|
||||
---
|
||||
|
||||
## 2. Full System Audit Results
|
||||
|
||||
### 2.1 Model Status Matrix
|
||||
|
||||
| Model | Table | Schema | Flat Columns | `address_id` FK | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| [`Address`](backend/app/models/identity/address.py:39) | `system.addresses` | `system` | ❌ (central) | UUID PK (self) | ✅ **Source of Truth** |
|
||||
| [`Organization`](backend/app/models/marketplace/organization.py:73) | `fleet.organizations` | `fleet` | ❌ (removed) | ✅ UUID FK (3x) | ✅ **Fully Refactored** |
|
||||
| [`Person`](backend/app/models/identity/person.py) | `identity.persons` | `identity` | ❌ | ✅ UUID FK | ✅ **Already correct** |
|
||||
| [`Branch`](backend/app/models/marketplace/organization.py) | `fleet.branches` | `fleet` | ❌ | ✅ UUID FK | ✅ **Already correct** |
|
||||
| [`ServiceProfile`](backend/app/models/marketplace/service.py:21) | `marketplace.service_profiles` | `marketplace` | ❌ | ❌ (uses `location` Geometry) | ✅ **Clean design** |
|
||||
| [`ServiceStaging`](backend/app/models/marketplace/staged_data.py:24) | `marketplace.service_staging` | `marketplace` | ✅ (`city`, `postal_code`, `full_address`) | ✅ UUID FK | ⚠️ **Hybrid — needs cleanup** |
|
||||
| [`ServiceProvider`](backend/app/models/identity/social.py:23) | `marketplace.service_providers` | `marketplace` | ✅ (`city`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`) | ❌ **NONE** | 🔴 **CORE PROBLEM** |
|
||||
|
||||
### 2.2 ServiceProvider Model — The Core Problem
|
||||
|
||||
File: [`backend/app/models/identity/social.py`](backend/app/models/identity/social.py:23)
|
||||
|
||||
The `ServiceProvider` model at lines 23-71 defines these flat address columns:
|
||||
|
||||
```python
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # line 38
|
||||
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # line 39
|
||||
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True) # line 40
|
||||
address_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # line 41
|
||||
address_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # line 42
|
||||
```
|
||||
|
||||
**There is NO `address_id` UUID foreign key to `system.addresses`.**
|
||||
|
||||
This means:
|
||||
- Address data cannot be shared/deduplicated across entities
|
||||
- No postal code normalization (no `GeoPostalCode` relationship)
|
||||
- The `AddressManager` creates `system.addresses` records but the results are **mapped back to flat columns** instead of storing the FK
|
||||
- The `_build_unified_providers_query()` in [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:284) must use a complex UNION ALL with flat field extraction
|
||||
|
||||
### 2.3 ServiceStaging Model — Hybrid State
|
||||
|
||||
File: [`backend/app/models/marketplace/staged_data.py`](backend/app/models/marketplace/staged_data.py:24)
|
||||
|
||||
The `ServiceStaging` model at line 81 already has the FK:
|
||||
|
||||
```python
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
|
||||
) # line 81
|
||||
```
|
||||
|
||||
But it ALSO retains flat columns at lines 25-27:
|
||||
|
||||
```python
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
postal_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
full_address: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
```
|
||||
|
||||
This hybrid state means the API writes to BOTH the `address_id` FK and the flat columns, creating data duplication.
|
||||
|
||||
### 2.4 Admin API — Partial Fix Complexity
|
||||
|
||||
File: [`backend/app/api/v1/endpoints/admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1157)
|
||||
|
||||
The `update_provider()` function at line 1157 has complex branching logic:
|
||||
|
||||
- **Lines 1242-1262**: For `ServiceStaging` (which has `address_id` FK) → uses `AddressManager.create_or_update()` then sets `provider_obj.address_id`
|
||||
- **Lines 1262-1302**: For `ServiceProvider` (NO `address_id` FK) → uses `AddressManager.create_or_update()` then **maps the result back to 12+ flat columns** via `addr_mapping` (lines 1277-1302)
|
||||
|
||||
This dual-write pattern is fragile and error-prone. The `addr_mapping` dict at lines 1277-1302 maps each `AddressIn` field to its flat column counterpart.
|
||||
|
||||
Similarly, on response:
|
||||
- **Lines 1507-1518**: For `ServiceProvider` → tries to resolve `address_detail` from `address_id` (which may be NULL for existing records)
|
||||
- **Lines 1548-1560**: For `ServiceStaging` → resolves `address_detail` from `address_id`
|
||||
|
||||
The [`_build_unified_providers_query()`](backend/app/api/v1/endpoints/admin_providers.py:284) at line 284 uses UNION ALL across 3 tables with flat field extraction:
|
||||
|
||||
```sql
|
||||
-- ServiceProvider branch (lines 306-320): selects flat columns
|
||||
-- ServiceStaging branch (lines 335-352): selects flat columns
|
||||
-- Organization branch (lines 369-390): JOINs with system.addresses
|
||||
```
|
||||
|
||||
### 2.5 Frontend Status
|
||||
|
||||
| Page | Path | SharedAddressForm | address_detail | Flat Fallback |
|
||||
|---|---|---|---|---|
|
||||
| **Provider Edit** | [`providers/[id]/edit.vue`](frontend_admin/pages/providers/%5Bid%5D/edit.vue) | ✅ (line 343) | ✅ (lines 810-823) | ⚠️ (lines 1133-1134) |
|
||||
| **Provider Detail** | [`providers/[id]/index.vue`](frontend_admin/pages/providers/%5Bid%5D/index.vue) | ❌ | ✅ (lines 144-166) | ✅ (lines 146, 153) |
|
||||
| **Provider List** | [`providers/index.vue`](frontend_admin/pages/providers/index.vue) | ❌ | ❌ uses `provider.address` only | ✅ (line 137) |
|
||||
| **Garage Edit** | [`garages/[id]/index.vue`](frontend_admin/pages/garages/%5Bid%5D/index.vue) | ✅ (line 534) | ✅ (lines 1014-1023) | ❌ |
|
||||
| **Garage List** | [`garages/index.vue`](frontend_admin/pages/garages/index.vue) | ❌ | ❌ uses `garage.city` only | ✅ (line 157) |
|
||||
|
||||
### 2.6 Already-Refactored Endpoints (Reference)
|
||||
|
||||
These endpoints already use `AddressManager` correctly and serve as reference implementations:
|
||||
|
||||
| Endpoint | File | How |
|
||||
|---|---|---|
|
||||
| **Admin Organizations** | [`admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py) | Lines 1097-1145: `AddressOut.model_validate(org.address)` for 3 address types |
|
||||
| **User Organizations** | [`organizations.py`](backend/app/api/v1/endpoints/organizations.py) | Lines 71-82: `AddressManager.create_or_update()` on org creation |
|
||||
| **Admin Persons** | [`admin_persons.py`](backend/app/api/v1/endpoints/admin_persons.py) | Lines 654-661: `AddressManager.create_or_update()` for person.address |
|
||||
| **User Profile** | [`users.py`](backend/app/api/v1/endpoints/users.py) | Lines 355-374: `GeoService.get_or_create_full_address()` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Migration Strategy: 3-Phase Plan
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Phase1["Phase 1: Database (Alembic)"]
|
||||
A1[Add address_id UUID FK to marketplace.service_providers]
|
||||
A2[Create data migration: backfill system.addresses from flat columns]
|
||||
A3[Set FK constraint + add relationship to ServiceProvider model]
|
||||
A4[Run sync_engine to verify schema]
|
||||
end
|
||||
|
||||
subgraph Phase2["Phase 2: Backend API"]
|
||||
B1[Simplify update_provider: remove addr_mapping logic]
|
||||
B2[Refactor _build_unified_providers_query: JOIN with system.addresses]
|
||||
B3[Update ProviderListItem/ProviderDetail schemas]
|
||||
B4[Remove flat address field fallback in get_provider_detail]
|
||||
end
|
||||
|
||||
subgraph Phase3["Phase 3: Frontend"]
|
||||
C1[providers/index.vue: show address_detail instead of flat address]
|
||||
C2[providers/[id]/index.vue: remove flat fallback]
|
||||
C3[Clean up flat address fields from edit.vue]
|
||||
C4[garages/index.vue: show address_detail instead of flat city]
|
||||
end
|
||||
|
||||
Phase1 --> Phase2 --> Phase3
|
||||
```
|
||||
|
||||
### Phase 1 — Database Migration (Model + sync_engine)
|
||||
|
||||
**Goal:** Add `address_id` FK to `ServiceProvider` and migrate existing data.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Add `address_id` column to `ServiceProvider` model** in [`social.py`](backend/app/models/identity/social.py:23):
|
||||
```python
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
|
||||
)
|
||||
address: Mapped[Optional["Address"]] = relationship("Address", lazy="selectin")
|
||||
```
|
||||
|
||||
2. **Create data migration script** that:
|
||||
- Iterates all `ServiceProvider` records with flat address data and NULL `address_id`
|
||||
- For each record, creates a `system.addresses` record via `AddressManager.create_or_update()`
|
||||
- Sets `service_providers.address_id` to the new UUID
|
||||
- Logs the migration results
|
||||
|
||||
3. **Run sync_engine**: `docker exec sf_api python /app/backend/app/scripts/sync_engine.py`
|
||||
|
||||
4. **Verify**: Query `service_providers` to confirm `address_id` is populated.
|
||||
|
||||
5. **Deferred**: Drop flat columns in a future migration after Phase 3 is complete and verified.
|
||||
|
||||
### Phase 2 — Backend API Refactor
|
||||
|
||||
**Goal:** Simplify `admin_providers.py` to use `AddressManager` directly without flat column mapping.
|
||||
|
||||
**Key changes in [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py):**
|
||||
|
||||
1. **Simplify `update_provider()`** (lines 1242-1302):
|
||||
- Remove the `addr_mapping` dict (lines 1277-1302) for `ServiceProvider`
|
||||
- Both branches now just call `AddressManager.create_or_update()` and set `provider_obj.address_id`
|
||||
- Remove flat column updates for `address_zip`, `address_street_name`, etc.
|
||||
|
||||
2. **Refactor `_build_unified_providers_query()`** (lines 284-407):
|
||||
- For `ServiceProvider`: Add LEFT JOIN to `system.addresses` and extract fields via relationship
|
||||
- Remove flat column SELECT for ServiceProvider
|
||||
- Keep flat fallback temporarily for backward compatibility
|
||||
|
||||
3. **Update response schemas** (lines 59-105, 134-151):
|
||||
- `ProviderListItem`: Add `address_detail: Optional[AddressOut]`
|
||||
- `ProviderDetail`: Mark flat `address` and `city` as DEPRECATED
|
||||
- `ProviderUpdateInput`: Keep `address_detail` as primary, mark flat fields deprecated
|
||||
|
||||
4. **Simplify `get_provider_detail()`** (lines 534-659):
|
||||
- Remove flat field fallback (lines 586-587: `sp.address or ""`)
|
||||
- Always resolve from `address_id` via `AddressManager.get_normalized()`
|
||||
|
||||
### Phase 3 — Frontend Cleanup
|
||||
|
||||
**Goal:** All provider/garage pages display and edit addresses via `address_detail` only.
|
||||
|
||||
**Detailed changes:**
|
||||
|
||||
1. **`providers/index.vue`** (line 137): Replace `provider.address` with `provider.address_detail?.full_address_text || [provider.address_detail?.zip, ...].filter(Boolean).join(' ')`
|
||||
|
||||
2. **`providers/[id]/index.vue`** (lines 144-166): Remove `|| provider.address` and `|| provider.city` fallbacks (lines 146, 153)
|
||||
|
||||
3. **`providers/[id]/edit.vue`** (lines 1133-1134): Remove `data.address` fallback from `data.address_detail || data.address || {}`
|
||||
|
||||
4. **`garages/index.vue`** (line 157): Replace `garage.city || '—'` with `garage.address_detail?.city || '—'` or similar
|
||||
|
||||
---
|
||||
|
||||
## 4. Risk Assessment
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---|---|
|
||||
| **Data loss** during migration (flat → normalized) | High | Migration script must be transactional with rollback. Test on staging first. |
|
||||
| **Existing API consumers** send flat address fields | Medium | Keep deprecated fields in schemas with `[DEPRECATED]` tags for one release cycle |
|
||||
| **Frontend list view** breaks if address_detail is missing | Medium | Add fallback to flat fields in list view during transition period |
|
||||
| **Service robots** (OSM Scout, OCR) write flat columns directly | Medium | Audit `service_robot_1_scout_osm.py` — it's in the modified files list |
|
||||
| **Unified query** (3-way UNION ALL) breaks | High | Refactor query carefully to maintain backward compatibility |
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
### Phase 1 — Database Migration
|
||||
- [ ] `marketplace.service_providers` has `address_id` UUID FK → `system.addresses.id`
|
||||
- [ ] All existing ServiceProvider records with flat address data have corresponding `system.addresses` records linked via `address_id`
|
||||
- [ ] `ServiceProvider` model has `address` relationship with `lazy="selectin"`
|
||||
- [ ] `sync_engine` reports no schema drift
|
||||
|
||||
### Phase 2 — Backend API
|
||||
- [ ] `update_provider()` no longer maps `AddressIn` fields to flat columns for `ServiceProvider`
|
||||
- [ ] `get_provider_detail()` returns `address_detail` from `address_id` relationship for all providers
|
||||
- [ ] `list_providers()` includes `address_detail` in each item
|
||||
- [ ] `_build_unified_providers_query()` uses LEFT JOIN for ServiceProvider instead of flat column extraction
|
||||
- [ ] Flat address fields (`address`, `city`) still work in API requests but are marked `[DEPRECATED]` in schemas
|
||||
|
||||
### Phase 3 — Frontend
|
||||
- [ ] Provider list page shows addresses from `address_detail` (not flat `provider.address`)
|
||||
- [ ] Provider detail page has no flat fallback for address fields
|
||||
- [ ] Provider edit page sends `address_detail` only (no flat fields)
|
||||
- [ ] Garage list page shows city from `address_detail`
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- [AddressManager service](backend/app/services/address_manager.py:41) — 4 static methods: `resolve()`, `create_or_update()`, `get_normalized()`, `_resolve_postal_code()`
|
||||
- [AddressIn/AddressOut schemas](backend/app/schemas/address.py:18) — Pydantic models for address API contracts
|
||||
- [SharedAddressForm component](frontend_admin/components/SharedAddressForm.vue) — Vue form component (search for usage via `SharedAddressForm`)
|
||||
- [Address model](backend/app/models/identity/address.py:39) — UUID PK, `postal_code_id` FK → `system.geo_postal_codes`
|
||||
- [Organization model (REFACTORED)](backend/app/models/marketplace/organization.py:105) — Reference implementation with 3 `address_id` FKs
|
||||
- [Admin Persons (REFACTORED)](backend/app/api/v1/endpoints/admin_persons.py:654) — Reference API implementation
|
||||
- [Ghost columns cleanup SQL](docs/sql/cleanup_ghost_columns_2026_07.sql) — Reference for how Organization's flat columns were removed
|
||||
279
docs/p0_discovery_provider_validation_audit.md
Normal file
279
docs/p0_discovery_provider_validation_audit.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# P0 DISCOVERY - Audit Report: Provider Validation & Scoring Engine
|
||||
|
||||
**Dátum:** 2026-07-07
|
||||
**Auditor:** Service Finder Rendszer-Architect
|
||||
**Verzió:** 1.0
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Executive Summary
|
||||
|
||||
Az Architect által definiált Trust & Validation scoring logika (Crawler=20, First Entry=40, User Confirm=+20, Auto-Approve Threshold=80, Admin Approve=100) **NINCS egységesen implementálva**. A pontozás három párhuzamos, egymástól független rendszerben fut, és egyik sem felel meg a specifikációnak.
|
||||
|
||||
---
|
||||
|
||||
## 1. 📡 CRAWLER/IMPORT ENDPOINT AUDIT
|
||||
|
||||
### 1.1 Robot 0 (Hunter - Google Places)
|
||||
|
||||
| Fájl | Sor |
|
||||
|------|-----|
|
||||
| `service_robot_0_hunter.py` | `trust_score=30 # Alapértelmezett bizalmi szint` (line 115) |
|
||||
|
||||
```python
|
||||
new_entry = ServiceStaging(
|
||||
...
|
||||
trust_score=30 # Hardcoded!
|
||||
)
|
||||
```
|
||||
|
||||
**Probléma:** A `trust_score` hardcoded `30`, míg az Architect specifikáció szerint Crawler forrásnál `20` kellene legyen. A `validation_level` mezőt nem állítja be explicit (default: `40` lesz a modellből).
|
||||
|
||||
### 1.2 Robot 1 (Scout - OSM)
|
||||
|
||||
| Fájl | Sor |
|
||||
|------|-----|
|
||||
| `service_robot_1_scout_osm.py` | `"trust_score": 20` (csak raw_data JSONB-ben!) (line 98) |
|
||||
|
||||
```python
|
||||
raw_payload = {
|
||||
"osm_tags": tags,
|
||||
"source": "osm_scout_v2",
|
||||
"trust_score": 20 # CSAK a raw_data JSONB-ben!
|
||||
}
|
||||
new_entry = ServiceStaging(
|
||||
...
|
||||
raw_data=raw_payload # trust_score NEM kerül a tényleges oszlopba
|
||||
)
|
||||
```
|
||||
|
||||
**Probléma:** A `trust_score: 20` a `raw_data` JSONB objektumba kerül, **NEM** a `ServiceStaging.trust_score` mezőbe! Az adatbázis oszlop értéke `0` marad (modell default). Ez adatvesztéshez vezet.
|
||||
|
||||
### 1.3 User Submission (Gamification Endpoint)
|
||||
|
||||
| Fájl | Sor |
|
||||
|------|-----|
|
||||
| `gamification.py` | `trust_weight = min(20 + (user_lvl * 6), 90)` (line 317) |
|
||||
|
||||
```python
|
||||
trust_weight = min(20 + (user_lvl * 6), 90)
|
||||
...
|
||||
new_staging = ServiceStaging(
|
||||
...
|
||||
trust_score=trust_weight, # Dinamikus, de NEM a First Entry=40 logikát követi
|
||||
)
|
||||
```
|
||||
|
||||
**Probléma:** A dinamikus számítás (20 + szint*6) NEM felel meg a "First Entry = 40" szabálynak. Egy 1-es szintű user `26`-ot kap, nem `40`-et.
|
||||
|
||||
### 1.4 Crawler Import Endpoint (Admin API)
|
||||
|
||||
Az `admin_providers.py` fájlban **nincs dedikált crawler import endpoint**. A meglévő `POST /{provider_id}/approve` és `POST /{provider_id}/reject` végpontok csak a már létező `ServiceProvider` rekordok státuszát módosítják, nem hoznak létre újakat.
|
||||
|
||||
---
|
||||
|
||||
## 2. ⚙️ VALIDATION ENGINE AUDIT
|
||||
|
||||
### 2.1 Több, egymástól független scoring mechanizmus
|
||||
|
||||
A rendszerben **három párhuzamos scoring rendszer** létezik, amelyek NINCSENEK összekapcsolva:
|
||||
|
||||
| # | Scoring System | Tábla | Mező | Ki működteti? |
|
||||
|---|---------------|-------|------|---------------|
|
||||
| 1 | **Trust Score** | `marketplace.service_staging` | `trust_score` (default: **0**) | Robotok, User Submission |
|
||||
| 2 | **Validation Level** | `marketplace.service_staging` | `validation_level` (default: **40**) | Community validation (`services.py`) |
|
||||
| 3 | **Validation Score** | `marketplace.service_providers` | `validation_score` (default: **0**) | Admin moderáció (`admin_providers.py`) |
|
||||
|
||||
### 2.2 Modellek összehasonlítása
|
||||
|
||||
#### `ServiceStaging` (`staged_data.py:54-59`)
|
||||
```python
|
||||
trust_score: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||
validation_level: Mapped[int] = mapped_column(Integer, default=40, server_default=text("40"))
|
||||
```
|
||||
- `trust_score`: Robot bizalmi szint (0-100)
|
||||
- `validation_level`: Közösségi validációs szint (default 40, max 80)
|
||||
|
||||
#### `ServiceProvider` (`social.py:59`)
|
||||
```python
|
||||
validation_score: Mapped[int] = mapped_column(Integer, default=0)
|
||||
```
|
||||
- `validation_score`: Moderációs pontszám (csak admin által állítható)
|
||||
|
||||
#### `ProviderValidation` (`social.py:87-123`)
|
||||
```python
|
||||
class ProviderValidation(Base):
|
||||
__tablename__ = "provider_validations"
|
||||
# validation_type: approve | reject | flag
|
||||
# weight: admin weight (default=1, admin uses 5)
|
||||
# metadata: JSONB for details
|
||||
```
|
||||
|
||||
### 2.3 ProviderValidation tábla - Jelenlegi használat
|
||||
|
||||
A `_create_provider_validation()` függvény minden admin akciókor (approve/reject/restore/flag) létrehoz egy rekordot:
|
||||
|
||||
| Akció | `validation_type` | `weight` | `validation_score` módosítás |
|
||||
|-------|-------------------|----------|------------------------------|
|
||||
| Approve | `approve` | 5 | `+= 50` |
|
||||
| Reject | `reject` | 5 | `= 0` |
|
||||
| Restore | `approve` | 5 | `= 50` |
|
||||
| Flag | `flag` | 5 | `-= 20` (min 0) |
|
||||
|
||||
**Hiányzó funkció:** A `weight` mezőt soha nem aggregálja a rendszer. Nincs olyan logika, ami a `ProviderValidation.weight`-ok összegét vizsgálná, és annak alapján auto-approve/-reject döntést hozna.
|
||||
|
||||
### 2.4 Community Validation (SocialService)
|
||||
|
||||
| Fájl | Sor |
|
||||
|------|-----|
|
||||
| `social_service.py` | `validation_score += vote_value` (lines 45-53) |
|
||||
|
||||
```python
|
||||
provider.validation_score += vote_value # +1 vagy -1
|
||||
if provider.validation_score >= 5:
|
||||
provider.status = ModerationStatus.approved
|
||||
elif provider.validation_score <= -3:
|
||||
provider.status = ModerationStatus.rejected
|
||||
```
|
||||
|
||||
**Probléma:** Ez az OLD community voting rendszer (`Vote` tábla, `+-1` szavazatok), ami NEM kapcsolódik a `validation_level` vagy az Architect által definiált scoring logikához. A küszöbértékek (`5` és `-3`) teljesen függetlenek a `80`-as auto-approve threshold-tól.
|
||||
|
||||
### 2.5 Service Validation (User Confirm)
|
||||
|
||||
| Fájl | Sor |
|
||||
|------|-----|
|
||||
| `services.py` | `validation_level += 10` (max 80) (lines 72-75) |
|
||||
|
||||
```python
|
||||
new_level = staging.validation_level + 10
|
||||
if new_level > 80:
|
||||
new_level = 80
|
||||
```
|
||||
|
||||
Ez a `validation_level`-t növeli (max 80), de ez **NEM befolyásolja** sem a `trust_score`-t, sem a `validation_score`-t. A `+20` User Confirm bónusz NINCS implementálva.
|
||||
|
||||
---
|
||||
|
||||
## 3. 🚀 PROMOTION LOGIC AUDIT (Threshold = 80)
|
||||
|
||||
### 3.1 System Robot 2 (ServiceAuditor)
|
||||
|
||||
| Fájl | Sor |
|
||||
|------|-----|
|
||||
| `system_robot_2_service_auditor.py` | Threshold default: **50** (line 37) |
|
||||
|
||||
```python
|
||||
threshold = value.get('trust_score', 50) if isinstance(value, dict) else 50
|
||||
if stage.trust_score >= threshold:
|
||||
# SIKERES AUDIT -> Organization + ServiceProfile
|
||||
```
|
||||
|
||||
**GAP:** A threshold default **50**, nem **80**. És a `trust_score`-t vizsgálja, nem a `validation_level`-t.
|
||||
|
||||
### 3.2 Service Robot 5 (Auditor)
|
||||
|
||||
| Fájl | Sor |
|
||||
|------|-----|
|
||||
| `service_robot_5_auditor.py` | Threshold default: **70** (lines 40-45) |
|
||||
|
||||
```python
|
||||
param = result.scalar_one_or_none()
|
||||
if param and param.value:
|
||||
return int(param.value)
|
||||
return 70 # Default
|
||||
if staging.trust_score < trust_threshold:
|
||||
staging.status = 'rejected'
|
||||
```
|
||||
|
||||
**GAP:** A threshold default **70**, nem **80**. És itt is a `trust_score`-t vizsgálja.
|
||||
|
||||
### 3.3 Admin Approve (validation_level = 100)
|
||||
|
||||
| Fájl | Sor |
|
||||
|------|-----|
|
||||
| `admin.py` | `validation_level = 100` (lines 265-266) |
|
||||
|
||||
```python
|
||||
staging.validation_level = 100
|
||||
staging.status = "approved"
|
||||
```
|
||||
|
||||
**Egyedül ez felel meg** az Architect specifikáció "Admin Approve = 100" szabályának, de ez csak a `ServiceStaging` rekordot jelöli meg, nem hoz létre `ServiceProvider`-t vagy `Organization`-t automatikusan.
|
||||
|
||||
---
|
||||
|
||||
## 4. 📊 HIÁNYOSSÁGOK ÖSSZEGZÉSE
|
||||
|
||||
### 🔴 P0 Critical Gaps
|
||||
|
||||
| # | Gap | Helye | Következmény |
|
||||
|---|-----|-------|-------------|
|
||||
| **G1** | **Nincs egységes scoring engine** | A teljes scoring logika hiányzik | A Crawler=20, First Entry=40, User Confirm=+20 szabályok NINCSENEK sehol implementálva |
|
||||
| **G2** | **Robot 1 (Scout) trust_score elveszik** | `service_robot_1_scout_osm.py:98` | A `trust_score: 20` a `raw_data` JSONB-ben landol, NEM az oszlopban. A rekord trust_score=0 marad. |
|
||||
| **G3** | **Auto-Approve Threshold=80 sehol nem érvényesül** | `system_robot_2_service_auditor.py:37` és `service_robot_5_auditor.py:45` | A robotok 50-es és 70-es threshold-ot használnak. A validation_level max 80-as limit elérése nem triggerel semmit. |
|
||||
|
||||
### 🟡 P1 Medium Gaps
|
||||
|
||||
| # | Gap | Helye | Következmény |
|
||||
|---|-----|-------|-------------|
|
||||
| **G4** | **Három független scoring rendszer** | `trust_score`, `validation_level`, `validation_score` | Nincs átjárás a rendszerek között. Egy magas validation_level nem növeli a validation_score-t. |
|
||||
| **G5** | **ProviderValidation weight aggregáció hiányzik** | `admin_providers.py:211` | A `weight` mező létezik, de soha nincs összegezve auto-approve döntéshez. |
|
||||
| **G6** | **User submission scoring nem spec-konform** | `gamification.py:317` | `min(20 + (user_lvl*6), 90)` helyett `40` kellene First Entry-nél. |
|
||||
|
||||
### 🟢 P2 Minor Gaps
|
||||
|
||||
| # | Gap | Helye | Következmény |
|
||||
|---|-----|-------|-------------|
|
||||
| **G7** | **Admin approve nem hoz létre Organization-t** | `admin.py:265-266` | Csak a `validation_level=100`-at állítja, de a promotion logika (Staging→Organization) nem fut le. |
|
||||
| **G8** | **SocialService vote nem kapcsolódik a scoring engine-hez** | `social_service.py:45-53` | A community vote (`+-1`) teljesen külön úton halad, a threshold-ok (5/-3) nem konfigurálhatók. |
|
||||
|
||||
---
|
||||
|
||||
## 5. 📋 Adatfolyam Diagram (Current State vs Desired State)
|
||||
|
||||
### Jelenlegi állapot (As-Is):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Robot 0 Hunter] -->|trust_score=30| B[ServiceStaging]
|
||||
C[Robot 1 Scout] -->|trust_score=0 raw_data.trust_score=20| B
|
||||
D[User Submission] -->|trust_score=26-90| B
|
||||
B -->|status=auditor_ready| E[System Robot 2]
|
||||
E -->|trust_score>=50| F[Organization + ServiceProfile]
|
||||
B -->|status=pending| G[Community Validation]
|
||||
G -->|validation_level+=10 max 80| B
|
||||
B -->|status=auditing| H[Service Robot 5]
|
||||
H -->|trust_score>=70| I[Organization + ServiceProfile]
|
||||
J[Admin] -->|validation_score+=50| K[ServiceProvider]
|
||||
```
|
||||
|
||||
### Kívánt állapot (To-Be) az Architect specifikáció alapján:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Crawler Import] -->|Crawler=20| B[Validation Engine]
|
||||
C[First Entry User] -->|First Entry=40| B
|
||||
D[User Confirm] -->|+20| B
|
||||
E[Admin Approve] -->|=100| B
|
||||
B -->|score>=80 Auto-Approve| F[Organization + ServiceProvider]
|
||||
B -->|score<80| G[Pending Review]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 🔧 Javasolt Javítási Sorrend
|
||||
|
||||
1. **P0 - Create a unified `validation_service.py`** that implements the Architect's scoring math:
|
||||
- `Crawler = 20` (source="bot")
|
||||
- `First Entry = 40` (source="manual", first submission)
|
||||
- `User Confirm = +20` (max 80)
|
||||
- `Auto-Approve Threshold = 80`
|
||||
- `Admin Approve = 100`
|
||||
|
||||
2. **P0 - Fix Robot 1 Scout** to write `trust_score` to the actual column, not just `raw_data`.
|
||||
|
||||
3. **P1 - Unify thresholds** in System Robot 2 and Service Robot 5 to use `validation_level` (or a unified score) instead of `trust_score`.
|
||||
|
||||
4. **P1 - Connect scoring systems** so `validation_level` increments also affect the provider's `validation_score`.
|
||||
|
||||
5. **P2 - Add auto-promotion** when unified score >= 80: auto-create `ServiceProvider` (or `Organization`) from `ServiceStaging`.
|
||||
193
docs/p0_provider_ingestion_gamification_audit_report.md
Normal file
193
docs/p0_provider_ingestion_gamification_audit_report.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# 🔍 P0 DISCOVERY — Provider Ingestion & Gamification Workflow Audit Report
|
||||
|
||||
**Dátum:** 2026-07-09
|
||||
**Auditor:** Service Finder Rendszer-Architect
|
||||
**Verzió:** 1.0
|
||||
**Mód:** READ-ONLY — NO FILES MODIFIED
|
||||
|
||||
---
|
||||
|
||||
## 📋 Executive Summary
|
||||
|
||||
Ez az audit a teljes Provider Ingestion & Gamification Workflow-t vizsgálja. A vizsgálat 4 audit területet fed le.
|
||||
|
||||
---
|
||||
|
||||
## 1. 🟢 AUDIT: Deduplication & Matching — STÁTUSZ: READY
|
||||
|
||||
### READY ✅
|
||||
|
||||
| Komponens | Fájl | Státusz |
|
||||
|-----------|------|---------|
|
||||
| Provider name exact match (ILIKE) | `backend/app/services/provider_service.py` (line 125) | ✅ READY |
|
||||
| Provider name fuzzy match (pg_trgm, threshold 0.6) | `backend/app/services/provider_service.py` (line 163) | ✅ READY |
|
||||
| find_or_create_provider_by_name() returns (provider, created) tuple | `backend/app/services/provider_service.py` (line 125) | ✅ READY |
|
||||
| Address dedup (postal_code_id + street_name + house_number) | `backend/app/services/address_manager.py` (line 145) | ✅ READY |
|
||||
| Union ALL search (3 sources) | `backend/app/services/provider_service.py` (line 215) | ✅ READY |
|
||||
|
||||
### PARTIAL 🟡
|
||||
|
||||
| Komponens | Státusz |
|
||||
|-----------|---------|
|
||||
| Tax number dedup for providers | 🟡 PARTIAL — Only exists for Organizations, not ServiceProviders |
|
||||
| Combined matching (name + address + tax_number) | 🟡 PARTIAL — Name-only dedup exists |
|
||||
|
||||
### MISSING ❌
|
||||
|
||||
| Komponens | Státusz |
|
||||
|-----------|---------|
|
||||
| Dedup by address for providers | ❌ MISSING — Name-only matching, no geo/address dedup for ServiceProvider |
|
||||
|
||||
---
|
||||
|
||||
## 2. 🟢 AUDIT: Gamification & Rules Engine — STÁTUSZ: PARTIAL
|
||||
|
||||
### READY ✅
|
||||
|
||||
| Komponens | Fájl | Státusz |
|
||||
|-----------|------|---------|
|
||||
| Dynamic point rules (26 rules in DB) | `gamification.point_rules` table | ✅ READY |
|
||||
| GamificationService.process_activity() | `backend/app/services/gamification_service.py` (line 74) | ✅ READY |
|
||||
| Penalty system (4 levels L0-L3) | `backend/app/services/gamification_service.py` (line 226) | ✅ READY |
|
||||
| Level-up logic | `backend/app/services/gamification_service.py` (line 258) | ✅ READY |
|
||||
| Social points → credits conversion | `backend/app/services/gamification_service.py` (line 265) | ✅ READY |
|
||||
| ADD_NEW_PROVIDER (500 XP) awarded | `backend/app/services/provider_service.py` (line 63) | ✅ READY |
|
||||
| PROVIDER_DISCOVERY (300 XP) awarded in expense hook | `backend/app/api/v1/endpoints/expenses.py` (line 568) | ✅ READY |
|
||||
| Admin approve → ADD_NEW_PROVIDER XP | `backend/app/api/v1/endpoints/admin_providers.py` (line 729) | ✅ READY |
|
||||
| Admin gamification API endpoints (full CRUD) | `backend/app/api/v1/endpoints/admin_gamification.py` | ✅ READY |
|
||||
| User-facing gamification endpoints | `backend/app/api/v1/endpoints/gamification.py` | ✅ READY |
|
||||
|
||||
### PARTIAL 🟡
|
||||
|
||||
| Komponens | Státusz |
|
||||
|-----------|---------|
|
||||
| PROVIDER_CONFIRMATION (150 XP) | 🟡 Defined in DB but NEVER awarded |
|
||||
| PROVIDER_VERIFIED_USE (100 XP) | 🟡 Defined in DB but NEVER awarded |
|
||||
| USE_UNVERIFIED_PROVIDER (200 XP) | 🟡 Defined in DB but NEVER awarded |
|
||||
| RATE_PROVIDER (250 XP) | 🟡 Defined in DB but NEVER awarded |
|
||||
| Subscription-level gamification multiplier | 🟡 No field exists in subscription_tiers |
|
||||
|
||||
### MISSING ❌
|
||||
|
||||
| Komponens | Státusz |
|
||||
|-----------|---------|
|
||||
| `social_service.py` hardcoded XP (50) — `process_activity(db, user_id, 50, 10, ...)` should be dynamic | ❌ MISSING |
|
||||
| `provider_validations` table — Masterbook spec, NEVER created | ❌ MISSING |
|
||||
|
||||
---
|
||||
|
||||
## 3. 🟢 AUDIT: AI / Invoice OCR — STÁTUSZ: PARTIAL
|
||||
|
||||
### READY ✅
|
||||
|
||||
| Komponens | Fájl | Státusz |
|
||||
|-----------|------|---------|
|
||||
| OCR Robot (OCRRobot class) | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 19) | ✅ READY |
|
||||
| AIService.process_ocr_document() | `backend/app/services/ai_service.py` (line 174) | ✅ READY |
|
||||
| Vision model support (Llama Vision) | `backend/app/services/ai_service.py` (line 48) | ✅ READY |
|
||||
| Fallback AI (Gemini/Groq) | `backend/app/services/ai_service.py` (line 81) | ✅ READY |
|
||||
| Trust Matching by tax_number | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 101) | ✅ READY |
|
||||
| OCR save to Document.ocr_data (JSONB) | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 122) | ✅ READY |
|
||||
| PREMIUM/VIP user filtering | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 42) | ✅ READY |
|
||||
|
||||
### MISSING ❌
|
||||
|
||||
| Komponens | Státusz |
|
||||
|-----------|---------|
|
||||
| AI prompt templates for invoice extraction (ai_prompt_ocr_*) | ❌ KRITIKUS — 0 rows in system_parameters |
|
||||
| OCR → service_staging pipeline | ❌ KRITIKUS — OCR saves to Document.ocr_data only, NEVER feeds into staging |
|
||||
| OCR → gamification hook | ❌ MISSING — No XP awarded for OCR-based provider discovery |
|
||||
|
||||
---
|
||||
|
||||
## 4. 🟢 AUDIT: Admin Configuration UI — STÁTUSZ: READY
|
||||
|
||||
### READY ✅
|
||||
|
||||
| Komponens | Fájl | Státusz |
|
||||
|-----------|------|---------|
|
||||
| Providers list page (stats, filters, search, pagination) | `frontend_admin/pages/providers/index.vue` | ✅ READY |
|
||||
| Providers pending queue (approve/reject) | `frontend_admin/pages/providers/pending.vue` | ✅ READY |
|
||||
| Providers rejected queue (restore) | `frontend_admin/pages/providers/rejected.vue` | ✅ READY |
|
||||
| Point rules CRUD (inline editing) | `frontend_admin/pages/gamification/point-rules.vue` | ✅ READY |
|
||||
| Master config editor (XP, penalty, conversion) | `frontend_admin/pages/gamification/config.vue` | ✅ READY |
|
||||
| Validation rules editor | `frontend_admin/pages/gamification/validation-rules.vue` | ✅ READY |
|
||||
| System parameters editor | `frontend_admin/pages/gamification/parameters.vue` | ✅ READY |
|
||||
| Badges, Competitions, Leaderboard, Levels, Seasons, Users | `frontend_admin/pages/gamification/` | ✅ READY |
|
||||
| Admin Provider API (full endpoint suite) | `backend/app/api/v1/endpoints/admin_providers.py` | ✅ READY |
|
||||
| Promotion Engine (staging → live) | `backend/app/api/v1/endpoints/admin_providers.py` (line 1399) | ✅ READY |
|
||||
|
||||
---
|
||||
|
||||
## 5. 📊 END-TO-END WORKFLOW STATUS MATRIX
|
||||
|
||||
```
|
||||
User uploads invoice
|
||||
│
|
||||
├── ✅ Step 1: OCR document created (pending_ocr)
|
||||
│
|
||||
├── ❌ Step 2: AI prompt templates exist? → NEM (0 rows in system_parameters)
|
||||
│ └── ⚠️ OCR Robot uses fallback prompt
|
||||
│
|
||||
├── 🟡 Step 3: AIService extracts provider data
|
||||
│ ├── ✅ Trust Matching by tax_number
|
||||
│ └── ❌ NO mapping to service_staging or service_providers
|
||||
│
|
||||
├── ❌ Step 4: OCR output → service_staging → NINCS KAPCSOLAT
|
||||
│
|
||||
├── ✅ Step 5: User creates expense with external_vendor_name
|
||||
│ └── ✅ PROVIDER_DISCOVERY hook fires (300 XP)
|
||||
│
|
||||
├── 🟡 Step 6: Provider status-based XP
|
||||
│ ├── ✅ ADD_NEW_PROVIDER (500 XP)
|
||||
│ ├── 🟡 PROVIDER_DISCOVERY (300 XP) — only for new providers
|
||||
│ ├── ❌ PROVIDER_CONFIRMATION (150 XP) — NEVER
|
||||
│ ├── ❌ PROVIDER_VERIFIED_USE (100 XP) — NEVER
|
||||
│ ├── ❌ USE_UNVERIFIED_PROVIDER (200 XP) — NEVER
|
||||
│ └── ❌ RATE_PROVIDER (250 XP) — NEVER
|
||||
│
|
||||
├── ✅ Step 7: Admin moderation (approve/reject/flag/restore)
|
||||
│ └── ✅ Promotion Engine (staging → live)
|
||||
│
|
||||
├── ✅ Step 8: Admin UI for all gamification config
|
||||
│
|
||||
└── ❌ Step 9: Subscription-tier multiplier → NINCS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 🔴 KRITIKUS HIÁNYOSSÁGOK (P0 Priority)
|
||||
|
||||
| # | Hiányosság | Hatás |
|
||||
|---|-----------|-------|
|
||||
| **P0-1** | AI prompt sablonok hiányoznak (`ai_prompt_ocr_*`) | OCR Robot nem tud pontos adatot kinyerni |
|
||||
| **P0-2** | OCR → service_staging pipeline hiányzik | OCR-ből kinyert provider adatok NEM kerülnek moderációs várólistába |
|
||||
| **P0-3** | 5 pontszabály sosem kerül kiosztásra | PROVIDER_CONFIRMATION, PROVIDER_VERIFIED_USE, USE_UNVERIFIED_PROVIDER, RATE_PROVIDER |
|
||||
| **P0-4** | Subscription tiers-ben nincs gamification multiplier | Előfizetési csomag nem befolyásolja a pontszerzést |
|
||||
| **P0-5** | `social_service.py` hardcoded 50 XP | Nem a dinamikus pontszabályt használja |
|
||||
|
||||
---
|
||||
|
||||
## 7. 🟡 JAVASLATOK (P1 Priority)
|
||||
|
||||
| # | Javaslat |
|
||||
|---|----------|
|
||||
| P1-1 | AI prompt sablonok seedelése (`ai_prompt_ocr_invoice`, `ai_prompt_ocr_provider`) |
|
||||
| P1-2 | OCR → staging pipeline megépítése |
|
||||
| P1-3 | Pontszabályok bekötése a `find_or_create_provider_by_name()`-be |
|
||||
| P1-4 | RATE_PROVIDER bekötése a `vote_for_provider()` metódusba |
|
||||
| P1-5 | Subscription-tier multiplier hozzáadása a `rules` JSONB-hez |
|
||||
|
||||
---
|
||||
|
||||
## 8. 📂 Korábbi Auditok Állapota
|
||||
|
||||
| Audit | Dátum | Státusz most |
|
||||
|-------|-------|-------------|
|
||||
| `p0_discovery_provider_validation_audit.md` | 2026-07-07 | Több GAP (3 párhuzamos scoring rendszer) továbbra is fennáll |
|
||||
| `p0_service_provider_gamification_audit_report.md` | 2026-06-30 | CRITICAL GAP (admin_providers.py hiányzott) → **MEGOLDVA** (implementálva 2026-07-07) |
|
||||
| `gamification_provider_connection_analysis.md` | 2026-06-30 | 5 pontszabály nem volt bekötve → PROVIDER_DISCOVERY **MEGOLDVA**, a többi 4 továbbra is hiányzik |
|
||||
|
||||
---
|
||||
|
||||
*Report generated by Service Finder Rendszer-Architect (DeepSeek Reasoner)*
|
||||
145
docs/p0_provider_taxonomy_vehicle_compatibility_audit.md
Normal file
145
docs/p0_provider_taxonomy_vehicle_compatibility_audit.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 🔍 P0 RECONNAISSANCE — Provider Taxonomy & Vehicle Compatibility Audit
|
||||
|
||||
**Date:** 2026-07-01
|
||||
**Scope:** Backend models, schemas, admin API endpoints
|
||||
**Status:** READ-ONLY AUDIT COMPLETE — NO FILES MODIFIED
|
||||
|
||||
---
|
||||
|
||||
## 🏁 EXECUTIVE SUMMARY
|
||||
|
||||
| Capability | Status | Details |
|
||||
|-----------|--------|---------|
|
||||
| **ExpertiseTag (4-level hierarchy)** | ✅ **EXISTS** | Full tree with `parent_id`, `level`, `path`, multi-language |
|
||||
| **Many-to-many Service→Tag link** | ✅ **EXISTS** | `ServiceExpertise` junction table with `confidence_level` |
|
||||
| **Vehicle types as Level-0 tags** | ✅ **EXISTS** | 6 vehicle types in taxonomy seed |
|
||||
| **Public API `category_ids` support** | ✅ **EXISTS** | `ProviderUpdateIn` + `ProviderQuickAddIn` accept `category_ids: List[int]` |
|
||||
| **Public API `ServiceExpertise` sync** | ✅ **EXISTS** | `_sync_service_expertises()` in `provider_service.py` |
|
||||
| **Admin API `category_ids` support** | ❌ **MISSING** | `ProviderUpdateInput` has only plain-text `category` field |
|
||||
| **Admin PATCH → ServiceExpertise sync** | ❌ **MISSING** | PATCH endpoint does NOT call `_sync_service_expertises()` |
|
||||
| **Vehicle Type Compatibility field** | ❌ **MISSING** | No `supported_vehicle_types` on any provider model |
|
||||
| **Vehicle Type→Provider junction table** | ❌ **MISSING** | No dedicated relationship table exists |
|
||||
|
||||
---
|
||||
|
||||
## 1️⃣ CATEGORY TAXONOMY (ExpertiseTag) — ✅ FULLY IMPLEMENTED
|
||||
|
||||
### Model: `ExpertiseTag` (`backend/app/models/marketplace/service.py:83`)
|
||||
- **Table:** `marketplace.expertise_tags`
|
||||
- **Multi-language:** `name_hu`, `name_en`, `name_translations` (JSONB)
|
||||
- **4-level hierarchy:** `parent_id`, `level` (0-3), `path`
|
||||
- **Self-referential tree:** `children` / `parent` relationship
|
||||
|
||||
### Junction: `ServiceExpertise` (`backend/app/models/marketplace/service.py:146`)
|
||||
- **Table:** `marketplace.service_expertises`
|
||||
- **Links:** `service_id` → `ServiceProfile.id` ←→ `expertise_id` → `ExpertiseTag.id`
|
||||
- **Extra:** `confidence_level`
|
||||
|
||||
### Vehicle Types in the 4-Level Taxonomy (Level 0)
|
||||
From `seed_expertise_taxonomy.py`:
|
||||
|
||||
| Key | HU Name | EN Name |
|
||||
|-----|---------|---------|
|
||||
| `szemelygepjarmu` | Személygépjármű | Passenger Car |
|
||||
| `motor` | Motorkerékpár | Motorcycle |
|
||||
| `kishaszonjarmu` | Kishaszonjármű | LCV |
|
||||
| `nagyhaszonjarmu_kamion` | Nagyhaszonjármű (Kamion) | HGV / Truck |
|
||||
| `munkagep_traktor` | Munkagép / Traktor | Agricultural / Construction Machinery |
|
||||
| `potkocsi_utanfuto` | Pótkocsi / Utánfutó | Trailer |
|
||||
|
||||
Plus **Universal** Level-1: Fuel Stations, Restaurants, Accommodation, Parking.
|
||||
|
||||
---
|
||||
|
||||
## 2️⃣ VEHICLE TYPE COMPATIBILITY — ❌ COMPLETELY MISSING
|
||||
|
||||
### `ServiceProvider` (`backend/app/models/identity/social.py:23`)
|
||||
- Fields: `id`, `name`, `address`, `category` (str), `city`, contact fields, status, source
|
||||
- **NO** `supported_vehicle_types`
|
||||
- **NO** vehicle type relationship
|
||||
|
||||
### `ServiceProfile` (`backend/app/models/marketplace/service.py:21`)
|
||||
- Has: `expertises` (ServiceExpertise relationship), `specialization_tags` (JSONB)
|
||||
- **NO** `supported_vehicle_types`
|
||||
|
||||
### Implicit tracking only
|
||||
The ONLY way vehicle type compatibility is tracked today is through the 4-level ExpertiseTag hierarchy (Level-0 tags). This is implicit, not explicit — no dedicated queryable field exists.
|
||||
|
||||
### `vehicle.vehicle_types` table exists but UNLINKED
|
||||
`VehicleType` (`backend/app/models/vehicle/vehicle_definitions.py:17`) is in the `vehicle` schema, used for catalog data. No FK link to providers.
|
||||
|
||||
---
|
||||
|
||||
## 3️⃣ ADMIN PROVIDER SCHEMAS — ⚠️ PARTIALLY IMPLEMENTED
|
||||
|
||||
### Public Schemas (✅ Handle `category_ids`)
|
||||
|
||||
| Schema | `category_ids` | `new_tags` |
|
||||
|--------|---------------|-----------|
|
||||
| `ProviderQuickAddIn` (`schemas/provider.py:135`) | ✅ `Optional[List[int]]` | ✅ `Optional[List[str]]` |
|
||||
| `ProviderUpdateIn` (`schemas/provider.py:173`) | ✅ `Optional[List[int]]` | ✅ `Optional[List[str]]` |
|
||||
|
||||
### Admin Schema (❌ MISSING)
|
||||
|
||||
| Schema | `category_ids` | `new_tags` | `supported_vehicle_types` |
|
||||
|--------|---------------|-----------|--------------------------|
|
||||
| `ProviderUpdateInput` (`admin_providers.py:123`) | ❌ **MISSING** | ❌ **MISSING** | ❌ **MISSING** |
|
||||
|
||||
Admin `ProviderUpdateInput` only has: `name`, `city`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`, `plus_code`, `contact_phone`, `contact_email`, `website`, plain-text `category`, `status`, `validation_score`.
|
||||
|
||||
### Admin PATCH (`admin_providers.py:1072`) — No ServiceExpertise sync
|
||||
The `update_provider()` function sets fields via `setattr` and logs to AuditLog, but **never** calls `_sync_service_expertises()`.
|
||||
|
||||
Compare with public endpoint in `provider_service.py:1070` which:
|
||||
1. Collects `category_ids` + `new_tag_ids`
|
||||
2. Creates new `ExpertiseTag` for `new_tags`
|
||||
3. Calls `_sync_service_expertises()` at line 1094
|
||||
|
||||
---
|
||||
|
||||
## 4️⃣ REQUIRED BACKEND CHANGES BEFORE FRONTEND MATRIX UI
|
||||
|
||||
### 🔴 P0 — MUST FIX: Admin `ProviderUpdateInput` schema
|
||||
**File:** `backend/app/api/v1/endpoints/admin_providers.py:123`
|
||||
Add: `category_ids: Optional[List[int]]`, `new_tags: Optional[List[str]]`
|
||||
|
||||
### 🔴 P0 — MUST FIX: Admin PATCH endpoint
|
||||
**File:** `backend/app/api/v1/endpoints/admin_providers.py:1072`
|
||||
Add `ServiceExpertise` sync after field updates.
|
||||
|
||||
### 🔴 P0 — MUST CREATE: Vehicle Type Compatibility Model
|
||||
New junction table `marketplace.provider_vehicle_types` linking `service_provider_id` to `expertise_tags.id` (Level-0 vehicle type tags).
|
||||
|
||||
### 🟡 P1 — ADD: `supported_vehicle_type_ids` to ProviderUpdateInput
|
||||
List of Level-0 ExpertiseTag IDs.
|
||||
|
||||
### 🟡 P1 — ADD: Categories + Vehicle Types to ProviderDetail response
|
||||
Add `supported_vehicle_types: List[dict]` and `categories: List[dict]` fields.
|
||||
|
||||
### 🟢 P2 — ADD: Frontend Edit Page `/providers/[id]/edit`
|
||||
Tabbed matrix layout: Basic Info | Categories (4-level tree) | Vehicle Types | Location
|
||||
|
||||
---
|
||||
|
||||
## 5️⃣ DATA FLOW COMPARISON
|
||||
|
||||
```
|
||||
CURRENT STATE:
|
||||
ServiceProvider ──category(str)──→ [plain text, no relationship]
|
||||
│
|
||||
└──ServiceProfile──ServiceExpertise──ExpertiseTag (public API only)
|
||||
|
||||
TARGET STATE:
|
||||
ServiceProvider ──category_ids──→ ServiceExpertise──ExpertiseTag (service categories)
|
||||
│
|
||||
└──supported_vehicle_type_ids──→ ProviderVehicleType──ExpertiseTag (Level-0 types)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6️⃣ KEY TAKEAWAYS
|
||||
|
||||
1. **ExpertiseTag hierarchy is production-ready** — 6 vehicle types at Level-0, full multi-language.
|
||||
2. **Public API already handles `category_ids`** — admin PATCH is the gap.
|
||||
3. **Vehicle type compatibility is entirely new** — reuse Level-0 ExpertiseTags as vehicle type references.
|
||||
4. **`category` varchar field is deprecated** — admin PATCH needs to catch up to structured ServiceExpertise.
|
||||
336
docs/p0_service_provider_gamification_audit_report.md
Normal file
336
docs/p0_service_provider_gamification_audit_report.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# 🔍 P0 RECONNAISSANCE — Service Provider & Gamification State Audit Report
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Auditor:** Rendszer-Architect (DeepSeek Reasoner)
|
||||
**Mode:** READ-ONLY — NO FILES MODIFIED
|
||||
**Status:** COMPLETE
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This report documents the deep-dive reconnaissance audit of the Service Provider ecosystem, Ratings/Validations, and Gamification modules. The audit covered 3 database schemas (`marketplace`, `identity`, `gamification`), 6 model files, 4 service files, 5 API endpoint files, and 12 router registrations.
|
||||
|
||||
**CRITICAL GAP FOUND:** No `admin_providers.py` router exists — the admin provider approval workflow is completely missing from the API layer. Admin UI for `/providers` pages cannot be built without this foundational layer.
|
||||
|
||||
---
|
||||
|
||||
## 1. Database Model Map
|
||||
|
||||
### 1.1 Marketplace Schema (`marketplace`)
|
||||
|
||||
| Table | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| `service_profiles` | `backend/app/models/marketplace/service.py:21` | Rich service profile linked to orgs or providers |
|
||||
| `expertise_tags` | `backend/app/models/marketplace/service.py:97` | 4-level category hierarchy (L0-L3) |
|
||||
| `service_expertises` | `backend/app/models/marketplace/service.py:147` | Junction table: ServiceProfile ↔ ExpertiseTag |
|
||||
| `service_staging` | `backend/app/models/marketplace/service.py:159` | Robot-collected service data (pre-moderation) |
|
||||
| `service_staging` | `backend/app/models/marketplace/staged_data.py:1` | Extended staging model (extend_existing=True) |
|
||||
| `discovery_parameters` | `backend/app/models/marketplace/service.py:191` | Robot discovery parameters |
|
||||
| `cost` | `backend/app/models/marketplace/service.py:205` | Service cost structure |
|
||||
|
||||
### 1.2 Identity Schema (`identity` — Social)
|
||||
|
||||
| Table | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| `service_providers` | `backend/app/models/identity/social.py:133` | Crowdsourced provider submissions (P0 refactor) |
|
||||
| `votes` | `backend/app/models/identity/social.py:186` | Community validation (+1/-1 per user/provider) |
|
||||
| `competitions` | `backend/app/models/identity/social.py:209` | Social competitions |
|
||||
| `user_scores` | `backend/app/models/identity/social.py:243` | User competition scores |
|
||||
| `service_reviews` | `backend/app/models/identity/social.py:266` | Verified reviews tied to financial transactions |
|
||||
|
||||
### 1.3 Gamification Schema (`gamification`)
|
||||
|
||||
| Table | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| `point_rules` | `backend/app/models/gamification/gamification.py:31` | Dynamic point rules (action_key → points) |
|
||||
| `points_ledger` | `backend/app/models/gamification/gamification.py:89` | Point transaction log |
|
||||
| `user_stats` | `backend/app/models/gamification/gamification.py:103` | User gamification stats |
|
||||
| `user_contributions` | `backend/app/models/gamification/gamification.py:137` | Contribution tracking + moderation |
|
||||
| `level_configs` | `backend/app/models/gamification/gamification.py:179` | Level definitions (positive + penalty) |
|
||||
| `seasonal_competitions` | `backend/app/models/gamification/gamification.py:213` | Seasonal events |
|
||||
| `badges` | `backend/app/models/gamification/gamification.py:252` | Achievement badges |
|
||||
| `user_badges` | `backend/app/models/gamification/gamification.py:275` | User-badge assignments |
|
||||
| `seasons` | `backend/app/models/gamification/gamification.py:296` | Competition seasons |
|
||||
|
||||
---
|
||||
|
||||
## 2. Trust & Validation Score Architecture
|
||||
|
||||
### 2.1 Score Fields on Models
|
||||
|
||||
| Field | Model | Default | Purpose |
|
||||
|-------|-------|---------|---------|
|
||||
| `validation_score` | `ServiceProvider` | `0` | Community validation aggregate (+1/-1 from Votes) |
|
||||
| `trust_score` | `ServiceProfile` | `30` | Trust engine score (Gondos Gazda Index) |
|
||||
| `is_verified` | `ServiceProfile` | `False` | Admin/manual verification flag |
|
||||
| `rating_*_avg` | `ServiceProfile` | `NULL` | Aggregated verified review ratings (4 dimensions) |
|
||||
| `rating_overall` | `ServiceProfile` | `NULL` | Overall rating from verified reviews |
|
||||
|
||||
### 2.2 CRITICAL: `provider_validations` Table NEVER Created
|
||||
|
||||
The Masterbook 2.0 spec mentioned a `provider_validations` table for XP farming prevention, **but it was never created in the codebase**. No model file, no Alembic migration, no sync_engine artifact exists for this table.
|
||||
|
||||
**Impact:** XP farming prevention for provider submissions relies solely on:
|
||||
- `UserContribution` status (pending/approved/rejected) — exists
|
||||
- `UserStats.providers_added_count` — exists
|
||||
- `Vote` table duplicate prevention (UniqueConstraint on user_id + provider_id) — exists
|
||||
|
||||
The dedicated anti-farming table was spec-only and never implemented.
|
||||
|
||||
### 2.3 Review Validation Rules
|
||||
|
||||
`ServiceReview` (from `backend/app/models/identity/social.py:266`):
|
||||
- Mandatory FK to `audit.financial_ledger.transaction_id` (UniqueConstraint)
|
||||
- 4 rating dimensions: `price_rating`, `quality_rating`, `time_rating`, `communication_rating` (1-10 scale)
|
||||
- Only users with verified financial transaction can review
|
||||
- `ServiceProfile.rating_*_avg` fields aggregate these values
|
||||
|
||||
---
|
||||
|
||||
## 3. Gamification Engine Analysis
|
||||
|
||||
### 3.1 Dynamic Point Rules
|
||||
|
||||
The gamification engine uses **dynamic rules** from `gamification.point_rules` table (not hardcoded):
|
||||
|
||||
| Action Key | Points | Description |
|
||||
|------------|--------|-------------|
|
||||
| `ADD_NEW_PROVIDER` | 500 | Adding a new service provider |
|
||||
| `USE_UNVERIFIED_PROVIDER` | 200 | Using an unverified provider |
|
||||
| `RATE_PROVIDER` | 250 | Rating a provider |
|
||||
| `UPDATE_PROVIDER` | 100 | Updating provider info |
|
||||
|
||||
Seeded by `backend/app/scripts/seed_gamification_rules.py`.
|
||||
|
||||
### 3.2 GamificationService Flow
|
||||
|
||||
From `backend/app/services/gamification_service.py`:
|
||||
1. `process_activity(user_id, action_key, ...)` — Main entry point
|
||||
2. Reads `PointRule` from DB by action_key
|
||||
3. Applies level multiplier via `_calculate_multiplier()`
|
||||
4. Penalty system: 4 levels (L0=100%, L1=50%, L2=10%, L3=0%)
|
||||
5. Calls `_handle_level_up()` for cross-level logic
|
||||
6. Social points → credits conversion at configurable rate
|
||||
7. Config loaded from `GAMIFICATION_MASTER_CONFIG` system parameter
|
||||
|
||||
### 3.3 Provider Service Gamification Integration
|
||||
|
||||
From `backend/app/services/provider_service.py`:
|
||||
- `_award_provider_points()` — Reads PointRule from DB, awards through GamificationService
|
||||
- Called on: `quick_add_provider()` and `update_provider()`
|
||||
- `UserContribution` records created with status "pending" for admin review
|
||||
|
||||
---
|
||||
|
||||
## 4. API Endpoint Inventory
|
||||
|
||||
### 4.1 User-Facing Provider Endpoints (`/providers` prefix)
|
||||
|
||||
From `backend/app/api/v1/endpoints/providers.py`:
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/providers/categories/tree` | Full hierarchical category tree (4-level) |
|
||||
| GET | `/providers/categories/autocomplete` | Text search for L2-L3 tags |
|
||||
| GET | `/providers/categories` | Flat list of expertise categories |
|
||||
| GET | `/providers/search` | Unified provider search (3 sources) |
|
||||
| POST | `/providers/quick-add` | Quick provider submission (crowdsourced) |
|
||||
| PUT | `/providers/{provider_id}` | Update provider details (hybrid save) |
|
||||
|
||||
### 4.2 Admin Gamification Endpoints (`/admin/gamification` prefix)
|
||||
|
||||
From `backend/app/api/v1/endpoints/admin_gamification.py`:
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET/POST | `/admin/gamification/point-rules` | List/Create point rules |
|
||||
| GET/PUT/DELETE | `/admin/gamification/point-rules/{id}` | CRUD single rule |
|
||||
| GET/POST | `/admin/gamification/levels` | List/Create level configs |
|
||||
| PUT | `/admin/gamification/levels/{id}` | Update level config |
|
||||
| GET/POST | `/admin/gamification/badges` | List/Create badges |
|
||||
| GET/PUT | `/admin/gamification/user-stats` | List/Update user stats |
|
||||
| GET | `/admin/gamification/user-stats/{id}` | Get user stat detail |
|
||||
| POST | `/admin/gamification/user-stats/{id}/penalty` | Apply penalty |
|
||||
| POST | `/admin/gamification/user-stats/{id}/reward` | Apply reward |
|
||||
| GET | `/admin/gamification/contributions` | List contributions |
|
||||
| PUT | `/admin/gamification/contributions/{id}/review` | Approve/reject contribution |
|
||||
| GET/POST | `/admin/gamification/seasons` | List/Create seasons |
|
||||
| PUT/DELETE | `/admin/gamification/seasons/{id}` | Update/Delete season |
|
||||
| GET/POST | `/admin/gamification/competitions` | List/Create competitions |
|
||||
| PUT/DELETE | `/admin/gamification/competitions/{id}` | Update/Delete competition |
|
||||
| GET | `/admin/gamification/leaderboard` | Admin leaderboard |
|
||||
| GET | `/admin/gamification/dashboard` | Dashboard summary |
|
||||
| GET/PUT | `/admin/gamification/master-config` | Master config CRUD |
|
||||
|
||||
### 4.3 User-Facing Gamification Endpoints (`/gamification` prefix)
|
||||
|
||||
From `backend/app/api/v1/endpoints/gamification.py`:
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/gamification/my-stats` | Current user stats |
|
||||
| GET | `/gamification/leaderboard` | Public leaderboard |
|
||||
| GET | `/gamification/seasons` | List seasons |
|
||||
| GET | `/gamification/my-contributions` | User contributions |
|
||||
| POST | `/gamification/submit-service` | Submit service (gamified) |
|
||||
| GET | `/gamification/quiz/daily` | Daily quiz |
|
||||
| POST | `/gamification/quiz/answer` | Answer quiz |
|
||||
| GET/POST | `/gamification/badges` | Badge listing/awarding |
|
||||
| GET | `/gamification/achievements` | Achievement list |
|
||||
|
||||
### 4.4 Existing Admin Routers
|
||||
|
||||
| Router File | Prefix | Module |
|
||||
|-------------|--------|--------|
|
||||
| `admin.py` | `/admin` | General admin (health, params, pending actions) |
|
||||
| `admin_gamification.py` | `/admin/gamification` | Gamification admin CRUD |
|
||||
| `admin_organizations.py` | `/admin/organizations` | Organization CRM |
|
||||
| `admin_users.py` | `/admin/users` | User management |
|
||||
| `admin_persons.py` | `/admin/persons` | Person management |
|
||||
| `admin_packages.py` | `/admin/packages` | Package management |
|
||||
| `admin_services.py` | `/admin/services` | Service catalog |
|
||||
| `admin_permissions.py` | `(root)` | Permission/RBAC management |
|
||||
|
||||
---
|
||||
|
||||
## 5. GAP ANALYSIS — Missing Admin Provider API
|
||||
|
||||
### 5.1 CRITICAL GAP: No `admin_providers.py` Router
|
||||
|
||||
**There is NO `/admin/providers` router.** The file `admin_providers.py` does not exist in the endpoints directory. The following router files DO exist:
|
||||
- `admin_gamification.py`, `admin_organizations.py`, `admin_users.py`, `admin_persons.py`, `admin_packages.py`, `admin_services.py`, `admin_permissions.py`
|
||||
- `admin.py` (general admin)
|
||||
|
||||
**Impact:** Admin users cannot manage providers through the API. The Nuxt Admin Frontend cannot build `/providers` pages without this backend layer.
|
||||
|
||||
### 5.2 Complete List of Missing Endpoints
|
||||
|
||||
| # | Method | Path | Purpose |
|
||||
|---|--------|------|---------|
|
||||
| 1 | GET | `/admin/providers` | List all providers from 3 sources (orgs, staging, crowd) |
|
||||
| 2 | GET | `/admin/providers/pending` | List pending providers awaiting approval |
|
||||
| 3 | GET | `/admin/providers/{id}` | Get detailed provider info (merged from all sources) |
|
||||
| 4 | PUT | `/admin/providers/{id}/approve` | Approve provider → set status, award submitter XP |
|
||||
| 5 | PUT | `/admin/providers/{id}/reject` | Reject provider with reason → flag UserContribution |
|
||||
| 6 | GET | `/admin/providers/staging` | List robot-collected staging entries |
|
||||
| 7 | PUT | `/admin/providers/staging/{id}/promote` | Promote staging → ServiceProfile (verify & create org) |
|
||||
| 8 | PUT | `/admin/providers/staging/{id}/reject` | Reject staging entry with reason |
|
||||
| 9 | GET | `/admin/providers/{id}/votes` | List community validation votes for a provider |
|
||||
| 10 | GET | `/admin/providers/{id}/reviews` | List service reviews for a provider |
|
||||
| 11 | PUT | `/admin/providers/{provider_id}/verify` | Toggle verified status on ServiceProfile |
|
||||
| 12 | PUT | `/admin/providers/{provider_id}/trust-score` | Manually adjust trust_score |
|
||||
| 13 | GET | `/admin/providers/validations` | List pending community validations (Votes table) |
|
||||
| 14 | PUT | `/admin/providers/staging/{id}` | Update staging entry (edit robot-collected data) |
|
||||
|
||||
### 5.3 Dependencies for Building These Endpoints
|
||||
|
||||
| Dependency | Current Status | Notes |
|
||||
|------------|---------------|-------|
|
||||
| `ServiceProvider` model (`social.py:133`) | Complete | Includes validation_score, status, source |
|
||||
| `ServiceProfile` model (`service.py:21`) | Complete | Includes trust_score, is_verified, verification_log |
|
||||
| `ServiceStaging` model (`service.py:159`) | Complete | Includes status, validation_level, audit_trail |
|
||||
| `Vote` model (`social.py:186`) | Complete | Includes weight, user_id, provider_id |
|
||||
| `ServiceReview` model (`social.py:266`) | Complete | Verified reviews with financial transaction linkage |
|
||||
| `GamificationService` (`gamification_service.py`) | Complete | Can award XP when admin approves |
|
||||
| `UserContribution` model (`gamification.py:137`) | Complete | Status field for pending/approved/rejected |
|
||||
| Permission `providers:manage` | Unknown | Needs to exist in RBAC or be created |
|
||||
| Schemas for admin provider endpoints | MISSING | Need `ProviderAdminOut`, `ProviderAdminList`, etc. |
|
||||
|
||||
### 5.4 Provider Data Flow (Current State)
|
||||
|
||||
```
|
||||
+-------------------+ +-------------------+ +-------------------+
|
||||
| Robot (Staging) | | Crowdsourced | | Verified Org |
|
||||
| service_staging | | service_providers | | fleet.orgs |
|
||||
| status: pending | | status: pending | | is_verified: T |
|
||||
+--------+----------+ +--------+----------+ +--------+----------+
|
||||
| | |
|
||||
v v v
|
||||
+---------------------------------------------------------------+
|
||||
| User-Facing API (providers.py) |
|
||||
| GET /providers/search -- UNION of all 3 sources |
|
||||
| POST /providers/quick-add -- Creates ServiceProvider |
|
||||
| PUT /providers/{id} -- Update with migration logic |
|
||||
+---------------------------------------------------------------+
|
||||
| | |
|
||||
| ** NO ADMIN LAYER EXISTS ** |
|
||||
v v v
|
||||
+---------------------------------------------------------------+
|
||||
| MISSING: Admin Provider Workflow |
|
||||
| - No approval/rejection for pending providers |
|
||||
| - No staging promotion to verified org |
|
||||
| - No trust_score manual adjustment |
|
||||
| - No community validation vote review |
|
||||
| - No service review moderation |
|
||||
+---------------------------------------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Gitea Task Breakdown (Required for Implementation)
|
||||
|
||||
Based on the 3A granularity principle, the implementation of the admin provider layer requires these Gitea tasks:
|
||||
|
||||
### Epic: Admin Provider Management
|
||||
|
||||
| # | Task | Priority | Dependencies |
|
||||
|---|------|----------|-------------|
|
||||
| 1 | Create schemas for admin provider endpoints | P0 | None |
|
||||
| 2 | Implement `admin_providers.py` - List/Get providers | P0 | Schema |
|
||||
| 3 | Implement approve/reject provider logic | P0 | GamificationService |
|
||||
| 4 | Implement staging management (list/promote/reject) | P0 | None |
|
||||
| 5 | Implement community validation vote review | P1 | #4 |
|
||||
| 6 | Implement service review moderation | P1 | #5 |
|
||||
| 7 | Register router in `api.py` at `/admin/providers` | P0 | #2 |
|
||||
| 8 | Implement manual trust_score adjustment endpoint | P1 | #6 |
|
||||
| 9 | Create RBAC permission `providers:manage` | P0 | None |
|
||||
| 10 | E2E tests for admin provider workflow | P1 | #2-#9 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Gamification Gaps & Observations
|
||||
|
||||
### 7.1 No Admin-Triggered Gamification Rewards
|
||||
|
||||
When an admin approves a provider submission, there is **no endpoint** to trigger `GamificationService.process_activity()` for the submitter. The `UserContribution` record exists but there's no automated XP award upon approval.
|
||||
|
||||
**Fix:** The `PUT /admin/providers/{id}/approve` endpoint must call:
|
||||
```python
|
||||
await gamification_service.process_activity(
|
||||
db=db,
|
||||
user_id=submitter_user_id,
|
||||
action_key="ADD_NEW_PROVIDER",
|
||||
source_type="provider_approval",
|
||||
source_id=provider_id
|
||||
)
|
||||
```
|
||||
|
||||
### 7.2 No Rejected Contribution Feedback Loop
|
||||
|
||||
When a provider is rejected, the `UserContribution.status` should be set to `rejected` with a rejection reason, but there's no endpoint to trigger this for admin workflows.
|
||||
|
||||
### 7.3 Gamification Config is Dynamic but Has No Validation
|
||||
|
||||
The `GAMIFICATION_MASTER_CONFIG` system parameter can be edited freely via the admin gamification API, but there's **no schema validation** on the JSONB structure. A malformed config could break the gamification engine.
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary of Findings
|
||||
|
||||
| Category | Status | Details |
|
||||
|----------|--------|---------|
|
||||
| Database Models | COMPLETE | All 15+ models across 3 schemas |
|
||||
| User-Facing Provider API | COMPLETE | 6 endpoints in providers.py |
|
||||
| User-Facing Gamification API | COMPLETE | 15+ endpoints in gamification.py |
|
||||
| Admin Gamification API | COMPLETE | 25+ endpoints in admin_gamification.py |
|
||||
| Gamification Service | COMPLETE | Full engine with dynamic rules, penalties |
|
||||
| Provider Service | COMPLETE | P0 hybrid vendor refactor applied |
|
||||
| **Admin Provider API** | **MISSING** | **No admin_providers.py - 14 endpoints missing** |
|
||||
| **provider_validations table** | **NEVER CREATED** | Spec-only, not in codebase |
|
||||
| **Trust Engine** | **STUB** | trust_score exists but no active scoring logic |
|
||||
| **Admin Rejection Gamification** | **MISSING** | No endpoint to award/reject XP on moderation |
|
||||
|
||||
---
|
||||
|
||||
*End of P0 Reconnaissance Report. This is a READ-ONLY analysis - no files were modified.*
|
||||
65
docs/sql/cleanup_ghost_columns_2026_07.sql
Normal file
65
docs/sql/cleanup_ghost_columns_2026_07.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
-- =============================================================================
|
||||
-- P0 ARCHITECTURE CLEANUP: Ghost Column Removal Script
|
||||
-- Generated: 2026-07-01
|
||||
--
|
||||
-- ⚠️ WARNING: Run this script ONLY after verifying that ALL code references
|
||||
-- to these ghost columns have been removed from the codebase.
|
||||
--
|
||||
-- These columns were previously on fleet.organizations but have been
|
||||
-- replaced by the unified address system (system.addresses FK via
|
||||
-- address_id, billing_address_id, notification_address_id).
|
||||
--
|
||||
-- The Organization model now uses Python properties that delegate to
|
||||
-- the `address` relationship (Address → GeoPostalCode).
|
||||
--
|
||||
-- Audit reference: docs/p0_address_manager_usage_audit_2026-07-01.md
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- fleet.organizations — Primary Address Ghost Columns
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS address_city;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS address_zip;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS address_street_name;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS address_street_type;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS address_house_number;
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- fleet.organizations — Billing Address Ghost Columns
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS billing_zip;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS billing_city;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS billing_street_name;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS billing_street_type;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS billing_house_number;
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- fleet.organizations — Notification Address Ghost Columns
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS notification_zip;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS notification_city;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS notification_street_name;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS notification_street_type;
|
||||
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS notification_house_number;
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- Verification Queries (run after migration to confirm cleanup)
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Should return 0 rows (all ghost columns removed):
|
||||
-- SELECT column_name
|
||||
-- FROM information_schema.columns
|
||||
-- WHERE table_schema = 'fleet'
|
||||
-- AND table_name = 'organizations'
|
||||
-- AND column_name IN (
|
||||
-- 'address_city', 'address_zip', 'address_street_name', 'address_street_type', 'address_house_number',
|
||||
-- 'billing_zip', 'billing_city', 'billing_street_name', 'billing_street_type', 'billing_house_number',
|
||||
-- 'notification_zip', 'notification_city', 'notification_street_name', 'notification_street_type', 'notification_house_number'
|
||||
-- );
|
||||
|
||||
COMMIT;
|
||||
305
docs/tag_category_system_audit_report.md
Normal file
305
docs/tag_category_system_audit_report.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# 🏷️ Címke/Kategória Rendszer Egységességi Audit — Backend & API
|
||||
|
||||
**Dátum:** 2026-07-01
|
||||
**Auditor:** Rendszer-Architect
|
||||
**Hatáskör:** Backend modellek, Pydantic sémák, API végpontok, service réteg
|
||||
**Cél:** A címke-kezelés (tag/category/specialization) egységességének vizsgálata
|
||||
|
||||
---
|
||||
|
||||
## 1. VEZETŐI ÖSSZEFOGLALÓ
|
||||
|
||||
A rendszerben **5 különböző címke koncepció** létezik egymás mellett, részben átfedő funkciókkal. A legkritikusabb probléma a **duális címke rendszer** (`ExpertiseTag` struktúrált fa vs. `specialization_tags` JSONB), valamint a **hiányzó `tags` mező** az admin API-ból. Az alábbi jelentés részletesen feltárja az összes anomáliát.
|
||||
|
||||
---
|
||||
|
||||
## 2. AZONOSÍTOTT CÍMKE RENDSZEREK
|
||||
|
||||
### 2.1 ExpertiseTag (4-szintű hierarchikus fa)
|
||||
| Tulajdonság | Érték |
|
||||
|---|---|
|
||||
| **Tábla** | `marketplace.expertise_tags` |
|
||||
| **Struktúra** | Adjacency List (`parent_id`) + Materialized Path (`path`) |
|
||||
| **Szintek** | Level 0 (Járműtípus) → Level 3 (Specifikus feladat) |
|
||||
| **Kötés** | `ServiceExpertise` junction táblán keresztül |
|
||||
| **Többnyelvűség** | `name_hu`, `name_en`, `name_translations` (JSONB) |
|
||||
| **Játékosítás** | `is_official`, `discovery_points`, `usage_count`, `suggested_by_id` |
|
||||
| **API végpontok** | `GET /providers/categories/tree`, `/categories/autocomplete`, `/categories` |
|
||||
|
||||
**Státusz:** ✅ Jól strukturált, DDD-konform.
|
||||
|
||||
### 2.2 ServiceProfile.specialization_tags (JSONB — strukturálatlan)
|
||||
| Tulajdonság | Érték |
|
||||
|---|---|
|
||||
| **Modell** | `ServiceProfile.specialization_tags` |
|
||||
| **Típus** | `JSONB`, server_default=`'{}'::jsonb` |
|
||||
| **Tartalom** | `{"primary_category": "str", "user_tags": ["cimke1", "cimke2"]}` |
|
||||
| **API-ban** | `ProviderSearchResult.tags: List[str]` — csak a `user_tags` érték |
|
||||
| **Service réteg** | `provider_service.py` — `spec_tags.get("user_tags", [])` |
|
||||
|
||||
**Státusz:** ⚠️ Átfedésben az ExpertiseTag rendszerrel.
|
||||
|
||||
### 2.3 ServiceProfile.specializations (JSONB — struktúrált)
|
||||
| Tulajdonság | Érték |
|
||||
|---|---|
|
||||
| **Modell** | `ServiceProfile.specializations` |
|
||||
| **Típus** | `JSONB`, server_default=`'{}'::jsonb` |
|
||||
| **Tartalom** | `{"brands": ["Volvo"], "propulsion": ["EV"]}` |
|
||||
| **Ugyanez a mező** | `ServiceProvider.specializations` — identikus struktúra |
|
||||
|
||||
**Státusz:** ✅ Konzisztens, jól elkülönített koncepció.
|
||||
|
||||
### 2.4 Organization.tags (JSONB tömb — közösségi értékelés)
|
||||
| Tulajdonság | Érték |
|
||||
|---|---|
|
||||
| **Modell** | `Organization.tags` |
|
||||
| **Típus** | `JSONB` tömb, server_default=`'[]'::jsonb` |
|
||||
| **Tartalom** | `["gyors", "megbízható", "kedvező ár"]` |
|
||||
| **API séma** | `OrganizationResponse.tags: List[str]` |
|
||||
| **Frissítés** | `OrganizationUpdate.tags: Optional[List[str]]` |
|
||||
|
||||
**Státusz:** ⚠️ Nómenklatúra ütközés a `specialization_tags` "tags" elnevezéssel.
|
||||
|
||||
### 2.5 ServiceProvider.category (Egyszerű string)
|
||||
| Tulajdonság | Érték |
|
||||
|---|---|
|
||||
| **Modell** | `ServiceProvider.category` |
|
||||
| **Típus** | `Optional[str]` |
|
||||
| **Tartalom** | Pl. `"brake_service"` — az ExpertiseTag `key` mezője |
|
||||
| **API-ban** | `ProviderSearchResult.category: Optional[str]` |
|
||||
|
||||
**Státusz:** ⚠️ Legacy mező, az új 4-level rendszer ezt fokozatosan kiváltja.
|
||||
|
||||
### 2.6 További címke/kategória mezők
|
||||
| Mező | Modell | Típus | Cél |
|
||||
|---|---|---|---|
|
||||
| `supported_vehicle_classes` | ServiceProfile, ServiceProvider | `ARRAY(String)` | Járműosztály kompatibilitás |
|
||||
| `CategoryInfo` (schema) | `provider.py` | Pydantic | API válaszban kategória adatok |
|
||||
| `SystemParameter.category` | `system.py` | `String` | Rendszerparaméter csoportosítás |
|
||||
| `Cost.category` | `service.py` | `String` | Költség kategorizálás |
|
||||
| `CostCategory` (tábla) | `fleet_finance.cost_categories` | SQL tábla | Struktúrált költségkategóriák |
|
||||
|
||||
---
|
||||
|
||||
## 3. FELTÁRT INKONZISZTENCIÁK
|
||||
|
||||
### 🔴 KRITIKUS (P0)
|
||||
|
||||
#### 3.1 Duális címke rendszer — ExpertiseTag vs specialization_tags
|
||||
|
||||
A ServiceProfile-on **KÉT** független címke tároló mező van:
|
||||
|
||||
```python
|
||||
# 1. Strukturált (FK-alapú)
|
||||
expertises: Mapped[List["ServiceExpertise"]] # → ExpertiseTag tábla
|
||||
|
||||
# 2. Strukturálatlan (JSONB)
|
||||
specialization_tags: Mapped[Any] = mapped_column(JSONB, ...) # {"user_tags": [...]}
|
||||
```
|
||||
|
||||
**Probléma:**
|
||||
- Ugyanazt a koncepciót („milyen szolgáltatást nyújt a provider?”) két különböző módon tárolja
|
||||
- A `specialization_tags.user_tags` értékei **NINCSENEK szinkronizálva** az `ExpertiseTag` táblával
|
||||
- A `quick_add_provider` és `update_provider` a `category_ids`-t az ExpertiseTag-be teszi, de a `tags`-t csak a `specialization_tags` JSONB-be
|
||||
|
||||
**Hatás:** Adatduplikáció és inkonzisztencia a két rendszer között.
|
||||
|
||||
**Javaslat:** Konszolidáció — a `specialization_tags` fokozatos kivezetése és minden címke az `ExpertiseTag` → `ServiceExpertise` kapcsolaton keresztül történjen.
|
||||
|
||||
---
|
||||
|
||||
### 🟠 MAGAS (P1)
|
||||
|
||||
#### 3.2 ProviderSearchResult.specialization — Holt mező
|
||||
|
||||
`ProviderSearchResult.specialization: List[str]` deklarálva van, de **SEHOL** sincs beállítva:
|
||||
|
||||
```python
|
||||
class ProviderSearchResult(BaseModel):
|
||||
specialization: List[str] = Field(default_factory=list) # ← MINDIG üres lista!
|
||||
```
|
||||
|
||||
A `provider_service.py` a `results.append(ProviderSearchResult(...))` hívásnál nem adja át a `specialization` paramétert, így az mindig a default `[]` értéket kapja.
|
||||
|
||||
**Hatás:** A frontend soha nem kap specializációs adatokat ezen a mezőn keresztül.
|
||||
|
||||
**Javaslat:** Távolítsd el, vagy implementáld a feltöltését.
|
||||
|
||||
---
|
||||
|
||||
#### 3.3 Admin API-ból hiányzó `tags` mező
|
||||
|
||||
A `ProviderDetail` séma **NEM tartalmaz** `tags` mezőt:
|
||||
|
||||
```python
|
||||
class ProviderDetail(BaseModel):
|
||||
category: Optional[str] = None # ✓ Van
|
||||
category_ids: Optional[List[int]] = None # ✓ Van
|
||||
supported_vehicle_classes: Optional[List[str]] = None # ✓ Van
|
||||
# tags: Optional[List[str]] = None # ✗ HIÁNYZIK!
|
||||
```
|
||||
|
||||
Ugyanez igaz a `ProviderUpdateInput` sémára is — nincs `tags` mező, pedig a user API (`ProviderUpdateIn`) tartalmazza.
|
||||
|
||||
**Hatás:** Az admin felületen nem lehet szerkeszteni a specialization_tags.user_tags értékeit.
|
||||
|
||||
**Javaslat:** Add hozzá a `tags: Optional[List[str]]` mezőt mindkét admin sémához.
|
||||
|
||||
---
|
||||
|
||||
#### 3.4 Nómenklatúra káosz
|
||||
|
||||
A rendszerben több, hasonló nevű, de eltérő koncepciójú mező van:
|
||||
|
||||
| Mező név | Hol | Típus | Koncepció |
|
||||
|---|---|---|---|
|
||||
| `specialization_tags` | ServiceProfile | JSONB dict | `{"primary_category", "user_tags"}` |
|
||||
| `specializations` | ServiceProfile | JSONB dict | `{"brands", "propulsion"}` |
|
||||
| `specialization` | ProviderSearchResult | `List[str]` | **HOLT MEZŐ** |
|
||||
| `tags` | Organization | JSONB array | Közösségi értékelő címkék |
|
||||
| `tags` | ProviderSearchResult | `List[str]` | `specialization_tags.user_tags` |
|
||||
| `tags` | ProviderQuickAddIn | `List[str]` | `specialization_tags.user_tags` |
|
||||
| `category` | ServiceProvider | `Optional[str]` | Egyszerű string |
|
||||
| `category` | ExpertiseTag | `Optional[str]` | Csoportosítás |
|
||||
| `category_ids` | Több séma | `List[int]` | ExpertiseTag ID-k |
|
||||
|
||||
**Javaslat:** Vezess be egységes nevezéktant — pl. `expertise_ids` a kategória ID-kra, `user_tags` a szabad szöveges címkékre.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 KÖZEPES (P2)
|
||||
|
||||
#### 3.5 Organization.tags vs ServiceProfile.specialization_tags — Azonos név, eltérő koncepció
|
||||
|
||||
Az `Organization.tags` célja: "Crowdsourced evaluation characteristics / tags (e.g. ['gyors', 'megbízható', 'kedvező ár'])".
|
||||
|
||||
A `ServiceProfile.specialization_tags` célja: "Milyen szakmai szolgáltatást nyújt?"
|
||||
|
||||
**Mindkettőt `tags`-nek hívja a schema, de teljesen más a jelentésük.** Ez a frontend oldalán is zavart okoz.
|
||||
|
||||
---
|
||||
|
||||
#### 3.6 `new_tags` — Ingyenes string → ExpertiseTag konverzió
|
||||
|
||||
A `_create_new_tags` függvény a user által gépelt szöveges címkéket konvertálja ExpertiseTag rekordokk:
|
||||
|
||||
```python
|
||||
new_tag = ExpertiseTag(
|
||||
key=_slugify(tag_name), # pl. "gyors olajcsere" → "gyors_olajcsere"
|
||||
category="user_created", # HARDCODED!
|
||||
level=3, # HARDCODED!
|
||||
is_official=False,
|
||||
)
|
||||
```
|
||||
|
||||
**Probléma:** A `category="user_created"` hardkódolt érték, ami eltér a rendszer által használt kategória értékektől. A `level=3` szintén minden új címkére fix.
|
||||
|
||||
**Javaslat:** A `category` mezőt az API-ból (vagy ML-alapú besorolásból) kellene kapnia.
|
||||
|
||||
---
|
||||
|
||||
#### 3.7 ServiceProvider nélkülözi a `specialization_tags`-et
|
||||
|
||||
A `ServiceProvider` modellben nincs `specialization_tags` JSONB mező, csak a `ServiceProfile`-ban. Amikor a `quick_add_provider` létrehoz egy ServiceProvider-t, a `specialization_tags` adatok a ServiceProfile-ba kerülnek. Az admin lekérdezés azonban a `ProviderDetail`-ben nem adja vissza ezeket.
|
||||
|
||||
---
|
||||
|
||||
### 🔵 ALACSONY (P3)
|
||||
|
||||
#### 3.8 Cost.category vs CostCategory hivatkozás
|
||||
|
||||
A `Cost` modellben van egy `category: str` mező, de a költségek ténylegesen egy külön `CostCategory` táblára hivatkoznak `category_id`-n keresztül. Ez a kettősség redundáns.
|
||||
|
||||
#### 3.9 VehicleModelDefinition.category
|
||||
|
||||
`vehicle_definitions.py` — Egyszerű `String(50)` mező, nem kapcsolódik a 4-level rendszerhez.
|
||||
|
||||
---
|
||||
|
||||
## 4. ADATFOLYAMAT ÁBRA
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "BEMENET (API)"
|
||||
UI1["ProviderQuickAddIn<br/>{tags, category_ids, new_tags}"]
|
||||
UI2["ProviderUpdateIn<br/>{tags, category_ids, new_tags}"]
|
||||
ADMIN["ProviderUpdateInput<br/>HIÁNYZIK: tags mező"]
|
||||
end
|
||||
|
||||
subgraph "SERVICE RÉTEG"
|
||||
QAP["quick_add_provider()"]
|
||||
UP["update_provider()"]
|
||||
SP["search_providers()"]
|
||||
end
|
||||
|
||||
subgraph "TÁROLÁS"
|
||||
ET["ExpertiseTag<br/>(4-level fa)"]
|
||||
SE["ServiceExpertise<br/>(junction)"]
|
||||
SPT["ServiceProfile<br/>.specialization_tags"]
|
||||
SPS["ServiceProfile<br/>.specializations"]
|
||||
ORG["Organization<br/>.tags (crowd)"]
|
||||
end
|
||||
|
||||
subgraph "KIMENET (API)"
|
||||
PSR["ProviderSearchResult<br/>{tags, specializations, categories}"]
|
||||
PD["ProviderDetail (admin)<br/>HIÁNYZIK: tags"]
|
||||
end
|
||||
|
||||
UI1 --> QAP
|
||||
UI2 --> UP
|
||||
ADMIN --> UP
|
||||
|
||||
QAP -->|category_ids| SE
|
||||
QAP -->|tags| SPT
|
||||
QAP -->|specializations| SPS
|
||||
QAP -->|new_tags| ET
|
||||
|
||||
UP -->|category_ids| SE
|
||||
UP -->|tags| SPT
|
||||
UP -->|specializations| SPS
|
||||
UP -->|new_tags| ET
|
||||
|
||||
SE -->|categories| PSR
|
||||
SPT -->|tags| PSR
|
||||
SPS -->|specializations| PSR
|
||||
ORG -->|tags| PSR
|
||||
|
||||
SE -.-> PD
|
||||
SPS -.->|HIÁNYZIK| PD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. JAVASLATOK ÖSSZEFOGLALÓ
|
||||
|
||||
| Priorítás | Javaslat | Érintett fájlok |
|
||||
|---|---|---|
|
||||
| **P0** | Konszolidáld a két címke rendszert — `specialization_tags` → `ExpertiseTag` | `service.py`, `provider_service.py` |
|
||||
| **P1** | Távolítsd el vagy implementáld a `ProviderSearchResult.specialization` mezőt | `provider.py` |
|
||||
| **P1** | Add hozzá a `tags` mezőt az `AdminProviderDetail` és `ProviderUpdateInput` sémákhoz | `admin_providers.py` |
|
||||
| **P2** | Vezess be egységes nevezéktant — `expertise_ids`, `crowd_tags`, `user_tags` | Több fájl |
|
||||
| **P2** | A `_create_new_tags` ne használjon hardkódolt `category="user_created"` értéket | `provider_service.py` |
|
||||
| **P3** | Takarítsd el a `Cost.category` vs `CostCategory` duplikációt | `service.py` |
|
||||
|
||||
---
|
||||
|
||||
## 6. MELLÉKLET: TELJES MEZŐTÉRKÉP
|
||||
|
||||
| # | Mező | Modell/Schema | Típus | API-ban | Használat |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `expertises` | ServiceProfile | `List[ServiceExpertise]` | `categories` | Strukturált kategória kapcsolat |
|
||||
| 2 | `specialization_tags` | ServiceProfile | `JSONB` | `tags` | Strukturálatlan címkék |
|
||||
| 3 | `specializations` | ServiceProfile | `JSONB` | `specializations` | Strukturált (brands, propulsion) |
|
||||
| 4 | `supported_vehicle_classes` | ServiceProfile | `ARRAY[String]` | `supported_vehicle_classes` | Jármű kompatibilitás |
|
||||
| 5 | `tags` | Organization | `JSONB[]` | `tags` | Közösségi értékelés |
|
||||
| 6 | `aliases` | Organization | `JSONB[]` | `aliases` | Alternatív nevek |
|
||||
| 7 | `category` | ServiceProvider | `Optional[str]` | `category` | Legacy string |
|
||||
| 8 | `specializations` | ServiceProvider | `JSONB` | `specializations` | Strukturált specializáció |
|
||||
| 9 | `category` | ExpertiseTag | `Optional[str]` | `category` | Címke csoport (pl. "vehicle") |
|
||||
| 10 | `category` | SystemParameter | `String` | `category` | Rendszer paraméter csoport |
|
||||
| 11 | `category` | Cost | `String` | - | Költség kategória string |
|
||||
| 12 | `category_id` | AssetCost | `Integer (FK)` | `category_id` | FK a CostCategory táblába |
|
||||
|
||||
---
|
||||
|
||||
*Audit készült: 2026-07-01 — Architect mód*
|
||||
10
frontend_admin/.nuxt/components.d.ts
vendored
10
frontend_admin/.nuxt/components.d.ts
vendored
@@ -14,6 +14,11 @@ type HydrationStrategies = {
|
||||
type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }> & T
|
||||
|
||||
|
||||
export const LanguageSwitcher: typeof import("../components/LanguageSwitcher.vue")['default']
|
||||
export const RawDataViewer: typeof import("../components/RawDataViewer.vue")['default']
|
||||
export const SharedAddressForm: typeof import("../components/SharedAddressForm.vue")['default']
|
||||
export const SmartLocationInput: typeof import("../components/SmartLocationInput.vue")['default']
|
||||
export const TreeNode: typeof import("../components/TreeNode.vue")['default']
|
||||
export const ChartsUserTrendChart: typeof import("../components/charts/UserTrendChart.vue")['default']
|
||||
export const GaragesGarageFleetTab: typeof import("../components/garages/GarageFleetTab.vue")['default']
|
||||
export const GaragesGarageGeneralTab: typeof import("../components/garages/GarageGeneralTab.vue")['default']
|
||||
@@ -43,6 +48,11 @@ export const Head: typeof import("../node_modules/nuxt/dist/head/runtime/compone
|
||||
export const Html: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Html']
|
||||
export const Body: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Body']
|
||||
export const NuxtIsland: typeof import("../node_modules/nuxt/dist/app/components/nuxt-island")['default']
|
||||
export const LazyLanguageSwitcher: LazyComponent<typeof import("../components/LanguageSwitcher.vue")['default']>
|
||||
export const LazyRawDataViewer: LazyComponent<typeof import("../components/RawDataViewer.vue")['default']>
|
||||
export const LazySharedAddressForm: LazyComponent<typeof import("../components/SharedAddressForm.vue")['default']>
|
||||
export const LazySmartLocationInput: LazyComponent<typeof import("../components/SmartLocationInput.vue")['default']>
|
||||
export const LazyTreeNode: LazyComponent<typeof import("../components/TreeNode.vue")['default']>
|
||||
export const LazyChartsUserTrendChart: LazyComponent<typeof import("../components/charts/UserTrendChart.vue")['default']>
|
||||
export const LazyGaragesGarageFleetTab: LazyComponent<typeof import("../components/garages/GarageFleetTab.vue")['default']>
|
||||
export const LazyGaragesGarageGeneralTab: LazyComponent<typeof import("../components/garages/GarageGeneralTab.vue")['default']>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user